diff --git a/boot b/boot index ccadbcc..40f4245 100755 --- a/boot +++ b/boot @@ -2,8 +2,9 @@ # Panama's front door: the one command a fresh Fedora machine needs. # -# bash <(curl -fsSL https://git.gbrown.org/gib/Panama/raw/branch/main/boot) -# bash <(curl -fsSL https://git.gbrown.org/gib/Panama/raw/branch/main/boot) --server +# Download this file from the documented commit URL, verify its documented +# SHA-256, then pass both immutable values as PANAMA_BOOT_REVISION and +# PANAMA_BOOT_SHA256 when invoking it. # # Deliberately dumb, because a copy of this script leaves the repository the # moment somebody curls it -- nothing here can be fixed by re-running @@ -17,9 +18,85 @@ set -euo pipefail +if [[ ! "${PANAMA_BOOT_REVISION:-}" =~ ^[0-9a-f]{40}$ ]]; then + echo "boot: PANAMA_BOOT_REVISION must be a full lowercase commit" >&2 + exit 1 +fi +if [[ ! "${PANAMA_BOOT_SHA256:-}" =~ ^[0-9a-f]{64}$ ]]; then + echo "boot: PANAMA_BOOT_SHA256 must be a lowercase SHA-256" >&2 + exit 1 +fi +actual_boot_sha="$(sha256sum "${BASH_SOURCE[0]}" | cut -d' ' -f1)" +if [[ "$actual_boot_sha" != "$PANAMA_BOOT_SHA256" ]]; then + echo "boot: downloaded boot file does not match PANAMA_BOOT_SHA256" >&2 + exit 1 +fi + REPO_URL="https://git.gbrown.org/gib/Panama.git" PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" export PANAMA_PATH +BOOTSTRAP_USER="" + +checkout_command() { + if [[ -n "$BOOTSTRAP_USER" ]]; then + runuser -u "$BOOTSTRAP_USER" -- "$@" + else + "$@" + fi +} + +prepare_panama_checkout() { + local checkout="$1" actual_head checkout_status + + if [[ -e "$checkout" && ! -d "$checkout/.git" ]]; then + printf 'boot: %s exists but is not a Panama Git checkout\n' "$checkout" >&2 + return 1 + fi + + if [[ -d "$checkout/.git" ]]; then + checkout_status="$(checkout_command git -C "$checkout" status --porcelain)" || { + printf 'boot: could not inspect the existing checkout at %s\n' "$checkout" >&2 + return 1 + } + if [[ -n "$checkout_status" ]]; then + printf 'boot: existing checkout at %s has local changes\n' "$checkout" >&2 + return 1 + fi + + echo "Panama is already cloned at $checkout; fetching the verified revision" + checkout_command git -C "$checkout" fetch origin "$PANAMA_BOOT_REVISION" || return 1 + if ! checkout_command git -C "$checkout" merge-base --is-ancestor \ + HEAD "$PANAMA_BOOT_REVISION"; then + echo "boot: existing checkout cannot fast-forward to the verified revision" >&2 + return 1 + fi + checkout_command git -C "$checkout" merge --ff-only "$PANAMA_BOOT_REVISION" || return 1 + else + checkout_command mkdir -p "$(dirname "$checkout")" || return 1 + checkout_command git init "$checkout" || return 1 + checkout_command git -C "$checkout" remote add origin "$REPO_URL" || return 1 + checkout_command git -C "$checkout" fetch --depth=1 origin \ + "$PANAMA_BOOT_REVISION" || return 1 + checkout_command git -C "$checkout" checkout --detach \ + "$PANAMA_BOOT_REVISION" || return 1 + + actual_head="$(checkout_command git -C "$checkout" rev-parse 'HEAD^{commit}')" || return 1 + if [[ "$actual_head" != "$PANAMA_BOOT_REVISION" ]]; then + echo "boot: fetched checkout does not match PANAMA_BOOT_REVISION" >&2 + return 1 + fi + + checkout_command git -C "$checkout" checkout -b main || return 1 + checkout_command git -C "$checkout" config branch.main.remote origin || return 1 + checkout_command git -C "$checkout" config branch.main.merge refs/heads/main || return 1 + fi + + actual_head="$(checkout_command git -C "$checkout" rev-parse 'HEAD^{commit}')" || return 1 + if [[ "$actual_head" != "$PANAMA_BOOT_REVISION" ]]; then + echo "boot: checkout HEAD does not match PANAMA_BOOT_REVISION" >&2 + return 1 + fi +} SERVER=0 INSTALL_ARGS=() @@ -397,18 +474,11 @@ if [[ "$(id -u)" -eq 0 ]]; then dnf install -y git fi - # Cloned straight into the user's home and owned by them: this is the - # checkout `panama update` will pull from for the life of the machine, and - # a root-owned .git in a user's home is a wound that never heals. + # Create or advance the checkout as the target user. A root-owned .git in a + # user's home would break every later update. PANAMA_PATH="$bootstrap_home/.local/share/Panama" - if [[ -d "$PANAMA_PATH/.git" ]]; then - echo "Panama is already cloned at $PANAMA_PATH; updating" - runuser -u "$username" -- git -C "$PANAMA_PATH" pull --ff-only \ - || echo "Could not fast-forward; installing from the clone as it is" >&2 - else - runuser -u "$username" -- mkdir -p "$bootstrap_home/.local/share" - runuser -u "$username" -- git clone "$REPO_URL" "$PANAMA_PATH" - fi + BOOTSTRAP_USER="$username" + prepare_panama_checkout "$PANAMA_PATH" echo "Handing off to install as $username" exec runuser -u "$username" -- env PANAMA_PATH="$PANAMA_PATH" \ @@ -422,19 +492,10 @@ if ! command -v git >/dev/null 2>&1; then sudo dnf install -y git fi -if [[ -d "$PANAMA_PATH/.git" ]]; then - # An existing clone makes this the recovery command too. Only a fast-forward: - # local work is never rewritten, and a diverged clone still installs from - # what it has rather than stopping someone mid-repair. - echo "Panama is already cloned at $PANAMA_PATH; updating" - git -C "$PANAMA_PATH" pull --ff-only \ - || echo "Could not fast-forward; installing from the clone as it is" >&2 -else - git clone "$REPO_URL" "$PANAMA_PATH" -fi +prepare_panama_checkout "$PANAMA_PATH" -# `curl | bash` and `bash <(curl ...)` can leave stdin as the pipe, and the -# first thing install runs is the interview, which has to be able to ask. +# A shell invoked from automation can have a pipe as stdin, while the first +# thing install runs is the interview, which has to be able to ask. # Reattach the terminal when there is one; without one the interview will say # so itself. # The probe actually opens /dev/tty rather than testing -r: a process with no diff --git a/tests/setup/boot-contract b/tests/setup/boot-contract index c451a28..b8890cb 100755 --- a/tests/setup/boot-contract +++ b/tests/setup/boot-contract @@ -1,18 +1,9 @@ #!/usr/bin/env bash -# The front door: `boot` is the script the README tells a fresh machine to -# curl, so it runs before anything else Panama ships -- including its own -# tests. What it must get right is small and worth pinning: -# -# * a machine without the clone gets one, from the documented URL, at -# PANAMA_PATH, and the install runs -# * a machine with the clone is not re-cloned -- the same command is the -# recovery command -- and a fast-forward failure does not stop the install -# * boot hands off to the clone's own install, with PANAMA_PATH exported, -# so a clone at a chosen location installs from that location -# -# Run against stub git and install in a throwaway PANAMA_PATH; nothing here -# touches the real clone or the network. +# `boot` is downloaded before the repository exists. It may hand off only +# after both the downloaded script and the requested Git commit have been +# verified. This fixture stubs Git and install inside a throwaway PANAMA_PATH; +# it never contacts the network or mutates the real checkout. set -uo pipefail @@ -28,13 +19,15 @@ work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT calls="$work/calls" +state="$work/state" stub_dir="$work/bin" clone_dir="$work/Panama" -mkdir -p "$stub_dir" +revision='0123456789abcdef0123456789abcdef01234567' +ancestor_revision='1111111111111111111111111111111111111111' +mismatched_revision='fedcba9876543210fedcba9876543210fedcba98' +boot_sha="$(sha256sum "$boot" | cut -d' ' -f1)" +mkdir -p "$stub_dir" "$state" -# The stub install records that it ran and what PANAMA_PATH it saw. The stub -# git records its arguments, and materializes a clone the way the real one -# would -- boot execs the clone's install, so the clone has to contain one. cat >"$work/fake-install" <>"$calls" @@ -44,56 +37,216 @@ chmod +x "$work/fake-install" cat >"$stub_dir/git" <>"$calls" -if [[ "\$1" == "clone" ]]; then - mkdir -p "\$3/.git" - cp "$work/fake-install" "\$3/install" -fi +mode="\$(<"$state/mode")" + +case "\${1:-}" in + init) + [[ "\$#" -eq 2 && "\$2" == "$clone_dir" ]] || exit 97 + mkdir -p "$clone_dir/.git" + ;; + -C) + [[ "\${2:-}" == "$clone_dir" ]] || exit 97 + case "\${3:-}" in + remote) + [[ "\$#" -eq 6 && "\$4" == add && "\$5" == origin \ + && "\$6" == https://git.gbrown.org/gib/Panama.git ]] || exit 97 + ;; + fetch) + if [[ "\${4:-}" == --depth=1 ]]; then + [[ "\$#" -eq 6 && "\$5" == origin && "\$6" == "$revision" ]] || exit 97 + else + [[ "\$#" -eq 5 && "\$4" == origin && "\$5" == "$revision" ]] || exit 97 + fi + [[ "\$mode" != fetch-failure && "\$mode" != fresh-fetch-failure ]] || exit 42 + ;; + checkout) + if [[ "\${4:-}" == --detach ]]; then + [[ "\$#" -eq 5 && "\$5" == "$revision" ]] || exit 97 + cp "$work/fake-install" "$clone_dir/install" + chmod +x "$clone_dir/install" + elif [[ "\${4:-}" == -b ]]; then + [[ "\$#" -eq 5 && "\$5" == main ]] || exit 97 + else + exit 97 + fi + ;; + config) + case "\${4:-}:\${5:-}:\${6:-}" in + branch.main.remote:origin:|branch.main.merge:refs/heads/main:) ;; + *) exit 97 ;; + esac + ;; + status) + [[ "\$#" -eq 4 && "\$4" == --porcelain ]] || exit 97 + cat "$state/status" + ;; + merge-base) + [[ "\$#" -eq 6 && "\$4" == --is-ancestor && "\$5" == HEAD \ + && "\$6" == "$revision" ]] || exit 97 + [[ "\$mode" != divergent ]] || exit 1 + ;; + merge) + [[ "\$#" -eq 5 && "\$4" == --ff-only && "\$5" == "$revision" ]] || exit 97 + if [[ "\$mode" == existing-head-mismatch ]]; then + printf '%s\n' '$mismatched_revision' >"$state/head-revision" + else + printf '%s\n' '$revision' >"$state/head-revision" + fi + cp "$work/fake-install" "$clone_dir/install" + chmod +x "$clone_dir/install" + ;; + rev-parse) + [[ "\$#" -eq 4 && "\$4" == 'HEAD^{commit}' ]] || exit 97 + cat "$state/head-revision" + ;; + *) exit 97 ;; + esac + ;; + *) exit 97 ;; +esac STUB chmod +x "$stub_dir/git" -run_boot() { +configure_case() { + local mode="$1" head_revision="${2:-$revision}" status="${3:-}" + rm -rf "$clone_dir" : >"$calls" - PATH="$stub_dir:$PATH" PANAMA_PATH="$clone_dir" bash "$boot" /dev/null 2>&1 + printf '%s\n' "$mode" >"$state/mode" + printf '%s\n' "$head_revision" >"$state/head-revision" + printf '%s' "$status" >"$state/status" } -# ── A machine without the clone ────────────────────────────────────────────── +configure_existing_case() { + configure_case "$@" + mkdir -p "$clone_dir/.git" + cp "$work/fake-install" "$clone_dir/install" + chmod +x "$clone_dir/install" +} -run_boot || note 'boot failed on a machine without the clone' +run_boot() { + local supplied_revision="$1" supplied_sha="$2" + local -a env_args=( + "PATH=$stub_dir:$PATH" + "PANAMA_PATH=$clone_dir" + ) + [[ "$supplied_revision" == UNSET ]] \ + && env_args+=(-u PANAMA_BOOT_REVISION) \ + || env_args+=("PANAMA_BOOT_REVISION=$supplied_revision") + [[ "$supplied_sha" == UNSET ]] \ + && env_args+=(-u PANAMA_BOOT_SHA256) \ + || env_args+=("PANAMA_BOOT_SHA256=$supplied_sha") + env "${env_args[@]}" bash "$boot" /dev/null 2>&1 + run_status=$? +} -grep -q "git clone https://git.gbrown.org/gib/Panama.git $clone_dir" "$calls" \ - || note 'boot does not clone the documented repository to PANAMA_PATH' -grep -q "install PANAMA_PATH=$clone_dir" "$calls" \ - || note 'boot does not hand off to the clone'\''s install with PANAMA_PATH exported' +assert_no_git_or_install() { + local case_name="$1" + if grep -qE '^(git|install) ' "$calls"; then + note "$case_name reached Git or install" + fi +} -# ── A machine that already has it ──────────────────────────────────────────── +assert_no_install_or_rewrite() { + local case_name="$1" + grep -q '^install ' "$calls" && note "$case_name reached install" + grep -qE '^git .* (reset|checkout -B|checkout -f)($| )' "$calls" \ + && note "$case_name rewrote the checkout" +} -run_boot || note 'boot failed on a machine that already has the clone' +# Missing, malformed, or mismatched bootstrap inputs fail before Git. +input_cases=( + 'missing revision|UNSET|BOOT_SHA' + 'short revision|01234567|BOOT_SHA' + 'uppercase revision|0123456789ABCDEF0123456789ABCDEF01234567|BOOT_SHA' + 'missing digest|REVISION|UNSET' + 'short digest|REVISION|01234567' + 'uppercase digest|REVISION|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'mismatched digest|REVISION|aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +) -grep -q 'git clone' "$calls" \ - && note 'boot re-clones over an existing checkout' -grep -q 'git -C .* pull --ff-only' "$calls" \ - || note 'boot does not fast-forward an existing clone' -grep -q "install PANAMA_PATH=$clone_dir" "$calls" \ - || note 'boot does not run the install from an existing clone' +for input_case in "${input_cases[@]}"; do + IFS='|' read -r name supplied_revision supplied_sha <<<"$input_case" + [[ "$supplied_revision" == REVISION ]] && supplied_revision="$revision" + [[ "$supplied_sha" == BOOT_SHA ]] && supplied_sha="$boot_sha" + configure_case validation-only + run_boot "$supplied_revision" "$supplied_sha" + (( run_status != 0 )) || note "$name was accepted" + assert_no_git_or_install "$name" +done -# ── A diverged clone still installs ────────────────────────────────────────── -# -# pull --ff-only refusing is normal life -- local commits, a rebase upstream. -# The command doubles as the repair path, so a refusal must be stepped over. +# A fresh install fetches only the requested commit, verifies checked-out HEAD, +# creates the tracked local main branch, and hands off. +configure_case fresh +run_boot "$revision" "$boot_sha" +(( run_status == 0 )) || note 'verified fresh bootstrap failed' +grep -qF "git init $clone_dir" "$calls" \ + || note 'fresh bootstrap did not initialize PANAMA_PATH' +grep -qF "git -C $clone_dir fetch --depth=1 origin $revision" "$calls" \ + || note 'fresh bootstrap did not fetch the exact revision' +grep -qF "git -C $clone_dir rev-parse HEAD^{commit}" "$calls" \ + || note 'fresh bootstrap did not resolve the checked-out commit' +grep -qF "git -C $clone_dir checkout -b main" "$calls" \ + || note 'fresh bootstrap did not create local main after verification' +grep -qF "git -C $clone_dir config branch.main.remote origin" "$calls" \ + || note 'fresh bootstrap did not configure main remote tracking' +grep -qF "git -C $clone_dir config branch.main.merge refs/heads/main" "$calls" \ + || note 'fresh bootstrap did not configure main merge tracking' +grep -qF "install PANAMA_PATH=$clone_dir" "$calls" \ + || note 'verified fresh bootstrap did not hand off with PANAMA_PATH' +grep -qE '^git (clone|.* pull)' "$calls" \ + && note 'fresh bootstrap used mutable clone or pull behavior' -cat >"$stub_dir/git" <<'STUB' -#!/usr/bin/env bash -[[ "$*" == *pull* ]] && exit 1 -exit 0 -STUB -chmod +x "$stub_dir/git" +# A fetched checkout whose HEAD does not equal the requested commit never +# creates the trusted branch or reaches install. +configure_case head-mismatch "$mismatched_revision" +run_boot "$revision" "$boot_sha" +(( run_status != 0 )) || note 'fresh HEAD mismatch returned success' +assert_no_install_or_rewrite 'fresh HEAD mismatch' +grep -qF "git -C $clone_dir checkout -b main" "$calls" \ + && note 'fresh HEAD mismatch created local main before equality passed' -: >"$calls" -if ! PATH="$stub_dir:$PATH" PANAMA_PATH="$clone_dir" bash "$boot" /dev/null 2>&1; then - note 'a clone that cannot fast-forward stops the install instead of proceeding' -fi -grep -q "install PANAMA_PATH=$clone_dir" "$calls" \ - || note 'the install does not run when the fast-forward is refused' +# A clean existing ancestor is fetched and advanced with fast-forward only. +configure_existing_case existing "$ancestor_revision" +run_boot "$revision" "$boot_sha" +(( run_status == 0 )) || note 'clean ancestor bootstrap failed' +grep -qF "git -C $clone_dir status --porcelain" "$calls" \ + || note 'existing checkout cleanliness was not checked' +grep -qF "git -C $clone_dir fetch origin $revision" "$calls" \ + || note 'existing checkout did not fetch the exact revision' +grep -qF "git -C $clone_dir merge-base --is-ancestor HEAD $revision" "$calls" \ + || note 'existing checkout ancestry was not checked' +grep -qF "git -C $clone_dir merge --ff-only $revision" "$calls" \ + || note 'existing checkout was not advanced fast-forward-only' +grep -qF "install PANAMA_PATH=$clone_dir" "$calls" \ + || note 'verified existing checkout did not reach install' +grep -qE '^git .* (reset|pull)($| )' "$calls" \ + && note 'existing checkout used reset or pull instead of the exact revision' + +# Dirty and divergent checkouts fail closed without rewriting or installing. +configure_existing_case dirty "$ancestor_revision" $' M boot\n' +run_boot "$revision" "$boot_sha" +(( run_status != 0 )) || note 'dirty checkout returned success' +grep -qF "git -C $clone_dir fetch origin $revision" "$calls" \ + && note 'dirty checkout fetched before refusing local work' +assert_no_install_or_rewrite 'dirty checkout' + +configure_existing_case divergent "$ancestor_revision" +run_boot "$revision" "$boot_sha" +(( run_status != 0 )) || note 'divergent checkout returned success' +grep -qF "git -C $clone_dir merge --ff-only $revision" "$calls" \ + && note 'divergent checkout attempted a merge' +assert_no_install_or_rewrite 'divergent checkout' + +# Fetch and post-fast-forward equality failures also stop before handoff. +configure_existing_case fetch-failure "$ancestor_revision" +run_boot "$revision" "$boot_sha" +(( run_status != 0 )) || note 'fetch failure returned success' +assert_no_install_or_rewrite 'fetch failure' + +configure_existing_case existing-head-mismatch "$ancestor_revision" +run_boot "$revision" "$boot_sha" +(( run_status != 0 )) || note 'existing HEAD mismatch returned success' +assert_no_install_or_rewrite 'existing HEAD mismatch' if (( ${#findings[@]} > 0 )); then printf 'boot contract: %d finding(s)\n' "${#findings[@]}" >&2 diff --git a/tests/setup/root-server-bootstrap-contract b/tests/setup/root-server-bootstrap-contract index e084510..790dc20 100755 --- a/tests/setup/root-server-bootstrap-contract +++ b/tests/setup/root-server-bootstrap-contract @@ -36,6 +36,10 @@ boot = sys.argv[1] work = Path(tempfile.mkdtemp()) atexit.register(shutil.rmtree, work, ignore_errors=True) findings: list[str] = [] +BOOT_REVISION = "0123456789abcdef0123456789abcdef01234567" +BOOT_SHA256 = subprocess.run( + ["sha256sum", boot], check=True, capture_output=True, text=True +).stdout.split()[0] def note(message: str) -> None: @@ -145,12 +149,42 @@ exec /usr/bin/ssh-keygen "$@" write_executable(stub_dir / "git", common + r''' log git "$@" case "${1:-}" in - clone) - mkdir -p "$3/.git" - cp "$PANAMA_BOOT_FIXTURE_ROOT/stub-install" "$3/install" - chmod +x "$3/install" + init) + [[ "$#" -eq 2 ]] || exit 97 + mkdir -p "$2/.git" + ;; + -C) + case "${3:-}" in + remote) + [[ "$#" -eq 6 && "$4" == add && "$5" == origin \ + && "$6" == https://git.gbrown.org/gib/Panama.git ]] || exit 97 + ;; + fetch) + [[ "$#" -eq 6 && "$4" == --depth=1 && "$5" == origin \ + && "$6" == "$PANAMA_BOOT_REVISION" ]] || exit 97 + ;; + checkout) + if [[ "${4:-}" == --detach ]]; then + [[ "$#" -eq 5 && "$5" == "$PANAMA_BOOT_REVISION" ]] || exit 97 + cp "$PANAMA_BOOT_FIXTURE_ROOT/stub-install" "$2/install" + chmod +x "$2/install" + else + [[ "$#" -eq 5 && "$4" == -b && "$5" == main ]] || exit 97 + fi + ;; + rev-parse) + [[ "$#" -eq 4 && "$4" == 'HEAD^{commit}' ]] || exit 97 + printf '%s\n' "$PANAMA_BOOT_REVISION" + ;; + config) + case "${4:-}:${5:-}:${6:-}" in + branch.main.remote:origin:|branch.main.merge:refs/heads/main:) ;; + *) exit 97 ;; + esac + ;; + *) exit 97 ;; + esac ;; - -C) [[ "${3:-}" == pull && "${4:-}" == --ff-only ]] || exit 97 ;; *) exit 97 ;; esac ''') @@ -483,6 +517,8 @@ def run_case( **os.environ, "PATH": f"{stub_dir}:/usr/bin:/bin", "PANAMA_BOOT_FIXTURE_ROOT": str(fixture_root), + "PANAMA_BOOT_REVISION": BOOT_REVISION, + "PANAMA_BOOT_SHA256": BOOT_SHA256, "PANAMA_PATH": f"{fixture_root}/home/gib/.local/share/Panama", "HOME": f"{fixture_root}/root", } @@ -586,6 +622,8 @@ guard_root.mkdir() guard_env = { **os.environ, "PANAMA_BOOT_FIXTURE_ROOT": str(guard_root), + "PANAMA_BOOT_REVISION": BOOT_REVISION, + "PANAMA_BOOT_SHA256": BOOT_SHA256, "HOME": str(guard_root), } if os.geteuid() == 0: