Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 103 additions & 9 deletions cmd/wfctl/plugin_registry_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Comment thread
intel352 marked this conversation as resolved.

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
}
Comment thread
intel352 marked this conversation as resolved.

func releaseAssetPlatform(assetName string) (string, string, bool) {
nameNoExt := strings.TrimSuffix(assetName, ".tar.gz")
nameNoExt = strings.TrimSuffix(nameNoExt, ".tgz")
Expand Down Expand Up @@ -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,
Comment thread
intel352 marked this conversation as resolved.
})
}
if sha != "" {
entry["sha256"] = sha
}
dlAny = append(dlAny, entry)
}
raw["downloads"] = dlAny
}
Expand All @@ -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 {
Expand Down
124 changes: 124 additions & 0 deletions cmd/wfctl/plugin_registry_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)

Expand Down
Loading