Pulse/internal/updates/selftest.go
rcourtman 0c91799e0f Self-test the new binary before the in-app update swaps it in
Checksum and SSHSIG verification prove the downloaded artifact matches
what was published, not that it can run on this host or is the version
the user approved. The apply pipeline previously swapped the binary and
exited, relying on systemd to restart into an unproven executable; a
wrong-arch fallback asset or unstamped build would take Pulse down with
the backup left unused.

ApplyUpdate now locates the extracted binary and probes it with
--version before the backup and swap stages, failing the update with
zero changes applied when the probe fails or reports a version other
than the apply target. Same pattern the agent updater already uses.
Pinned in the deployment-installability contract with
internal/updates/selftest_test.go as the owned proof surface.
2026-07-10 00:44:01 +01:00

64 lines
2.4 KiB
Go

package updates
import (
"context"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"time"
)
// updateSelfTestTimeout bounds the --version probe so a hung artifact cannot
// stall the update pipeline indefinitely.
const updateSelfTestTimeout = 30 * time.Second
// updateSelfTestMaxOutput caps how much probe output is echoed into errors.
const updateSelfTestMaxOutput = 2048
// updateSelfTestCommandContext is swapped out by tests.
var updateSelfTestCommandContext = exec.CommandContext
// selfTestVersionTokenRegex matches version tokens in --version output, with
// or without the leading v (release builds print "Pulse vX.Y.Z").
var selfTestVersionTokenRegex = regexp.MustCompile(`v?\d+\.\d+\.\d+(?:-[A-Za-z0-9.]*\d[A-Za-z0-9.]*)?`)
// selfTestNewBinary runs the extracted update binary with --version before it
// replaces the running one. Checksum and signature verification prove the
// download matches the published artifact; they cannot prove the artifact
// runs on this host (wrong-arch fallback asset, incompatible libc) or that it
// is the version the user approved. --version exits before any server
// startup, so the probe is side-effect free.
func selfTestNewBinary(ctx context.Context, binaryPath, workDir, expectedVersion string) error {
if err := os.Chmod(binaryPath, 0o755); err != nil {
return fmt.Errorf("failed to mark new binary executable for self-test: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, updateSelfTestTimeout)
defer cancel()
cmd := updateSelfTestCommandContext(ctx, binaryPath, "--version")
// The tarball ships VERSION at its root; run from there so the probe sees
// the same layout an installed binary would.
cmd.Dir = workDir
output, err := cmd.CombinedOutput()
probeOutput := strings.TrimSpace(string(output))
if len(probeOutput) > updateSelfTestMaxOutput {
probeOutput = probeOutput[:updateSelfTestMaxOutput]
}
if err != nil {
return fmt.Errorf("new pulse binary failed --version self-test: %w (output: %q)", err, probeOutput)
}
expected := strings.TrimPrefix(strings.TrimSpace(expectedVersion), "v")
if expected == "" {
return nil
}
for _, token := range selfTestVersionTokenRegex.FindAllString(probeOutput, -1) {
if strings.TrimPrefix(token, "v") == expected {
return nil
}
}
return fmt.Errorf("new pulse binary self-test reported %q, which does not include expected version %s", probeOutput, expectedVersion)
}