mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
Bound signed manual-update bootstrap downloads (#3243)
**Bound signed manual-update bootstrap downloads** Keep both bootstrap requests within curl's connection and whole-request deadlines, without ambient retry settings. Arm cleanup before allocation so a second mktemp failure cannot strand the first input. Exercise the emitted helper's signed success, partial download failures, allocation failures and signature rejection without native or production execution. **Keep native recovery proof aligned with bounded manual downloads** Admit only the manual helper's exact new curl envelope in the existing closed transport fixture. Require a signed emitted-helper round trip and reject altered deadlines, retries and TLS flags through the registered installer suite; do not widen the adapter or execute a native journey. **Preserve each configuration-backup safety control's deadline** The containing installer suite exhausted a single 30-second Python-class budget after eleven controls and killed the last. Use the existing serial discovery runner with all twelve required methods, retaining each control's 30-second bound and the existing inner shell bounds rather than treating cumulative class time as a safety deadline. --- Fleet candidate `20261025T062429Z-delivery-trust` from the `delivery-trust` lane. Reviewed tip `b6618c19a3f2925fff019db2651d885ad1ede9b8` on `main`. [How Pulse handles triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
@@ -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,
|
||||
|
||||
+7
-3
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user