From 7d58cd4c047c76ba587df65f3f6cc6af2fb47af9 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:02:20 +0800 Subject: [PATCH 1/5] Orchestrate managed local migrations --- .github/workflows/web-ci.yml | 3 + scripts/install.sh | 151 ++++++++++++++++++++- scripts/test-install-managed-migrations.sh | 105 ++++++++++++++ 3 files changed, 258 insertions(+), 1 deletion(-) create mode 100755 scripts/test-install-managed-migrations.sh diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index f72135a8..5671b741 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -79,6 +79,9 @@ jobs: cache-dependency-path: web/package-lock.json - run: npm install + - name: Exercise managed local installer migration orchestration + working-directory: . + run: bash scripts/test-install-managed-migrations.sh - name: Install redis-cli for migration proof working-directory: . run: | diff --git a/scripts/install.sh b/scripts/install.sh index c9d4b4e7..3b2ee296 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1406,11 +1406,143 @@ prepare_web_app() { bash -c 'cd "$1" && npm install --no-audit --no-fund --progress=true' _ "$REPO_ROOT/web" mark_web_node_modules_clean fi - run "npm run db:migrate" bash -c 'cd "$1" && FORGE_WORKSPACE_ROOT="$2" FORGE_ENV_FILE="$3" FORGE_SUPPRESS_MIGRATION_NOTICES=1 npm run db:migrate --silent' _ "$REPO_ROOT/web" "$WORKSPACE_ROOT" "$ENV_FILE" + if managed_local_migrations_enabled; then + run_managed_local_migrations + else + run "npm run db:migrate" bash -c 'cd "$1" && FORGE_WORKSPACE_ROOT="$2" FORGE_ENV_FILE="$3" FORGE_SUPPRESS_MIGRATION_NOTICES=1 npm run db:migrate --silent' _ "$REPO_ROOT/web" "$WORKSPACE_ROOT" "$ENV_FILE" + fi grant_forge_privileges run "npm run db:seed-agents" bash -c 'cd "$1" && FORGE_WORKSPACE_ROOT="$2" FORGE_ENV_FILE="$3" FORGE_PROMPT_UPGRADE_MODE="$4" npm run db:seed-agents' _ "$REPO_ROOT/web" "$WORKSPACE_ROOT" "$ENV_FILE" "$PROMPT_UPGRADE_MODE" } +managed_local_migrations_enabled() { + [ "$SERVICE_MODE" = "native" ] || return 1 + should_manage_local_db + [ "$MANAGE_LOCAL_DB" = "1" ] +} + +resolve_managed_local_admin() { + MANAGED_LOCAL_ADMIN_MODE="" + MANAGED_LOCAL_ADMIN_SOCKET="" + MANAGED_LOCAL_ADMIN_USER="" + + if [ -n "${FORGE_INSTALL_TEST_ADMIN_MODE:-}" ]; then + case "$FORGE_INSTALL_TEST_ADMIN_MODE" in + current|sudo|runuser) MANAGED_LOCAL_ADMIN_MODE="$FORGE_INSTALL_TEST_ADMIN_MODE" ;; + unavailable) return 1 ;; + *) die "Unknown installer test admin mode." ;; + esac + MANAGED_LOCAL_ADMIN_SOCKET="/tmp" + MANAGED_LOCAL_ADMIN_USER="postgres" + return 0 + fi + + if psql -d postgres -tAc 'SELECT current_user' >/dev/null 2>&1; then + MANAGED_LOCAL_ADMIN_MODE="current" + MANAGED_LOCAL_ADMIN_USER="$(psql -d postgres -tAc 'SELECT current_user' | tr -d '[:space:]')" + MANAGED_LOCAL_ADMIN_SOCKET="$(psql -d postgres -tAc 'SHOW unix_socket_directories' | tr -d '[:space:]' | cut -d, -f1)" + elif [ "$OS_NAME" = "Linux" ] && id postgres >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1 && sudo -n -u postgres psql -d postgres -tAc 'SELECT 1' >/dev/null 2>&1; then + MANAGED_LOCAL_ADMIN_MODE="sudo" + MANAGED_LOCAL_ADMIN_USER="postgres" + MANAGED_LOCAL_ADMIN_SOCKET="$(sudo -n -u postgres psql -d postgres -tAc 'SHOW unix_socket_directories' | tr -d '[:space:]' | cut -d, -f1)" + elif [ "$OS_NAME" = "Linux" ] && id postgres >/dev/null 2>&1 && command -v runuser >/dev/null 2>&1 && runuser -u postgres -- psql -d postgres -tAc 'SELECT 1' >/dev/null 2>&1; then + MANAGED_LOCAL_ADMIN_MODE="runuser" + MANAGED_LOCAL_ADMIN_USER="postgres" + MANAGED_LOCAL_ADMIN_SOCKET="$(runuser -u postgres -- psql -d postgres -tAc 'SHOW unix_socket_directories' | tr -d '[:space:]' | cut -d, -f1)" + else + return 1 + fi + + [ -n "$MANAGED_LOCAL_ADMIN_USER" ] && [ -n "$MANAGED_LOCAL_ADMIN_SOCKET" ] +} + +run_managed_local_migration_stage() { + local description="$1" stage="$2" + shift 2 + + if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "managed-local-migrations" ]; then + printf '%s\n' "$stage" >> "${FORGE_INSTALL_TEST_STAGE_LOG:?}" + if [ "${FORGE_INSTALL_TEST_FAIL_STAGE:-}" = "$stage" ]; then + [ "$stage" != "s5" ] || printf 's5-cleanup-attempted\n' >> "${FORGE_INSTALL_TEST_STAGE_LOG:?}" + return 1 + fi + return 0 + fi + + case "$MANAGED_LOCAL_ADMIN_MODE" in + current) + run "$description" bash -c 'cd "$1"; case "$2" in + release) npm run protocol:bootstrap-epic-172-release-roles ;; + migrate-0025) npx tsx scripts/ci/migrate-through-0025.ts ;; + s3) npm run protocol:bootstrap-epic-172-s3-release-owner ;; + migrate-0026) npx tsx scripts/ci/migrate-through-0026.ts ;; + s4) npm run protocol:bootstrap-epic-172-s4-roles ;; + migrate-0027) npx tsx scripts/ci/migrate-through-0027.ts ;; + s5) bash scripts/ci/apply-epic-172-s5-recovery-migration.sh ;; + latest) npm run db:migrate ;; + *) exit 64 ;; + esac' _ "$REPO_ROOT/web" "$stage" + ;; + sudo) + run "$description" sudo -n -u postgres --preserve-env=DATABASE_URL,FORGE_DATABASE_ADMIN_URL,PGHOST,PGUSER,FORGE_WORKSPACE_ROOT,FORGE_ENV_FILE,FORGE_SUPPRESS_MIGRATION_NOTICES,PATH bash -c 'cd "$1"; case "$2" in + release) npm run protocol:bootstrap-epic-172-release-roles ;; + migrate-0025) npx tsx scripts/ci/migrate-through-0025.ts ;; + s3) npm run protocol:bootstrap-epic-172-s3-release-owner ;; + migrate-0026) npx tsx scripts/ci/migrate-through-0026.ts ;; + s4) npm run protocol:bootstrap-epic-172-s4-roles ;; + migrate-0027) npx tsx scripts/ci/migrate-through-0027.ts ;; + s5) bash scripts/ci/apply-epic-172-s5-recovery-migration.sh ;; + latest) npm run db:migrate ;; + *) exit 64 ;; + esac' _ "$REPO_ROOT/web" "$stage" + ;; + runuser) + run "$description" runuser -u postgres --preserve-environment -- bash -c 'cd "$1"; case "$2" in + release) npm run protocol:bootstrap-epic-172-release-roles ;; + migrate-0025) npx tsx scripts/ci/migrate-through-0025.ts ;; + s3) npm run protocol:bootstrap-epic-172-s3-release-owner ;; + migrate-0026) npx tsx scripts/ci/migrate-through-0026.ts ;; + s4) npm run protocol:bootstrap-epic-172-s4-roles ;; + migrate-0027) npx tsx scripts/ci/migrate-through-0027.ts ;; + s5) bash scripts/ci/apply-epic-172-s5-recovery-migration.sh ;; + latest) npm run db:migrate ;; + *) exit 64 ;; + esac' _ "$REPO_ROOT/web" "$stage" + ;; + *) die "Managed local PostgreSQL administrator mode is unavailable." ;; + esac +} + +run_managed_local_migrations() { + step "Applying managed local database migrations" + if [ "$DRY_RUN" = "1" ]; then + info "[dry-run] Bootstrap release roles, migrate through 0025, bootstrap S3, migrate through 0026, bootstrap S4, migrate through 0027, apply S5 with cleanup, then run the latest migrator." + return 0 + fi + + resolve_managed_local_admin || die "Could not establish passwordless local PostgreSQL administrator access for managed migrations. Use a native local PostgreSQL peer login, or run the documented operator migration procedure for a custom database." + + local DATABASE_URL FORGE_DATABASE_ADMIN_URL PGHOST PGUSER FORGE_WORKSPACE_ROOT FORGE_ENV_FILE FORGE_SUPPRESS_MIGRATION_NOTICES + DATABASE_URL="$(env_value DATABASE_URL)" + [ -n "$DATABASE_URL" ] || die "Managed local migrations require DATABASE_URL in the local Forge environment file." + FORGE_DATABASE_ADMIN_URL="postgresql:///forge" + PGHOST="$MANAGED_LOCAL_ADMIN_SOCKET" + PGUSER="$MANAGED_LOCAL_ADMIN_USER" + FORGE_WORKSPACE_ROOT="$WORKSPACE_ROOT" + FORGE_ENV_FILE="$ENV_FILE" + FORGE_SUPPRESS_MIGRATION_NOTICES=1 + export DATABASE_URL FORGE_DATABASE_ADMIN_URL PGHOST PGUSER FORGE_WORKSPACE_ROOT FORGE_ENV_FILE FORGE_SUPPRESS_MIGRATION_NOTICES + + run_managed_local_migration_stage "Bootstrap release roles for managed local migration" release || die "Managed local migration failed while bootstrapping release roles." + run_managed_local_migration_stage "Migrate managed local database through 0025" migrate-0025 || die "Managed local migration failed while applying migrations through 0025." + run_managed_local_migration_stage "Bootstrap S3 owner handoff for managed local migration" s3 || die "Managed local migration failed while bootstrapping the S3 owner handoff." + run_managed_local_migration_stage "Migrate managed local database through 0026" migrate-0026 || die "Managed local migration failed while applying migrations through 0026." + run_managed_local_migration_stage "Bootstrap S4 owner handoff for managed local migration" s4 || die "Managed local migration failed while bootstrapping the S4 owner handoff." + run_managed_local_migration_stage "Migrate managed local database through 0027" migrate-0027 || die "Managed local migration failed while applying migrations through 0027." + run_managed_local_migration_stage "Apply S5 managed local migration with mandatory cleanup" s5 || die "Managed local migration failed while applying S5; its cleanup wrapper preserves the original migration failure." + run_managed_local_migration_stage "Run the latest managed local migrator" latest || die "Managed local migration failed while applying the latest migration set." +} + run_doctor() { step "Running the doctor" run "npm run doctor" bash -c 'cd "$1" && FORGE_WORKSPACE_ROOT="$2" FORGE_ENV_FILE="$3" npm run doctor' _ "$REPO_ROOT/web" "$WORKSPACE_ROOT" "$ENV_FILE" @@ -1692,6 +1824,23 @@ if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "start-native-services" ]; then exit 0 fi +if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "managed-local-migrations" ]; then + SERVICE_MODE="${FORGE_INSTALL_TEST_SERVICE_MODE:-native}" + printf 'admin:%s\n' "${FORGE_INSTALL_TEST_ADMIN_MODE:-unset}" >> "${FORGE_INSTALL_TEST_STAGE_LOG:?}" + run_managed_local_migrations + exit 0 +fi + +if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "managed-local-migrations-enabled" ]; then + SERVICE_MODE="${FORGE_INSTALL_TEST_SERVICE_MODE:-native}" + if managed_local_migrations_enabled; then + printf 'managed-local-migrations-enabled\n' + else + printf 'managed-local-migrations-bypassed\n' + fi + exit 0 +fi + bold "Forge installer" info "Repo: $REPO_ROOT" info "OS: $OS_NAME" diff --git a/scripts/test-install-managed-migrations.sh b/scripts/test-install-managed-migrations.sh new file mode 100755 index 00000000..44ea9e03 --- /dev/null +++ b/scripts/test-install-managed-migrations.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Focused executable coverage for managed-local migration orchestration. +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALLER="$SCRIPT_DIR/install.sh" +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/forge-managed-migrations.XXXXXX")" +trap 'rm -rf "$TEST_ROOT"' EXIT +TEST_SECRET='TEST_APP_DATABASE_URL_MUST_NOT_APPEAR' + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_contains() { + grep -Fq "$1" "$2" || fail "expected '$1' in $2" +} + +assert_not_contains() { + ! grep -Fq "$1" "$2" || fail "did not expect '$1' in $2" +} + +assert_stages() { + local file="$1" + shift + local expected actual + expected="$(printf '%s\n' "$@")" + actual="$(sed -n '2,$p' "$file")" + [ "$actual" = "$expected" ] || fail "unexpected stages in $file: $actual" +} + +run_managed_case() { + local name="$1" admin_mode="$2" dry_run="${3:-0}" fail_stage="${4:-}" + local case_dir="$TEST_ROOT/$name" + mkdir -p "$case_dir/state" + printf 'DATABASE_URL=postgresql://forge:%s@localhost:5432/forge\n' "$TEST_SECRET" > "$case_dir/forge.env" + : > "$case_dir/stages" + set +e + FORGE_INSTALL_TEST_HOOK=managed-local-migrations \ + FORGE_INSTALL_TEST_ADMIN_MODE="$admin_mode" \ + FORGE_INSTALL_TEST_STAGE_LOG="$case_dir/stages" \ + FORGE_INSTALL_TEST_FAIL_STAGE="$fail_stage" \ + FORGE_DRY_RUN="$dry_run" \ + FORGE_ENV_FILE="$case_dir/forge.env" \ + FORGE_INSTALL_STATE_DIR="$case_dir/state" \ + bash "$INSTALLER" > "$case_dir/stdout" 2> "$case_dir/stderr" + CASE_STATUS=$? + set -e + CASE_DIR="$case_dir" +} + +expected_stages=(release migrate-0025 s3 migrate-0026 s4 migrate-0027 s5 latest) + +for admin_mode in current sudo runuser; do + run_managed_case "$admin_mode" "$admin_mode" + [ "$CASE_STATUS" -eq 0 ] || fail "$admin_mode managed migration should succeed" + assert_contains "admin:$admin_mode" "$CASE_DIR/stages" + assert_stages "$CASE_DIR/stages" "${expected_stages[@]}" +done + +run_managed_case idempotent-first current +[ "$CASE_STATUS" -eq 0 ] || fail 'first idempotent orchestration run should succeed' +run_managed_case idempotent-second current +[ "$CASE_STATUS" -eq 0 ] || fail 'second idempotent orchestration run should succeed' +assert_stages "$CASE_DIR/stages" "${expected_stages[@]}" + +run_managed_case dry-run current 1 +[ "$CASE_STATUS" -eq 0 ] || fail 'dry-run should succeed' +assert_contains '[dry-run] Bootstrap release roles, migrate through 0025' "$CASE_DIR/stdout" +[ "$(wc -l < "$CASE_DIR/stages" | tr -d '[:space:]')" = 1 ] || fail 'dry-run must not execute migration stages' + +run_managed_case admin-unavailable unavailable +[ "$CASE_STATUS" -ne 0 ] || fail 'unavailable local admin must fail closed' +assert_contains 'Could not establish passwordless local PostgreSQL administrator access' "$CASE_DIR/stderr" + +run_managed_case s5-failure current 0 s5 +[ "$CASE_STATUS" -ne 0 ] || fail 'S5 migration failure must fail the orchestration' +assert_contains 's5-cleanup-attempted' "$CASE_DIR/stages" +assert_not_contains 'latest' "$CASE_DIR/stages" +assert_contains 'its cleanup wrapper preserves the original migration failure' "$CASE_DIR/stderr" + +run_enabled_case() { + local name="$1" service_mode="$2" database_url="$3" + local case_dir="$TEST_ROOT/$name" + mkdir -p "$case_dir/state" + printf 'DATABASE_URL=%s\n' "$database_url" > "$case_dir/forge.env" + FORGE_INSTALL_TEST_HOOK=managed-local-migrations-enabled \ + FORGE_INSTALL_TEST_SERVICE_MODE="$service_mode" \ + FORGE_ENV_FILE="$case_dir/forge.env" \ + FORGE_INSTALL_STATE_DIR="$case_dir/state" \ + bash "$INSTALLER" > "$case_dir/stdout" 2> "$case_dir/stderr" + CASE_DIR="$case_dir" +} + +run_enabled_case custom native "postgresql://custom:${TEST_SECRET}@example.invalid/forge" +assert_contains 'managed-local-migrations-bypassed' "$CASE_DIR/stdout" +run_enabled_case docker docker "postgresql://forge:${TEST_SECRET}@localhost:5432/forge" +assert_contains 'managed-local-migrations-bypassed' "$CASE_DIR/stdout" + +if rg -F "$TEST_SECRET" "$TEST_ROOT" --glob '!forge.env' >/dev/null; then + fail 'test sentinel leaked outside the local environment fixture' +fi + +printf 'PASS: managed local migration orchestration coverage\n' From 98907f4686879faea5f798fd78d85c61e365416b Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:18:39 +0800 Subject: [PATCH 2/5] Harden managed migration proofs --- .github/workflows/web-ci.yml | 10 ++ .../ci/prove-installer-managed-migrations.sh | 106 ++++++++++++++++++ scripts/install.sh | 75 +++++++++++-- scripts/test-install-managed-migrations.sh | 48 +++++++- 4 files changed, 225 insertions(+), 14 deletions(-) create mode 100755 scripts/ci/prove-installer-managed-migrations.sh diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index 5671b741..a1ec4f87 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -112,6 +112,8 @@ jobs: CREATE DATABASE forge_migration_0027_upgrade_test OWNER forge_migration_test; CREATE DATABASE forge_migration_0028_upgrade_test OWNER forge_migration_test; CREATE DATABASE forge_s5_handoff_failure_test OWNER forge_migration_test; + CREATE DATABASE forge_installer_managed_migration_test OWNER forge_migration_test; + CREATE DATABASE forge_installer_managed_s5_failure_test OWNER forge_migration_test; CREATE DATABASE forge_s4_ci_test OWNER forge_migration_test; SQL - name: Prove populated migration 0025 to 0026 upgrade @@ -119,6 +121,14 @@ jobs: env: FORGE_MIGRATION_0026_DATABASE_URL: postgresql://forge_migration_test:forge_migration_test@localhost:5432/forge_migration_0026_upgrade_test FORGE_DATABASE_ADMIN_URL: postgresql://forge_e2e:forge@localhost:5432/forge_migration_0026_upgrade_test + - name: Prove real installer-managed migration sequence and S5 cleanup + working-directory: . + run: bash scripts/ci/prove-installer-managed-migrations.sh + env: + FORGE_INSTALLER_MANAGED_DATABASE_URL: postgresql://forge_migration_test:forge_migration_test@localhost:5432/forge_installer_managed_migration_test + FORGE_INSTALLER_MANAGED_FAILURE_DATABASE_URL: postgresql://forge_migration_test:forge_migration_test@localhost:5432/forge_installer_managed_s5_failure_test + FORGE_INSTALLER_MANAGED_ADMIN_URL: postgresql://forge_e2e:forge@localhost:5432/forge_installer_managed_migration_test + FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_URL: postgresql://forge_e2e:forge@localhost:5432/forge_installer_managed_s5_failure_test - name: Prove populated migration 0026 to latest recovery upgrade and strict cutover run: npm run test:migration-0027-upgrade env: diff --git a/scripts/ci/prove-installer-managed-migrations.sh b/scripts/ci/prove-installer-managed-migrations.sh new file mode 100755 index 00000000..289a5ba1 --- /dev/null +++ b/scripts/ci/prove-installer-managed-migrations.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Hosted disposable-PostgreSQL proof for the installer-managed migration path. +set -Eeuo pipefail + +: "${FORGE_INSTALLER_MANAGED_DATABASE_URL:?Set the disposable successful-upgrade database URL.}" +: "${FORGE_INSTALLER_MANAGED_FAILURE_DATABASE_URL:?Set the disposable S5-failure database URL.}" +: "${FORGE_INSTALLER_MANAGED_ADMIN_URL:?Set the successful-upgrade PostgreSQL administrator URL.}" +: "${FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_URL:?Set the S5-failure PostgreSQL administrator URL.}" + +SCRIPT_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -P "$SCRIPT_DIR/../.." && pwd)" +INSTALLER="$REPO_ROOT/scripts/install.sh" +TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/forge-installer-managed-proof.XXXXXX")" +trap 'rm -rf "$TEMP_ROOT"' EXIT + +prepare_0025_baseline() { + local database_url="$1" admin_url="$2" + echo 'Preparing exact migration-0025 disposable baseline.' + ( + cd "$REPO_ROOT/web" + DATABASE_URL="$database_url" FORGE_DATABASE_ADMIN_URL="$admin_url" \ + npm run protocol:bootstrap-epic-172-release-roles + DATABASE_URL="$database_url" npx tsx scripts/ci/migrate-through-0025.ts + ) +} + +run_real_managed_sequence() { + local database_url="$1" admin_url="$2" env_file="$3" + printf 'DATABASE_URL=%s\n' "$database_url" > "$env_file" + FORGE_INSTALL_TEST_HOOK=managed-local-migrations-real \ + FORGE_INSTALL_TEST_ADMIN_MODE=current \ + FORGE_INSTALL_TEST_ADMIN_URL="$admin_url" \ + FORGE_ENV_FILE="$env_file" \ + FORGE_INSTALL_STATE_DIR="$(dirname "$env_file")/state" \ + /bin/bash "$INSTALLER" +} + +assert_latest_and_clean() { + local admin_url="$1" + psql "$admin_url" --set ON_ERROR_STOP=1 <<'SQL' +DO $proof$ +BEGIN + IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 29 + OR (SELECT max(created_at) FROM drizzle.__drizzle_migrations) <> 1784274000000 THEN + RAISE EXCEPTION 'Managed installer did not apply the exact latest migration ledger'; + END IF; + IF pg_catalog.to_regclass('public.forge_epic_172_s3_release_state') IS NULL THEN + RAISE EXCEPTION 'Managed installer did not create the S3 release state'; + END IF; + IF EXISTS ( + SELECT 1 FROM pg_catalog.pg_auth_members membership + WHERE membership.roleid IN ('forge_release_routines_owner'::regrole, 'forge_s4_routines_owner'::regrole) + ) THEN + RAISE EXCEPTION 'Managed installer retained owner membership'; + END IF; +END; +$proof$; +SQL +} + +assert_s5_cleanup() { + local admin_url="$1" + psql "$admin_url" --set ON_ERROR_STOP=1 <<'SQL' +DO $proof$ +DECLARE + migration_login name := 'forge_migration_test'; +BEGIN + IF pg_catalog.has_function_privilege( + migration_login, + 'public.forge_begin_epic_172_s4_owner_bootstrap_v1()'::regprocedure, + 'EXECUTE' + ) OR pg_catalog.pg_has_role(migration_login, 'forge_s4_routines_owner', 'member') + OR pg_catalog.has_schema_privilege(migration_login, 'forge', 'CREATE') THEN + RAISE EXCEPTION 'S5 cleanup retained temporary migration authority'; + END IF; +END; +$proof$; +SQL +} + +success_env="$TEMP_ROOT/success.env" +failure_env="$TEMP_ROOT/failure.env" + +echo 'Proving the real installer-managed sequence from migration 0025 through latest.' +prepare_0025_baseline "$FORGE_INSTALLER_MANAGED_DATABASE_URL" "$FORGE_INSTALLER_MANAGED_ADMIN_URL" +run_real_managed_sequence "$FORGE_INSTALLER_MANAGED_DATABASE_URL" "$FORGE_INSTALLER_MANAGED_ADMIN_URL" "$success_env" +assert_latest_and_clean "$FORGE_INSTALLER_MANAGED_ADMIN_URL" + +echo 'Re-running the real installer-managed sequence to prove already-latest idempotency.' +run_real_managed_sequence "$FORGE_INSTALLER_MANAGED_DATABASE_URL" "$FORGE_INSTALLER_MANAGED_ADMIN_URL" "$success_env" +assert_latest_and_clean "$FORGE_INSTALLER_MANAGED_ADMIN_URL" + +echo 'Proving real S5 failure cleanup preserves the original migration failure.' +prepare_0025_baseline "$FORGE_INSTALLER_MANAGED_FAILURE_DATABASE_URL" "$FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_URL" +set +e +FORGE_S5_FORCE_HANDOFF_FAILURE=1 \ + run_real_managed_sequence "$FORGE_INSTALLER_MANAGED_FAILURE_DATABASE_URL" "$FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_URL" "$failure_env" +failure_status=$? +set -e +if [ "$failure_status" -eq 0 ]; then + echo 'The induced S5 failure unexpectedly succeeded.' >&2 + exit 1 +fi +assert_s5_cleanup "$FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_URL" + +echo 'Installer-managed migration sequence, rerun, and S5 failure-cleanup proof passed.' diff --git a/scripts/install.sh b/scripts/install.sh index 3b2ee296..e64bd3df 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1497,22 +1497,57 @@ run_managed_local_migration_stage() { esac' _ "$REPO_ROOT/web" "$stage" ;; runuser) - run "$description" runuser -u postgres --preserve-environment -- bash -c 'cd "$1"; case "$2" in - release) npm run protocol:bootstrap-epic-172-release-roles ;; - migrate-0025) npx tsx scripts/ci/migrate-through-0025.ts ;; - s3) npm run protocol:bootstrap-epic-172-s3-release-owner ;; - migrate-0026) npx tsx scripts/ci/migrate-through-0026.ts ;; - s4) npm run protocol:bootstrap-epic-172-s4-roles ;; - migrate-0027) npx tsx scripts/ci/migrate-through-0027.ts ;; - s5) bash scripts/ci/apply-epic-172-s5-recovery-migration.sh ;; - latest) npm run db:migrate ;; - *) exit 64 ;; - esac' _ "$REPO_ROOT/web" "$stage" + run_managed_local_migration_as_runuser "$description" "$stage" ;; *) die "Managed local PostgreSQL administrator mode is unavailable." ;; esac } +managed_local_executable_path() { + local tool path result="" + for tool in node npm npx bash; do + path="$(command -v "$tool")" || die "Managed local migrations require $tool on PATH." + case ":$result:" in + *":$(dirname "$path"):"*) ;; + *) result="${result:+$result:}$(dirname "$path")" ;; + esac + done + printf '%s:/usr/local/bin:/usr/bin:/bin\n' "$result" +} + +run_managed_local_migration_as_runuser() { + local description="$1" stage="$2" + local controlled_path + controlled_path="$(managed_local_executable_path)" + + # `runuser --preserve-environment` is needed so the child receives the + # process-only socket/admin context without serialising a credential into an + # argv value. Constrain its parent environment first: this subshell exports + # exactly the migration inputs and a controlled executable path. + ( + local name + for name in $(compgen -e); do + case "$name" in + DATABASE_URL|FORGE_DATABASE_ADMIN_URL|PGHOST|PGUSER|FORGE_WORKSPACE_ROOT|FORGE_ENV_FILE|FORGE_SUPPRESS_MIGRATION_NOTICES|PATH) ;; + *) unset "$name" ;; + esac + done + PATH="$controlled_path" + export PATH + run "$description" runuser -u postgres --preserve-environment -- bash -c 'cd "$1"; case "$2" in + release) npm run protocol:bootstrap-epic-172-release-roles ;; + migrate-0025) npx tsx scripts/ci/migrate-through-0025.ts ;; + s3) npm run protocol:bootstrap-epic-172-s3-release-owner ;; + migrate-0026) npx tsx scripts/ci/migrate-through-0026.ts ;; + s4) npm run protocol:bootstrap-epic-172-s4-roles ;; + migrate-0027) npx tsx scripts/ci/migrate-through-0027.ts ;; + s5) bash scripts/ci/apply-epic-172-s5-recovery-migration.sh ;; + latest) npm run db:migrate ;; + *) exit 64 ;; + esac' _ "$REPO_ROOT/web" "$stage" + ) +} + run_managed_local_migrations() { step "Applying managed local database migrations" if [ "$DRY_RUN" = "1" ]; then @@ -1525,7 +1560,11 @@ run_managed_local_migrations() { local DATABASE_URL FORGE_DATABASE_ADMIN_URL PGHOST PGUSER FORGE_WORKSPACE_ROOT FORGE_ENV_FILE FORGE_SUPPRESS_MIGRATION_NOTICES DATABASE_URL="$(env_value DATABASE_URL)" [ -n "$DATABASE_URL" ] || die "Managed local migrations require DATABASE_URL in the local Forge environment file." - FORGE_DATABASE_ADMIN_URL="postgresql:///forge" + if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "managed-local-migrations-real" ] && [ -n "${FORGE_INSTALL_TEST_ADMIN_URL:-}" ]; then + FORGE_DATABASE_ADMIN_URL="$FORGE_INSTALL_TEST_ADMIN_URL" + else + FORGE_DATABASE_ADMIN_URL="postgresql:///forge" + fi PGHOST="$MANAGED_LOCAL_ADMIN_SOCKET" PGUSER="$MANAGED_LOCAL_ADMIN_USER" FORGE_WORKSPACE_ROOT="$WORKSPACE_ROOT" @@ -1831,6 +1870,18 @@ if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "managed-local-migrations" ]; then exit 0 fi +if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "managed-local-migrations-runuser-environment" ]; then + SERVICE_MODE="native" + run_managed_local_migrations + exit 0 +fi + +if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "managed-local-migrations-real" ]; then + SERVICE_MODE="native" + run_managed_local_migrations + exit 0 +fi + if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "managed-local-migrations-enabled" ]; then SERVICE_MODE="${FORGE_INSTALL_TEST_SERVICE_MODE:-native}" if managed_local_migrations_enabled; then diff --git a/scripts/test-install-managed-migrations.sh b/scripts/test-install-managed-migrations.sh index 44ea9e03..8d3c816d 100755 --- a/scripts/test-install-managed-migrations.sh +++ b/scripts/test-install-managed-migrations.sh @@ -44,7 +44,7 @@ run_managed_case() { FORGE_DRY_RUN="$dry_run" \ FORGE_ENV_FILE="$case_dir/forge.env" \ FORGE_INSTALL_STATE_DIR="$case_dir/state" \ - bash "$INSTALLER" > "$case_dir/stdout" 2> "$case_dir/stderr" + /bin/bash "$INSTALLER" > "$case_dir/stdout" 2> "$case_dir/stderr" CASE_STATUS=$? set -e CASE_DIR="$case_dir" @@ -89,7 +89,7 @@ run_enabled_case() { FORGE_INSTALL_TEST_SERVICE_MODE="$service_mode" \ FORGE_ENV_FILE="$case_dir/forge.env" \ FORGE_INSTALL_STATE_DIR="$case_dir/state" \ - bash "$INSTALLER" > "$case_dir/stdout" 2> "$case_dir/stderr" + /bin/bash "$INSTALLER" > "$case_dir/stdout" 2> "$case_dir/stderr" CASE_DIR="$case_dir" } @@ -98,6 +98,50 @@ assert_contains 'managed-local-migrations-bypassed' "$CASE_DIR/stdout" run_enabled_case docker docker "postgresql://forge:${TEST_SECRET}@localhost:5432/forge" assert_contains 'managed-local-migrations-bypassed' "$CASE_DIR/stdout" +run_runuser_environment_case() { + local case_dir="$TEST_ROOT/runuser-environment" + mkdir -p "$case_dir/bin" "$case_dir/state" + printf 'DATABASE_URL=postgresql://forge:%s@localhost:5432/forge\n' "$TEST_SECRET" > "$case_dir/forge.env" + cat > "$case_dir/bin/runuser" <<'EOF' +#!/bin/bash +marker_dir="$(dirname "$FORGE_ENV_FILE")" +if printenv | cut -d= -f1 | grep -Fx 'UNRELATED_SECRET_SENTINEL' >/dev/null; then + printf 'unrelated-secret-leaked\n' > "$marker_dir/runuser-result" + exit 1 +fi +printenv | cut -d= -f1 | sort > "$marker_dir/runuser-environment-names" +printf 'clean\n' > "$marker_dir/runuser-result" +while [ "$1" != "--" ]; do shift; done +shift +exec "$@" +EOF + cat > "$case_dir/bin/bash" <<'EOF' +#!/bin/bash +exit 0 +EOF + for command in node npm npx; do + printf '#!/usr/bin/env bash\nexit 0\n' > "$case_dir/bin/$command" + done + chmod +x "$case_dir/bin/runuser" "$case_dir/bin/bash" "$case_dir/bin/node" "$case_dir/bin/npm" "$case_dir/bin/npx" + PATH="$case_dir/bin:$PATH" \ + UNRELATED_SECRET_SENTINEL='unrelated-value-must-not-reach-postgres' \ + FORGE_INSTALL_TEST_HOOK=managed-local-migrations-runuser-environment \ + FORGE_INSTALL_TEST_ADMIN_MODE=runuser \ + FORGE_ENV_FILE="$case_dir/forge.env" \ + FORGE_INSTALL_STATE_DIR="$case_dir/state" \ + /bin/bash "$INSTALLER" > "$case_dir/stdout" 2> "$case_dir/stderr" + CASE_DIR="$case_dir" +} + +run_runuser_environment_case +assert_contains 'clean' "$CASE_DIR/runuser-result" +assert_not_contains 'UNRELATED_SECRET_SENTINEL' "$CASE_DIR/runuser-environment-names" +assert_contains 'DATABASE_URL' "$CASE_DIR/runuser-environment-names" +assert_contains 'FORGE_DATABASE_ADMIN_URL' "$CASE_DIR/runuser-environment-names" +assert_contains 'PGHOST' "$CASE_DIR/runuser-environment-names" +assert_contains 'PGUSER' "$CASE_DIR/runuser-environment-names" +assert_not_contains "$TEST_SECRET" "$CASE_DIR/runuser-environment-names" + if rg -F "$TEST_SECRET" "$TEST_ROOT" --glob '!forge.env' >/dev/null; then fail 'test sentinel leaked outside the local environment fixture' fi From 0fd9208efb66c07c845077e639a20810779ffc09 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:29:47 +0800 Subject: [PATCH 3/5] Constrain elevated migration toolchain --- .github/workflows/web-ci.yml | 5 + .../ci/prove-installer-managed-migrations.sh | 34 +++--- scripts/install.sh | 110 +++++++++++------- scripts/test-install-managed-migrations.sh | 40 ++++++- 4 files changed, 130 insertions(+), 59 deletions(-) diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index a1ec4f87..3479a5e0 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -129,6 +129,11 @@ jobs: FORGE_INSTALLER_MANAGED_FAILURE_DATABASE_URL: postgresql://forge_migration_test:forge_migration_test@localhost:5432/forge_installer_managed_s5_failure_test FORGE_INSTALLER_MANAGED_ADMIN_URL: postgresql://forge_e2e:forge@localhost:5432/forge_installer_managed_migration_test FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_URL: postgresql://forge_e2e:forge@localhost:5432/forge_installer_managed_s5_failure_test + FORGE_INSTALLER_MANAGED_ADMIN_HOST: localhost + FORGE_INSTALLER_MANAGED_ADMIN_USER: forge_e2e + FORGE_INSTALLER_MANAGED_ADMIN_PASSWORD: forge + FORGE_INSTALLER_MANAGED_ADMIN_DATABASE: forge_installer_managed_migration_test + FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_DATABASE: forge_installer_managed_s5_failure_test - name: Prove populated migration 0026 to latest recovery upgrade and strict cutover run: npm run test:migration-0027-upgrade env: diff --git a/scripts/ci/prove-installer-managed-migrations.sh b/scripts/ci/prove-installer-managed-migrations.sh index 289a5ba1..2e0960e6 100755 --- a/scripts/ci/prove-installer-managed-migrations.sh +++ b/scripts/ci/prove-installer-managed-migrations.sh @@ -6,6 +6,11 @@ set -Eeuo pipefail : "${FORGE_INSTALLER_MANAGED_FAILURE_DATABASE_URL:?Set the disposable S5-failure database URL.}" : "${FORGE_INSTALLER_MANAGED_ADMIN_URL:?Set the successful-upgrade PostgreSQL administrator URL.}" : "${FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_URL:?Set the S5-failure PostgreSQL administrator URL.}" +: "${FORGE_INSTALLER_MANAGED_ADMIN_HOST:?Set the fixed disposable PostgreSQL admin host.}" +: "${FORGE_INSTALLER_MANAGED_ADMIN_USER:?Set the fixed disposable PostgreSQL admin user.}" +: "${FORGE_INSTALLER_MANAGED_ADMIN_PASSWORD:?Set the fixed disposable PostgreSQL admin password.}" +: "${FORGE_INSTALLER_MANAGED_ADMIN_DATABASE:?Set the successful-upgrade admin database name.}" +: "${FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_DATABASE:?Set the S5-failure admin database name.}" SCRIPT_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd -P "$SCRIPT_DIR/../.." && pwd)" @@ -27,17 +32,20 @@ prepare_0025_baseline() { run_real_managed_sequence() { local database_url="$1" admin_url="$2" env_file="$3" printf 'DATABASE_URL=%s\n' "$database_url" > "$env_file" - FORGE_INSTALL_TEST_HOOK=managed-local-migrations-real \ - FORGE_INSTALL_TEST_ADMIN_MODE=current \ - FORGE_INSTALL_TEST_ADMIN_URL="$admin_url" \ - FORGE_ENV_FILE="$env_file" \ - FORGE_INSTALL_STATE_DIR="$(dirname "$env_file")/state" \ - /bin/bash "$INSTALLER" + ( + export DATABASE_URL="$database_url" + export FORGE_DATABASE_ADMIN_URL="$admin_url" + export FORGE_ENV_FILE="$env_file" + export FORGE_INSTALL_LIBRARY=1 + source "$INSTALLER" + MANAGED_LOCAL_ADMIN_MODE=current + run_managed_local_migration_sequence + ) } assert_latest_and_clean() { - local admin_url="$1" - psql "$admin_url" --set ON_ERROR_STOP=1 <<'SQL' + local database_name="$1" + PGPASSWORD="$FORGE_INSTALLER_MANAGED_ADMIN_PASSWORD" PGHOST="$FORGE_INSTALLER_MANAGED_ADMIN_HOST" PGUSER="$FORGE_INSTALLER_MANAGED_ADMIN_USER" PGDATABASE="$database_name" psql --set ON_ERROR_STOP=1 <<'SQL' DO $proof$ BEGIN IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 29 @@ -59,8 +67,8 @@ SQL } assert_s5_cleanup() { - local admin_url="$1" - psql "$admin_url" --set ON_ERROR_STOP=1 <<'SQL' + local database_name="$1" + PGPASSWORD="$FORGE_INSTALLER_MANAGED_ADMIN_PASSWORD" PGHOST="$FORGE_INSTALLER_MANAGED_ADMIN_HOST" PGUSER="$FORGE_INSTALLER_MANAGED_ADMIN_USER" PGDATABASE="$database_name" psql --set ON_ERROR_STOP=1 <<'SQL' DO $proof$ DECLARE migration_login name := 'forge_migration_test'; @@ -84,11 +92,11 @@ failure_env="$TEMP_ROOT/failure.env" echo 'Proving the real installer-managed sequence from migration 0025 through latest.' prepare_0025_baseline "$FORGE_INSTALLER_MANAGED_DATABASE_URL" "$FORGE_INSTALLER_MANAGED_ADMIN_URL" run_real_managed_sequence "$FORGE_INSTALLER_MANAGED_DATABASE_URL" "$FORGE_INSTALLER_MANAGED_ADMIN_URL" "$success_env" -assert_latest_and_clean "$FORGE_INSTALLER_MANAGED_ADMIN_URL" +assert_latest_and_clean "$FORGE_INSTALLER_MANAGED_ADMIN_DATABASE" echo 'Re-running the real installer-managed sequence to prove already-latest idempotency.' run_real_managed_sequence "$FORGE_INSTALLER_MANAGED_DATABASE_URL" "$FORGE_INSTALLER_MANAGED_ADMIN_URL" "$success_env" -assert_latest_and_clean "$FORGE_INSTALLER_MANAGED_ADMIN_URL" +assert_latest_and_clean "$FORGE_INSTALLER_MANAGED_ADMIN_DATABASE" echo 'Proving real S5 failure cleanup preserves the original migration failure.' prepare_0025_baseline "$FORGE_INSTALLER_MANAGED_FAILURE_DATABASE_URL" "$FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_URL" @@ -101,6 +109,6 @@ if [ "$failure_status" -eq 0 ]; then echo 'The induced S5 failure unexpectedly succeeded.' >&2 exit 1 fi -assert_s5_cleanup "$FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_URL" +assert_s5_cleanup "$FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_DATABASE" echo 'Installer-managed migration sequence, rerun, and S5 failure-cleanup proof passed.' diff --git a/scripts/install.sh b/scripts/install.sh index e64bd3df..72429ef0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1441,14 +1441,14 @@ resolve_managed_local_admin() { MANAGED_LOCAL_ADMIN_MODE="current" MANAGED_LOCAL_ADMIN_USER="$(psql -d postgres -tAc 'SELECT current_user' | tr -d '[:space:]')" MANAGED_LOCAL_ADMIN_SOCKET="$(psql -d postgres -tAc 'SHOW unix_socket_directories' | tr -d '[:space:]' | cut -d, -f1)" - elif [ "$OS_NAME" = "Linux" ] && id postgres >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1 && sudo -n -u postgres psql -d postgres -tAc 'SELECT 1' >/dev/null 2>&1; then + elif [ "$OS_NAME" = "Linux" ] && id postgres >/dev/null 2>&1 && trusted_linux_tool sudo >/dev/null 2>&1 && "$(trusted_linux_tool sudo)" -n -u postgres psql -d postgres -tAc 'SELECT 1' >/dev/null 2>&1; then MANAGED_LOCAL_ADMIN_MODE="sudo" MANAGED_LOCAL_ADMIN_USER="postgres" - MANAGED_LOCAL_ADMIN_SOCKET="$(sudo -n -u postgres psql -d postgres -tAc 'SHOW unix_socket_directories' | tr -d '[:space:]' | cut -d, -f1)" - elif [ "$OS_NAME" = "Linux" ] && id postgres >/dev/null 2>&1 && command -v runuser >/dev/null 2>&1 && runuser -u postgres -- psql -d postgres -tAc 'SELECT 1' >/dev/null 2>&1; then + MANAGED_LOCAL_ADMIN_SOCKET="$("$(trusted_linux_tool sudo)" -n -u postgres psql -d postgres -tAc 'SHOW unix_socket_directories' | tr -d '[:space:]' | cut -d, -f1)" + elif [ "$OS_NAME" = "Linux" ] && id postgres >/dev/null 2>&1 && trusted_linux_tool runuser >/dev/null 2>&1 && "$(trusted_linux_tool runuser)" -u postgres -- psql -d postgres -tAc 'SELECT 1' >/dev/null 2>&1; then MANAGED_LOCAL_ADMIN_MODE="runuser" MANAGED_LOCAL_ADMIN_USER="postgres" - MANAGED_LOCAL_ADMIN_SOCKET="$(runuser -u postgres -- psql -d postgres -tAc 'SHOW unix_socket_directories' | tr -d '[:space:]' | cut -d, -f1)" + MANAGED_LOCAL_ADMIN_SOCKET="$("$(trusted_linux_tool runuser)" -u postgres -- psql -d postgres -tAc 'SHOW unix_socket_directories' | tr -d '[:space:]' | cut -d, -f1)" else return 1 fi @@ -1484,17 +1484,7 @@ run_managed_local_migration_stage() { esac' _ "$REPO_ROOT/web" "$stage" ;; sudo) - run "$description" sudo -n -u postgres --preserve-env=DATABASE_URL,FORGE_DATABASE_ADMIN_URL,PGHOST,PGUSER,FORGE_WORKSPACE_ROOT,FORGE_ENV_FILE,FORGE_SUPPRESS_MIGRATION_NOTICES,PATH bash -c 'cd "$1"; case "$2" in - release) npm run protocol:bootstrap-epic-172-release-roles ;; - migrate-0025) npx tsx scripts/ci/migrate-through-0025.ts ;; - s3) npm run protocol:bootstrap-epic-172-s3-release-owner ;; - migrate-0026) npx tsx scripts/ci/migrate-through-0026.ts ;; - s4) npm run protocol:bootstrap-epic-172-s4-roles ;; - migrate-0027) npx tsx scripts/ci/migrate-through-0027.ts ;; - s5) bash scripts/ci/apply-epic-172-s5-recovery-migration.sh ;; - latest) npm run db:migrate ;; - *) exit 64 ;; - esac' _ "$REPO_ROOT/web" "$stage" + run_managed_local_migration_as_sudo "$description" "$stage" ;; runuser) run_managed_local_migration_as_runuser "$description" "$stage" @@ -1503,22 +1493,42 @@ run_managed_local_migration_stage() { esac } -managed_local_executable_path() { - local tool path result="" - for tool in node npm npx bash; do - path="$(command -v "$tool")" || die "Managed local migrations require $tool on PATH." - case ":$result:" in - *":$(dirname "$path"):"*) ;; - *) result="${result:+$result:}$(dirname "$path")" ;; - esac +trusted_linux_tool() { + local tool="$1" candidate resolved owner mode + for candidate in "/usr/local/sbin/$tool" "/usr/local/bin/$tool" "/usr/sbin/$tool" "/usr/bin/$tool" "/sbin/$tool" "/bin/$tool"; do + [ -x "$candidate" ] || continue + resolved="$(/usr/bin/readlink -f "$candidate" 2>/dev/null || true)" + [ -n "$resolved" ] && [ -f "$resolved" ] || continue + owner="$(/usr/bin/stat -c '%U' "$resolved" 2>/dev/null || true)" + mode="$(/usr/bin/stat -c '%a' "$resolved" 2>/dev/null || true)" + [ "$owner" = root ] && [ -n "$mode" ] || continue + [ $((8#$mode & 022)) -eq 0 ] || continue + while [ "$resolved" != / ]; do + resolved="$(/usr/bin/dirname "$resolved")" + owner="$(/usr/bin/stat -c '%U' "$resolved" 2>/dev/null || true)" + mode="$(/usr/bin/stat -c '%a' "$resolved" 2>/dev/null || true)" + [ "$owner" = root ] && [ -n "$mode" ] && [ $((8#$mode & 022)) -eq 0 ] || break 2 + done + printf '%s\n' "$(/usr/bin/readlink -f "$candidate")" + return 0 done - printf '%s:/usr/local/bin:/usr/bin:/bin\n' "$result" + return 1 +} + +prepare_trusted_linux_migration_toolchain() { + [ "$OS_NAME" = Linux ] || die "Elevated managed local migrations are only supported on Linux." + MANAGED_LOCAL_BASH="$(trusted_linux_tool bash)" || die "Could not find a root-owned non-writable bash for elevated managed migrations." + MANAGED_LOCAL_NODE="$(trusted_linux_tool node)" || die "Could not find a root-owned non-writable node for elevated managed migrations." + MANAGED_LOCAL_NPM="$(trusted_linux_tool npm)" || die "Could not find a root-owned non-writable npm for elevated managed migrations." + MANAGED_LOCAL_NPX="$(trusted_linux_tool npx)" || die "Could not find a root-owned non-writable npx for elevated managed migrations." + MANAGED_LOCAL_PATH="$(dirname "$MANAGED_LOCAL_NODE"):$(dirname "$MANAGED_LOCAL_NPM"):$(dirname "$MANAGED_LOCAL_NPX"):$(dirname "$MANAGED_LOCAL_BASH"):/usr/local/bin:/usr/bin:/bin" } run_managed_local_migration_as_runuser() { local description="$1" stage="$2" local controlled_path - controlled_path="$(managed_local_executable_path)" + prepare_trusted_linux_migration_toolchain + controlled_path="$MANAGED_LOCAL_PATH" # `runuser --preserve-environment` is needed so the child receives the # process-only socket/admin context without serialising a credential into an @@ -1534,7 +1544,29 @@ run_managed_local_migration_as_runuser() { done PATH="$controlled_path" export PATH - run "$description" runuser -u postgres --preserve-environment -- bash -c 'cd "$1"; case "$2" in + run "$description" "$(trusted_linux_tool runuser)" -u postgres --preserve-environment -- "$MANAGED_LOCAL_BASH" -c 'cd "$1"; case "$2" in + release) npm run protocol:bootstrap-epic-172-release-roles ;; + migrate-0025) npx tsx scripts/ci/migrate-through-0025.ts ;; + s3) npm run protocol:bootstrap-epic-172-s3-release-owner ;; + migrate-0026) npx tsx scripts/ci/migrate-through-0026.ts ;; + s4) npm run protocol:bootstrap-epic-172-s4-roles ;; + migrate-0027) npx tsx scripts/ci/migrate-through-0027.ts ;; + s5) bash scripts/ci/apply-epic-172-s5-recovery-migration.sh ;; + latest) npm run db:migrate ;; + *) exit 64 ;; + esac' _ "$REPO_ROOT/web" "$stage" + ) +} + +run_managed_local_migration_as_sudo() { + local description="$1" stage="$2" + local sudo_bin + prepare_trusted_linux_migration_toolchain + sudo_bin="$(trusted_linux_tool sudo)" || die "Could not find a root-owned non-writable sudo for elevated managed migrations." + ( + PATH="$MANAGED_LOCAL_PATH" + export PATH + "$sudo_bin" -n -u postgres --preserve-env=DATABASE_URL,FORGE_DATABASE_ADMIN_URL,PGHOST,PGUSER,FORGE_WORKSPACE_ROOT,FORGE_ENV_FILE,FORGE_SUPPRESS_MIGRATION_NOTICES,PATH "$MANAGED_LOCAL_BASH" -c 'cd "$1"; case "$2" in release) npm run protocol:bootstrap-epic-172-release-roles ;; migrate-0025) npx tsx scripts/ci/migrate-through-0025.ts ;; s3) npm run protocol:bootstrap-epic-172-s3-release-owner ;; @@ -1560,11 +1592,7 @@ run_managed_local_migrations() { local DATABASE_URL FORGE_DATABASE_ADMIN_URL PGHOST PGUSER FORGE_WORKSPACE_ROOT FORGE_ENV_FILE FORGE_SUPPRESS_MIGRATION_NOTICES DATABASE_URL="$(env_value DATABASE_URL)" [ -n "$DATABASE_URL" ] || die "Managed local migrations require DATABASE_URL in the local Forge environment file." - if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "managed-local-migrations-real" ] && [ -n "${FORGE_INSTALL_TEST_ADMIN_URL:-}" ]; then - FORGE_DATABASE_ADMIN_URL="$FORGE_INSTALL_TEST_ADMIN_URL" - else - FORGE_DATABASE_ADMIN_URL="postgresql:///forge" - fi + FORGE_DATABASE_ADMIN_URL="postgresql:///forge" PGHOST="$MANAGED_LOCAL_ADMIN_SOCKET" PGUSER="$MANAGED_LOCAL_ADMIN_USER" FORGE_WORKSPACE_ROOT="$WORKSPACE_ROOT" @@ -1572,6 +1600,10 @@ run_managed_local_migrations() { FORGE_SUPPRESS_MIGRATION_NOTICES=1 export DATABASE_URL FORGE_DATABASE_ADMIN_URL PGHOST PGUSER FORGE_WORKSPACE_ROOT FORGE_ENV_FILE FORGE_SUPPRESS_MIGRATION_NOTICES + run_managed_local_migration_sequence +} + +run_managed_local_migration_sequence() { run_managed_local_migration_stage "Bootstrap release roles for managed local migration" release || die "Managed local migration failed while bootstrapping release roles." run_managed_local_migration_stage "Migrate managed local database through 0025" migrate-0025 || die "Managed local migration failed while applying migrations through 0025." run_managed_local_migration_stage "Bootstrap S3 owner handoff for managed local migration" s3 || die "Managed local migration failed while bootstrapping the S3 owner handoff." @@ -1854,6 +1886,10 @@ acquire_install_lock() { die "Another Forge install appears to be running. Remove $LOCK_DIR only if you are sure it is stale." } +if [ "${FORGE_INSTALL_LIBRARY:-0}" = "1" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then + return 0 +fi + # Internal seam for the focused installer regression test. It intentionally # bypasses all setup work and invokes only the native-service function. if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "start-native-services" ]; then @@ -1870,18 +1906,6 @@ if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "managed-local-migrations" ]; then exit 0 fi -if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "managed-local-migrations-runuser-environment" ]; then - SERVICE_MODE="native" - run_managed_local_migrations - exit 0 -fi - -if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "managed-local-migrations-real" ]; then - SERVICE_MODE="native" - run_managed_local_migrations - exit 0 -fi - if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "managed-local-migrations-enabled" ]; then SERVICE_MODE="${FORGE_INSTALL_TEST_SERVICE_MODE:-native}" if managed_local_migrations_enabled; then diff --git a/scripts/test-install-managed-migrations.sh b/scripts/test-install-managed-migrations.sh index 8d3c816d..647ae0df 100755 --- a/scripts/test-install-managed-migrations.sh +++ b/scripts/test-install-managed-migrations.sh @@ -123,13 +123,32 @@ EOF printf '#!/usr/bin/env bash\nexit 0\n' > "$case_dir/bin/$command" done chmod +x "$case_dir/bin/runuser" "$case_dir/bin/bash" "$case_dir/bin/node" "$case_dir/bin/npm" "$case_dir/bin/npx" - PATH="$case_dir/bin:$PATH" \ + cat > "$case_dir/driver.sh" <<'EOF' +#!/bin/bash +FORGE_INSTALL_LIBRARY=1 source "$INSTALLER" +test_toolchain_dir="$FORGE_TEST_TOOLCHAIN_DIR" +trusted_linux_tool() { printf '%s/%s\n' "$test_toolchain_dir" "$1"; } +OS_NAME=Linux +SERVICE_MODE=native +DRY_RUN=0 +run_managed_local_migrations +EOF + chmod +x "$case_dir/driver.sh" + set +e + INSTALLER="$INSTALLER" \ + FORGE_TEST_TOOLCHAIN_DIR="$case_dir/bin" \ + PATH="$case_dir/bin:$PATH" \ UNRELATED_SECRET_SENTINEL='unrelated-value-must-not-reach-postgres' \ - FORGE_INSTALL_TEST_HOOK=managed-local-migrations-runuser-environment \ FORGE_INSTALL_TEST_ADMIN_MODE=runuser \ FORGE_ENV_FILE="$case_dir/forge.env" \ FORGE_INSTALL_STATE_DIR="$case_dir/state" \ - /bin/bash "$INSTALLER" > "$case_dir/stdout" 2> "$case_dir/stderr" + /bin/bash "$case_dir/driver.sh" > "$case_dir/stdout" 2> "$case_dir/stderr" + local driver_status=$? + set -e + if [ "$driver_status" -ne 0 ]; then + sed -n '1,80p' "$case_dir/stderr" >&2 + fail 'runuser environment driver failed' + fi CASE_DIR="$case_dir" } @@ -142,6 +161,21 @@ assert_contains 'PGHOST' "$CASE_DIR/runuser-environment-names" assert_contains 'PGUSER' "$CASE_DIR/runuser-environment-names" assert_not_contains "$TEST_SECRET" "$CASE_DIR/runuser-environment-names" +run_shadow_refusal_case() { + local case_dir="$TEST_ROOT/shadow-refusal" + mkdir -p "$case_dir/bin" + printf '#!/bin/bash\nprintf shadow-used > "$FORGE_SHADOW_MARKER"\nexit 0\n' > "$case_dir/bin/sudo" + chmod +x "$case_dir/bin/sudo" + INSTALLER="$INSTALLER" FORGE_SHADOW_MARKER="$case_dir/marker" PATH="$case_dir/bin:$PATH" /bin/bash -c ' + FORGE_INSTALL_LIBRARY=1 source "$INSTALLER" + resolved="$(trusted_linux_tool sudo 2>/dev/null || true)" + [ "$resolved" != "$PWD/bin/sudo" ] + ' > "$case_dir/stdout" 2> "$case_dir/stderr" + [ ! -e "$case_dir/marker" ] || fail 'caller PATH shadow reached the privileged sudo resolver' +} + +run_shadow_refusal_case + if rg -F "$TEST_SECRET" "$TEST_ROOT" --glob '!forge.env' >/dev/null; then fail 'test sentinel leaked outside the local environment fixture' fi From 98195310a864734557972d14d28d48261f41bba4 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:38:30 +0800 Subject: [PATCH 4/5] Harden elevated migration paths --- .../ci/prove-installer-managed-migrations.sh | 1 + scripts/install.sh | 45 +++++++++++++------ scripts/test-install-managed-migrations.sh | 44 ++++++++++++++---- 3 files changed, 68 insertions(+), 22 deletions(-) diff --git a/scripts/ci/prove-installer-managed-migrations.sh b/scripts/ci/prove-installer-managed-migrations.sh index 2e0960e6..d2f744af 100755 --- a/scripts/ci/prove-installer-managed-migrations.sh +++ b/scripts/ci/prove-installer-managed-migrations.sh @@ -33,6 +33,7 @@ run_real_managed_sequence() { local database_url="$1" admin_url="$2" env_file="$3" printf 'DATABASE_URL=%s\n' "$database_url" > "$env_file" ( + set -- export DATABASE_URL="$database_url" export FORGE_DATABASE_ADMIN_URL="$admin_url" export FORGE_ENV_FILE="$env_file" diff --git a/scripts/install.sh b/scripts/install.sh index 72429ef0..7fe91df9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -16,7 +16,7 @@ # set -Eeuo pipefail -SCRIPT_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_DIR="$(cd -P "${BASH_SOURCE[0]%/*}" && pwd)" REPO_ROOT="$(cd -P "$SCRIPT_DIR/.." && pwd)" expand_home_path_early() { case "${1:-}" in @@ -161,8 +161,9 @@ cleanup() { } trap cleanup EXIT -while [ "$#" -gt 0 ]; do - case "$1" in +if ! { [ "${FORGE_INSTALL_LIBRARY:-0}" = "1" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; }; then + while [ "$#" -gt 0 ]; do + case "$1" in --skip-ollama) SKIP_OLLAMA=1 ;; @@ -207,9 +208,10 @@ while [ "$#" -gt 0 ]; do *) die "Unknown option: $1" ;; - esac - shift -done + esac + shift + done +fi case "$SERVICE_MODE" in auto|native|docker) ;; @@ -1422,6 +1424,7 @@ managed_local_migrations_enabled() { } resolve_managed_local_admin() { + local psql_bin sudo_bin runuser_bin MANAGED_LOCAL_ADMIN_MODE="" MANAGED_LOCAL_ADMIN_SOCKET="" MANAGED_LOCAL_ADMIN_USER="" @@ -1441,14 +1444,18 @@ resolve_managed_local_admin() { MANAGED_LOCAL_ADMIN_MODE="current" MANAGED_LOCAL_ADMIN_USER="$(psql -d postgres -tAc 'SELECT current_user' | tr -d '[:space:]')" MANAGED_LOCAL_ADMIN_SOCKET="$(psql -d postgres -tAc 'SHOW unix_socket_directories' | tr -d '[:space:]' | cut -d, -f1)" - elif [ "$OS_NAME" = "Linux" ] && id postgres >/dev/null 2>&1 && trusted_linux_tool sudo >/dev/null 2>&1 && "$(trusted_linux_tool sudo)" -n -u postgres psql -d postgres -tAc 'SELECT 1' >/dev/null 2>&1; then + elif [ "$OS_NAME" = "Linux" ] && id postgres >/dev/null 2>&1 \ + && sudo_bin="$(trusted_linux_tool sudo)" && psql_bin="$(trusted_linux_tool psql)" \ + && "$sudo_bin" -n -u postgres "$psql_bin" -d postgres -tAc 'SELECT 1' >/dev/null 2>&1; then MANAGED_LOCAL_ADMIN_MODE="sudo" MANAGED_LOCAL_ADMIN_USER="postgres" - MANAGED_LOCAL_ADMIN_SOCKET="$("$(trusted_linux_tool sudo)" -n -u postgres psql -d postgres -tAc 'SHOW unix_socket_directories' | tr -d '[:space:]' | cut -d, -f1)" - elif [ "$OS_NAME" = "Linux" ] && id postgres >/dev/null 2>&1 && trusted_linux_tool runuser >/dev/null 2>&1 && "$(trusted_linux_tool runuser)" -u postgres -- psql -d postgres -tAc 'SELECT 1' >/dev/null 2>&1; then + MANAGED_LOCAL_ADMIN_SOCKET="$("$sudo_bin" -n -u postgres "$psql_bin" -d postgres -tAc 'SHOW unix_socket_directories' | tr -d '[:space:]' | cut -d, -f1)" + elif [ "$OS_NAME" = "Linux" ] && id postgres >/dev/null 2>&1 \ + && runuser_bin="$(trusted_linux_tool runuser)" && psql_bin="$(trusted_linux_tool psql)" \ + && "$runuser_bin" -u postgres -- "$psql_bin" -d postgres -tAc 'SELECT 1' >/dev/null 2>&1; then MANAGED_LOCAL_ADMIN_MODE="runuser" MANAGED_LOCAL_ADMIN_USER="postgres" - MANAGED_LOCAL_ADMIN_SOCKET="$("$(trusted_linux_tool runuser)" -u postgres -- psql -d postgres -tAc 'SHOW unix_socket_directories' | tr -d '[:space:]' | cut -d, -f1)" + MANAGED_LOCAL_ADMIN_SOCKET="$("$runuser_bin" -u postgres -- "$psql_bin" -d postgres -tAc 'SHOW unix_socket_directories' | tr -d '[:space:]' | cut -d, -f1)" else return 1 fi @@ -1504,7 +1511,8 @@ trusted_linux_tool() { [ "$owner" = root ] && [ -n "$mode" ] || continue [ $((8#$mode & 022)) -eq 0 ] || continue while [ "$resolved" != / ]; do - resolved="$(/usr/bin/dirname "$resolved")" + resolved="${resolved%/*}" + [ -n "$resolved" ] || resolved=/ owner="$(/usr/bin/stat -c '%U' "$resolved" 2>/dev/null || true)" mode="$(/usr/bin/stat -c '%a' "$resolved" 2>/dev/null || true)" [ "$owner" = root ] && [ -n "$mode" ] && [ $((8#$mode & 022)) -eq 0 ] || break 2 @@ -1516,19 +1524,28 @@ trusted_linux_tool() { } prepare_trusted_linux_migration_toolchain() { + local tool directory path_result="" [ "$OS_NAME" = Linux ] || die "Elevated managed local migrations are only supported on Linux." MANAGED_LOCAL_BASH="$(trusted_linux_tool bash)" || die "Could not find a root-owned non-writable bash for elevated managed migrations." MANAGED_LOCAL_NODE="$(trusted_linux_tool node)" || die "Could not find a root-owned non-writable node for elevated managed migrations." MANAGED_LOCAL_NPM="$(trusted_linux_tool npm)" || die "Could not find a root-owned non-writable npm for elevated managed migrations." MANAGED_LOCAL_NPX="$(trusted_linux_tool npx)" || die "Could not find a root-owned non-writable npx for elevated managed migrations." - MANAGED_LOCAL_PATH="$(dirname "$MANAGED_LOCAL_NODE"):$(dirname "$MANAGED_LOCAL_NPM"):$(dirname "$MANAGED_LOCAL_NPX"):$(dirname "$MANAGED_LOCAL_BASH"):/usr/local/bin:/usr/bin:/bin" + for tool in "$MANAGED_LOCAL_NODE" "$MANAGED_LOCAL_NPM" "$MANAGED_LOCAL_NPX" "$MANAGED_LOCAL_BASH"; do + directory="${tool%/*}" + case ":$path_result:" in + *":$directory:"*) ;; + *) path_result="${path_result:+$path_result:}$directory" ;; + esac + done + MANAGED_LOCAL_PATH="$path_result" } run_managed_local_migration_as_runuser() { local description="$1" stage="$2" - local controlled_path + local controlled_path runuser_bin prepare_trusted_linux_migration_toolchain controlled_path="$MANAGED_LOCAL_PATH" + runuser_bin="$(trusted_linux_tool runuser)" || die "Could not find a root-owned non-writable runuser for elevated managed migrations." # `runuser --preserve-environment` is needed so the child receives the # process-only socket/admin context without serialising a credential into an @@ -1544,7 +1561,7 @@ run_managed_local_migration_as_runuser() { done PATH="$controlled_path" export PATH - run "$description" "$(trusted_linux_tool runuser)" -u postgres --preserve-environment -- "$MANAGED_LOCAL_BASH" -c 'cd "$1"; case "$2" in + run "$description" "$runuser_bin" -u postgres --preserve-environment -- "$MANAGED_LOCAL_BASH" -c 'cd "$1"; case "$2" in release) npm run protocol:bootstrap-epic-172-release-roles ;; migrate-0025) npx tsx scripts/ci/migrate-through-0025.ts ;; s3) npm run protocol:bootstrap-epic-172-s3-release-owner ;; diff --git a/scripts/test-install-managed-migrations.sh b/scripts/test-install-managed-migrations.sh index 647ae0df..da2fc5c2 100755 --- a/scripts/test-install-managed-migrations.sh +++ b/scripts/test-install-managed-migrations.sh @@ -104,12 +104,12 @@ run_runuser_environment_case() { printf 'DATABASE_URL=postgresql://forge:%s@localhost:5432/forge\n' "$TEST_SECRET" > "$case_dir/forge.env" cat > "$case_dir/bin/runuser" <<'EOF' #!/bin/bash -marker_dir="$(dirname "$FORGE_ENV_FILE")" -if printenv | cut -d= -f1 | grep -Fx 'UNRELATED_SECRET_SENTINEL' >/dev/null; then +marker_dir="${FORGE_ENV_FILE%/*}" +if [ -n "${UNRELATED_SECRET_SENTINEL+x}" ]; then printf 'unrelated-secret-leaked\n' > "$marker_dir/runuser-result" exit 1 fi -printenv | cut -d= -f1 | sort > "$marker_dir/runuser-environment-names" +compgen -e > "$marker_dir/runuser-environment-names" printf 'clean\n' > "$marker_dir/runuser-result" while [ "$1" != "--" ]; do shift; done shift @@ -164,18 +164,46 @@ assert_not_contains "$TEST_SECRET" "$CASE_DIR/runuser-environment-names" run_shadow_refusal_case() { local case_dir="$TEST_ROOT/shadow-refusal" mkdir -p "$case_dir/bin" - printf '#!/bin/bash\nprintf shadow-used > "$FORGE_SHADOW_MARKER"\nexit 0\n' > "$case_dir/bin/sudo" - chmod +x "$case_dir/bin/sudo" - INSTALLER="$INSTALLER" FORGE_SHADOW_MARKER="$case_dir/marker" PATH="$case_dir/bin:$PATH" /bin/bash -c ' + local command + for command in dirname node npm npx bash sudo runuser; do + printf '#!/bin/bash\nprintf shadow-used > "$FORGE_SHADOW_MARKER"\nexit 0\n' > "$case_dir/bin/$command" + done + chmod +x "$case_dir/bin"/* + set +e + INSTALLER="$INSTALLER" FORGE_SHADOW_MARKER="$case_dir/marker" FORGE_SHADOW_DIRECTORY="$case_dir/bin" PATH="$case_dir/bin:$PATH" /bin/bash -c ' FORGE_INSTALL_LIBRARY=1 source "$INSTALLER" - resolved="$(trusted_linux_tool sudo 2>/dev/null || true)" - [ "$resolved" != "$PWD/bin/sudo" ] + OS_NAME=Linux + prepare_trusted_linux_migration_toolchain + [ ":$MANAGED_LOCAL_PATH:" != *":$FORGE_SHADOW_DIRECTORY:"* ] ' > "$case_dir/stdout" 2> "$case_dir/stderr" + local shadow_status=$? + set -e + if [ "$shadow_status" -eq 0 ]; then + assert_not_contains "$case_dir/bin" "$case_dir/stdout" + fi [ ! -e "$case_dir/marker" ] || fail 'caller PATH shadow reached the privileged sudo resolver' } run_shadow_refusal_case +run_library_argument_case() { + local case_dir="$TEST_ROOT/library-arguments" + mkdir -p "$case_dir" + INSTALLER="$INSTALLER" /bin/bash -c ' + set -- postgresql://argument-must-not-reach-installer + FORGE_INSTALL_LIBRARY=1 source "$INSTALLER" + [ "$#" -eq 1 ] + ( + set -- + FORGE_INSTALL_LIBRARY=1 source "$INSTALLER" + [ "$#" -eq 0 ] + declare -F run_managed_local_migration_sequence >/dev/null + ) + ' > "$case_dir/stdout" 2> "$case_dir/stderr" || fail 'library source did not ignore caller positional arguments' +} + +run_library_argument_case + if rg -F "$TEST_SECRET" "$TEST_ROOT" --glob '!forge.env' >/dev/null; then fail 'test sentinel leaked outside the local environment fixture' fi From 3a6221c10c594c9b8e601ad7ce5fa420523a1faf Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:44:13 +0800 Subject: [PATCH 5/5] Close migration tool resolution race --- scripts/install.sh | 49 ++++++++++++++-------- scripts/test-install-managed-migrations.sh | 38 +++++++++++++++++ 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 7fe91df9..2341605c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1500,25 +1500,40 @@ run_managed_local_migration_stage() { esac } +trusted_linux_path_chain() { + local path="$1" owner mode + while :; do + [ -e "$path" ] || [ -L "$path" ] || return 1 + owner="$(/usr/bin/stat -c '%U' "$path" 2>/dev/null || true)" + [ "$owner" = root ] || return 1 + if [ ! -L "$path" ]; then + mode="$(/usr/bin/stat -c '%a' "$path" 2>/dev/null || true)" + [ -n "$mode" ] && [ $((8#$mode & 022)) -eq 0 ] || return 1 + fi + [ "$path" = / ] && return 0 + path="${path%/*}" + [ -n "$path" ] || path=/ + done +} + +canonicalize_trusted_linux_candidate() { + /usr/bin/readlink -f "$1" 2>/dev/null +} + +trusted_linux_candidate() { + local candidate="$1" canonical + [ -x "$candidate" ] || return 1 + trusted_linux_path_chain "$candidate" || return 1 + canonical="$(canonicalize_trusted_linux_candidate "$candidate")" || return 1 + [ -n "$canonical" ] && [ -f "$canonical" ] || return 1 + trusted_linux_path_chain "$canonical" || return 1 + printf '%s\n' "$canonical" +} + trusted_linux_tool() { - local tool="$1" candidate resolved owner mode + local tool="$1" candidate for candidate in "/usr/local/sbin/$tool" "/usr/local/bin/$tool" "/usr/sbin/$tool" "/usr/bin/$tool" "/sbin/$tool" "/bin/$tool"; do - [ -x "$candidate" ] || continue - resolved="$(/usr/bin/readlink -f "$candidate" 2>/dev/null || true)" - [ -n "$resolved" ] && [ -f "$resolved" ] || continue - owner="$(/usr/bin/stat -c '%U' "$resolved" 2>/dev/null || true)" - mode="$(/usr/bin/stat -c '%a' "$resolved" 2>/dev/null || true)" - [ "$owner" = root ] && [ -n "$mode" ] || continue - [ $((8#$mode & 022)) -eq 0 ] || continue - while [ "$resolved" != / ]; do - resolved="${resolved%/*}" - [ -n "$resolved" ] || resolved=/ - owner="$(/usr/bin/stat -c '%U' "$resolved" 2>/dev/null || true)" - mode="$(/usr/bin/stat -c '%a' "$resolved" 2>/dev/null || true)" - [ "$owner" = root ] && [ -n "$mode" ] && [ $((8#$mode & 022)) -eq 0 ] || break 2 - done - printf '%s\n' "$(/usr/bin/readlink -f "$candidate")" - return 0 + trusted_linux_candidate "$candidate" && return 0 done return 1 } diff --git a/scripts/test-install-managed-migrations.sh b/scripts/test-install-managed-migrations.sh index da2fc5c2..4bbd5172 100755 --- a/scripts/test-install-managed-migrations.sh +++ b/scripts/test-install-managed-migrations.sh @@ -204,6 +204,44 @@ run_library_argument_case() { run_library_argument_case +run_trusted_candidate_toc_tou_case() { + local case_dir="$TEST_ROOT/trusted-candidate-toc-tou" + mkdir -p "$case_dir" + : > "$case_dir/target-one" + : > "$case_dir/target-two" + chmod +x "$case_dir/target-one" "$case_dir/target-two" + ln -s "$case_dir/target-one" "$case_dir/candidate" + INSTALLER="$INSTALLER" \ + FORGE_TOC_TOU_CANDIDATE="$case_dir/candidate" \ + FORGE_TOC_TOU_TARGET_ONE="$case_dir/target-one" \ + FORGE_TOC_TOU_TARGET_TWO="$case_dir/target-two" \ + FORGE_TOC_TOU_COUNT="$case_dir/canonicalizer-count" \ + /bin/bash -c ' + FORGE_INSTALL_LIBRARY=1 source "$INSTALLER" + trusted_linux_path_chain() { return 0; } + canonicalize_trusted_linux_candidate() { + printf x >> "$FORGE_TOC_TOU_COUNT" + if [ "$(<"$FORGE_TOC_TOU_COUNT")" = x ]; then + printf "%s\\n" "$FORGE_TOC_TOU_TARGET_ONE" + else + printf "%s\\n" "$FORGE_TOC_TOU_TARGET_TWO" + fi + } + resolved="$(trusted_linux_candidate "$FORGE_TOC_TOU_CANDIDATE")" + [ "$resolved" = "$FORGE_TOC_TOU_TARGET_ONE" ] + [ "$(<"$FORGE_TOC_TOU_COUNT")" = x ] + trusted_linux_path_chain() { + [ "$1" = "$FORGE_TOC_TOU_CANDIDATE" ] && [ -L "$1" ] && return 1 + return 0 + } + rm -f "$FORGE_TOC_TOU_COUNT" + ! trusted_linux_candidate "$FORGE_TOC_TOU_CANDIDATE" + [ ! -e "$FORGE_TOC_TOU_COUNT" ] + ' > "$case_dir/stdout" 2> "$case_dir/stderr" || fail 'trusted candidate canonicalization was not single-resolution and fail-closed' +} + +run_trusted_candidate_toc_tou_case + if rg -F "$TEST_SECRET" "$TEST_ROOT" --glob '!forge.env' >/dev/null; then fail 'test sentinel leaked outside the local environment fixture' fi