Files
pulse/scripts/installtests/pulse_auto_update_test.go
T
pulse-triage[bot] 8d8a1790ad Refuse incomplete updater manager observations
Keep stderr and trailing blank records in service-state and running-PID admission. Incomplete reads must not authorise installation, restart, rollback or update success. Require production caller and real-process controls for both read boundaries; preserve existing recovery, trust and deadlines.

Change-source: pulse-maintainer
2026-10-14 11:58:09 +01:00

1355 lines
55 KiB
Go

package installtests
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// The native CI runner is checked without host-manager access. These controls
// exercise its exact recipe, admission, signature/archive and recovery wiring;
// the hosted runner still supplies the actual systemd/real-Pulse verdict.
func TestAutoUpdateNativeSystemdHarnessContracts(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "python3", repoFile("scripts", "tests", "test_auto_update_systemd.py"), "-v")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("native updater harness controls: %v\n%s", err, out)
}
results, err := nativeHarnessControlResults(string(out))
if err != nil {
t.Fatalf("incomplete native-harness result inventory: %v\n%s", err, out)
}
// The Linux server's ELF staging cannot execute on a macOS agent runner.
// Its connected check must pass on both Linux architectures; macOS skips
// only that check, never the portable signing/admission/recovery oracles.
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_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",
"test_runtime_readback_rejects_post_comparison_drift",
"test_primary_command_survives_successful_or_failed_cleanup",
"test_primary_timeout_is_not_replaced_by_cleanup_exit",
"test_assertion_and_cleanup_only_errors_do_not_invent_primary_command",
"test_successful_receipt_retains_no_failure_record",
"test_connected_linux_control_is_required_without_macho_or_validator_bypass",
} {
if results[name] != "ok" {
t.Fatalf("required portable native-harness control %s did not pass:\n%s", name, out)
}
}
const connected = "test_connected_signed_archive_and_recovery_controls_without_host_manager"
verdict := "ok"
if runtime.GOOS != "linux" {
verdict = "skipped 'Linux server ELF execution; portable controls remain required on macOS'"
}
if results[connected] != verdict {
t.Fatalf("required Linux connected control has wrong platform verdict:\n%s", out)
}
}
// unittest writes its result to stderr while the native recipe prints phase
// markers to stdout. CombinedOutput can put those markers between the method
// header and its verdict. Require a complete, unique verdict rather than a
// single adjacent header/"ok" string; do not mistake a phase for completion.
func nativeHarnessControlResults(output string) (map[string]string, error) {
results := make(map[string]string)
current := ""
for _, line := range strings.Split(output, "\n") {
if name, tail, found := strings.Cut(line, " (__main__.NativeContracts."); found && strings.HasPrefix(name, "test_") {
if current != "" {
return nil, fmt.Errorf("missing verdict for %s", current)
}
if _, duplicate := results[name]; duplicate {
return nil, fmt.Errorf("duplicate result for %s", name)
}
verdict, exact := strings.CutPrefix(tail, name+") ... ")
if !exact {
return nil, fmt.Errorf("mismatched method identity for %s", name)
}
current = name
line = verdict
}
if current != "" && (line == "ok" || strings.HasPrefix(line, "skipped ")) {
results[current] = line
current = ""
}
}
if current != "" {
return nil, fmt.Errorf("missing verdict for %s", current)
}
return results, nil
}
func TestAutoUpdateNativeControlResultsRetainInterleavedPhaseVerdicts(t *testing.T) {
const header = "test_fixture (__main__.NativeContracts.test_fixture) ... "
for _, verdict := range []string{"ok", "skipped 'Linux-only fixture'"} {
for _, phases := range []string{"", "NATIVE_UPDATER_PHASE=forward\nNATIVE_UPDATER_PHASE=manual-reverse\n"} {
out := header + phases + verdict + "\n\nRan 1 test\n\nOK\n"
results, err := nativeHarnessControlResults(out)
if err != nil || len(results) != 1 || results["test_fixture"] != verdict {
t.Fatalf("lost exact verdict %q with phase output %q: %v, %v", verdict, phases, results, err)
}
}
}
for _, out := range []string{
header + "NATIVE_UPDATER_PHASE=forward\n\nOK\n",
header + "ok\n" + header + "ok\n",
header + "NATIVE_UPDATER_PHASE=forward\n" + "test_next (__main__.NativeContracts.test_next) ... ok\n",
"test_fixture (__main__.NativeContracts.test_other) ... ok\n",
} {
if _, err := nativeHarnessControlResults(out); err == nil {
t.Fatalf("accepted incomplete/duplicate/mismatched method results: %q", out)
}
}
}
// The standalone helper passes an explicit name to the installer. Exercise
// that producer and its real entry/handoff, not just installer discovery.
func TestAutoUpdateServiceDiscoveryAndInstallerHandoff(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "python3", repoFile("scripts", "tests", "test_server_service_discovery.py"), "-v",
"ServiceDiscovery.test_fresh_host_no_matching_unit_files_is_an_empty_inventory",
"ServiceDiscovery.test_first_inventory_failure_is_not_replaced_by_silent_absence",
"ServiceDiscovery.test_only_silent_exit_one_is_admitted_as_absence",
"ServiceDiscovery.test_blank_line_output_is_not_a_silent_failed_inventory",
"ServiceDiscovery.test_automatic_helper_preserves_exact_names_and_explicit_instances",
"ServiceDiscovery.test_automatic_helper_cannot_short_read_the_installer_handoff",
"ServiceDiscovery.test_automatic_helper_refuses_before_backup_or_installer_even_in_or_list",
"ServiceDiscovery.test_automatic_helper_timeout_stops_before_service_observation",
// A fresh host has no Pulse unit files and systemd then exits 1 with no output; the
// updater's own discovery must read that as absence, not as a failed inventory.
"ServiceDiscovery.test_fresh_host_no_matching_unit_files_is_an_empty_inventory",
"ServiceDiscovery.test_real_systemctl_on_a_host_without_pulse_units_selects_the_default")
cmd.Env = append(os.Environ(),
"PULSE_INSTALLER_UNDER_TEST="+repoFile("install.sh"),
"PULSE_AUTO_UPDATER_UNDER_TEST="+repoFile("scripts", "pulse-auto-update.sh"))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("unattended service discovery/installer handoff controls: %v\n%s", err, out)
}
}
func TestAutoUpdateDownloadFailures(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "python3", repoFile("scripts", "tests", "test_auto_update_service_state.py"), "-v", "UpdateDownloads")
cmd.Env = append(os.Environ(), "PULSE_AUTO_UPDATER_UNDER_TEST="+repoFile("scripts", "pulse-auto-update.sh"))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("complete updater download-failure controls: %v\n%s", err, out)
}
for _, name := range []string{
"test_both_complete_downloads_keep_the_exact_signed_installer_handoff",
"test_partial_failed_downloads_never_execute_or_mutate_the_install",
} {
if !strings.Contains(string(out), fmt.Sprintf("%s (", name)) {
t.Fatalf("missing updater download control %s:\n%s", name, out)
}
}
}
// Service-state admission is independent of the signed/metadata transaction
// matrix below; no native service or published input is used by this fixture.
func TestAutoUpdateServiceStateFailsClosed(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "python3", repoFile("scripts", "tests", "test_auto_update_service_state.py"), "-v", "UpdateServiceState")
cmd.Env = append(os.Environ(), "PULSE_AUTO_UPDATER_UNDER_TEST="+repoFile("scripts", "pulse-auto-update.sh"))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("unattended service state/rollback controls: %v\n%s", err, out)
}
// Keep the manager/collector controls part of actual package acceptance, not just
// an optional Python check. A green fixture with these methods removed
// would otherwise silently lose early-failure and final-line coverage.
required := []string{
"test_manager_diagnostics_refuse_initial_update_and_restart",
"test_manager_diagnostics_after_install_retain_backup_without_mutation",
"test_manager_diagnostics_after_stop_retain_backup_without_restart",
"test_success_cannot_claim_unverified_running_executable",
"test_rollback_keeps_backup_until_restored_running_executable_matches",
"test_early_collector_failure_survives_later_success_and_drains_installer",
"test_unterminated_final_installer_line_is_collected_and_its_failure_retained",
}
for _, name := range required {
passed := false
for _, line := range strings.Split(string(out), "\n") {
if strings.HasPrefix(line, name+" (") && strings.HasSuffix(line, " ... ok") {
passed = true
}
}
if !passed {
t.Fatalf("required updater collector control %s did not pass:\n%s", name, out)
}
}
}
// Keep the full real-sink matrix mandatory, but give it its own existing-size
// bound rather than exhausting the service-state suite's manager deadlines.
func TestAutoUpdateLogSinkFailures(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "python3", repoFile("scripts", "tests", "test_auto_update_service_state.py"), "-v", "UpdateLogSinks")
cmd.Env = append(os.Environ(), "PULSE_AUTO_UPDATER_UNDER_TEST="+repoFile("scripts", "pulse-auto-update.sh"))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("unattended real-log sink controls: %v\n%s", err, out)
}
for _, name := range []string{
"test_logger_double_keeps_complete_argument_frames_and_exit_sequence",
"test_real_log_retains_each_sink_failure_in_conditional_calls",
"test_real_logger_failure_drains_installer_and_preserves_recovery",
} {
passed := false
for _, line := range strings.Split(string(out), "\n") {
if strings.HasPrefix(line, name+" (") && strings.HasSuffix(line, " ... ok") {
passed = true
}
}
if !passed {
t.Fatalf("required real-log sink control %s did not pass:\n%s", name, out)
}
}
}
// A real owned process retains its old executable after its disk path changes.
// This tests Linux process identity, not a native systemd/application journey.
func TestAutoUpdateRunningExecutableIdentity(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("Linux /proc executable identity; portable transaction controls remain required")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "python3", repoFile("scripts", "tests", "test_auto_update_service_state.py"), "-v", "RunningExecutable")
cmd.Env = append(os.Environ(), "PULSE_AUTO_UPDATER_UNDER_TEST="+repoFile("scripts", "pulse-auto-update.sh"))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("running executable identity controls: %v\n%s", err, out)
}
for _, name := range []string{
"test_actual_process_not_replaced_disk_path_is_checked",
"test_complete_stable_main_pid_tuple_is_required",
"test_manager_diagnostics_refuse_either_running_identity_read",
"test_identity_read_timeout_is_bounded_and_owned_process_is_reaped",
} {
passed := false
for _, line := range strings.Split(string(out), "\n") {
if strings.HasPrefix(line, name+" (") && strings.HasSuffix(line, " ... ok") {
passed = true
}
}
if !passed {
t.Fatalf("required running executable control %s did not pass:\n%s", name, out)
}
}
}
func extractAutoUpdateServiceFunctions(t *testing.T) string {
t.Helper()
var functions []string
for _, name := range []string{"read_update_service_state", "start_update_service_if_stopped", "stop_update_service_for_rollback", "wait_for_service_active", "ensure_service_restarted"} {
functions = append(functions, extractAutoUpdateFunction(t, name))
}
// These pure-shell controls have simulated service state and script
// executables, not a managed process. The registered Python controls
// exercise real owned /proc entries and fail-closed transaction wiring;
// containing source CI separately checks actual systemd/Pulse processes.
functions = append(functions, "verify_update_running_executable() { return 0; }")
return strings.Join(functions, "\n")
}
// TestIsPrereleaseTagRecognizesPrereleases asserts that any semver tag with
// a hyphen after the patch component is flagged as a prerelease. This is the
// pattern used for Pulse RCs (`-rc.N`), betas (`-beta.N`), alphas, nightlies,
// etc. The unattended updater must refuse these on the stable channel.
func TestIsPrereleaseTagRecognizesPrereleases(t *testing.T) {
cases := []string{
"v6.0.0-rc.2",
"v5.1.28-rc.3",
"v5.1.28-beta.1",
"v5.1.28-alpha.2",
"v5.1.28-nightly",
"v5.1.28-pre.1",
"6.0.0-rc.2",
"5.1.28-beta",
}
for _, tag := range cases {
tag := tag
t.Run(tag, func(t *testing.T) {
script := extractAutoUpdateFunction(t, "is_prerelease_tag") + `
is_prerelease_tag "` + tag + `"
echo $?
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
if got := strings.TrimSpace(string(out)); got != "0" {
t.Fatalf("is_prerelease_tag %q returned %q, want 0 (is prerelease)", tag, got)
}
})
}
}
// TestIsPrereleaseTagAcceptsStableTags asserts that plain MAJOR.MINOR.PATCH
// tags - the only thing the unattended stable updater should ever accept -
// are not flagged.
func TestIsPrereleaseTagAcceptsStableTags(t *testing.T) {
cases := []string{
"v5.1.28",
"v5.1.27",
"v6.0.0",
"v6.1.0",
"5.1.28",
"6.0.0",
}
for _, tag := range cases {
tag := tag
t.Run(tag, func(t *testing.T) {
script := extractAutoUpdateFunction(t, "is_prerelease_tag") + `
is_prerelease_tag "` + tag + `"
echo $?
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
if got := strings.TrimSpace(string(out)); got != "1" {
t.Fatalf("is_prerelease_tag %q returned %q, want 1 (stable)", tag, got)
}
})
}
}
// TestIsPrereleaseTagFailsClosed asserts that empty or malformed input is
// treated as a prerelease - callers should refuse to act on it rather than
// proceed with an unrecognized tag.
func TestIsPrereleaseTagFailsClosed(t *testing.T) {
cases := []string{
"",
"latest",
"v5",
"v5.1",
"garbage",
"main",
}
for _, tag := range cases {
tag := tag
name := tag
if name == "" {
name = "empty"
}
t.Run(name, func(t *testing.T) {
script := extractAutoUpdateFunction(t, "is_prerelease_tag") + `
is_prerelease_tag "` + tag + `"
echo $?
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
if got := strings.TrimSpace(string(out)); got != "0" {
t.Fatalf("is_prerelease_tag %q returned %q, want 0 (fail closed)", tag, got)
}
})
}
}
// TestPerformUpdateRefusesPrereleaseTag asserts the defense-in-depth guard
// at the perform_update entry: even if every caller above this point thinks
// the tag is safe, perform_update itself refuses prerelease-shaped tags
// before touching the installer. This is the last line of defense against
// the 2026-04-16 incident recurring.
func TestPerformUpdateRefusesPrereleaseTag(t *testing.T) {
script := `
set -u
GITHUB_REPO="rcourtman/Pulse"
INSTALL_DIR="/tmp/pulse-nonexistent-test-install"
log() { echo "[$1] $2"; }
detect_service_name() { echo pulse; }
get_current_version() { echo v5.1.27; }
# Pure-function manager doubles stay in this shell; the Python fixture uses
# real timeout with an external confined manager to test actual deadlines.
timeout() { case "$1" in -k) shift 2 ;; --kill-after=*) shift ;; esac; shift; "$@"; }
systemctl() { return 0; }
# If any of these get called, the guard has failed.
curl() { echo "FAIL: curl invoked during refused update"; exit 99; }
mktemp() { echo "FAIL: mktemp invoked during refused update"; exit 99; }
verify_release_signature() { echo "FAIL: signature verify invoked"; exit 99; }
` + extractAutoUpdateFunction(t, "is_prerelease_tag") + `
` + extractAutoUpdateFunction(t, "resolve_install_script_url") + `
` + extractAutoUpdateFunction(t, "perform_update") + `
if perform_update v6.0.0-rc.2; then
echo "ACCEPTED"
else
echo "REFUSED"
fi
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
got := string(out)
if !strings.Contains(got, "REFUSED") {
t.Fatalf("perform_update did not refuse prerelease tag:\n%s", got)
}
if strings.Contains(got, "FAIL:") {
t.Fatalf("perform_update invoked installer machinery despite refusal:\n%s", got)
}
if !strings.Contains(got, "Refusing to install prerelease") {
t.Fatalf("perform_update did not log refusal reason:\n%s", got)
}
}
// TestGetLatestStableVersionRefusesPrereleaseFlag asserts that even if the
// tag name looks stable, a `"prerelease": true` flag in the API response
// causes the function to return empty. This was the suspected trigger for
// the 2026-04-16 jump from 5.1.27 to 6.0.0-rc.2.
func TestGetLatestStableVersionRefusesPrereleaseFlag(t *testing.T) {
script := `
set -u
GITHUB_REPO="rcourtman/Pulse"
log() { echo "[$1] $2" >&2; }
# Stub curl: respond with JSON where prerelease=true.
curl() {
# Drain flags; we don't care what was requested.
cat <<'EOF'
{
"tag_name": "v5.1.28",
"prerelease": true,
"name": "Pulse v5.1.28"
}
EOF
}
` + extractAutoUpdateFunction(t, "is_prerelease_tag") + `
` + extractAutoUpdateFunction(t, "version_greater_than") + `
` + extractAutoUpdateFunction(t, "pick_highest_stable_tag") + `
` + extractAutoUpdateFunction(t, "get_latest_stable_version") + `
result=$(get_latest_stable_version)
echo "RESULT=[$result]"
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
got := string(out)
if !strings.Contains(got, "RESULT=[]") {
t.Fatalf("get_latest_stable_version returned a tag despite prerelease=true:\n%s", got)
}
}
// TestGetLatestStableVersionRefusesPrereleaseShapedTag asserts the shape
// check: if the API says `prerelease=false` but the tag itself is clearly a
// prerelease (e.g. during a brief window where the flag was miswritten),
// the function still refuses.
func TestGetLatestStableVersionRefusesPrereleaseShapedTag(t *testing.T) {
script := `
set -u
GITHUB_REPO="rcourtman/Pulse"
log() { echo "[$1] $2" >&2; }
curl() {
cat <<'EOF'
{
"tag_name": "v6.0.0-rc.2",
"prerelease": false,
"name": "Pulse v6.0.0-rc.2"
}
EOF
}
` + extractAutoUpdateFunction(t, "is_prerelease_tag") + `
` + extractAutoUpdateFunction(t, "version_greater_than") + `
` + extractAutoUpdateFunction(t, "pick_highest_stable_tag") + `
` + extractAutoUpdateFunction(t, "get_latest_stable_version") + `
result=$(get_latest_stable_version)
echo "RESULT=[$result]"
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
got := string(out)
if !strings.Contains(got, "RESULT=[]") {
t.Fatalf("get_latest_stable_version returned prerelease-shaped tag v6.0.0-rc.2 despite flag=false:\n%s", got)
}
}
// TestGetLatestStableVersionAcceptsStable asserts the happy path: a stable
// tag with prerelease=false flows through unchanged.
func TestGetLatestStableVersionAcceptsStable(t *testing.T) {
script := `
set -u
GITHUB_REPO="rcourtman/Pulse"
log() { echo "[$1] $2" >&2; }
curl() {
cat <<'EOF'
{
"tag_name": "v5.1.28",
"draft": false,
"prerelease": false,
"name": "Pulse v5.1.28"
}
EOF
}
` + extractAutoUpdateFunction(t, "is_prerelease_tag") + `
` + extractAutoUpdateFunction(t, "version_greater_than") + `
` + extractAutoUpdateFunction(t, "pick_highest_stable_tag") + `
` + extractAutoUpdateFunction(t, "get_latest_stable_version") + `
result=$(get_latest_stable_version)
echo "RESULT=[$result]"
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
got := string(out)
if !strings.Contains(got, "RESULT=[v5.1.28]") {
t.Fatalf("get_latest_stable_version did not return expected stable tag:\n%s", got)
}
}
// TestGetLatestStableVersionPrefersHighestVersion asserts that the updater
// selects the highest stable version from the release list rather than the
// most recently created one. This repo interleaves v5-line maintenance
// releases with v6 releases (v5.1.36 was created the day before v6.0.5), so
// GitHub's created_at ordering — and its /releases/latest pointer — can name
// an older version line, which would strand v6 installs until the next v6
// release ships. Draft releases, metadata-flagged prereleases, and
// prerelease- or non-semver-shaped tags (helm-chart-*) must all be ignored.
func TestGetLatestStableVersionPrefersHighestVersion(t *testing.T) {
script := `
set -u
GITHUB_REPO="rcourtman/Pulse"
log() { echo "[$1] $2" >&2; }
# Stub curl: the /releases list in GitHub's created_at ordering, with the
# v5-line release first. Any fallback call (e.g. /releases/latest) would
# return the same list and must not be needed for selection.
curl() {
cat <<'EOF'
[
{
"tag_name": "v5.1.37",
"draft": false,
"prerelease": false
},
{
"tag_name": "v9.9.9",
"draft": true,
"prerelease": false
},
{
"tag_name": "helm-chart-6.0.5",
"draft": false,
"prerelease": true
},
{
"tag_name": "v6.0.5",
"draft": false,
"prerelease": false
},
{
"tag_name": "v6.0.5-rc.4",
"draft": false,
"prerelease": true
},
{
"tag_name": "v6.0.4",
"draft": false,
"prerelease": false
}
]
EOF
}
` + extractAutoUpdateFunction(t, "is_prerelease_tag") + `
` + extractAutoUpdateFunction(t, "version_greater_than") + `
` + extractAutoUpdateFunction(t, "pick_highest_stable_tag") + `
` + extractAutoUpdateFunction(t, "get_latest_stable_version") + `
result=$(get_latest_stable_version)
echo "RESULT=[$result]"
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
got := string(out)
if !strings.Contains(got, "RESULT=[v6.0.5]") {
t.Fatalf("get_latest_stable_version = %s, want v6.0.5 (highest stable, not most recently created)", got)
}
}
// TestInstalledBinaryIsPulseProGuard proves the unattended updater's Pro
// guard: a binary whose --version reports "Pulse Pro" is detected (so main()
// exits before any community download can replace it), while the community
// "Pulse vX" binary is not flagged. The community auto-update flow must never
// reinstall the public build over a paid Pro runtime.
func TestInstalledBinaryIsPulseProGuard(t *testing.T) {
fn := extractAutoUpdateFunction(t, "installed_binary_is_pulse_pro")
cases := []struct {
name string
versionLine string
wantPro bool
}{
{"pro binary detected", "Pulse Pro v6.0.5", true},
{"community binary not flagged", "Pulse v6.0.5", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
installDir := t.TempDir()
binDir := filepath.Join(installDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
stub := "#!/bin/bash\necho \"" + tc.versionLine + "\"\n"
if err := os.WriteFile(filepath.Join(binDir, "pulse"), []byte(stub), 0o755); err != nil {
t.Fatalf("write pulse stub: %v", err)
}
script := fn + "\nINSTALL_DIR=" + installDir + "\nif installed_binary_is_pulse_pro; then echo pro; else echo community; fi\n"
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
got := strings.TrimSpace(string(out))
want := "community"
if tc.wantPro {
want = "pro"
}
if got != want {
t.Fatalf("installed_binary_is_pulse_pro on %q = %q, want %q", tc.versionLine, got, want)
}
})
}
t.Run("missing binary is unknown", func(t *testing.T) {
script := fn + "\nINSTALL_DIR=" + t.TempDir() + "\nif installed_binary_is_pulse_pro; then echo pro; else echo STATUS=$?; fi\n"
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
if got := strings.TrimSpace(string(out)); got != "STATUS=2" {
t.Fatalf("missing binary must not establish community edition, got %q", got)
}
})
}
// TestPerformUpdateRestartsServiceWhenInstallerFails asserts the #1630
// guarantee: when the downloaded installer exits non-zero (for example a
// write to a read-only path aborting it under errexit) after it has already
// stopped the service, perform_update must restore the backup AND leave the
// service running. The generated pulse-update.service gates on
// ExecCondition=systemctl is-active, so a service left stopped would also
// disable every future unattended run.
func TestPerformUpdateRestartsServiceWhenInstallerFails(t *testing.T) {
script := `
set -uo pipefail
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
GITHUB_REPO="rcourtman/Pulse"
INSTALL_DIR="$TMP/opt/pulse"
CONFIG_DIR="$TMP/etc/pulse"
mkdir -p "$INSTALL_DIR/bin" "$CONFIG_DIR"
printf 'v5.1.24\n' > "$INSTALL_DIR/VERSION"
printf '#!/usr/bin/env bash\necho v5.1.24\n' > "$INSTALL_DIR/bin/pulse"
chmod +x "$INSTALL_DIR/bin/pulse"
export INSTALL_DIR
log() { echo "[$1] ${*:2}"; }
detect_service_name() { echo pulse; }
get_current_version() { tr -d '\r\n' < "$INSTALL_DIR/VERSION"; }
verify_release_signature() { return 0; }
sleep() { :; }
# curl writes the installer / signature to the -o target; the fake installer
# fails outright, like the real one aborting on a read-only filesystem after
# it has stopped the service.
curl() {
local out="" prev="" arg
for arg in "$@"; do
if [[ "$prev" == "-o" ]]; then out="$arg"; fi
prev="$arg"
done
if [[ -n "$out" ]]; then
case "$out" in
*.sig.*) printf 'dummy-signature\n' > "$out" ;;
*) printf '#!/usr/bin/env bash\necho inactive > "${PULSE_INSTALL_DIR}/service-state"\nexit 1\n' > "$out" ;;
esac
fi
return 0
}
# Service is running at the was-active capture, then down (the installer
# stopped it before failing) until an explicit start/restart.
IS_ACTIVE_CALLS=0
printf 'active\n' > "$INSTALL_DIR/service-state"
SERVICE_UP="no"
STARTS=0
# Pure-function manager doubles stay in this shell; the Python fixture uses
# real timeout with an external confined manager to test actual deadlines.
timeout() { case "$1" in -k) shift 2 ;; --kill-after=*) shift ;; esac; shift; "$@"; }
systemctl() {
case "$1" in
show) printf 'LoadState=loaded\nActiveState=%s\n' "$(cat "$INSTALL_DIR/service-state")"; return 0 ;;
is-active)
((IS_ACTIVE_CALLS += 1))
if (( IS_ACTIVE_CALLS == 1 )); then return 0; fi
[[ "$SERVICE_UP" == "yes" ]] && return 0 || return 1
;;
start|restart)
((STARTS += 1))
SERVICE_UP="yes"
printf 'active\n' > "$INSTALL_DIR/service-state"
return 0
;;
stop) SERVICE_UP="no"; printf 'inactive\n' > "$INSTALL_DIR/service-state"; return 0 ;;
esac
return 1
}
` + extractAutoUpdateFunction(t, "is_prerelease_tag") + `
` + extractAutoUpdateFunction(t, "resolve_install_script_url") + `
` + extractAutoUpdateServiceFunctions(t) + `
` + extractAutoUpdateFunction(t, "download_update_asset") + `
` + extractAutoUpdateFunction(t, "perform_update") + `
if perform_update v5.1.25; then
echo "RESULT:succeeded"
else
echo "RESULT:failed"
fi
echo "STARTS:$STARTS"
echo "SERVICE:$SERVICE_UP"
echo "VERSION:$(tr -d '\r\n' < "$INSTALL_DIR/VERSION")"
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
got := string(out)
for _, want := range []string{"RESULT:failed", "SERVICE:yes", "VERSION:v5.1.24"} {
if !strings.Contains(got, want) {
t.Fatalf("missing %q in perform_update installer-failure output:\n%s", want, got)
}
}
if strings.Contains(got, "STARTS:0") {
t.Fatalf("service was never restarted after installer failure:\n%s", got)
}
}
// TestEnsureServiceRestartedHonorsPriorServiceState asserts the RETURN-trap
// backstop behind the #1630 fix: a service that was inactive before the
// update is left alone, and a service that was active is started again.
func TestEnsureServiceRestartedHonorsPriorServiceState(t *testing.T) {
script := `
set -uo pipefail
SERVICE_UP="no"
STARTS=0
# Pure-function manager doubles stay in this shell; the Python fixture uses
# real timeout with an external confined manager to test actual deadlines.
timeout() { case "$1" in -k) shift 2 ;; --kill-after=*) shift ;; esac; shift; "$@"; }
systemctl() {
case "$1" in
show) printf 'LoadState=loaded\n'; if [[ "$SERVICE_UP" == yes ]]; then echo ActiveState=active; else echo ActiveState=inactive; fi; return 0 ;;
is-active) [[ "$SERVICE_UP" == "yes" ]] && return 0 || return 1 ;;
start|restart) ((STARTS += 1)); SERVICE_UP="yes"; return 0 ;;
esac
return 1
}
sleep() { :; }
log() { echo "[$1] ${*:2}"; }
` + extractAutoUpdateServiceFunctions(t) + `
ensure_service_restarted pulse false || echo "INACTIVE_PATH_FAILED"
echo "STARTS_AFTER_INACTIVE:$STARTS"
ensure_service_restarted pulse true || echo "ACTIVE_PATH_FAILED"
echo "STARTS_AFTER_ACTIVE:$STARTS"
echo "SERVICE:$SERVICE_UP"
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
got := string(out)
for _, want := range []string{"STARTS_AFTER_INACTIVE:0", "STARTS_AFTER_ACTIVE:1", "SERVICE:yes"} {
if !strings.Contains(got, want) {
t.Fatalf("missing %q in ensure_service_restarted output:\n%s", want, got)
}
}
for _, reject := range []string{"INACTIVE_PATH_FAILED", "ACTIVE_PATH_FAILED"} {
if strings.Contains(got, reject) {
t.Fatalf("ensure_service_restarted must always return 0 (it runs in a RETURN trap under set -e):\n%s", got)
}
}
}
// TestPerformUpdateDoesNotLeakReturnTrap asserts the #2128 regression: a
// RETURN trap is not scoped to the function that installs it, so
// perform_update's trap must disarm itself on its first invocation. If it
// leaks, it re-runs when a later function returns; the function-local
// installer_tmp/signature_tmp are gone by then, and under `set -u` the updater
// aborts with "installer_tmp: unbound variable" after a *successful* update,
// leaving pulse-update.service failed even though the install worked.
func TestPerformUpdateDoesNotLeakReturnTrap(t *testing.T) {
script := `
set -uo pipefail
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
GITHUB_REPO="rcourtman/Pulse"
INSTALL_DIR="$TMP/opt/pulse"
CONFIG_DIR="$TMP/etc/pulse"
mkdir -p "$INSTALL_DIR/bin" "$CONFIG_DIR"
printf 'v5.1.24\n' > "$INSTALL_DIR/VERSION"
printf '#!/usr/bin/env bash\necho v5.1.24\n' > "$INSTALL_DIR/bin/pulse"
chmod +x "$INSTALL_DIR/bin/pulse"
export INSTALL_DIR
export FAKE_NEW_VERSION="v5.1.25"
log() { echo "[$1] ${*:2}"; }
detect_service_name() { echo pulse; }
get_current_version() { tr -d '\r\n' < "$INSTALL_DIR/VERSION"; }
verify_release_signature() { return 0; }
sleep() { :; }
# curl writes the installer / signature to the -o target; the fake installer
# bumps VERSION to the new version, simulating a successful install.
curl() {
local out="" prev="" arg
for arg in "$@"; do
if [[ "$prev" == "-o" ]]; then out="$arg"; fi
prev="$arg"
done
if [[ -n "$out" ]]; then
case "$out" in
*.sig.*) printf 'dummy-signature\n' > "$out" ;;
*)
cat > "$out" <<'INSTALLER'
#!/usr/bin/env bash
printf '%s\n' "${FAKE_NEW_VERSION}" > "${PULSE_INSTALL_DIR}/VERSION"
exit 0
INSTALLER
;;
esac
fi
return 0
}
# Pulse was running and stays running, so perform_update succeeds.
# Pure-function manager doubles stay in this shell; the Python fixture uses
# real timeout with an external confined manager to test actual deadlines.
timeout() { case "$1" in -k) shift 2 ;; --kill-after=*) shift ;; esac; shift; "$@"; }
systemctl() {
if [[ "$1" == show ]]; then printf 'LoadState=loaded\nActiveState=active\n'; fi
return 0
}
` + extractAutoUpdateFunction(t, "is_prerelease_tag") + `
` + extractAutoUpdateFunction(t, "resolve_install_script_url") + `
` + extractAutoUpdateServiceFunctions(t) + `
` + extractAutoUpdateFunction(t, "download_update_asset") + `
` + extractAutoUpdateFunction(t, "perform_update") + `
if perform_update v5.1.25; then echo "RESULT:succeeded"; else echo "RESULT:failed"; fi
echo "LEAKED:$(trap -p RETURN)"
# The real caller (main) returns after perform_update; a leaked trap would
# re-run here with the locals gone.
caller() { return 0; }
caller
echo "AFTER_CALLER"
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
got := string(out)
for _, want := range []string{"RESULT:succeeded", "LEAKED:\n", "AFTER_CALLER"} {
if !strings.Contains(got, want) {
t.Fatalf("missing %q in perform_update success output:\n%s", want, got)
}
}
if strings.Contains(got, "unbound variable") {
t.Fatalf("perform_update leaked its RETURN trap; a later function return aborted under set -u:\n%s", got)
}
}
// Exercise the complete sourced updater in its real conditional-call context.
// Only downloads and systemd are replaced; file copies, comparisons, renames,
// executable version reads and SSH signature verification are real and local.
func TestAutoUpdateBackupTransaction(t *testing.T) {
if _, err := exec.LookPath("ssh-keygen"); err != nil {
t.Skip("ssh-keygen unavailable for signed offline installer fixture")
}
type scenario struct {
name, fault, outcome, layout string
noVersion, retained, success, preflight bool
}
cases := []scenario{
{name: "backup_directory_failure", fault: "backup_dir", preflight: true},
{name: "binary_snapshot_failure", fault: "backup_binary", preflight: true},
{name: "version_snapshot_failure", fault: "backup_version", preflight: true},
{name: "corrupt_snapshot", fault: "backup_corrupt", preflight: true},
{name: "installer_file_failure", fault: "installer_tmp", preflight: true},
{name: "signature_file_failure", fault: "signature_tmp", preflight: true},
{name: "missing_binary", fault: "no_binary", preflight: true},
{name: "symlink_binary", fault: "binary_symlink", preflight: true},
{name: "symlink_version", fault: "version_symlink", preflight: true},
{name: "installer_download_failure", fault: "download_installer", preflight: true},
{name: "signature_download_failure", fault: "download_signature", preflight: true},
{name: "invalid_signature", fault: "invalid_signature", preflight: true},
{name: "installer_failure", outcome: "failure"},
{name: "wrong_version", outcome: "mismatch"},
{name: "failed_version_probe", outcome: "probe_failure"},
{name: "empty_version_probe", outcome: "probe_empty"},
{name: "unrecognised_version_brand", outcome: "probe_unknown"},
{name: "different_edition", outcome: "probe_pro"},
{name: "nonexecutable_replacement", outcome: "probe_nonexec"},
{name: "missing_replacement", outcome: "probe_missing"},
{name: "version_on_later_line", outcome: "probe_later_line"},
{name: "invalid_version_suffix", outcome: "probe_trailing"},
{name: "valid_build_details", outcome: "probe_details", success: true},
{name: "valid_unprefixed_version", outcome: "probe_unprefixed", success: true},
{name: "original_version_absent", outcome: "failure", noVersion: true},
{name: "extended_metadata", fault: "xattrs", outcome: "failure"},
{name: "legacy_layout_migration", outcome: "failure", layout: "legacy"},
{name: "both_binary_paths", outcome: "failure", layout: "both"},
{name: "new_destination_symlink", outcome: "symlink"},
{name: "rollback_stop_failure", fault: "stop", outcome: "failure", retained: true},
{name: "rollback_allocation_failure", fault: "restore_tmp", outcome: "failure", retained: true},
{name: "rollback_copy_failure", fault: "restore_copy", outcome: "failure", retained: true},
{name: "corrupt_rollback_copy", fault: "restore_corrupt", outcome: "failure", retained: true},
{name: "rollback_rename_failure", fault: "restore_rename", outcome: "failure", retained: true},
{name: "rollback_rename_noop", fault: "restore_noop", outcome: "failure", retained: true},
{name: "rollback_nonregular_destination", fault: "restore_noop", outcome: "fifo", retained: true},
{name: "rollback_destination_changed", fault: "restore_changed", outcome: "failure", retained: true},
{name: "version_restore_failure", fault: "version_rename", outcome: "failure", retained: true},
{name: "new_destination_directory", outcome: "directory", retained: true},
{name: "successful_update", success: true},
}
t.Run("new_service_failure", func(t *testing.T) {
runAutoUpdateTransactionScenario(t, "", "service_failure", "", "true", false, false, false, false)
})
for _, tc := range cases {
for _, active := range []string{"true", "false"} {
t.Run(tc.name+"/prior_active_"+active, func(t *testing.T) {
runAutoUpdateTransactionScenario(t, tc.fault, tc.outcome, tc.layout, active, tc.noVersion, tc.preflight, tc.retained, tc.success)
})
}
}
t.Run("restored_service_start_failure", func(t *testing.T) {
runAutoUpdateTransactionScenario(t, "restart", "failure", "", "true", false, false, true, false)
})
t.Run("restored_service_liveness_failure", func(t *testing.T) {
runAutoUpdateTransactionScenario(t, "restart_liveness", "failure", "", "true", false, false, true, false)
})
}
func runAutoUpdateTransactionScenario(t *testing.T, fault, outcome, layout, active string, noVersion, preflight, retained, success bool) {
t.Helper()
if fault == "xattrs" {
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 unavailable for extended metadata fixture")
}
}
updater, err := filepath.Abs(repoFile("scripts", "pulse-auto-update.sh"))
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
script := `
set -euo pipefail
source "$UPDATER_UNDER_TEST"
# This signed transaction matrix simulates the manager. Running-process
# identity is exercised by the real owned-process Python controls, not here.
verify_update_running_executable() { return 0; }
INSTALL_DIR="$FIXTURE_DIR/install"
CONFIG_DIR="$FIXTURE_DIR/config"
mkdir -p "$INSTALL_DIR/bin" "$CONFIG_DIR" "$FIXTURE_DIR/tmp" "$FIXTURE_DIR/original"
printf 'operator settings unchanged\n' > "$CONFIG_DIR/system.json"
printf 'local identity unchanged\n' > "$CONFIG_DIR/agent-id"
printf 'victim unchanged\n' > "$FIXTURE_DIR/victim"
printf '#!/usr/bin/env bash\nprintf "Pulse v6.4.5\\n"\n' > "$FIXTURE_DIR/original/pulse-bin"
printf '#!/usr/bin/env bash\nprintf "Pulse v6.4.4\\n"\n' > "$FIXTURE_DIR/original/pulse-legacy"
printf 'v6.4.5\n' > "$FIXTURE_DIR/original/VERSION"
chmod 751 "$FIXTURE_DIR/original/pulse-bin" "$FIXTURE_DIR/original/pulse-legacy"
chmod 640 "$FIXTURE_DIR/original/VERSION"
case "$LAYOUT" in
legacy) cp -p "$FIXTURE_DIR/original/pulse-bin" "$INSTALL_DIR/pulse" ;;
both) cp -p "$FIXTURE_DIR/original/pulse-bin" "$INSTALL_DIR/bin/pulse"; cp -p "$FIXTURE_DIR/original/pulse-legacy" "$INSTALL_DIR/pulse" ;;
*) cp -p "$FIXTURE_DIR/original/pulse-bin" "$INSTALL_DIR/bin/pulse" ;;
esac
if [[ "$NO_VERSION" == false ]]; then cp -p "$FIXTURE_DIR/original/VERSION" "$INSTALL_DIR/VERSION"; fi
case "$FAULT" in
no_binary) rm -f "$INSTALL_DIR/bin/pulse" ;;
binary_symlink) mv "$INSTALL_DIR/bin/pulse" "$FIXTURE_DIR/original/linked-binary"; ln -s "$FIXTURE_DIR/original/linked-binary" "$INSTALL_DIR/bin/pulse" ;;
version_symlink) rm "$INSTALL_DIR/VERSION"; ln -s "$FIXTURE_DIR/original/VERSION" "$INSTALL_DIR/VERSION" ;;
esac
if [[ "$FAULT" == xattrs ]]; then
if python3 - "$INSTALL_DIR" <<'METADATA'
import errno, os, sys
try:
for name in ('bin/pulse', 'VERSION'):
os.setxattr(os.path.join(sys.argv[1], name), 'user.pulse-updater-test', b'original extended metadata')
except AttributeError:
sys.exit(77)
except OSError as exc:
if exc.errno in (errno.ENOTSUP, errno.EOPNOTSUPP):
sys.exit(77)
raise
METADATA
then :
else
metadata_status=$?
if [[ "$metadata_status" == 77 ]]; then echo TRANSACTION_METADATA_UNAVAILABLE; exit 0; fi
exit "$metadata_status"
fi
fi
printf '%s\n' "$PRIOR_ACTIVE" > "$FIXTURE_DIR/active"
export FIXTURE_DIR OUTCOME FAULT
cat > "$FIXTURE_DIR/installer" <<'INSTALLER'
set -euo pipefail
printf 'executed\n' > "$FIXTURE_DIR/installer-executed"
printf 'false\n' > "$FIXTURE_DIR/active"
[[ "$FAULT" != stop ]] || printf 'true\n' > "$FIXTURE_DIR/active"
mkdir -p "$PULSE_INSTALL_DIR/bin"
new_version=v6.5.0
[[ "$OUTCOME" != mismatch ]] || new_version=v6.4.6
printf '#!/usr/bin/env bash\nprintf "Pulse %s\\n"\n' "$new_version" > "$PULSE_INSTALL_DIR/bin/pulse"
chmod 700 "$PULSE_INSTALL_DIR/bin/pulse"
if [[ -f "$PULSE_INSTALL_DIR/pulse" ]]; then printf 'changed legacy binary\n' > "$PULSE_INSTALL_DIR/pulse"; fi
printf '%s\n' "$new_version" > "$PULSE_INSTALL_DIR/VERSION"
chmod 600 "$PULSE_INSTALL_DIR/VERSION"
# Keep the target VERSION sidecar even when the signed installer leaves an
# unusable executable. The real post-install probe must trigger full rollback.
case "$OUTCOME" in
probe_failure) printf '#!/bin/bash\necho "Pulse v6.5.0"; exit 1\n' > "$PULSE_INSTALL_DIR/bin/pulse" ;;
probe_empty) printf '#!/bin/bash\nexit 0\n' > "$PULSE_INSTALL_DIR/bin/pulse" ;;
probe_unknown) printf '#!/bin/bash\necho "Other v6.5.0 private-detail"\n' > "$PULSE_INSTALL_DIR/bin/pulse" ;;
probe_pro) printf '#!/bin/bash\necho "Pulse Pro v6.5.0"\n' > "$PULSE_INSTALL_DIR/bin/pulse" ;;
probe_nonexec) chmod 600 "$PULSE_INSTALL_DIR/bin/pulse" ;;
probe_missing) rm "$PULSE_INSTALL_DIR/bin/pulse" ;;
probe_later_line) printf '#!/bin/bash\nprintf "Other\\nPulse v6.5.0\\n"\n' > "$PULSE_INSTALL_DIR/bin/pulse" ;;
probe_trailing) printf '#!/bin/bash\necho "Pulse v6.5.0-other!"\n' > "$PULSE_INSTALL_DIR/bin/pulse" ;;
probe_details) printf '#!/bin/bash\nprintf "Pulse v6.5.0\\nBuilt: yesterday\\n"\n' > "$PULSE_INSTALL_DIR/bin/pulse" ;;
probe_unprefixed) printf '#!/bin/bash\necho "Pulse 6.5.0"\n' > "$PULSE_INSTALL_DIR/bin/pulse" ;;
esac
if [[ "$OUTCOME" == symlink ]]; then
rm "$PULSE_INSTALL_DIR/bin/pulse"
ln -s "$FIXTURE_DIR/victim" "$PULSE_INSTALL_DIR/bin/pulse"
fi
if [[ "$OUTCOME" == directory ]]; then
rm "$PULSE_INSTALL_DIR/bin/pulse"
mkdir "$PULSE_INSTALL_DIR/bin/pulse"
printf 'unexpected directory\n' > "$PULSE_INSTALL_DIR/bin/pulse/keep"
fi
if [[ "$OUTCOME" == fifo ]]; then
rm "$PULSE_INSTALL_DIR/bin/pulse"
mkfifo "$PULSE_INSTALL_DIR/bin/pulse"
fi
case "$OUTCOME" in failure|symlink|directory|fifo) exit 23 ;; esac
exit 0
INSTALLER
command ssh-keygen -q -t ed25519 -N '' -f "$FIXTURE_DIR/fixture-key"
PINNED_RELEASE_SSH_PUBLIC_KEY=$(cat "$FIXTURE_DIR/fixture-key.pub")
command ssh-keygen -Y sign -f "$FIXTURE_DIR/fixture-key" -n pulse-install "$FIXTURE_DIR/installer" >/dev/null 2>&1
[[ -s "$FIXTURE_DIR/installer.sig" ]]
[[ "$FAULT" != invalid_signature ]] || printf 'tampered\n' >> "$FIXTURE_DIR/installer"
log() { printf '[%s] %s\n' "$1" "${*:2}"; }
detect_service_name() { printf 'pulse\n'; }
sleep() { :; }
file_mode() { command stat -c '%a' "$1" 2>/dev/null || command stat -f '%Lp' "$1"; }
# Keep real crypto but place all temporary files inside this disposable fixture.
mktemp() {
local -a args=("$@")
local template="${args[${#args[@]}-1]}" result
case "$template:$FAULT" in
/tmp/pulse-backup.*:backup_dir|/tmp/pulse-update-installer.XXXXXX:installer_tmp|/tmp/pulse-update-installer.sig.*:signature_tmp|*.rollback.*:restore_tmp) return 1 ;;
esac
if [[ "$template" == /tmp/* ]]; then args[${#args[@]}-1]="$FIXTURE_DIR/tmp/${template##*/}"; fi
result=$(command mktemp "${args[@]}") || return
if [[ "$template" == /tmp/pulse-backup.* ]]; then printf '%s\n' "$result" > "$FIXTURE_DIR/backup-path"; fi
printf '%s\n' "$result"
}
# Record/clean the parent's timestamp backup as well, for the negative control.
date() { printf 'transaction-%s\n' "${FIXTURE_DIR##*/}"; }
mkdir() {
if [[ "${*: -1}" == /tmp/pulse-backup-* ]]; then printf '%s\n' "${*: -1}" > "$FIXTURE_DIR/backup-path"; fi
command mkdir "$@"
}
cp() {
local src="${@: -2:1}" dest="${@: -1}"
if [[ "$dest" == *pulse-backup* ]]; then
case "$FAULT:${src##*/}" in backup_binary:pulse|backup_version:VERSION) return 1 ;; esac
command cp "$@" || return
if [[ "$FAULT" == backup_corrupt ]]; then
[[ -d "$dest" ]] && dest="$dest/${src##*/}"
printf 'corrupt\n' > "$dest"
fi
elif [[ "$src" == *pulse-backup*/* ]]; then
[[ "$FAULT" != restore_copy ]] || return 1
command cp "$@" || return
[[ "$FAULT" != restore_corrupt ]] || printf 'corrupt\n' > "$dest"
else
command cp "$@"
fi
}
mv() {
local dest="${*: -1}"
if [[ "$FAULT" == restore_rename && "$dest" == "$INSTALL_DIR/bin/pulse" ]]; then return 1; fi
if [[ "$FAULT" == restore_noop && "$dest" == "$INSTALL_DIR/bin/pulse" ]]; then return 0; fi
if [[ "$FAULT" == version_rename && "$dest" == "$INSTALL_DIR/VERSION" ]]; then return 1; fi
command mv "$@" || return
if [[ "$FAULT" == restore_changed && "$dest" == "$INSTALL_DIR/bin/pulse" ]]; then printf 'changed\n' > "$dest"; fi
}
curl() {
local out="" url="" arg
while (( $# )); do
arg="$1"; shift
case "$arg" in -o) out="$1"; shift ;; https://*) url="$arg" ;; esac
done
printf '%s\n' "$url" >> "$FIXTURE_DIR/downloads"
if [[ "$url" == *.sshsig ]]; then
[[ "$FAULT" != download_signature ]] || return 1
command cp "$FIXTURE_DIR/installer.sig" "$out"
else
[[ "$FAULT" != download_installer ]] || return 1
command cp "$FIXTURE_DIR/installer" "$out"
fi
}
# Pure-function manager doubles stay in this shell; the Python fixture uses
# real timeout with an external confined manager to test actual deadlines.
timeout() { case "$1" in -k) shift 2 ;; --kill-after=*) shift ;; esac; shift; "$@"; }
systemctl() {
case "$1" in
show) printf 'LoadState=loaded\n'; if [[ "$(cat "$FIXTURE_DIR/active")" == true ]]; then echo ActiveState=active; else echo ActiveState=inactive; fi ;;
is-active) [[ "$(cat "$FIXTURE_DIR/active")" == true ]] ;;
stop)
printf 'stop\n' >> "$FIXTURE_DIR/service-operations"
[[ "$FAULT" != stop ]] || return 1
printf 'false\n' > "$FIXTURE_DIR/active"
;;
start|restart)
printf '%s:%s\n' "$1" "$(get_current_version)" >> "$FIXTURE_DIR/service-operations"
[[ "$FAULT" != restart ]] || return 1
if [[ "$OUTCOME" == service_failure && "$(get_current_version)" != v6.4.5 ]]; then return 1; fi
[[ "$FAULT" == restart_liveness ]] || printf 'true\n' > "$FIXTURE_DIR/active"
;;
*) return 1 ;;
esac
}
cleanup_fixture() {
if [[ -f "$FIXTURE_DIR/backup-path" ]]; then
local old_backup; old_backup=$(cat "$FIXTURE_DIR/backup-path")
case "$old_backup" in /tmp/pulse-backup-transaction-*) command rm -rf -- "$old_backup" ;; esac
fi
}
trap cleanup_fixture EXIT
result=failure
if perform_update v6.5.0; then result=success; fi
[[ "$result" == "$EXPECTED_RESULT" ]] || { echo "unexpected result: $result"; exit 1; }
[[ -z "$(trap -p RETURN)" ]] || { echo 'leaked RETURN trap'; exit 1; }
caller() { :; }; caller
[[ "$(cat "$CONFIG_DIR/system.json")" == 'operator settings unchanged' ]]
[[ "$(cat "$CONFIG_DIR/agent-id")" == 'local identity unchanged' ]]
[[ "$(cat "$FIXTURE_DIR/victim")" == 'victim unchanged' ]]
if [[ "$PREFLIGHT" == true ]]; then
[[ ! -f "$FIXTURE_DIR/installer-executed" ]] || { echo 'installer executed without a usable backup/admission'; exit 1; }
[[ ! -s "$FIXTURE_DIR/service-operations" ]] || { echo 'service mutated on rejected preflight'; exit 1; }
[[ "$(cat "$FIXTURE_DIR/active")" == "$PRIOR_ACTIVE" ]]
else
[[ -f "$FIXTURE_DIR/installer-executed" ]]
fi
backup=""; [[ ! -f "$FIXTURE_DIR/backup-path" ]] || backup=$(cat "$FIXTURE_DIR/backup-path")
if [[ "$RETAINED" == true ]]; then
[[ -d "$backup" ]] || { echo 'rollback backup lost after failed recovery'; exit 1; }
[[ "$(file_mode "$backup")" == 700 ]]
cmp "$FIXTURE_DIR/original/pulse-bin" "$backup/pulse-bin"
cmp "$FIXTURE_DIR/original/VERSION" "$backup/VERSION"
else
[[ -z "$backup" || ! -e "$backup" ]] || { echo 'unneeded backup leaked'; exit 1; }
fi
# Incomplete file restoration must not activate an unverified executable.
case "$FAULT" in restore_tmp|restore_copy|restore_corrupt|restore_rename|restore_noop|restore_changed|version_rename|stop)
if [[ -f "$FIXTURE_DIR/service-operations" ]]; then
! grep -Eq '^(start|restart):' "$FIXTURE_DIR/service-operations"
fi
;;
esac
if [[ "$PREFLIGHT" != true && "$RETAINED" != true && "$EXPECTED_RESULT" != success ]]; then
case "$LAYOUT" in
legacy) [[ ! -e "$INSTALL_DIR/bin/pulse" ]]; cmp "$FIXTURE_DIR/original/pulse-bin" "$INSTALL_DIR/pulse" ;;
both) cmp "$FIXTURE_DIR/original/pulse-bin" "$INSTALL_DIR/bin/pulse"; cmp "$FIXTURE_DIR/original/pulse-legacy" "$INSTALL_DIR/pulse" ;;
*) cmp "$FIXTURE_DIR/original/pulse-bin" "$INSTALL_DIR/bin/pulse"; [[ ! -e "$INSTALL_DIR/pulse" ]] ;;
esac
if [[ "$NO_VERSION" == true ]]; then
[[ ! -e "$INSTALL_DIR/VERSION" ]]
else
cmp "$FIXTURE_DIR/original/VERSION" "$INSTALL_DIR/VERSION"
[[ "$(file_mode "$INSTALL_DIR/VERSION")" == 640 ]]
fi
[[ "$(get_current_version)" == v6.4.5 ]]
binary="$INSTALL_DIR/bin/pulse"; [[ "$LAYOUT" != legacy ]] || binary="$INSTALL_DIR/pulse"
[[ "$(file_mode "$binary")" == 751 ]]
[[ "$(cat "$FIXTURE_DIR/active")" == "$PRIOR_ACTIVE" ]]
fi
if [[ "$EXPECTED_RESULT" == success ]]; then [[ "$(get_current_version)" == v6.5.0 ]]; fi
if [[ "$FAULT" == xattrs ]]; then
python3 - "$INSTALL_DIR" <<'METADATA'
import os, sys
for name in ('bin/pulse', 'VERSION'):
assert os.getxattr(os.path.join(sys.argv[1], name), 'user.pulse-updater-test') == b'original extended metadata'
METADATA
fi
printf 'TRANSACTION_ASSERTIONS_PASSED\n'
`
cmd := exec.Command("bash", "-c", script)
boolString := func(v bool) string {
if v {
return "true"
}
return "false"
}
want := "failure"
if success {
want = "success"
}
cmd.Env = append(os.Environ(), "UPDATER_UNDER_TEST="+updater, "FIXTURE_DIR="+dir,
"FAULT="+fault, "OUTCOME="+outcome, "LAYOUT="+layout, "PRIOR_ACTIVE="+active,
"NO_VERSION="+boolString(noVersion), "PREFLIGHT="+boolString(preflight),
"RETAINED="+boolString(retained), "EXPECTED_RESULT="+want)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("complete updater transaction: %v\n%s", err, out)
}
if strings.Contains(string(out), "TRANSACTION_METADATA_UNAVAILABLE") {
t.Skip("extended metadata unsupported by fixture filesystem")
}
if !strings.Contains(string(out), "TRANSACTION_ASSERTIONS_PASSED") {
t.Fatalf("transaction assertions did not finish:\n%s", out)
}
if !preflight {
installerExit := "0"
switch outcome {
case "failure", "symlink", "directory", "fifo":
installerExit = "23"
}
observed := "(installer exit: " + installerExit + "; log collector exit: 0)"
if !strings.Contains(string(out), observed) {
t.Fatalf("missing observed pipeline exits %q after signed installer execution:\n%s", observed, out)
}
}
}
// Stable admission is object-bound, not a line-oriented tag/flag accumulator.
// Each fixture runs the real selector with local transport stubs only.
func TestAutoUpdateReleaseMetadataAdmission(t *testing.T) {
const stable = `{"tag_name":"v6.5.0","draft":false,"prerelease":false}`
cases := []struct {
name, list, latest, want string
listExit, calls int
}{
{"compact list", `[{"tag_name":"v6.4.5","draft":false,"prerelease":false},` + stable + `]`, `{}`, "v6.5.0", 0, 1},
{"reordered fields", `[{"prerelease":false,"draft":false,"tag_name":"v6.5.0"}]`, `{}`, "v6.5.0", 0, 1},
{"no sibling maturity borrowing", "[\n{\n\"draft\":true,\n\"prerelease\":false,\n\"tag_name\":\"v9.9.9\"\n},\n{\n\"prerelease\":false,\n\"draft\":false,\n\"tag_name\":\"v6.5.0\"\n}\n]", `{}`, "v6.5.0", 0, 1},
{"confirmed latest fallback", `[]`, stable, "v6.5.0", 0, 2},
{"missing draft", `[]`, "{\n\"tag_name\":\"v9.9.9\",\n\"prerelease\":false\n}", "", 0, 2},
{"missing prerelease", `[]`, "{\n\"tag_name\":\"v9.9.9\",\n\"draft\":false\n}", "", 0, 2},
{"draft latest", `[]`, "{\n\"tag_name\":\"v9.9.9\",\n\"draft\":true,\n\"prerelease\":false\n}", "", 0, 2},
{"string flags", `[{"tag_name":"v9.9.9","draft":"false","prerelease":"false"}]`, `{}`, "", 0, 2},
{"nested flags", `[{"tag_name":"v9.9.9","status":{"draft":false,"prerelease":false}}]`, `{}`, "", 0, 2},
{"body is not release metadata", `[{"body":{"tag_name":"v9.9.9","draft":false,"prerelease":false}}]`, `{}`, "", 0, 2},
{"wrong list shape", stable, `{}`, "", 0, 2},
{"wrong latest shape", `[]`, `[` + stable + `]`, "", 0, 2},
{"non-object entry", `[` + stable + `,false]`, `{}`, "", 0, 2},
{"truncated list", `[` + stable + `,`, `{}`, "", 0, 2},
{"concatenated documents", `[` + stable + `] []`, `{}`, "", 0, 2},
{"partial transport output", `[` + stable + `]`, `{}`, "", 22, 2},
{"transport failure with confirmed fallback", `[` + stable + `]`, stable, "v6.5.0", 22, 2},
{"redirect is not maturity", `[]`, "Location: https://github.com/rcourtman/Pulse/releases/tag/v9.9.9\r\n", "", 0, 2},
{"multiline tag", `[{"tag_name":"unknown\nv9.9.9","draft":false,"prerelease":false}]`, `{}`, "", 0, 2},
{"preview and chart tags", `[{"tag_name":"v9.9.9-rc.1","draft":false,"prerelease":false},{"tag_name":"helm-chart-9.9.9","draft":false,"prerelease":false}]`, `{}`, "", 0, 2},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
for name, data := range map[string]string{"list": tc.list, "latest": tc.latest} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
}
script := `set -euo pipefail
GITHUB_REPO=rcourtman/Pulse
log() { printf '%s\n' "$*" >&2; }
curl() {
printf '%s\n' "$*" >> "$FIXTURE_DIR/calls"
local url="${!#}"
case "$url" in
*/releases?per_page=30) cat "$FIXTURE_DIR/list"; return "$LIST_EXIT" ;;
*/releases/latest) cat "$FIXTURE_DIR/latest" ;;
*) echo "UNEXPECTED_TRANSPORT" >&2; return 99 ;;
esac
}
` + extractAutoUpdateFunction(t, "is_prerelease_tag") + "\n" +
extractAutoUpdateFunction(t, "version_greater_than") + "\n" +
extractAutoUpdateFunction(t, "pick_highest_stable_tag") + "\n" +
extractAutoUpdateFunction(t, "get_latest_stable_version") + `
get_latest_stable_version
`
cmd := exec.Command("bash", "-c", script)
listExit := "0"
if tc.listExit != 0 {
listExit = "22"
}
cmd.Env = append(os.Environ(), "FIXTURE_DIR="+dir, "LIST_EXIT="+listExit)
out, err := cmd.CombinedOutput()
if err != nil || strings.TrimSpace(string(out)) != tc.want {
t.Fatalf("release selection: %v; got %q, want %q", err, out, tc.want)
}
calls, err := os.ReadFile(filepath.Join(dir, "calls"))
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(calls)), "\n")
if len(lines) != tc.calls {
t.Fatalf("transport calls: got %d, want %d: %s", len(lines), tc.calls, calls)
}
for _, line := range lines {
if !strings.HasPrefix(line, "--disable --fail --silent --show-error --connect-timeout 5 --max-time 20 --proto =https https://api.github.com/repos/rcourtman/Pulse/") {
t.Fatalf("unbounded or ambient-config transport: %s", line)
}
}
})
}
}
func TestAutoUpdateReleaseMetadataRequiresParserBeforeTransport(t *testing.T) {
script := `set -euo pipefail
command() { if [[ "$*" == '-v jq' ]]; then return 1; fi; builtin command "$@"; }
log() { printf '%s\n' "$*" >&2; }
curl() { echo UNEXPECTED_TRANSPORT; return 99; }
` + extractAutoUpdateFunction(t, "get_latest_stable_version") + `
result=$(get_latest_stable_version)
[[ -z "$result" ]] && echo PARSER_REFUSED
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil || !strings.Contains(string(out), "PARSER_REFUSED") || strings.Contains(string(out), "UNEXPECTED_TRANSPORT") {
t.Fatalf("missing parser must stop before discovery: %v\n%s", err, out)
}
}