diff --git a/install b/install index 2cb4dd4..fe99448 100755 --- a/install +++ b/install @@ -22,7 +22,7 @@ # * setup-identity and install-hardware are dropped. They exist only to # consume interview answers -- git identity, NVIDIA, Secure Boot, firmware # -- and every one of those is a first-run decision. -# * install-packages runs only when the package lists actually changed. +# * install-packages runs only when its tracked installation inputs changed. # * Migrations always run rather than baseline. See the migrations block. # # Everything else is shared on purpose: the sudo keepalive, the per-stage @@ -64,12 +64,15 @@ done source "$PANAMA_PATH/bin/ascii" -# ── Have the package lists changed? ────────────────────────────────────────── +# ── Have the installation inputs changed? ─────────────────────────────────── # # install-packages is the slow stage -- a dnf metadata refresh, a Flathub # round-trip, and a transaction that resolves to "nothing to do" almost every -# time. On an upgrade it is worth running only when the lists it reads actually -# changed, so this hashes them and remembers the result. +# time. On an upgrade it is worth running only when its package lists or +# reviewed installer trust inputs changed, so this hashes them and remembers +# the result. The framed, sorted stream includes top-level package files, the +# package-stage adapter, the provenance helper, and regular provenance files; +# both relative paths and bytes are part of the state. # # A content hash rather than a git range, because Panama is developed in place: # a package added to a list and not yet committed must still install. A range @@ -86,8 +89,21 @@ STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama" PACKAGES_HASH="$STATE_DIR/packages-hash" hash_packages() { - find "$PANAMA_PATH/setup/packages" -maxdepth 1 -type f -exec sha256sum {} + \ - | sort | sha256sum | cut -d' ' -f1 + local file relative size + + { + find "$PANAMA_PATH/setup/packages" -maxdepth 1 -type f -print0 + printf '%s\0' \ + "$PANAMA_PATH/setup/scripts/install-packages" \ + "$PANAMA_PATH/setup/lib/artifact-provenance" + find "$PANAMA_PATH/setup/provenance" -type f -print0 + } | LC_ALL=C sort -z | while IFS= read -r -d '' file; do + relative="${file#"$PANAMA_PATH"/}" + size="$(wc -c <"$file")" + printf '%s\0%s\0' "$relative" "$size" + cat -- "$file" + printf '\0' + done | sha256sum | cut -d' ' -f1 } packages_needed() { diff --git a/setup/scripts/link-vicinae-scripts b/setup/scripts/link-vicinae-scripts index da80fb3..843ee33 100755 --- a/setup/scripts/link-vicinae-scripts +++ b/setup/scripts/link-vicinae-scripts @@ -81,7 +81,7 @@ if [[ -d "$extensions_source" ]] && command -v npm >/dev/null 2>&1; then [[ -f "$extension/package.json" ]] || continue name="$(basename "$extension")" - # Skip a build that would produce what is already there. `npm install` + # Skip a build that would produce what is already there. `npm ci` # alone takes long enough to be worth not repeating on every re-run of # a stage that is otherwise nearly instant. built="$vicinae_data_dir/extensions/$name" @@ -91,7 +91,7 @@ if [[ -d "$extensions_source" ]] && command -v npm >/dev/null 2>&1; then fi printf 'Building Vicinae extension %s\n' "$name" - if ! (cd "$extension" && npm install --silent >/dev/null 2>&1 && npm run build >/dev/null 2>&1); then + if ! (cd "$extension" && npm ci --silent >/dev/null 2>&1 && npm run build >/dev/null 2>&1); then printf 'Vicinae extension %s did not build; skipping\n' "$name" >&2 fi done diff --git a/tests/setup/launcher-search-contract b/tests/setup/launcher-search-contract index bc44f37..c38b592 100755 --- a/tests/setup/launcher-search-contract +++ b/tests/setup/launcher-search-contract @@ -116,6 +116,41 @@ grep -q '/etc/profile.d/nvm.sh' "$stage" \ git -C "$repo_dir" check-ignore -q "$extension/node_modules" 2>/dev/null \ || note 'the extension node_modules is not gitignored' +# npm must honour the committed dependency graph. This disposable fixture +# simulates npm rejecting a mismatched lockfile, which must leave that lockfile +# untouched and keep the launcher stage nonfatal. +fixture_root="$(mktemp -d -t panama-vicinae-lock.XXXXXX)" +trap 'rm -rf -- "$fixture_root"' EXIT +mkdir -p "$fixture_root/config/local/share/vicinae/scripts" \ + "$fixture_root/config/local/share/vicinae/extensions/panama-search/src" \ + "$fixture_root/bin" +printf '{"name":"panama-search","dependencies":{"left-pad":"1.3.0"}}\n' \ + >"$fixture_root/config/local/share/vicinae/extensions/panama-search/package.json" +printf '{"lockfileVersion":3,"packages":{}}\n' \ + >"$fixture_root/config/local/share/vicinae/extensions/panama-search/package-lock.json" +lockfile="$fixture_root/config/local/share/vicinae/extensions/panama-search/package-lock.json" +lock_before="$fixture_root/package-lock.before" +cp -- "$lockfile" "$lock_before" +cat >"$fixture_root/bin/npm" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"${NPM_LOG:?}" +[[ "${1:-}" == ci ]] || exit 64 +exit 1 +EOF +chmod +x "$fixture_root/bin/npm" +stage_status=0 +stage_output="$(PATH="$fixture_root/bin:$PATH" PANAMA_PATH="$fixture_root" \ + VICINAE_DATA_DIR="$fixture_root/vicinae-data" NPM_LOG="$fixture_root/npm.log" \ + bash "$stage" 2>&1)" || stage_status=$? +[[ "$stage_status" -eq 0 ]] \ + || note "the Vicinae stage returned $stage_status for a lockfile mismatch instead of remaining nonfatal" +[[ "$(<"$fixture_root/npm.log")" == 'ci --silent' ]] \ + || note 'the Vicinae extension dependency command was not npm ci --silent' +[[ "$stage_output" == *'Vicinae extension panama-search did not build; skipping'* ]] \ + || note 'a nonzero Vicinae extension dependency install did not fail the extension build' +cmp -s -- "$lock_before" "$lockfile" \ + || note 'a rejected Vicinae lockfile mismatch changed package-lock.json' + # ── Report ─────────────────────────────────────────────────────────────────── if (( ${#findings[@]} > 0 )); then diff --git a/tests/setup/package-provenance-contract b/tests/setup/package-provenance-contract index c8b07c3..c63fc2c 100755 --- a/tests/setup/package-provenance-contract +++ b/tests/setup/package-provenance-contract @@ -128,6 +128,19 @@ if (( ${#unsafe_installers[@]} > 0 )); then fail 'replace each finding with a reviewed, verified installation path' fi +# Re-running install-packages must be keyed to every reviewed trust input it +# consumes. The update-command fixture proves each input changes the digest; +# this public-boundary guard keeps any of those inputs from being silently +# removed from the installer state definition. +for state_input in \ + 'setup/packages' \ + 'setup/scripts/install-packages' \ + 'setup/lib/artifact-provenance' \ + 'setup/provenance'; do + grep -Fq "$state_input" "$repo_dir/install" \ + || fail "packages hash does not name required state input: $state_input" +done + # This must be the only production file sourced by the contract. # shellcheck source=../../setup/lib/artifact-provenance source "$repo_dir/setup/lib/artifact-provenance" diff --git a/tests/setup/update-command-contract b/tests/setup/update-command-contract index 5d781fd..388141a 100755 --- a/tests/setup/update-command-contract +++ b/tests/setup/update-command-contract @@ -46,11 +46,15 @@ build_fixture() { local root="$1" packages_rc="${2:-0}" trust_rc="${3:-0}" rm -rf "$root" mkdir -p "$root/bin" "$root/setup/scripts" "$root/setup/packages" \ + "$root/setup/lib" "$root/setup/provenance/keys" \ "$root/config/dot/quickshell/scripts" cp "$installer" "$root/install" : >"$root/bin/ascii" - printf 'base-package\n' >"$root/setup/packages/base" + printf 'base-package\n' >"$root/setup/packages/core-packages" + printf 'artifact provenance\n' >"$root/setup/lib/artifact-provenance" + printf 'reviewed installer inputs\n' >"$root/setup/provenance/installers.conf" + printf 'reviewed key\n' >"$root/setup/provenance/keys/fixture.asc" local stage for stage in "${STAGE_NAMES[@]}"; do @@ -210,7 +214,7 @@ grep -qx 'install-packages' <<<"$ran_forced" \ || note '--packages did not force install-packages to run' # A changed list brings the stage back. -printf 'another-package\n' >>"$tmp/a/setup/packages/base" +printf 'another-package\n' >>"$tmp/a/setup/packages/core-packages" install_status=0 ran_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$? [[ "$install_status" -eq 0 ]] \ @@ -218,6 +222,24 @@ ran_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$? grep -qx 'install-packages' <<<"$ran_changed" \ || note 'a changed package list did not bring install-packages back' +# The package stage consumes more than package lists. Each reviewed input must +# independently invalidate a recorded package state so an upgrade cannot keep +# using a stale trust or installer policy. +for state_input in \ + 'setup/scripts/install-packages installer adapter' \ + 'setup/lib/artifact-provenance provenance helper' \ + 'setup/provenance/installers.conf installer provenance' \ + 'setup/provenance/keys/fixture.asc provenance key'; do + read -r relative label <<<"$state_input" + printf 'changed %s\n' "$label" >>"$tmp/a/$relative" + install_status=0 + ran_input_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$? + [[ "$install_status" -eq 0 ]] \ + || note "install --upgrade failed after a $label change with status $install_status" + grep -qx 'install-packages' <<<"$ran_input_changed" \ + || note "a changed $label did not bring install-packages back" +done + # A failing stage must not record the hash, or the failure is hidden forever. build_fixture "$tmp/c" 1 install_status=0