From 2dbee5639ae8bf08755cc370fb07aaa8671a0688 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:39:25 +0100 Subject: [PATCH] Stop unidentified editions entering community auto-updates Require a successful bounded executable identity probe before release discovery. Keep Pro and unknown runtime editions untouched even when VERSION offers a usable community version, and do not defer a broken primary binary to a legacy one. Exercise the real shell classifier and main boundary with adverse controls and a TERM-resistant deadline. Change-source: pulse-maintainer --- .../subsystems/deployment-installability.md | 22 +++ .../pulse_auto_update_consent_test.go | 165 +++++++++++++++++- .../installtests/pulse_auto_update_test.go | 8 +- scripts/pulse-auto-update.sh | 30 +++- 4 files changed, 214 insertions(+), 11 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 7420cecd91..4dc633f642 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -15,6 +15,28 @@ ## Purpose +### Community auto-updates require a verified installed edition + +Before release discovery or installation, the unattended updater requires a +regular executable in the primary or, only when absent, legacy binary path. +Its `--version` probe must exit successfully within five seconds, with a +one-second KILL backstop. A `Pulse Pro` first line leaves the paid runtime +untouched; only the public `Pulse vX.Y.Z` version format admits community +updates. Failed, missing, non-executable or unrecognised binaries stop with a +manual-recovery message. A VERSION file is not edition evidence, and a broken +primary executable cannot defer to a legacy community executable. Probe output +is not included in the error message. Signed assets, consent and rollback +requirements remain unchanged. + +`scripts/installtests/pulse_auto_update_consent_test.go` also executes this +edition boundary: primary/legacy precedence, successful and failed version +output, a real TERM-resistant probe deadline, and the actual `main` flow's +release-discovery/installer boundary with a plausible VERSION fallback. +`TestAutoUpdateEditionGateStopsMainBeforeDownloads` proves that unknown and Pro +editions cause no release read or installer action, while a recognised community +binary still reaches both. These ordinary-user shell fixtures do not establish +installed systemd, paid customer or native update acceptance. + ### Unattended update consent comes from complete configuration When `system.json` exists, the unattended updater requires one complete JSON diff --git a/scripts/installtests/pulse_auto_update_consent_test.go b/scripts/installtests/pulse_auto_update_consent_test.go index 46686eb316..03f36736fa 100644 --- a/scripts/installtests/pulse_auto_update_consent_test.go +++ b/scripts/installtests/pulse_auto_update_consent_test.go @@ -5,26 +5,187 @@ import ( "os/exec" "path/filepath" "regexp" + "strconv" "strings" "testing" + "time" ) // Keep these Bash-only checks runnable as a standalone file as well as part of // the installer package. They need no application imports or local listeners. // From this directory: go test -race pulse_auto_update_consent_test.go func extractAutoUpdateConsentGate(t *testing.T) string { + return extractAutoUpdateAdmissionFunction(t, "check_auto_updates_enabled") +} + +func extractAutoUpdateAdmissionFunction(t *testing.T, name string) string { t.Helper() content, err := os.ReadFile(filepath.Join("..", "pulse-auto-update.sh")) if err != nil { t.Fatal(err) } - match := regexp.MustCompile(`(?ms)^check_auto_updates_enabled\(\) \{\n.*?^\}`).Find(content) + match := regexp.MustCompile(`(?ms)^` + regexp.QuoteMeta(name) + `\(\) \{\n.*?^\}`).Find(content) if match == nil { - t.Fatal("cannot find the actual unattended-update consent gate") + t.Fatalf("cannot find actual unattended-update function %s", name) } return string(match) } +func TestAutoUpdateRequiresVerifiedCommunityEdition(t *testing.T) { + cases := []struct { + name, body, legacy string + status int // Pro=0, verified community=1, unknown=2. + nonExecutable bool + directory bool + }{ + {name: "community", body: "echo 'Pulse v6.5.0'", status: 1}, + {name: "community_build_details", body: "printf 'Pulse v6.5.0\\nBuilt: yesterday\\nCommit: abc\\n'", status: 1}, + {name: "community_rc", body: "echo 'Pulse v6.5.0-rc.1'", status: 1}, + {name: "legacy_community", legacy: "echo 'Pulse v6.5.0'", status: 1}, + {name: "pro", body: "echo 'Pulse Pro v6.5.0'"}, + {name: "legacy_pro", legacy: "echo 'Pulse Pro v6.5.0'"}, + {name: "primary_pro_over_legacy_community", body: "echo 'Pulse Pro v6.5.0'", legacy: "echo 'Pulse v6.5.0'"}, + {name: "failed_probe", body: "exit 1", status: 2}, + {name: "version_then_failure", body: "echo 'Pulse v6.5.0'; exit 1", status: 2}, + {name: "pro_then_failure", body: "echo 'Pulse Pro v6.5.0'; exit 1", status: 2}, + {name: "empty_probe", body: ":", status: 2}, + {name: "unknown_brand", body: "echo 'Other v6.5.0 private-detail'", status: 2}, + {name: "bare_version", body: "echo 'v6.5.0'", status: 2}, + {name: "invalid_version", body: "echo 'Pulse broken'", status: 2}, + {name: "non_executable", body: "echo 'Pulse v6.5.0'", nonExecutable: true, status: 2}, + {name: "primary_failure_over_legacy_community", body: "exit 1", legacy: "echo 'Pulse v6.5.0'", status: 2}, + {name: "primary_directory_over_legacy_community", directory: true, legacy: "echo 'Pulse v6.5.0'", status: 2}, + {name: "missing_binary_with_version_file", status: 2}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "bin"), 0700); err != nil { + t.Fatal(err) + } + primary := filepath.Join(dir, "bin", "pulse") + if tc.directory { + if err := os.Mkdir(primary, 0700); err != nil { + t.Fatal(err) + } + } else if tc.body != "" { + mode := os.FileMode(0700) + if tc.nonExecutable { + mode = 0600 + } + if err := os.WriteFile(primary, []byte("#!/bin/bash\n"+tc.body+"\n"), mode); err != nil { + t.Fatal(err) + } + } + if tc.legacy != "" { + if err := os.WriteFile(filepath.Join(dir, "pulse"), []byte("#!/bin/bash\n"+tc.legacy+"\n"), 0700); err != nil { + t.Fatal(err) + } + } + // VERSION deliberately offers a usable public version even when the + // installed executable cannot establish its edition. + if err := os.WriteFile(filepath.Join(dir, "VERSION"), []byte("v6.5.0\n"), 0600); err != nil { + t.Fatal(err) + } + script := "set -euo pipefail\n" + extractAutoUpdateAdmissionFunction(t, "installed_binary_is_pulse_pro") + ` +if installed_binary_is_pulse_pro; then status=0; else status=$?; fi +printf 'STATUS=%s\n' "$status" +` + cmd := exec.Command("bash", "-c", script) + cmd.Env = append(os.Environ(), "INSTALL_DIR="+dir) + out, err := cmd.CombinedOutput() + if err != nil || strings.TrimSpace(string(out)) != "STATUS="+strconv.Itoa(tc.status) { + t.Fatalf("classification: %v, output %s; want status %d", err, out, tc.status) + } + }) + } +} + +func TestAutoUpdateEditionProbeHasHardDeadline(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "bin"), 0700); err != nil { + t.Fatal(err) + } + // A version line is not success until the probe exits successfully. Even + // a TERM-resistant executable must stop at the timeout's KILL backstop. + if err := os.WriteFile(filepath.Join(dir, "bin", "pulse"), []byte("#!/bin/bash\ntrap '' TERM\necho 'Pulse Pro v6.5.0'\nexec sleep 60\n"), 0700); err != nil { + t.Fatal(err) + } + script := extractAutoUpdateAdmissionFunction(t, "installed_binary_is_pulse_pro") + ` +if installed_binary_is_pulse_pro; then status=0; else status=$?; fi +printf 'STATUS=%s\n' "$status" +` + cmd := exec.Command("bash", "-c", script) + cmd.Env = append(os.Environ(), "INSTALL_DIR="+dir) + start := time.Now() + out, err := cmd.CombinedOutput() + if err != nil || !strings.HasSuffix(string(out), "STATUS=2\n") || time.Since(start) > 9*time.Second { + t.Fatalf("deadline: %v, duration %s, output %s", err, time.Since(start), out) + } +} + +func TestAutoUpdateEditionGateStopsMainBeforeDownloads(t *testing.T) { + if _, err := os.Stat("/.dockerenv"); err == nil { + t.Fatal("edition main-flow proof requires a non-Docker environment") + } + updater, err := filepath.Abs(filepath.Join("..", "pulse-auto-update.sh")) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, body string + exit int + admitted bool + }{ + {"community", "echo 'Pulse v6.5.0'", 0, true}, + {"pro", "echo 'Pulse Pro v6.5.0'", 0, false}, + {"failed_probe", "exit 1", 1, false}, + {"misleading_failed_probe", "echo 'Pulse v6.5.0'; exit 1", 1, false}, + {"unknown_brand", "echo 'Other v6.5.0 private-detail'", 1, false}, + {"missing_binary", "", 1, false}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "bin"), 0700); err != nil { + t.Fatal(err) + } + if tc.body != "" { + if err := os.WriteFile(filepath.Join(dir, "bin", "pulse"), []byte("#!/bin/bash\n"+tc.body+"\n"), 0700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "VERSION"), []byte("v6.5.0\n"), 0600); err != nil { + t.Fatal(err) + } + script := `source "$UPDATER_UNDER_TEST" +log() { printf '%s\n' "$*"; } +check_auto_updates_enabled() { :; } +get_latest_stable_version() { echo RELEASE_READ >> "$INSTALL_DIR/actions"; echo v6.5.1; } +perform_update() { echo INSTALLER >> "$INSTALL_DIR/actions"; } +main +` + cmd := exec.Command("bash", "-c", script) + cmd.Env = append(os.Environ(), "UPDATER_UNDER_TEST="+updater, "PULSE_INSTALL_DIR="+dir, "PULSE_CONFIG_DIR="+filepath.Join(dir, "config")) + out, err := cmd.CombinedOutput() + exit := 0 + if err != nil { + if e, ok := err.(*exec.ExitError); ok { + exit = e.ExitCode() + } else { + t.Fatal(err) + } + } + actions, readErr := os.ReadFile(filepath.Join(dir, "actions")) + if exit != tc.exit || (readErr == nil) != tc.admitted || (tc.admitted && string(actions) != "RELEASE_READ\nINSTALLER\n") { + t.Fatalf("exit=%d, actions=%q, read=%v; want exit=%d admitted=%t; output %s", exit, actions, readErr, tc.exit, tc.admitted, out) + } + if strings.Contains(string(out), "private-detail") { + t.Fatalf("edition output leaked: %s", out) + } + }) + } +} + func TestAutoUpdateRequiresCompleteTopLevelConsent(t *testing.T) { if _, err := exec.LookPath("jq"); err != nil { t.Fatal("jq is required to execute the unattended-update consent checks") diff --git a/scripts/installtests/pulse_auto_update_test.go b/scripts/installtests/pulse_auto_update_test.go index 861a8b911e..99e697a2f9 100644 --- a/scripts/installtests/pulse_auto_update_test.go +++ b/scripts/installtests/pulse_auto_update_test.go @@ -376,14 +376,14 @@ func TestInstalledBinaryIsPulseProGuard(t *testing.T) { }) } - t.Run("missing binary is not flagged", func(t *testing.T) { - script := fn + "\nINSTALL_DIR=" + t.TempDir() + "\nif installed_binary_is_pulse_pro; then echo pro; else echo community; fi\n" + 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 != "community" { - t.Fatalf("missing binary should not be flagged as Pro, got %q", got) + if got := strings.TrimSpace(string(out)); got != "STATUS=2" { + t.Fatalf("missing binary must not establish community edition, got %q", got) } }) } diff --git a/scripts/pulse-auto-update.sh b/scripts/pulse-auto-update.sh index bb50327881..a171639858 100755 --- a/scripts/pulse-auto-update.sh +++ b/scripts/pulse-auto-update.sh @@ -140,15 +140,29 @@ get_current_version() { # Pro installs update in-app through the license server download broker, or # manually via https://pulserelay.pro/download.html + install.sh --archive. installed_binary_is_pulse_pro() { - local binary="" - if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then + local binary="" version_output="" first_line="" + # 0 = Pro (leave it alone), 1 = verified community, 2 = unknown (stop). + # A VERSION file cannot identify the edition, and a broken primary binary + # must not fall back to an unrelated legacy executable. + if [[ -e "$INSTALL_DIR/bin/pulse" || -L "$INSTALL_DIR/bin/pulse" ]]; then binary="$INSTALL_DIR/bin/pulse" - elif [[ -f "$INSTALL_DIR/pulse" ]]; then + elif [[ -e "$INSTALL_DIR/pulse" || -L "$INSTALL_DIR/pulse" ]]; then binary="$INSTALL_DIR/pulse" else + return 2 + fi + if [[ ! -f "$binary" || ! -x "$binary" ]] || + ! version_output=$(timeout --kill-after=1 5 "$binary" --version 2>/dev/null); then + return 2 + fi + first_line=${version_output%%$'\n'*} + if [[ "$first_line" == 'Pulse Pro '* ]]; then + return 0 + fi + if [[ "$first_line" =~ ^Pulse\ v?[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then return 1 fi - "$binary" --version 2>/dev/null | head -1 | grep -q '^Pulse Pro ' + return 2 } # Determine whether a tag is a semver pre-release. @@ -614,10 +628,16 @@ main() { exit 0 fi - # Never replace the Pulse Pro binary with a public community build + # Never replace a Pro or unidentified binary with a public community build. if installed_binary_is_pulse_pro; then log info "Pulse Pro binary detected; unattended community updates are disabled. Pro installs update in-app (license server download broker) or via https://pulserelay.pro/download.html" exit 0 + else + local edition_status=$? + if [[ "$edition_status" != 1 ]]; then + log error "Cannot verify installed Pulse edition; no unattended update attempted. Use the matching signed archive for manual recovery." + exit 1 + fi fi # Get current version