From 9438e6f848978f43e5bf4ff6445fd7b91c19b227 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 3 Jun 2026 05:53:08 -0400 Subject: [PATCH 1/2] fix(wfctl): preserve registry download checksums --- cmd/wfctl/plugin_registry_sync.go | 53 +++++++++++++++++++---- cmd/wfctl/plugin_registry_sync_test.go | 60 ++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/cmd/wfctl/plugin_registry_sync.go b/cmd/wfctl/plugin_registry_sync.go index 0d38d61d..4ea0ce06 100644 --- a/cmd/wfctl/plugin_registry_sync.go +++ b/cmd/wfctl/plugin_registry_sync.go @@ -270,10 +270,11 @@ func releaseExists(ghRepo, tag string) bool { } type releaseAsset struct { - Name string `json:"name"` - OS string `json:"os"` - Arch string `json:"arch"` - URL string `json:"url"` + Name string `json:"name"` + OS string `json:"os"` + Arch string `json:"arch"` + URL string `json:"url"` + SHA256 string `json:"sha256,omitempty"` } // releaseDownloads returns the platform release-asset list for a tag, in the @@ -294,17 +295,49 @@ func releaseDownloads(ghRepo, tag string) ([]releaseAsset, error) { if err := json.Unmarshal(out, &resp); err != nil { return nil, err } + checksums, _ := releaseChecksums(ghRepo, tag) var assets []releaseAsset for _, a := range resp.Assets { goos, goarch, ok := releaseAssetPlatform(a.Name) if !ok { continue } - assets = append(assets, releaseAsset{Name: a.Name, OS: goos, Arch: goarch, URL: a.URL}) + assets = append(assets, releaseAsset{ + Name: a.Name, + OS: goos, + Arch: goarch, + URL: a.URL, + SHA256: checksums[a.Name], + }) } return assets, nil } +func releaseChecksums(ghRepo, tag string) (map[string]string, error) { + cmd := exec.Command("gh", "release", "download", tag, "--repo", ghRepo, "--pattern", "checksums.txt", "--output", "-") // #nosec G204 -- ghRepo+tag from trusted manifest + out, err := cmd.Output() + if err != nil { + return nil, err + } + return parseReleaseChecksums(string(out)), nil +} + +func parseReleaseChecksums(text string) map[string]string { + checksums := make(map[string]string) + for _, line := range strings.Split(text, "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + sha, err := NormalizeSHA256Hex(fields[0]) + if err != nil { + continue + } + checksums[filepath.Base(fields[len(fields)-1])] = sha + } + return checksums +} + func releaseAssetPlatform(assetName string) (string, string, bool) { nameNoExt := strings.TrimSuffix(assetName, ".tar.gz") nameNoExt = strings.TrimSuffix(nameNoExt, ".tgz") @@ -511,18 +544,22 @@ func versionGT(newVer, oldVer string) bool { } func applyFix(manifestPath string, raw map[string]any, ghRepo, targetTag, targetVersion string) error { - downloads, _ := releaseDownloads(ghRepo, targetTag) + downloads, _ := registrySyncReleaseDownloads(ghRepo, targetTag) if len(downloads) == 0 { raw["version"] = targetVersion } else { raw["version"] = targetVersion dlAny := make([]any, 0, len(downloads)) for _, dl := range downloads { - dlAny = append(dlAny, map[string]any{ + entry := map[string]any{ "os": dl.OS, "arch": dl.Arch, "url": dl.URL, - }) + } + if dl.SHA256 != "" { + entry["sha256"] = dl.SHA256 + } + dlAny = append(dlAny, entry) } raw["downloads"] = dlAny } diff --git a/cmd/wfctl/plugin_registry_sync_test.go b/cmd/wfctl/plugin_registry_sync_test.go index 2687d402..a6f3c29d 100644 --- a/cmd/wfctl/plugin_registry_sync_test.go +++ b/cmd/wfctl/plugin_registry_sync_test.go @@ -145,6 +145,24 @@ func TestPluginRegistrySync_ReleaseAssetPlatform(t *testing.T) { } } +func TestPluginRegistrySync_ParseReleaseChecksums(t *testing.T) { + got := parseReleaseChecksums(` +44A1F367B554555A872EC274D9B50D71372D40FA66704C3F806F1CAFAF14412C workflow-plugin-aws-darwin-amd64.tar.gz +not-a-sha workflow-plugin-aws-linux-amd64.tar.gz +1f1043c2addbc1a668873d12b1696c03ab428c32df034425fc072d2235af664b ./dist/workflow-plugin-aws-darwin-arm64.tar.gz +`) + + if got["workflow-plugin-aws-darwin-amd64.tar.gz"] != "44a1f367b554555a872ec274d9b50d71372d40fa66704c3f806f1cafaf14412c" { + t.Fatalf("darwin amd64 checksum = %q", got["workflow-plugin-aws-darwin-amd64.tar.gz"]) + } + if got["workflow-plugin-aws-darwin-arm64.tar.gz"] != "1f1043c2addbc1a668873d12b1696c03ab428c32df034425fc072d2235af664b" { + t.Fatalf("darwin arm64 checksum = %q", got["workflow-plugin-aws-darwin-arm64.tar.gz"]) + } + if _, ok := got["workflow-plugin-aws-linux-amd64.tar.gz"]; ok { + t.Fatal("invalid checksum should be ignored") + } +} + func TestPluginRegistrySync_MetadataSyncProjectsIaCServices(t *testing.T) { raw := map[string]any{ "capabilities": map[string]any{ @@ -498,6 +516,48 @@ func TestPluginRegistrySync_AssetBinaryName(t *testing.T) { } } +func TestPluginRegistrySync_ApplyFixIncludesDownloadChecksums(t *testing.T) { + restoreRegistrySyncTestHooks(t) + + registrySyncReleaseDownloads = func(ghRepo, tag string) ([]releaseAsset, error) { + if ghRepo != "owner/repo" || tag != "v1.2.3" { + t.Fatalf("releaseDownloads args = %q %q, want owner/repo v1.2.3", ghRepo, tag) + } + return []releaseAsset{{ + Name: "workflow-plugin-foo-linux-amd64.tar.gz", + OS: "linux", + Arch: "amd64", + URL: "https://github.com/owner/repo/releases/download/v1.2.3/workflow-plugin-foo-linux-amd64.tar.gz", + SHA256: strings.Repeat("a", 64), + }}, nil + } + + manifest := filepath.Join(t.TempDir(), "manifest.json") + raw := map[string]any{ + "name": "workflow-plugin-foo", + "type": "external", + "version": "1.0.0", + } + + if err := applyFix(manifest, raw, "owner/repo", "v1.2.3", "1.2.3"); err != nil { + t.Fatalf("applyFix returned error: %v", err) + } + + var got map[string]any + data, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + downloads := got["downloads"].([]any) + first := downloads[0].(map[string]any) + if first["sha256"] != strings.Repeat("a", 64) { + t.Fatalf("sha256 = %q", first["sha256"]) + } +} + func TestPluginRegistrySync_VerifyCapabilitiesDownloadsExtractsAndSkipsName(t *testing.T) { restoreRegistrySyncTestHooks(t) From 9f3712d9d54b887bd1edad4f3f2f5d3c56b56a36 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 3 Jun 2026 06:07:13 -0400 Subject: [PATCH 2/2] fix(wfctl): harden registry checksum sync --- cmd/wfctl/plugin_registry_sync.go | 79 ++++++++++++++++++++++---- cmd/wfctl/plugin_registry_sync_test.go | 64 +++++++++++++++++++++ 2 files changed, 132 insertions(+), 11 deletions(-) diff --git a/cmd/wfctl/plugin_registry_sync.go b/cmd/wfctl/plugin_registry_sync.go index 4ea0ce06..67f2d768 100644 --- a/cmd/wfctl/plugin_registry_sync.go +++ b/cmd/wfctl/plugin_registry_sync.go @@ -167,7 +167,12 @@ func syncDefault(registryDir string, fix bool, pluginFilter string, verifyCaps b currentReleaseExists = false } if versionGT(latestVersion, manifestVersion) || !currentReleaseExists { - latestDownloads, _ := releaseDownloads(ghRepo, latestTag) + latestDownloads, err := releaseDownloads(ghRepo, latestTag) + if err != nil { + fmt.Fprintf(os.Stderr, " ERROR %s — list release downloads for %s: %v\n", pluginName, latestTag, err) + mismatches++ + continue + } switch { case len(latestDownloads) > 0: targetVersion = latestVersion @@ -295,7 +300,10 @@ func releaseDownloads(ghRepo, tag string) ([]releaseAsset, error) { if err := json.Unmarshal(out, &resp); err != nil { return nil, err } - checksums, _ := releaseChecksums(ghRepo, tag) + checksums, err := releaseChecksums(ghRepo, tag) + if err != nil { + return nil, err + } var assets []releaseAsset for _, a := range resp.Assets { goos, goarch, ok := releaseAssetPlatform(a.Name) @@ -315,9 +323,13 @@ func releaseDownloads(ghRepo, tag string) ([]releaseAsset, error) { func releaseChecksums(ghRepo, tag string) (map[string]string, error) { cmd := exec.Command("gh", "release", "download", tag, "--repo", ghRepo, "--pattern", "checksums.txt", "--output", "-") // #nosec G204 -- ghRepo+tag from trusted manifest - out, err := cmd.Output() + out, err := cmd.CombinedOutput() if err != nil { - return nil, err + msg := strings.ToLower(string(out)) + if strings.Contains(msg, "no assets match") || strings.Contains(msg, "no matching assets") || strings.Contains(msg, "not found") { + return map[string]string{}, nil + } + return nil, fmt.Errorf("download checksums.txt: %w: %s", err, strings.TrimSpace(string(out))) } return parseReleaseChecksums(string(out)), nil } @@ -325,15 +337,24 @@ func releaseChecksums(ghRepo, tag string) (map[string]string, error) { func parseReleaseChecksums(text string) map[string]string { checksums := make(map[string]string) for _, line := range strings.Split(text, "\n") { - fields := strings.Fields(line) - if len(fields) < 2 { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, " ", 2) + if len(parts) != 2 { continue } - sha, err := NormalizeSHA256Hex(fields[0]) + sha, err := NormalizeSHA256Hex(parts[0]) if err != nil { continue } - checksums[filepath.Base(fields[len(fields)-1])] = sha + name := strings.TrimSpace(parts[1]) + name = strings.TrimPrefix(name, "*") + if name == "" { + continue + } + checksums[filepath.Base(name)] = sha } return checksums } @@ -544,20 +565,28 @@ func versionGT(newVer, oldVer string) bool { } func applyFix(manifestPath string, raw map[string]any, ghRepo, targetTag, targetVersion string) error { - downloads, _ := registrySyncReleaseDownloads(ghRepo, targetTag) + downloads, err := registrySyncReleaseDownloads(ghRepo, targetTag) + if err != nil { + return fmt.Errorf("list release downloads: %w", err) + } if len(downloads) == 0 { raw["version"] = targetVersion } else { + existingSHAs := existingDownloadSHA256(raw) raw["version"] = targetVersion dlAny := make([]any, 0, len(downloads)) for _, dl := range downloads { + sha := dl.SHA256 + if sha == "" { + sha = existingSHAs[downloadIdentity(dl.OS, dl.Arch, dl.URL)] + } entry := map[string]any{ "os": dl.OS, "arch": dl.Arch, "url": dl.URL, } - if dl.SHA256 != "" { - entry["sha256"] = dl.SHA256 + if sha != "" { + entry["sha256"] = sha } dlAny = append(dlAny, entry) } @@ -579,6 +608,34 @@ func applyFix(manifestPath string, raw map[string]any, ghRepo, targetTag, target return os.WriteFile(manifestPath, out, 0644) // #nosec G306 } +func existingDownloadSHA256(raw map[string]any) map[string]string { + out := make(map[string]string) + downloads, _ := raw["downloads"].([]any) + for _, item := range downloads { + entry, _ := item.(map[string]any) + if entry == nil { + continue + } + goos, _ := entry["os"].(string) + goarch, _ := entry["arch"].(string) + url, _ := entry["url"].(string) + sha, _ := entry["sha256"].(string) + if goos == "" || goarch == "" || url == "" || sha == "" { + continue + } + normalized, err := NormalizeSHA256Hex(sha) + if err != nil { + continue + } + out[downloadIdentity(goos, goarch, url)] = normalized + } + return out +} + +func downloadIdentity(goos, goarch, url string) string { + return goos + "\x00" + goarch + "\x00" + url +} + func syncManifestMetadataFromPluginJSON(raw, pluginJSON map[string]any) { var caps map[string]any if caps, ok := pluginJSON["capabilities"]; ok && caps != nil { diff --git a/cmd/wfctl/plugin_registry_sync_test.go b/cmd/wfctl/plugin_registry_sync_test.go index a6f3c29d..128f40be 100644 --- a/cmd/wfctl/plugin_registry_sync_test.go +++ b/cmd/wfctl/plugin_registry_sync_test.go @@ -150,6 +150,7 @@ func TestPluginRegistrySync_ParseReleaseChecksums(t *testing.T) { 44A1F367B554555A872EC274D9B50D71372D40FA66704C3F806F1CAFAF14412C workflow-plugin-aws-darwin-amd64.tar.gz not-a-sha workflow-plugin-aws-linux-amd64.tar.gz 1f1043c2addbc1a668873d12b1696c03ab428c32df034425fc072d2235af664b ./dist/workflow-plugin-aws-darwin-arm64.tar.gz +b9dd1cc9c84498be7cfdea1fc3846a8965900ecc870d6be5dd2069300e8f351c plugin name with spaces.tar.gz `) if got["workflow-plugin-aws-darwin-amd64.tar.gz"] != "44a1f367b554555a872ec274d9b50d71372d40fa66704c3f806f1cafaf14412c" { @@ -161,6 +162,9 @@ not-a-sha workflow-plugin-aws-linux-amd64.tar.gz if _, ok := got["workflow-plugin-aws-linux-amd64.tar.gz"]; ok { t.Fatal("invalid checksum should be ignored") } + if got["plugin name with spaces.tar.gz"] != "b9dd1cc9c84498be7cfdea1fc3846a8965900ecc870d6be5dd2069300e8f351c" { + t.Fatalf("spaced filename checksum = %q", got["plugin name with spaces.tar.gz"]) + } } func TestPluginRegistrySync_MetadataSyncProjectsIaCServices(t *testing.T) { @@ -558,6 +562,66 @@ func TestPluginRegistrySync_ApplyFixIncludesDownloadChecksums(t *testing.T) { } } +func TestPluginRegistrySync_ApplyFixPreservesExistingDownloadChecksum(t *testing.T) { + restoreRegistrySyncTestHooks(t) + + url := "https://github.com/owner/repo/releases/download/v1.2.3/workflow-plugin-foo-linux-amd64.tar.gz" + registrySyncReleaseDownloads = func(string, string) ([]releaseAsset, error) { + return []releaseAsset{{ + Name: "workflow-plugin-foo-linux-amd64.tar.gz", + OS: "linux", + Arch: "amd64", + URL: url, + }}, nil + } + + manifest := filepath.Join(t.TempDir(), "manifest.json") + raw := map[string]any{ + "name": "workflow-plugin-foo", + "type": "external", + "version": "1.0.0", + "downloads": []any{ + map[string]any{ + "os": "linux", + "arch": "amd64", + "url": url, + "sha256": strings.Repeat("B", 64), + }, + }, + } + + if err := applyFix(manifest, raw, "owner/repo", "v1.2.3", "1.2.3"); err != nil { + t.Fatalf("applyFix returned error: %v", err) + } + + var got map[string]any + data, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + downloads := got["downloads"].([]any) + first := downloads[0].(map[string]any) + if first["sha256"] != strings.Repeat("b", 64) { + t.Fatalf("sha256 = %q", first["sha256"]) + } +} + +func TestPluginRegistrySync_ApplyFixPropagatesDownloadErrors(t *testing.T) { + restoreRegistrySyncTestHooks(t) + + registrySyncReleaseDownloads = func(string, string) ([]releaseAsset, error) { + return nil, errors.New("checksum fetch failed") + } + + err := applyFix(filepath.Join(t.TempDir(), "manifest.json"), map[string]any{}, "owner/repo", "v1.2.3", "1.2.3") + if err == nil || !strings.Contains(err.Error(), "checksum fetch failed") { + t.Fatalf("error = %v, want checksum fetch failure", err) + } +} + func TestPluginRegistrySync_VerifyCapabilitiesDownloadsExtractsAndSkipsName(t *testing.T) { restoreRegistrySyncTestHooks(t)