diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index e10cc6d730..c93b445949 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -6364,6 +6364,35 @@ The helper forwards both parsed channels explicitly, including the stable default for an existing file, so the containing installer cannot reinterpret that same configuration through its older saved-preference reader. +The helper fetches its installer and signature separately with ten-second +connection and sixty-second whole-request limits, without implicit retries. +Curl's first `-q` option disables ambient configuration, which otherwise could +introduce retries beyond those limits. Cleanup is armed before either temporary +input is allocated, so failure of the second allocation also removes the first. +A failed or partial download stops before signature verification or installer +execution; signature failure still forbids execution. Saved settings remain +untouched, and failed bootstrap cannot print the successful-update message. + +`TestRootManualUpdateDownloadDeadlines` executes the emitted helper with a real +SSH verifier and modelled transport/allocation faults. Six controls cover both +partial-download timeouts, both allocation failures, invalid signatures and a +successful signed bootstrap. They require the deadline/config-isolation options, +no request replay, temporary-input cleanup and saved-configuration preservation. +They do not measure live network deadlines or establish installed/native recovery. +The native systemd proof's closed transport adapter separately admits this exact +manual-helper envelope alongside the existing unattended envelope. Its registered +portable control runs the actual emitted helper and real verifier through that +adapter, and rejects altered deadlines, retries or TLS options without online +fallback. This preserves the hosted manual-recovery consumer, not native acceptance. + +Configuration-backup publication verification also uses the existing serial +per-control runner. All twelve current methods are required, all discovered +future methods run, and each retains the thirty-second execution bound. A +single cumulative suite budget must not cut off the final safety control. +The Python controls' own ten-second shell bounds and every backup assertion +remain unchanged; no host-contention diagnosis or production deadline change +follows from a cumulative harness timeout. + `TestManualUpdateChannelAdmission` generates and executes the actual helper against local transport fixtures and genuinely signed installer/sidecar bytes. It checks final arguments and the actual installer channel-selection functions, diff --git a/install.sh b/install.sh index 5e0eba4dd6..32af9051e7 100644 --- a/install.sh +++ b/install.sh @@ -4353,14 +4353,18 @@ if [[ \${#helper_args[@]} -gt 0 ]]; then fi echo "Updating Pulse..." +tmp_installer="" +tmp_signature="" +trap 'rm -f "\$tmp_installer" "\$tmp_signature"' EXIT tmp_installer=\$(mktemp /tmp/pulse-update-installer.XXXXXX) tmp_signature=\$(mktemp /tmp/pulse-update-installer.sig.XXXXXX) -trap 'rm -f "\$tmp_installer" "\$tmp_signature"' EXIT -if ! curl -fsSL "\$INSTALLER_URL" -o "\$tmp_installer"; then +# Do not let ambient curl retries or a stalled bootstrap hold update forever. +# No installer/service change is allowed until both bounded downloads verify. +if ! curl -q -fsSL --connect-timeout 10 --max-time 60 "\$INSTALLER_URL" -o "\$tmp_installer"; then echo "Failed to download Pulse installer from \$INSTALLER_URL" >&2 exit 1 fi -if ! curl -fsSL "\$INSTALLER_SIG_URL" -o "\$tmp_signature"; then +if ! curl -q -fsSL --connect-timeout 10 --max-time 60 "\$INSTALLER_SIG_URL" -o "\$tmp_signature"; then echo "Failed to download Pulse installer signature from \$INSTALLER_SIG_URL" >&2 exit 1 fi diff --git a/scripts/installtests/auto_update_systemd.py b/scripts/installtests/auto_update_systemd.py index 424d440d10..f4aca331e5 100644 --- a/scripts/installtests/auto_update_systemd.py +++ b/scripts/installtests/auto_update_systemd.py @@ -65,8 +65,11 @@ if pathlib.Path(sys.argv[0]).name == "mktemp": # arbitrary curl option parser. The short form is used by isolated controls. flags = ["--disable", "-fsSL", "--connect-timeout", "5", "--max-time", "60", "--proto", "=https", "--proto-redir", "=https"] +manual_flags = ["-q", "-fsSL", "--connect-timeout", "10", "--max-time", "60"] if args[:len(flags)] == flags: args = args[len(flags):] +elif args[:len(manual_flags)] == manual_flags: + args = args[len(manual_flags):] elif args[:1] == ["-fsSL"]: args = args[1:] else: diff --git a/scripts/installtests/manual_update_channel_test.go b/scripts/installtests/manual_update_channel_test.go index 13efa987f5..a68ea572ee 100644 --- a/scripts/installtests/manual_update_channel_test.go +++ b/scripts/installtests/manual_update_channel_test.go @@ -169,7 +169,8 @@ while [[ $# -gt 0 ]]; do case "$1" in -o) dest="$2"; shift 2 ;; https://github.com/rcourtman/Pulse/releases/latest/download/install.sh*) url="$1"; shift ;; - -fsSL) shift ;; + -q|-fsSL) shift ;; + --connect-timeout|--max-time) shift 2 ;; *) exit 98 ;; esac done diff --git a/scripts/installtests/pulse_auto_update_test.go b/scripts/installtests/pulse_auto_update_test.go index 4bb8b1287f..0a832c7ba2 100644 --- a/scripts/installtests/pulse_auto_update_test.go +++ b/scripts/installtests/pulse_auto_update_test.go @@ -33,6 +33,7 @@ func TestAutoUpdateNativeSystemdHarnessContracts(t *testing.T) { for _, name := range []string{ "test_prepare_owns_generated_signer_mode_before_first_real_signature", "test_signer_creation_refuses_adoption_wrong_type_owner_or_failed_mode", + "test_transport_accepts_emitted_manual_helper_and_rejects_option_drift", "test_real_verifier_accepts_fixture_and_refuses_tampering", "test_phase_cannot_accept_wrong_bytes_state_mask_persistence_or_backup", "test_managed_process_identity_follows_served_readiness_and_rejects_residuals", diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index b55a32a7ed..b2530ed6d1 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -2751,12 +2751,176 @@ fi } func TestRootInstallScriptConfigBackupPublicationPreservesPriorSnapshots(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - cmd := exec.CommandContext(ctx, "python3", repoFile("scripts", "tests", "test_server_config_backups.py"), "-v") - cmd.Env = append(os.Environ(), "PULSE_INSTALLER_UNDER_TEST="+repoFile("install.sh")) - out, err := cmd.CombinedOutput() + // A cumulative suite budget is not an individual backup-control deadline. + // Keep all discovered controls serial and retain the existing per-control bound. + runPythonFixtureControls(t, repoFile("scripts", "tests", "test_server_config_backups.py"), "ConfigBackupPublication", []string{ + "test_two_successful_same_second_copies_are_separate_complete_snapshots", + "test_failed_partial_copy_does_not_mutate_or_delete_a_previous_snapshot", + "test_allocation_failure_stops_before_copy_or_rotation", + "test_headroom_failure_creates_no_stage_and_preserves_prior_snapshot", + "test_failed_publication_does_not_rotate_or_publish_a_snapshot", + "test_exit_zero_skipped_publication_preserves_file_directory_and_link", + "test_current_snapshot_survives_rotation_even_after_clock_moves_backwards", + "test_incomplete_copy_is_not_another_invocations_rotation_candidate", + "test_discard_removes_only_this_invocations_new_snapshot", + "test_missing_config_clears_stale_ownership_without_deleting_prior_snapshot", + "test_read_only_config_parent_uses_the_existing_hardened_unit_fallback", + "test_every_update_entry_stops_on_failed_backup_even_in_an_or_list", + }, 30*time.Second, "PULSE_INSTALLER_UNDER_TEST="+repoFile("install.sh")) +} + +// Run the emitted helper and real SSH verifier. Curl exits are modelled, so +// these controls prove deadline arguments and safe failure, not a live outage. +func TestRootManualUpdateDownloadDeadlines(t *testing.T) { + sourcePath := os.Getenv("PULSE_INSTALLER_UNDER_TEST") + if sourcePath == "" { + sourcePath = filepath.Join("..", "..", "install.sh") + } + source, err := filepath.Abs(sourcePath) if err != nil { - t.Fatalf("configuration backup publication controls: %v\n%s", err, out) + t.Fatal(err) + } + assets := t.TempDir() + key := filepath.Join(assets, "key") + installer := filepath.Join(assets, "install.sh") + if err := os.WriteFile(installer, []byte("#!/bin/bash\nprintf '%s\\n' EXECUTED \"$@\" > \"$FIXTURE/executed\"\n"), 0600); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"-q", "-t", "ed25519", "-N", "", "-f", key}, + {"-q", "-Y", "sign", "-f", key, "-n", "pulse-install", installer}, + } { + if out, err := exec.Command("ssh-keygen", args...).CombinedOutput(); err != nil { + t.Fatalf("fixture signature: %v\n%s", err, out) + } + } + publicKey, err := os.ReadFile(key + ".pub") + if err != nil { + t.Fatal(err) + } + for _, fault := range []string{"success", "installer_timeout", "signature_timeout", "bad_signature", "first_allocation", "second_allocation"} { + t.Run(fault, func(t *testing.T) { + dir := t.TempDir() + for _, leaf := range []string{"config", "temporary", "tools"} { + if err := os.Mkdir(filepath.Join(dir, leaf), 0700); err != nil { + t.Fatal(err) + } + } + config := filepath.Join(dir, "config", "system.json") + const configBytes = `{"updateChannel":"stable","preserve":"fixture"}` + if err := os.WriteFile(config, []byte(configBytes), 0600); err != nil { + t.Fatal(err) + } + helper := filepath.Join(dir, "update") + env := append(os.Environ(), "FIXTURE="+dir, "FIXTURE_ASSETS="+assets, "FAULT="+fault, + "INSTALLER_SOURCE="+source, "FIXTURE_PUBLIC_KEY="+strings.TrimSpace(string(publicKey)), + "PULSE_INSTALL_DIR="+dir, "PULSE_CONFIG_DIR="+filepath.Join(dir, "config"), + "PULSE_UPDATE_HELPER_PATH="+helper, "PULSE_PROFILE_PATH="+filepath.Join(dir, "profile"), + "PULSE_BASHRC_PATH="+filepath.Join(dir, "bashrc")) + generate := exec.Command("bash", "-c", `source "$INSTALLER_SOURCE" +PINNED_RELEASE_SSH_PUBLIC_KEY="$FIXTURE_PUBLIC_KEY" +setup_update_command`) + generate.Env = env + if out, err := generate.CombinedOutput(); err != nil { + t.Fatalf("generate helper: %v\n%s", err, out) + } + tools := map[string]string{ + "mktemp": `#!/bin/bash +set -eu +count=0 +[[ ! -f "$FIXTURE/allocations" ]] || read -r count < "$FIXTURE/allocations" +count=$((count + 1)) +printf '%s\n' "$count" > "$FIXTURE/allocations" +[[ "$FAULT:$count" != first_allocation:1 && "$FAULT:$count" != second_allocation:2 ]] || exit 1 +exec /usr/bin/mktemp "$FIXTURE/temporary/input.XXXXXX" +`, + "curl": `#!/bin/bash +set -eu +[[ "$1" == -q ]] || { echo 'ambient curl configuration is not disabled' >&2; exit 99; } +url=""; dest=""; connect=""; maximum="" +while [[ $# -gt 0 ]]; do + case "$1" in + --connect-timeout) connect="$2"; shift 2 ;; + --max-time) maximum="$2"; shift 2 ;; + -o) dest="$2"; shift 2 ;; + -q|-fsSL) shift ;; + https://github.com/rcourtman/Pulse/releases/latest/download/install.sh*) url="$1"; shift ;; + *) exit 98 ;; + esac +done +printf '%s %s %s\n' "$url" "$connect" "$maximum" >> "$FIXTURE/transport" +[[ "$connect" == 10 && "$maximum" == 60 ]] || { echo 'missing bootstrap download deadline' >&2; exit 99; } +case "$url" in + *.sshsig) phase=signature; source="$FIXTURE_ASSETS/install.sh.sig" ;; + */install.sh) phase=installer; source="$FIXTURE_ASSETS/install.sh" ;; + *) exit 97 ;; +esac +# Retain partial writes too: neither failure may execute the installer or +# leave a partial bootstrap input behind. +if [[ "$FAULT" == "${phase}_timeout" ]]; then + printf partial > "$dest" + exit 28 +fi +if [[ "$phase:$FAULT" == signature:bad_signature ]]; then + printf invalid > "$dest" +else + cp "$source" "$dest" +fi +`, + } + for name, body := range tools { + if err := os.WriteFile(filepath.Join(dir, "tools", name), []byte(body), 0700); err != nil { + t.Fatal(err) + } + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "bash", helper) + cmd.Env = append(env, "PATH="+filepath.Join(dir, "tools")+string(os.PathListSeparator)+os.Getenv("PATH")) + out, runErr := cmd.CombinedOutput() + calls, callErr := os.ReadFile(filepath.Join(dir, "executed")) + if fault == "success" { + if runErr != nil || callErr != nil || string(calls) != "EXECUTED\n--stable\n" || !strings.Contains(string(out), "Update complete!") { + t.Fatalf("valid signed installer: %v, %v, %q\n%s", runErr, callErr, calls, out) + } + } else { + if runErr == nil || !os.IsNotExist(callErr) || strings.Contains(string(out), "Update complete!") { + t.Fatalf("failed bootstrap reported/executed success: %v, %v\n%s", runErr, callErr, out) + } + if strings.Contains(fault, "timeout") { + want := "Failed to download Pulse installer" + if fault == "signature_timeout" { + want += " signature" + } + if !strings.Contains(string(out), want) { + t.Fatalf("missing download failure diagnostic: %s", out) + } + } + } + if ctx.Err() != nil { + t.Fatalf("helper exceeded fixture bound: %v\n%s", ctx.Err(), out) + } + entries, err := os.ReadDir(filepath.Join(dir, "temporary")) + if err != nil || len(entries) != 0 { + t.Errorf("bootstrap left temporary inputs: %v, %v", entries, err) + } + got, err := os.ReadFile(config) + if err != nil || string(got) != configBytes { + t.Fatalf("bootstrap changed saved configuration: %v, %q", err, got) + } + transport, err := os.ReadFile(filepath.Join(dir, "transport")) + wantRequests := 2 + if fault == "installer_timeout" { + wantRequests = 1 + } else if strings.Contains(fault, "allocation") { + wantRequests = 0 + } + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + if got := strings.Count(string(transport), "\n"); got != wantRequests { + t.Errorf("bootstrap requests = %d, want %d (no retries): %q", got, wantRequests, transport) + } + }) } } diff --git a/scripts/tests/test_auto_update_systemd.py b/scripts/tests/test_auto_update_systemd.py index b934388e40..4ca21ea7f7 100644 --- a/scripts/tests/test_auto_update_systemd.py +++ b/scripts/tests/test_auto_update_systemd.py @@ -145,6 +145,52 @@ class NativeContracts(unittest.TestCase): self.assertEqual(result.returncode, 97) self.assertFalse((root / "refused").exists()) + def test_transport_accepts_emitted_manual_helper_and_rejects_option_drift(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for leaf in ("tools", "tmp", "config"): + (root / leaf).mkdir(mode=0o700) + for name in ("curl", "mktemp"): + path = root / "tools" / name + path.write_text(native.TRANSPORT) + path.chmod(0o755) + installer = root / "installer" + installer.write_text('#!/bin/bash\nprintf "%s\\n" "$@" > "$PULSE_NATIVE_ROOT/executed"\n') + key = root / "key" + subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], + check=True, capture_output=True) + subprocess.run(["ssh-keygen", "-q", "-Y", "sign", "-f", str(key), + "-n", "pulse-install", str(installer)], check=True, capture_output=True) + Path(str(installer) + ".sig").rename(root / "installer.sshsig") + helper = root / "update" + env = {**os.environ, "PULSE_NATIVE_ROOT": str(root), + "FIXTURE_KEY": (root / "key.pub").read_text().strip(), + "PULSE_INSTALL_DIR": str(root), "PULSE_CONFIG_DIR": str(root / "config"), + "PULSE_UPDATE_HELPER_PATH": str(helper), + "PULSE_PROFILE_PATH": str(root / "profile"), + "PULSE_BASHRC_PATH": str(root / "bashrc"), + "PATH": str(root / "tools") + os.pathsep + os.environ["PATH"]} + generated = subprocess.run(["bash", "-c", 'source "$1"; ' + 'PINNED_RELEASE_SSH_PUBLIC_KEY="$FIXTURE_KEY"; setup_update_command', + "fixture", str(ROOT / "install.sh")], env=env, capture_output=True) + self.assertEqual(generated.returncode, 0, generated.stdout + generated.stderr) + result = subprocess.run(["bash", str(helper), "--version", "v0.0.1"], + env=env, capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual((root / "executed").read_text(), "--version\nv0.0.1\n") + self.assertEqual(list((root / "tmp").iterdir()), []) + flags = ["-q", "-fsSL", "--connect-timeout", "10", "--max-time", "60"] + url = "https://github.com/rcourtman/Pulse/releases/latest/download/install.sh" + for changed in (flags[1:], [*flags, "--insecure"], [*flags, "--retry", "3"], + [arg if arg != "60" else "61" for arg in flags], + [arg if arg != "10" else "11" for arg in flags]): + with self.subTest(flags=changed): + target = root / "refused" + result = subprocess.run([str(root / "tools/curl"), *changed, url, "-o", str(target)], + env=env, capture_output=True, timeout=10) + self.assertEqual(result.returncode, 97) + self.assertFalse(target.exists()) + def test_real_verifier_accepts_fixture_and_refuses_tampering(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory)