diff --git a/cmd/wfctl/plugin_registry_sync.go b/cmd/wfctl/plugin_registry_sync.go index 0d38d61d..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 @@ -270,10 +275,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 +300,65 @@ func releaseDownloads(ghRepo, tag string) ([]releaseAsset, error) { if err := json.Unmarshal(out, &resp); err != nil { return nil, err } + 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) 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.CombinedOutput() + if err != nil { + 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 +} + +func parseReleaseChecksums(text string) map[string]string { + checksums := make(map[string]string) + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, " ", 2) + if len(parts) != 2 { + continue + } + sha, err := NormalizeSHA256Hex(parts[0]) + if err != nil { + continue + } + name := strings.TrimSpace(parts[1]) + name = strings.TrimPrefix(name, "*") + if name == "" { + continue + } + checksums[filepath.Base(name)] = sha + } + return checksums +} + func releaseAssetPlatform(assetName string) (string, string, bool) { nameNoExt := strings.TrimSuffix(assetName, ".tar.gz") nameNoExt = strings.TrimSuffix(nameNoExt, ".tgz") @@ -511,18 +565,30 @@ func versionGT(newVer, oldVer string) bool { } func applyFix(manifestPath string, raw map[string]any, ghRepo, targetTag, targetVersion string) error { - downloads, _ := releaseDownloads(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 { - dlAny = append(dlAny, map[string]any{ + 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 sha != "" { + entry["sha256"] = sha + } + dlAny = append(dlAny, entry) } raw["downloads"] = dlAny } @@ -542,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 2687d402..128f40be 100644 --- a/cmd/wfctl/plugin_registry_sync_test.go +++ b/cmd/wfctl/plugin_registry_sync_test.go @@ -145,6 +145,28 @@ 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 +b9dd1cc9c84498be7cfdea1fc3846a8965900ecc870d6be5dd2069300e8f351c plugin name with spaces.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") + } + 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) { raw := map[string]any{ "capabilities": map[string]any{ @@ -498,6 +520,108 @@ 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_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)