diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 0429c55dcd..10cd0c5077 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -53,6 +53,26 @@ 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 acceptance requires executable version evidence + +After the signed installer returns success, the unattended updater requires the +selected primary (or, only when absent, legacy) executable to report a recognised +community version on its first line and exit successfully within five seconds, +with a one-second KILL backstop. Optional build details on later lines remain +valid. An optional `v` prefix is normalised for comparison with the exact target. +VERSION sidecars, failed or empty probes, other editions/brands and versions +embedded in later lines cannot establish successful replacement. Unknown version +identity or a mismatch enters the existing verified rollback transaction, never +success. The same bounded read is used before release discovery. + +`TestAutoUpdateBackupTransaction` executes real local signed installers that +return success but leave each adverse executable beside a target VERSION file. +It checks failure, restored original bytes/modes/metadata/configuration and +only-prior-active service restoration, with valid build-detail and unprefixed +community controls. `TestAutoUpdateVersionProbeHasHardDeadline` exercises a real +TERM-resistant executable. These are source fixtures, not native systemd or +installed customer recovery. Consent, signatures and rollback gates are unchanged. + ### 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 03f36736fa..e8f5c5fa8b 100644 --- a/scripts/installtests/pulse_auto_update_consent_test.go +++ b/scripts/installtests/pulse_auto_update_consent_test.go @@ -297,3 +297,87 @@ systemctl() { return 0; } }) } } + +// VERSION is deliberately plausible in every fixture. Neither it nor a legacy +// executable can rescue a present primary whose own version evidence is bad. +func TestAutoUpdateVersionNeedsSuccessfulExecutable(t *testing.T) { + for _, tc := range []struct { + name, body, legacy, want string + nonExecutable, directory bool + }{ + {name: "community", body: "echo 'Pulse v6.5.0'", want: "v6.5.0"}, + {name: "unprefixed", body: "echo 'Pulse 6.5.0'", want: "v6.5.0"}, + {name: "build_details", body: "printf 'Pulse v6.5.0\\nBuilt: yesterday\\n'", want: "v6.5.0"}, + {name: "preview", body: "echo 'Pulse v6.5.0-rc.1'", want: "v6.5.0-rc.1"}, + {name: "legacy", legacy: "echo 'Pulse v6.5.0'", want: "v6.5.0"}, + {name: "failed", body: "echo 'Pulse v6.5.0'; exit 1"}, + {name: "empty", body: ":"}, + {name: "pro", body: "echo 'Pulse Pro v6.5.0'"}, + {name: "other_brand", body: "echo 'Other v6.5.0 private-detail'"}, + {name: "later_line", body: "printf 'Other\\nPulse v6.5.0\\n'"}, + {name: "invalid_suffix", body: "echo 'Pulse v6.5.0-extra!'"}, + {name: "non_executable", body: "echo 'Pulse v6.5.0'", nonExecutable: true}, + {name: "missing"}, + {name: "bad_primary_over_legacy", body: "exit 1", legacy: "echo 'Pulse v6.5.0'"}, + {name: "primary_directory", directory: true, legacy: "echo 'Pulse v6.5.0'"}, + } { + 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) + } + } + if err := os.WriteFile(filepath.Join(dir, "VERSION"), []byte("v6.5.0\n"), 0600); err != nil { + t.Fatal(err) + } + cmd := exec.Command("bash", "-c", "set -euo pipefail\n"+extractAutoUpdateAdmissionFunction(t, "get_current_version")+"\nget_current_version\n") + cmd.Env = append(os.Environ(), "INSTALL_DIR="+dir) + out, err := cmd.CombinedOutput() + want := tc.want + if want == "" { + want = "unknown" + } + if err != nil || strings.TrimSpace(string(out)) != want { + t.Fatalf("version: %v, output %q; want %s", err, out, want) + } + }) + } +} + +func TestAutoUpdateVersionProbeHasHardDeadline(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "bin"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "bin", "pulse"), []byte("#!/bin/bash\ntrap '' TERM\necho 'Pulse v6.5.0'\nexec sleep 60\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) + } + cmd := exec.Command("bash", "-c", extractAutoUpdateAdmissionFunction(t, "get_current_version")+"\nget_current_version\n") + cmd.Env = append(os.Environ(), "INSTALL_DIR="+dir) + start := time.Now() + out, err := cmd.CombinedOutput() + if err != nil || !strings.HasSuffix(string(out), "unknown\n") || time.Since(start) > 9*time.Second { + t.Fatalf("deadline: %v, duration %s, output %s", err, time.Since(start), out) + } +} diff --git a/scripts/installtests/pulse_auto_update_test.go b/scripts/installtests/pulse_auto_update_test.go index d0f5177264..4e85267a6d 100644 --- a/scripts/installtests/pulse_auto_update_test.go +++ b/scripts/installtests/pulse_auto_update_test.go @@ -639,6 +639,16 @@ func TestAutoUpdateBackupTransaction(t *testing.T) { {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"}, @@ -745,6 +755,20 @@ 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" diff --git a/scripts/pulse-auto-update.sh b/scripts/pulse-auto-update.sh index 57e08b66bf..51fcb0634c 100755 --- a/scripts/pulse-auto-update.sh +++ b/scripts/pulse-auto-update.sh @@ -115,23 +115,27 @@ check_auto_updates_enabled() { fi } -# Get current version +# Read version identity from the executable, never installation metadata. This +# runs both before discovery and after installer success: a VERSION sidecar can +# survive a failed replacement and cannot prove the new binary is usable. get_current_version() { - local version="" - - # Try to get version from binary - if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then - version=$("$INSTALL_DIR/bin/pulse" --version 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.]+)?' | head -1 || true) - elif [[ -f "$INSTALL_DIR/pulse" ]]; then - version=$("$INSTALL_DIR/pulse" --version 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.]+)?' | head -1 || true) + local binary="" version_output="" first_line="" + if [[ -e "$INSTALL_DIR/bin/pulse" || -L "$INSTALL_DIR/bin/pulse" ]]; then + binary="$INSTALL_DIR/bin/pulse" + elif [[ -e "$INSTALL_DIR/pulse" || -L "$INSTALL_DIR/pulse" ]]; then + binary="$INSTALL_DIR/pulse" fi - - # Fallback to VERSION file - if [[ -z "$version" ]] && [[ -f "$INSTALL_DIR/VERSION" ]]; then - version=$(cat "$INSTALL_DIR/VERSION" 2>/dev/null | tr -d '\n' || true) + if [[ -z "$binary" || ! -f "$binary" || ! -x "$binary" ]] || + ! version_output=$(timeout --kill-after=1 5 "$binary" --version 2>/dev/null); then + echo unknown + return 0 + fi + first_line=${version_output%%$'\n'*} + if [[ "$first_line" =~ ^Pulse\ v?([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?)$ ]]; then + printf 'v%s\n' "${BASH_REMATCH[1]}" + else + echo unknown fi - - echo "${version:-unknown}" } # The unattended updater installs public community builds. The separately @@ -492,7 +496,7 @@ perform_update() { # Verify new version local installed_version installed_version=$(get_current_version) - if [[ "$installed_version" == "$new_version" ]]; then + if [[ "$installed_version" != "unknown" && "${installed_version#v}" == "${new_version#v}" ]]; then log info "Version verified: $installed_version" # If Pulse was running before the update, make sure it is running