#!/usr/bin/env bash
# Case 2 row C2-13a (design 9.3, 10 step 2c): render and install the two
# system units a hub runs as, mosaic-bus@.service (enabled as
# mosaic-bus@<business>.service) and mosaic-webui.service, from the existing
# templates, which it doesn't change.
#
#   case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH
#                 [--profile factory|library] [--pm] [--claude PATH]
#                 -- SERVE FLAGS
#
# Root runs it from a root-owned checkout of the reviewed commit outside
# <home>, never from the release, because <user> can write the release. It
# writes both units under /etc/systemd/system as root:root 0644, runs
# `systemctl daemon-reload`, enables both, then starts the bus and then the
# Console (Q189). Both come back after a boot on their own. It never links
# a unit file into place. Root only stats files under <home> and reads bounded
# stdout from a config/target query run as the stack user through sudo. Root
# never imports copy code. This adds sudo and GNU timeout to the installer
# dependencies. It renders the default instance only.
#
# The library profile takes exactly these serve flags, each once:
#   --port N (1..1023)  --business ID (the unit's)  --public-host NAME
#   --auth-header HEADER  --allow-user ID  --send
#   --proof-header NAME  --proof-file PATH
# The factory profile takes --port N (1..1023) and --business ID, and an
# optional bare --tester-socket marker. Retired tester TCP refuses. Only factory takes
# --pm and --claude.
#
# On a re-install the installed units must equal the render in everything
# but the copy id, and both must name one copy, so a re-pin or rollback
# re-renders the pair together. A unit that was already running stays on its
# old copy, since `systemctl start` leaves it alone; the script says so and
# that the bus and Console must restart together, and restarts nothing.
#
# Test seams, for packages/cli/tests/case2-unit.test.mjs only:
#   CASE2_UNIT_DIR        the unit directory, default /etc/systemd/system;
#                         the root and root-owned checks apply only there
#   CASE2_SYSTEMCTL       the systemctl command, default systemctl
#   CASE2_GETENT          the getent command, default getent
#   CASE2_BUS_TEMPLATE    default packages/cli/systemd/mosaic-bus.service.in
#   CASE2_WEBUI_TEMPLATE  default packages/webui/systemd/mosaic-webui.service.in
#
# Exit 0 done, 1 refused, 4 usage.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEFAULT_UNIT_DIR=/etc/systemd/system
UNIT_DIR="${CASE2_UNIT_DIR:-$DEFAULT_UNIT_DIR}"
SYSTEMCTL="${CASE2_SYSTEMCTL:-systemctl}"
GETENT="${CASE2_GETENT:-getent}"
BUS_TEMPLATE="${CASE2_BUS_TEMPLATE:-$REPO/packages/cli/systemd/mosaic-bus.service.in}"
WEBUI_TEMPLATE="${CASE2_WEBUI_TEMPLATE:-$REPO/packages/webui/systemd/mosaic-webui.service.in}"
BUS_UNIT=mosaic-bus@.service
WEBUI_UNIT=mosaic-webui.service

usage() { echo 'usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--pm] [--claude PATH] -- SERVE FLAGS' >&2; exit 4; }
die() { echo "case2-unit: $*" >&2; exit 1; }
# A value the unit carries as itself: no space, %, $, quote, backslash, & or |.
plain() { case "$1" in *[!A-Za-z0-9/._+-]*) return 1 ;; esac; }
abs_plain() {
    case "$1" in /*) ;; *) die "$2 must be an absolute path: $1" ;; esac
    plain "$1" || die "$2 contains a character the unit cannot carry: $1"
    case "$1/" in */./*|*/../*|?*//*) die "$2 must be a normalized path: $1" ;; esac
    [ "$1" = / ] || [ "${1%/}" = "$1" ] || die "$2 must not end in /: $1"
}

PROFILE=library USER_NAME='' HOME_DIR='' BUSINESS='' COPY_ID='' NODE='' PM=0 CLAUDE='' CLAUDE_SET=0
while [ $# -gt 0 ]; do
    case "$1" in
        --profile|--user|--home|--business|--copy|--node|--claude)
            [ $# -ge 2 ] || usage
            case "$1" in
                --profile) PROFILE="$2" ;; --user) USER_NAME="$2" ;; --home) HOME_DIR="$2" ;;
                --business) BUSINESS="$2" ;; --copy) COPY_ID="$2" ;; --node) NODE="$2" ;;
                --claude) CLAUDE="$2"; CLAUDE_SET=1 ;;
            esac
            shift 2 ;;
        --pm) PM=1; shift ;;
        --) shift; break ;;
        *) usage ;;
    esac
done
SERVE=("$@")
[ -n "$USER_NAME" ] && [ -n "$HOME_DIR" ] && [ -n "$BUSINESS" ] && [ -n "$COPY_ID" ] && [ -n "$NODE" ] || usage
case "$PROFILE" in factory|library) ;; *) die "--profile must be factory or library: $PROFILE" ;; esac
if [ "$PROFILE" = library ]; then
    [ "$PM" = 0 ] || die "the library profile refuses --pm"
    [ "$CLAUDE_SET" = 0 ] || die "the library profile refuses --claude"
fi

if [ "$UNIT_DIR" = "$DEFAULT_UNIT_DIR" ]; then
    [ "$(id -u)" = 0 ] || die "writing $DEFAULT_UNIT_DIR needs root"
fi

# The user, its uid, primary group and home, from the passwd database.
[[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "--user must be a user name: $USER_NAME"
PW="$("$GETENT" passwd "$USER_NAME")" || die "no user $USER_NAME in the passwd database"
IFS=: read -r _ _ USER_UID USER_GID _ PW_HOME _ <<<"$PW"
[[ "$USER_UID" =~ ^[0-9]+$ ]] && [ "$USER_UID" -ne 0 ] || die "--user must not be root: $USER_NAME"
abs_plain "$HOME_DIR" '--home'
[ "$HOME_DIR" = "$PW_HOME" ] || die "--home $HOME_DIR is not $USER_NAME's home in the passwd database ($PW_HOME)"
GR="$("$GETENT" group "$USER_GID")" || die "no group $USER_GID in the group database"
GROUP="${GR%%:*}"
[[ "$GROUP" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "the primary group of $USER_NAME is not a group name: $GROUP"

case "$BUSINESS" in [a-z0-9]*) ;; *) die "--business must be a business id: $BUSINESS" ;; esac
case "$BUSINESS" in *[!a-z0-9._-]*) die "--business must be a business id: $BUSINESS" ;; esac
[ "${#BUSINESS}" -le 64 ] || die "--business must be a business id: $BUSINESS"

# The copy: copy_root's rule (scripts/stack-copy.sh:8, :22), under <home>.
COPIES="$HOME_DIR/.local/share/mosaic-stack/copies"
[[ "$COPY_ID" =~ ^[0-9a-f]{40}$ ]] || die "--copy must be a full 40-character lower-case commit id: $COPY_ID"
COPY="$COPIES/$COPY_ID"
[ -d "$COPY" ] && [ ! -L "$COPY" ] || die "no copy directory $COPY"
[ -f "$COPY/scripts/mosaic" ] || die "no copy $COPY_ID in $COPIES: $COPY/scripts/mosaic is missing"

# Owned by root and writable by no one else, the file and every directory above it.
root_only() {
    local p="$1" what="$2" owner mode
    while :; do
        owner="$(stat -c '%u' -- "$p")" || die "cannot stat $p"
        mode="$(stat -c '%a' -- "$p")" || die "cannot stat $p"
        [ "$owner" = 0 ] || die "$what: $p is not owned by root"
        (( (8#$mode & 8#022) == 0 )) || die "$what: $p is writable by its group or others"
        [ "$p" = / ] && break
        p="$(dirname -- "$p")"
    done
}

# --node: an absolute path to a root-owned regular file outside <home>.
abs_plain "$NODE" '--node'
case "$NODE/" in "$HOME_DIR"/*) die "--node must be outside $HOME_DIR: $NODE" ;; esac
[ ! -L "$NODE" ] || die "--node must not be a symlink; pass its target: $NODE"
[ -f "$NODE" ] && [ -x "$NODE" ] || die "--node must be an executable regular file: $NODE"
root_only "$NODE" '--node'

# --claude follows the --node rule, so nothing running as <user> can replace
# the binary the bus runs.
if [ "$CLAUDE_SET" = 1 ]; then
    abs_plain "$CLAUDE" '--claude'
    case "$CLAUDE/" in "$HOME_DIR"/*) die "--claude must be outside $HOME_DIR: $CLAUDE" ;; esac
    [ ! -L "$CLAUDE" ] || die "--claude must not be a symlink; pass its target: $CLAUDE"
    [ -f "$CLAUDE" ] && [ -x "$CLAUDE" ] || die "--claude must be an executable regular file: $CLAUDE"
    root_only "$CLAUDE" '--claude'
fi

# The checkout the templates come from, when installing for real.
if [ "$UNIT_DIR" = "$DEFAULT_UNIT_DIR" ]; then
    case "$REPO/" in "$HOME_DIR"/*) die "run this from a root-owned checkout outside $HOME_DIR, not $REPO" ;; esac
    root_only "$BUS_TEMPLATE" 'bus template'
    root_only "$WEBUI_TEMPLATE" 'Console template'
fi

# Row253: bounded unprivileged metadata, parsed by root without running copy code.
tester_capture() (
    local seconds="$1" maximum="$2" file child="" rc=0; shift 2
    file="$(mktemp)" || exit 1
    trap 'if [ -n "$child" ]; then kill -KILL -- "-$child" 2>/dev/null || :; wait "$child" 2>/dev/null || :; fi; rm -f -- "$file"' EXIT
    trap 'exit 1' HUP INT TERM
    timeout --signal=KILL "${seconds}s" /bin/bash -c 'set -o pipefail; maximum="$1"; shift; "$@" | head -c "$((maximum + 1))"' -- "$maximum" "$@" > "$file" 2>/dev/null &
    child=$!
    wait "$child" 2>/dev/null || rc=$?
    kill -KILL -- "-$child" 2>/dev/null || :
    [ "$rc" = 0 ] && [ "$(wc -c < "$file")" -le "$maximum" ] || exit 1
    cat -- "$file"
)
TESTER_QUERY='import {readFileSync} from "node:fs";import {pathToFileURL} from "node:url";const m=await import(pathToFileURL(process.argv[1]).href);const s=m.loadSystem();console.log(JSON.stringify({dataRoot:s.dataRoot,socket:readFileSync(process.argv[2],"utf8").includes("MOSAIC_TESTER_SOCKET_V1")}));'
tester_facts() {
    local LC_ALL=C pattern='^\{"dataRoot":"([A-Za-z0-9/._@+-]+)","socket":(true|false)\}$'
    [[ "$1" =~ $pattern ]] || return 1
    TS_DATA="${BASH_REMATCH[1]}" TS_MARKED="${BASH_REMATCH[2]}"
}
tester_location() {
    local LC_ALL=C data="$1" home="$2" uid="$3" prefix="$4" part at="" st
    TS_REASON="" TS_SOCKET=""
    case "$data" in /*) ;; *) TS_REASON=not-absolute; return 1 ;; esac
    case "$data" in *[!A-Za-z0-9/._@+-]*) TS_REASON=not-ascii; return 1 ;; esac
    case "$data/" in */./*|*/../*|*//* ) TS_REASON=not-normalized; return 1 ;; esac
    [ "${data%/}" = "$data" ] || { TS_REASON=not-normalized; return 1; }
    case "$data" in "$home"/*) ;; *) TS_REASON=outside-home; return 1 ;; esac
    case "$home/" in /home/*|/root/*|/run/user/*|/tmp/*|/var/tmp/*) TS_REASON=protected-private-home; return 1 ;; esac
    TS_SOCKET="$data/console/tester.sock"
    [ "${#TS_SOCKET}" -le 107 ] || { TS_REASON=too-long; return 1; }
    local -a parts
    IFS=/ read -r -a parts <<< "$data"
    for part in "${parts[@]}"; do
        [ -n "$part" ] || continue
        at="$at/$part"
        [ ! -L "$prefix$at" ] || { TS_REASON=symlink-component; return 1; }
        if [ -e "$prefix$at" ]; then
            [ -d "$prefix$at" ] || { TS_REASON=component-type; return 1; }
        fi
    done
    local parent="$prefix$data/console"
    [ ! -L "$parent" ] || { TS_REASON=symlink-parent; return 1; }
    if [ -e "$parent" ]; then
        [ -d "$parent" ] || { TS_REASON=parent-type; return 1; }
        st="$(stat -c '%u %a' -- "$parent")" || { TS_REASON=parent-stat; return 1; }
        [ "${st% *}" = "$uid" ] || { TS_REASON=parent-owner; return 1; }
        [[ "${st#* }" =~ ^[0-7]{3,4}$ ]] && (( (8#${st#* } & 8#077) == 0 )) || { TS_REASON=parent-mode; return 1; }
    fi
}
# tester-location-end

# The serve flags, checked as scripts/webui-service.sh serve_flags checks
# them (:201-264), and kept in the order given. That script compares the
# proof file's owner to `id -u`, which is root here, so this copy has its own.
serve_flags() {
    local -A seen=()
    local flags='' name value
    local port='' public_host='' auth_header='' allow_user='' proof_header='' proof_file='' tester_socket=0
    local i=0 n=${#SERVE[@]}
    while [ "$i" -lt "$n" ]; do
        name="${SERVE[$i]}"
        case "$name" in
            --tester-port) die "case2-unit.sh retires tester TCP; use --tester-socket" ;;
            --tester-socket) [ "$PROFILE" = factory ] || die "the library profile refuses --tester-socket" ;;
            --checkout) die "refusing --checkout: Case 2 has no queue panel" ;;
            --board) die "refusing --board: Case 2 runs no control board" ;;
            --sso) die "refusing --sso: the Console signs people in through the proxy, with the preview set and the proof pair (Q175 phase 2 is a follow-up)" ;;
            --port|--business) ;;
            --public-host|--auth-header|--allow-user|--proof-header|--proof-file|--send)
                [ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy" ;;
            *) die "unknown serve flag: $name" ;;
        esac
        [ -z "${seen[$name]:-}" ] || die "serve flag $name given twice"
        seen[$name]=1
        if [ "$name" = --tester-socket ]; then tester_socket=1; flags="$flags --tester-socket"; i=$((i + 1)); continue; fi
        if [ "$name" = --send ]; then flags="$flags --send"; i=$((i + 1)); continue; fi
        [ $((i + 1)) -lt "$n" ] || die "serve flag $name needs a value"
        value="${SERVE[$((i + 1))]}"
        case "$name" in
            --port) port="$value"
                [[ "$port" =~ ^[1-9][0-9]{0,3}$ ]] && [ "$port" -le 1023 ] || die "--port must be 1..1023, so only the unit's capability can bind it: $port" ;;
            --business) [ "$value" = "$BUSINESS" ] || die "serve --business $value is not the unit's business $BUSINESS" ;;
            --public-host) public_host="$value"
                case "$value" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $value" ;; esac
                case "$value" in *[!A-Za-z0-9.-]*) die "public host must be a hostname, no port or scheme: $value" ;; esac ;;
            --auth-header) auth_header="$value"
                case "$value" in [A-Za-z0-9]*) ;; *) die "auth header must be a header name: $value" ;; esac
                case "$value" in *[!A-Za-z0-9-]*) die "auth header must be a header name: $value" ;; esac ;;
            --allow-user) allow_user="$value"
                case "$value" in [A-Za-z0-9]*) ;; *) die "allowed user must start with a letter or digit: $value" ;; esac
                case "$value" in *[!A-Za-z0-9._@+-]*) die "allowed user contains a character the unit cannot carry: $value" ;; esac ;;
            --proof-header) proof_header="$value"
                [[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,63}$ ]] || die "invalid proxy proof header" ;;
            --proof-file) proof_file="$value" ;;
        esac
        flags="$flags $name $value"
        i=$((i + 2))
    done
    local required=(--port --business)
    [ "$PROFILE" = factory ] || required+=(--public-host --auth-header --allow-user --send --proof-header --proof-file)
    for name in "${required[@]}"; do
        [ -n "${seen[$name]:-}" ] || die "serve flag $name is required"
    done
    if [ "$PROFILE" = factory ]; then
        if [ "$tester_socket" = 1 ]; then
            local query
            query="$(tester_capture 5 4096 sudo -u "$USER_NAME" -H env -i "HOME=$HOME_DIR" "PATH=$UNIT_PATH" "$NODE" --input-type=module -e "$TESTER_QUERY" "$COPY/packages/cli/src/config.mjs" "$COPY/packages/webui/src/cli.mjs")" || die "tester config query failed"
            tester_facts "$query" || die "tester config query has invalid facts"
            [ "$TS_MARKED" = true ] || die "the target does not support --tester-socket"
            tester_location "$TS_DATA" "$HOME_DIR" "$USER_UID" "" || die "tester path refuses $TS_REASON"
        fi
        echo "${flags# }"; return 0
    fi
    # Lead decision 101: the uid header carries the Authentik uid.
    if [ "${auth_header,,}" = x-authentik-uid ] && ! [[ "$allow_user" =~ ^[0-9a-f]{64}$ ]]; then
        die "with --auth-header $auth_header the allowed user must be an Authentik uid, 64 lower-case hex characters (lead decision 101): $allow_user"
    fi
    case "${proof_header,,}" in host|origin|x-mosaic-tester|"${auth_header,,}") die "proxy proof header must differ from Host, Origin, tester and auth headers" ;; esac
    # The proof file: metadata only, never opened (design 9.3).
    local secrets="$HOME_DIR/.config/mosaic-dev/secrets" real owner mode
    abs_plain "$proof_file" 'proxy proof file'
    [ ! -L "$proof_file" ] || die "proxy proof file must not be a symlink: $proof_file"
    [ -f "$proof_file" ] || die "proxy proof file must be a regular file: $proof_file"
    real="$(realpath -e -- "$proof_file")" || die "cannot resolve proxy proof file: $proof_file"
    [ "$real" = "$proof_file" ] || die "proxy proof file path must have no symlink in it: $proof_file"
    case "$proof_file" in "$secrets"/*) ;; *) die "proxy proof file must be under $secrets, outside the data root: $proof_file" ;; esac
    owner="$(stat -c '%u' -- "$proof_file")" || die "cannot stat proxy proof file"
    mode="$(stat -c '%a' -- "$proof_file")" || die "cannot stat proxy proof file"
    [ "$owner" = "$USER_UID" ] || die "proxy proof file must be owned by $USER_NAME (uid $USER_UID): $proof_file"
    [ "$mode" = 600 ] || [ "$mode" = 400 ] || die "proxy proof file must be mode 0600 or 0400, not $mode: $proof_file"
    echo "${flags# }"
}
# PATH: the node's directory first, then the default list without it. Unlike
# scripts/webui-service.sh:303-305 this always puts node's directory first,
# so the bus's `exec node` (scripts/mosaic) finds the same node.
NODE_DIR="$(dirname -- "$NODE")"
UNIT_PATH="$NODE_DIR"
for d in /usr/local/bin /usr/bin /bin; do [ "$d" = "$NODE_DIR" ] || UNIT_PATH="$UNIT_PATH:$d"; done
FLAGS="$(serve_flags)" || exit 1

HARDENING="ProtectSystem=strict
ReadWritePaths=$HOME_DIR
ProtectHome=yes
PrivateTmp=yes"
CAPS="AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE"
# The default instance, as scripts/instance.sh:85-107 renders it: no
# MOSAIC_CONFIG line, and the session env under %h, which becomes <home>.
SESSION_ENV='%h/.config/mosaic-dev/secrets/claude-session.env'
ENV_FILE="-$HOME_DIR/.config/mosaic-dev/secrets/claude-session.env"
PM_FLAGS=''
[ "$PM" = 0 ] || PM_FLAGS=' --pm'
CLAUDE_ENV=''
[ "$CLAUDE_SET" = 0 ] || CLAUDE_ENV="Environment=MOSAIC_CLAUDE_BIN=$CLAUDE"

# One unit from its template. kind is bus or webui.
render() {
    local kind="$1" template="$2" text tokens
    [ -f "$template" ] && [ ! -L "$template" ] || die "template missing or not a regular file: $template"
    text="$(sed -e "s|@REPO@|$COPY|g" -e "s|@PATH@|$UNIT_PATH|g" -e "s|@NODE@|$NODE|g" \
        -e "s|@PM_FLAGS@|$PM_FLAGS|g" -e "s|^@CLAUDE_ENV@\$|$CLAUDE_ENV|" \
        -e '/^@CONFIG_ENV@$/d' -e "s|@SESSION_ENV@|$SESSION_ENV|g" \
        -e "s|%h|$HOME_DIR|g" -- "$template")"
    # Checked before @FLAGS@ goes in, since an allowed user may hold @.
    tokens="$(printf '%s\n' "$text" | grep -oE '@[A-Z_]+@' || true)"
    if [ "$kind" = webui ]; then
        [ "$tokens" = @FLAGS@ ] || die "$template: a token other than one @FLAGS@ is left after the render: $(echo $tokens)"
        text="${text//@FLAGS@/$FLAGS}"
    else
        [ -z "$tokens" ] || die "$template: a token is left after the render: $(echo $tokens)"
    fi
    if printf '%s\n' "$text" | grep -q '%h'; then die "$template: %h is left after the render"; fi
    local add="User=$USER_NAME\nGroup=$GROUP\n${HARDENING//$'\n'/\\n}\n"
    [ "$kind" = bus ] || add="$add${CAPS//$'\n'/\\n}\n"
    # The added lines go at the end of [Service], before its trailing blank
    # lines. In [Install], WantedBy=multi-user.target replaces
    # WantedBy=default.target in both units, so both start at boot (Q189).
    printf '%s\n' "$text" | awk -v add="$add" '
        function blanks_out() { for (; blanks > 0; blanks--) print "" }
        /^\[.*\]$/ {
            if (sec == "[Service]") printf "%s", add
            sec = $0
            blanks_out(); print; next
        }
        $0 == "" { blanks++; next }
        { blanks_out() }
        sec == "[Install]" && $0 == "WantedBy=default.target" { $0 = "WantedBy=multi-user.target" }
        { print }
        END { if (sec == "[Service]") printf "%s", add; blanks_out() }'
}

# The unit's settings as section<TAB>key<TAB>value, one per line, keys
# trimmed as systemd trims them. A continued line refuses, so no setting
# hides in one.
settings() {
    awk '
        { sub(/^[ \t]+/, ""); sub(/[ \t]+$/, "") }
        $0 == "" || /^[#;]/ { next }
        /\\$/ { print "CONTINUED"; exit }
        /^\[.*\]$/ { sec = $0; next }
        {
            i = index($0, "="); if (i == 0) { print "NOKEY\t" $0; next }
            k = substr($0, 1, i - 1); v = substr($0, i + 1)
            sub(/[ \t]+$/, "", k); sub(/^[ \t]+/, "", v)
            print sec "\t" k "\t" v
        }'
}

# Refuses unless one unit's fixed lines hold. what names the unit in messages,
# copy is the copy directory it should run from.
check_unit() {
    local kind="$1" text="$2" what="$3" copy="$4" s
    s="$(printf '%s\n' "$text" | settings)"
    ! printf '%s\n' "$s" | grep -qx CONTINUED || die "$what: a line continues onto the next"
    ! printf '%s\n' "$s" | grep -q '^NOKEY' || die "$what: a line is neither a section, a comment nor a setting"
    # %h in a system unit is root's home, not <user>'s.
    ! printf '%s\n' "$text" | grep -q '%h' || die "$what: %h is left; a system unit reads it as root's home"
    local key
    for key in RestrictNamespaces PrivateUsers SystemCallFilter PermissionsStartOnly; do
        ! printf '%s\n' "$s" | awk -F'\t' -v k="$key" '$2 == k { found = 1 } END { exit !found }' \
            || die "$what: refusing $key=, which the hub's units must not hold"
    done
    # An Exec*= value whose leading prefix characters hold + or ! runs with
    # full privileges, whatever User= says.
    local bad
    bad="$(printf '%s\n' "$s" | awk -F'\t' '$2 ~ /^Exec/ { p = $3; sub(/[^-@:+!].*$/, "", p); if (p ~ /[+!]/) print $2 "=" $3 }')"
    [ -z "$bad" ] || die "$what: refusing an Exec line that runs with full privileges: $bad"
    # The fixed lines, each exactly once, in [Service].
    one() {
        local k="$1" v="$2" got
        got="$(printf '%s\n' "$s" | awk -F'\t' -v k="$k" '$2 == k { print $1 "\t" $3 }')"
        [ "$got" = "[Service]	$v" ] || die "$what: $k= must be exactly once in [Service], as $k=$v"
    }
    none() {
        ! printf '%s\n' "$s" | awk -F'\t' -v k="$1" '$2 == k { found = 1 } END { exit !found }' || die "$what: refusing $1=, which this unit must not hold"
    }
    one User "$USER_NAME"
    one Group "$GROUP"
    one WorkingDirectory "$copy"
    # Environment= lines: PATH, and MOSAIC_CLAUDE_BIN only with --claude.
    local envs want_envs="[Service]	PATH=$UNIT_PATH"
    [ "$kind" = webui ] || [ -z "$CLAUDE_ENV" ] || want_envs="$want_envs
[Service]	MOSAIC_CLAUDE_BIN=$CLAUDE"
    envs="$(printf '%s\n' "$s" | awk -F'\t' '$2 == "Environment" { print $1 "\t" $3 }')"
    [ "$envs" = "$want_envs" ] || die "$what: the Environment= lines must be exactly: $(printf '%s' "$want_envs" | cut -f2 | tr '\n' ' ')"
    one NoNewPrivileges yes
    one ProtectSystem strict
    one ReadWritePaths "$HOME_DIR"
    one ProtectHome yes
    one PrivateTmp yes
    local order
    order="$(printf '%s\n' "$s" | awk -F'\t' '$2 ~ /^(ProtectSystem|ReadWritePaths|ProtectHome|PrivateTmp)$/ { printf "%s ", $2 }')"
    [ "$order" = "ProtectSystem ReadWritePaths ProtectHome PrivateTmp " ] || die "$what: the hardening lines must be in the order ProtectSystem, ReadWritePaths, ProtectHome, PrivateTmp"
    local wanted
    wanted="$(printf '%s\n' "$s" | awk -F'\t' '$2 == "WantedBy" { print $1 "\t" $3 }')"
    [ "$wanted" = "[Install]	multi-user.target" ] || die "$what: WantedBy= must be exactly once, as WantedBy=multi-user.target"
    if [ "$kind" = bus ]; then
        one ExecStart "$copy/scripts/mosaic bus start %i$PM_FLAGS"
        one EnvironmentFile "$ENV_FILE"
        none AmbientCapabilities
        none CapabilityBoundingSet
    else
        one ExecStart "$NODE $copy/packages/webui/src/cli.mjs serve $FLAGS"
        none EnvironmentFile
        one AmbientCapabilities CAP_NET_BIND_SERVICE
        one CapabilityBoundingSet CAP_NET_BIND_SERVICE
    fi
}

BUS_TEXT="$(render bus "$BUS_TEMPLATE")" || exit 1
WEBUI_TEXT="$(render webui "$WEBUI_TEMPLATE")" || exit 1
check_unit bus "$BUS_TEXT" "rendered $BUS_UNIT" "$COPY"
check_unit webui "$WEBUI_TEXT" "rendered $WEBUI_UNIT" "$COPY"

# A re-install: each installed unit passes the same checks, both name one
# copy, and each equals the render once its copy id is the new one.
[ -d "$UNIT_DIR" ] && [ ! -L "$UNIT_DIR" ] || die "no unit directory $UNIT_DIR"
installed_ids=''
for pair in "bus:$BUS_UNIT:BUS_TEXT" "webui:$WEBUI_UNIT:WEBUI_TEXT"; do
    IFS=: read -r kind name var <<<"$pair"
    path="$UNIT_DIR/$name"
    [ ! -L "$path" ] || die "refusing: $path is a symlink"
    [ -e "$path" ] || continue
    [ -f "$path" ] || die "refusing: $path is not a regular file"
    text="$(cat -- "$path")"
    old="$(printf '%s\n' "$text" | settings | awk -F'\t' -v c="$COPIES/" '$2 == "WorkingDirectory" && index($3, c) == 1 { print substr($3, length(c) + 1) }')"
    [[ "$old" =~ ^[0-9a-f]{40}$ ]] || die "installed $path names no copy under $COPIES"
    check_unit "$kind" "$text" "installed $path" "$COPIES/$old"
    [ "$text" = "$(printf '%s\n' "${!var}" | sed "s|$COPY|$COPIES/$old|g")" ] \
        || die "installed $path differs from the render in more than the copy id; the node path, serve flags, --pm and --claude must equal the installed ones"
    installed_ids="$installed_ids $old"
done
read -r -a ids <<<"$installed_ids"
[ "${#ids[@]}" -lt 2 ] || [ "${ids[0]}" = "${ids[1]}" ] || die "the installed units name different copies, ${ids[0]} and ${ids[1]}"

# `systemctl start` leaves a running unit on the copy it started from, so
# note which units run before the write (row 195 owns the restart).
BUS_NAME="mosaic-bus@$BUSINESS.service"
running=''
for name in "$BUS_NAME" "$WEBUI_UNIT"; do
    if "$SYSTEMCTL" is-active --quiet "$name"; then running="$running $name"; fi
done

# Write each unit to a temp file in the unit directory, then rename.
write_unit() {
    local name="$1" text="$2" tmp
    tmp="$(mktemp "$UNIT_DIR/.case2-unit.XXXXXX")"
    printf '%s\n' "$text" >"$tmp"
    chmod 0644 "$tmp"
    [ "$(id -u)" != 0 ] || chown root:root "$tmp"
    mv -fT -- "$tmp" "$UNIT_DIR/$name"
}
write_unit "$BUS_UNIT" "$BUS_TEXT"
write_unit "$WEBUI_UNIT" "$WEBUI_TEXT"
# Enable both, then start the bus, then the Console (design 10 step 2c).
# serve calls the bus per request, so the units carry no After= or Wants=.
"$SYSTEMCTL" daemon-reload || die "systemctl daemon-reload failed; the units are written, nothing is enabled or started"
"$SYSTEMCTL" enable "$BUS_NAME" "$WEBUI_UNIT" || die "systemctl enable $BUS_NAME $WEBUI_UNIT failed; nothing is started"
"$SYSTEMCTL" start "$BUS_NAME" || die "systemctl start $BUS_NAME failed; the Console is not started"
"$SYSTEMCTL" start "$WEBUI_UNIT" || die "systemctl start $WEBUI_UNIT failed; the bus is running"
if [ -z "$running" ]; then
    echo "case2-unit: wrote $UNIT_DIR/$BUS_UNIT and $UNIT_DIR/$WEBUI_UNIT from copy $COPY_ID; enabled and started $BUS_NAME, then $WEBUI_UNIT"
    exit 0
fi
echo "case2-unit: wrote $UNIT_DIR/$BUS_UNIT and $UNIT_DIR/$WEBUI_UNIT from copy $COPY_ID and enabled both"
for name in "$BUS_NAME" "$WEBUI_UNIT"; do
    case " $running " in
        *" $name "*) echo "case2-unit: $name was already running, so it is still on ${ids[0]:+copy }${ids[0]:-the copy it started from}, not copy $COPY_ID" ;;
        *) echo "case2-unit: started $name on copy $COPY_ID" ;;
    esac
done
echo "case2-unit: restart $BUS_NAME and $WEBUI_UNIT together to put both on copy $COPY_ID; until then they may run different copies"
