fix(updates): require exact server archive for update download

Release metadata contains agent, MCP and multiple architecture archives. An incomplete release must not offer an unrelated tarball or advertise an update that cannot be applied. Retain server archive names only, require the exact tag and runtime architecture, and record this installability boundary with regression coverage.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-09-27 23:08:56 +01:00
parent e02eeb8fa3
commit ab0aa1ef02
4 changed files with 90 additions and 16 deletions

View file

@ -4041,6 +4041,13 @@ tracks what the updater keeps rather than the response size. Buffering the
whole response behind a small bound is a release-blocking regression: it made
every stable-channel check fail on main and left 6.4.3-rc.1, which has the
bound without the fallback, unable to discover any update (#1881, #2282).
The release-list path must retain only server-shaped Linux archives, not
`pulse-agent-*` or `pulse-mcp-*` components. It may advertise an update as
available only when that release contains the exact server archive for the
running architecture and tag. A missing exact archive leaves the download URL
empty and the update unavailable; it must not fall back to another component,
architecture or version. This keeps an incomplete release visible as metadata
without presenting an unusable or wrong installation path.
Malformed metadata stays a hard error and only the typed over-limit condition
may fall through to the feed. Proof:
`internal/updates/issue2282_release_metadata_stream_test.go`.

View file

@ -205,3 +205,64 @@ func TestIssue2282StreamedDecodeKeepsTypedByteBound(t *testing.T) {
t.Fatalf("decodeReleaseList error = %v, want typed response size rejection", err)
}
}
func TestIssue2282OnlyServerArchivesAreRetained(t *testing.T) {
for _, tc := range []struct {
name string
want bool
}{
{"pulse-v6.4.5-linux-amd64.tar.gz", true},
{"pulse-v6.4.5-rc.4-linux-arm64.tar.gz", true},
{"pulse-agent-v6.4.5-linux-amd64.tar.gz", false},
{"pulse-mcp-v6.4.5-linux-amd64.tar.gz", false},
{"pulse-agent-helper-v6.4.5-linux-amd64.tar.gz", false},
{"pulse-v6.4.5-darwin-arm64.tar.gz", false},
{"pulse-v6.4.5-linux-amd64.tar.gz.sshsig", false},
} {
if got := isRuntimeReleaseAssetName(tc.name); got != tc.want {
t.Errorf("isRuntimeReleaseAssetName(%q) = %t, want %t", tc.name, got, tc.want)
}
}
}
func TestIssue2282UpdateCheckRequiresExactServerArchive(t *testing.T) {
withBuildVersion(t, "6.4.0")
exact, ok := updateReleaseAssetForRuntime("v99.0.0")
if !ok {
t.Skip("no release archive for this architecture")
}
otherArch := "amd64"
if strings.HasSuffix(exact.Name, "-amd64.tar.gz") {
otherArch = "arm64"
}
sameArch := strings.TrimSuffix(strings.TrimPrefix(exact.Name, "pulse-v99.0.0-linux-"), ".tar.gz")
decoys := []ReleaseAsset{
{Name: "pulse-agent-v99.0.0-linux-" + sameArch + ".tar.gz", BrowserDownloadURL: "https://example.invalid/agent"},
{Name: "pulse-mcp-v99.0.0-linux-" + sameArch + ".tar.gz", BrowserDownloadURL: "https://example.invalid/mcp"},
{Name: "pulse-v99.0.0-linux-" + otherArch + ".tar.gz", BrowserDownloadURL: "https://example.invalid/wrong-arch"},
{Name: "pulse-v98.0.0-linux-" + sameArch + ".tar.gz", BrowserDownloadURL: "https://example.invalid/wrong-version"},
}
for _, tc := range []struct {
name, wantURL string
wantAvailable bool
assets []ReleaseAsset
}{
{"missing exact archive", "", false, decoys},
{"exact archive after decoys", exact.BrowserDownloadURL, true, append(append([]ReleaseAsset{}, decoys...), exact)},
} {
t.Run(tc.name, func(t *testing.T) {
server := newReleaseServer(t, []ReleaseInfo{{TagName: "v99.0.0", Assets: tc.assets}}, nil)
defer server.Close()
t.Setenv("PULSE_UPDATE_SERVER", server.URL)
manager := NewManager(&config.Config{UpdateChannel: "stable"})
info, err := manager.CheckForUpdatesWithChannel(context.Background(), "stable")
if err != nil {
t.Fatalf("check update: %v", err)
}
if info.Available != tc.wantAvailable || info.DownloadURL != tc.wantURL {
t.Fatalf("update = %+v, want available=%t with download URL %q", info, tc.wantAvailable, tc.wantURL)
}
})
}
}

View file

@ -509,16 +509,9 @@ func (m *Manager) CheckForUpdatesWithOptions(ctx context.Context, options Update
}
}
}
// Fallback to any pulse tarball if exact match not found
if downloadURL == "" {
for _, asset := range release.Assets {
if isRuntimeReleaseAssetName(asset.Name) {
downloadURL = asset.BrowserDownloadURL
break
}
}
}
// A missing archive must not fall back to another architecture or to an
// agent/MCP tarball, nor advertise an update that cannot be applied.
available := latestVer.IsNewerThan(currentVer) && downloadURL != ""
isMajorUpgrade := latestVer.Major > currentVer.Major
// Derive prerelease from the parsed version tag (not GitHub metadata) so the
@ -526,7 +519,7 @@ func (m *Manager) CheckForUpdatesWithOptions(ctx context.Context, options Update
isPrerelease := release.Prerelease || latestVer.IsPrerelease()
info := &UpdateInfo{
Available: latestVer.IsNewerThan(currentVer),
Available: available,
CurrentVersion: currentInfo.Version,
LatestVersion: strings.TrimPrefix(release.TagName, "v"),
ReleaseNotes: release.Body,

View file

@ -63,12 +63,25 @@ func decodeSingleRelease(resp *http.Response) (ReleaseInfo, error) {
return release, nil
}
// isRuntimeReleaseAssetName matches the Pulse server archives the update check
// can offer: the exact runtime asset and the any-linux-tarball fallback.
// isRuntimeReleaseAssetName keeps server archives out of the much larger list
// of agent, MCP and other release artifacts. The update check still requires
// the exact release and architecture before offering an archive to the user.
func isRuntimeReleaseAssetName(name string) bool {
return strings.HasPrefix(name, "pulse-") &&
strings.Contains(name, "linux") &&
strings.HasSuffix(name, ".tar.gz")
if !strings.HasPrefix(name, "pulse-v") {
return false
}
for _, suffix := range [...]string{
"-linux-amd64.tar.gz",
"-linux-arm64.tar.gz",
"-linux-armv7.tar.gz",
"-linux-armv6.tar.gz",
"-linux-386.tar.gz",
} {
if strings.HasSuffix(name, suffix) {
return true
}
}
return false
}
func decodeReleaseObject(dec *json.Decoder, keepAsset func(string) bool) (ReleaseInfo, error) {