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
This commit is contained in:
pulse-triage[bot]
2026-10-07 16:39:25 +01:00
parent 6ab3c9a41b
commit 2dbee5639a
4 changed files with 214 additions and 11 deletions
@@ -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
@@ -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")
@@ -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)
}
})
}
+25 -5
View File
@@ -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