From 7cde02bf025d890a3c441305154efb4a59b03afa Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Tue, 21 Oct 2025 12:02:46 -0500 Subject: [PATCH] implement spdx license check for manifests, clean up generators --- .goreleaser.yaml | 1 + pkg/appview/licenses/.gitignore | 2 + pkg/appview/licenses/integration_test.go | 64 +++++++ pkg/appview/licenses/licenses.go | 166 ++++++++++++++++++ pkg/appview/licenses/licenses_test.go | 125 +++++++++++++ pkg/appview/licenses/template_example_test.go | 134 ++++++++++++++ pkg/appview/static/css/style.css | 66 ++++++- pkg/appview/templates/pages/repository.html | 22 ++- pkg/appview/templates/partials/push-list.html | 5 +- pkg/appview/ui.go | 6 + gen/main.go => pkg/atproto/generate.go | 9 +- pkg/atproto/lexicon.go | 2 + 12 files changed, 587 insertions(+), 15 deletions(-) create mode 100644 pkg/appview/licenses/.gitignore create mode 100644 pkg/appview/licenses/integration_test.go create mode 100644 pkg/appview/licenses/licenses.go create mode 100644 pkg/appview/licenses/licenses_test.go create mode 100644 pkg/appview/licenses/template_example_test.go rename gen/main.go => pkg/atproto/generate.go (77%) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7c57318..8c8088d 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -6,6 +6,7 @@ version: 2 before: hooks: - go mod tidy + - go generate ./... builds: # Credential helper - cross-platform native binary distribution diff --git a/pkg/appview/licenses/.gitignore b/pkg/appview/licenses/.gitignore new file mode 100644 index 0000000..7007865 --- /dev/null +++ b/pkg/appview/licenses/.gitignore @@ -0,0 +1,2 @@ +# Generated SPDX license data +spdx-licenses.json diff --git a/pkg/appview/licenses/integration_test.go b/pkg/appview/licenses/integration_test.go new file mode 100644 index 0000000..32bf530 --- /dev/null +++ b/pkg/appview/licenses/integration_test.go @@ -0,0 +1,64 @@ +package licenses_test + +import ( + "html/template" + "strings" + "testing" + + "atcr.io/pkg/appview/licenses" +) + +// Test template integration with parseLicenses +func TestTemplateIntegration(t *testing.T) { + funcMap := template.FuncMap{ + "parseLicenses": func(licensesStr string) []licenses.LicenseInfo { + return licenses.ParseLicenses(licensesStr) + }, + } + + tmplStr := `{{ range parseLicenses . }}{{ if .IsValid }}[VALID:{{ .SPDXID }}:{{ .URL }}]{{ else }}[INVALID:{{ .Name }}]{{ end }}{{ end }}` + + tmpl := template.Must(template.New("test").Funcs(funcMap).Parse(tmplStr)) + + tests := []struct { + name string + input string + wantText string + }{ + { + name: "MIT license", + input: "MIT", + wantText: "[VALID:MIT:https://spdx.org/licenses/MIT.html]", + }, + { + name: "Multiple licenses", + input: "MIT, Apache-2.0", + wantText: "[VALID:MIT:https://spdx.org/licenses/MIT.html][VALID:Apache-2.0:https://spdx.org/licenses/Apache-2.0.html]", + }, + { + name: "Unknown license", + input: "CustomProprietary", + wantText: "[INVALID:CustomProprietary]", + }, + { + name: "Mixed valid and invalid", + input: "MIT, CustomLicense, Apache-2.0", + wantText: "[VALID:MIT:https://spdx.org/licenses/MIT.html][INVALID:CustomLicense][VALID:Apache-2.0:https://spdx.org/licenses/Apache-2.0.html]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + err := tmpl.Execute(&buf, tt.input) + if err != nil { + t.Fatalf("Template execution failed: %v", err) + } + + got := buf.String() + if got != tt.wantText { + t.Errorf("Template output mismatch:\nGot: %s\nWant: %s", got, tt.wantText) + } + }) + } +} diff --git a/pkg/appview/licenses/licenses.go b/pkg/appview/licenses/licenses.go new file mode 100644 index 0000000..59aacfd --- /dev/null +++ b/pkg/appview/licenses/licenses.go @@ -0,0 +1,166 @@ +package licenses + +//go:generate curl -fsSL -o spdx-licenses.json https://spdx.org/licenses/licenses.json + +import ( + _ "embed" + "encoding/json" + "strings" +) + +//go:embed spdx-licenses.json +var spdxLicensesJSON []byte + +// SPDXLicense represents a license from the SPDX license list +type SPDXLicense struct { + LicenseID string `json:"licenseId"` + Name string `json:"name"` + Reference string `json:"reference"` + IsOsiApproved bool `json:"isOsiApproved"` + IsDeprecated bool `json:"isDeprecatedLicenseId"` + DetailsURL string `json:"detailsUrl"` + SeeAlso []string `json:"seeAlso"` + IsFsfLibre bool `json:"isFsfLibre,omitempty"` +} + +// SPDXLicenseList represents the complete SPDX license list JSON structure +type SPDXLicenseList struct { + LicenseListVersion string `json:"licenseListVersion"` + Licenses []SPDXLicense `json:"licenses"` + ReleaseDate string `json:"releaseDate"` +} + +// LicenseInfo represents parsed license information for template rendering +type LicenseInfo struct { + Name string // Original name from annotation + SPDXID string // Normalized SPDX identifier + URL string // Link to SPDX license page + IsValid bool // Whether this is a recognized SPDX license +} + +var spdxLicenses map[string]SPDXLicense +var spdxLicenseListVersion string + +// init parses the embedded SPDX license list JSON and builds a lookup map +func init() { + var list SPDXLicenseList + if err := json.Unmarshal(spdxLicensesJSON, &list); err != nil { + // If parsing fails, just use an empty map + spdxLicenses = make(map[string]SPDXLicense) + return + } + + spdxLicenseListVersion = list.LicenseListVersion + + // Build lookup map: licenseId -> SPDXLicense + spdxLicenses = make(map[string]SPDXLicense, len(list.Licenses)) + for _, lic := range list.Licenses { + // Store with original ID + spdxLicenses[lic.LicenseID] = lic + + // Also store normalized version (lowercase, no spaces/dashes) + normalized := normalizeID(lic.LicenseID) + spdxLicenses[normalized] = lic + } +} + +// normalizeID converts a license ID to a normalized form for fuzzy matching +// Examples: "Apache-2.0" -> "apache20", "GPL-3.0-only" -> "gpl30only" +func normalizeID(id string) string { + id = strings.ToLower(id) + id = strings.ReplaceAll(id, "-", "") + id = strings.ReplaceAll(id, "_", "") + id = strings.ReplaceAll(id, ".", "") + id = strings.ReplaceAll(id, " ", "") + return id +} + +// GetLicenseInfo looks up a license by SPDX ID with fuzzy matching +func GetLicenseInfo(licenseID string) (LicenseInfo, bool) { + // Try exact match first + if lic, ok := spdxLicenses[licenseID]; ok { + return LicenseInfo{ + Name: lic.Name, + SPDXID: lic.LicenseID, + URL: lic.Reference, + IsValid: true, + }, true + } + + // Try normalized match + normalized := normalizeID(licenseID) + if lic, ok := spdxLicenses[normalized]; ok { + return LicenseInfo{ + Name: lic.Name, + SPDXID: lic.LicenseID, + URL: lic.Reference, + IsValid: true, + }, true + } + + // Not found - return invalid license info + return LicenseInfo{ + Name: licenseID, + SPDXID: licenseID, + URL: "", + IsValid: false, + }, false +} + +// ParseLicenses parses a license string (possibly containing multiple licenses) +// and returns a slice of LicenseInfo structs. +// +// Supported separators: comma, semicolon, " AND ", " OR " +// Examples: +// - "MIT" -> [{MIT}] +// - "MIT, Apache-2.0" -> [{MIT}, {Apache-2.0}] +// - "MIT AND Apache-2.0" -> [{MIT}, {Apache-2.0}] +func ParseLicenses(licensesStr string) []LicenseInfo { + if licensesStr == "" { + return nil + } + + // Split on various separators + licensesStr = strings.ReplaceAll(licensesStr, " AND ", ",") + licensesStr = strings.ReplaceAll(licensesStr, " OR ", ",") + licensesStr = strings.ReplaceAll(licensesStr, ";", ",") + + parts := strings.Split(licensesStr, ",") + + var result []LicenseInfo + seen := make(map[string]bool) // Deduplicate + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + // Skip if we've already seen this license + if seen[part] { + continue + } + seen[part] = true + + // Look up license info + info, found := GetLicenseInfo(part) + if !found { + // Unknown license - still include it as invalid + info = LicenseInfo{ + Name: part, + SPDXID: part, + URL: "", + IsValid: false, + } + } + + result = append(result, info) + } + + return result +} + +// GetVersion returns the SPDX License List version +func GetVersion() string { + return spdxLicenseListVersion +} diff --git a/pkg/appview/licenses/licenses_test.go b/pkg/appview/licenses/licenses_test.go new file mode 100644 index 0000000..e6e454c --- /dev/null +++ b/pkg/appview/licenses/licenses_test.go @@ -0,0 +1,125 @@ +package licenses + +import ( + "testing" +) + +func TestGetLicenseInfo(t *testing.T) { + tests := []struct { + name string + input string + wantValid bool + wantSPDX string + }{ + {"MIT exact", "MIT", true, "MIT"}, + {"Apache-2.0 exact", "Apache-2.0", true, "Apache-2.0"}, + {"Apache 2.0 fuzzy", "Apache 2.0", true, "Apache-2.0"}, + {"GPL-3.0 exact", "GPL-3.0-only", true, "GPL-3.0-only"}, + {"Unknown license", "CustomProprietary", false, "CustomProprietary"}, + {"BSD-3-Clause", "BSD-3-Clause", true, "BSD-3-Clause"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info, found := GetLicenseInfo(tt.input) + + if info.IsValid != tt.wantValid { + t.Errorf("GetLicenseInfo(%q).IsValid = %v, want %v", tt.input, info.IsValid, tt.wantValid) + } + + if tt.wantValid && !found { + t.Errorf("GetLicenseInfo(%q) not found, want found", tt.input) + } + + if info.SPDXID != tt.wantSPDX { + t.Errorf("GetLicenseInfo(%q).SPDXID = %q, want %q", tt.input, info.SPDXID, tt.wantSPDX) + } + + if info.IsValid && info.URL == "" { + t.Errorf("GetLicenseInfo(%q).URL is empty for valid license", tt.input) + } + }) + } +} + +func TestParseLicenses(t *testing.T) { + tests := []struct { + name string + input string + wantCount int + wantFirst string + wantSecond string + }{ + {"Single license", "MIT", 1, "MIT", ""}, + {"Two licenses comma", "MIT, Apache-2.0", 2, "MIT", "Apache-2.0"}, + {"Two licenses AND", "MIT AND Apache-2.0", 2, "MIT", "Apache-2.0"}, + {"Three licenses", "MIT, Apache-2.0, GPL-3.0-only", 3, "MIT", "Apache-2.0"}, + {"Empty string", "", 0, "", ""}, + {"Whitespace", " MIT ", 1, "MIT", ""}, + {"Duplicate licenses", "MIT, MIT, Apache-2.0", 2, "MIT", "Apache-2.0"}, + {"Mixed separators", "MIT; Apache-2.0, BSD-3-Clause", 3, "MIT", "Apache-2.0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseLicenses(tt.input) + + if len(result) != tt.wantCount { + t.Errorf("ParseLicenses(%q) returned %d licenses, want %d", tt.input, len(result), tt.wantCount) + } + + if tt.wantCount > 0 && result[0].SPDXID != tt.wantFirst { + t.Errorf("ParseLicenses(%q)[0].SPDXID = %q, want %q", tt.input, result[0].SPDXID, tt.wantFirst) + } + + if tt.wantCount > 1 && result[1].SPDXID != tt.wantSecond { + t.Errorf("ParseLicenses(%q)[1].SPDXID = %q, want %q", tt.input, result[1].SPDXID, tt.wantSecond) + } + }) + } +} + +func TestNormalizeID(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"MIT", "mit"}, + {"Apache-2.0", "apache20"}, + {"GPL-3.0-only", "gpl30only"}, + {"BSD-3-Clause", "bsd3clause"}, + {"CC-BY-4.0", "ccby40"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := normalizeID(tt.input) + if got != tt.want { + t.Errorf("normalizeID(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestGetVersion(t *testing.T) { + version := GetVersion() + if version == "" { + t.Error("GetVersion() returned empty string") + } + t.Logf("SPDX License List version: %s", version) +} + +func TestSPDXDataLoaded(t *testing.T) { + if len(spdxLicenses) == 0 { + t.Fatal("SPDX license data not loaded") + } + t.Logf("Loaded %d SPDX licenses", len(spdxLicenses)) + + // Verify some common licenses exist + commonLicenses := []string{"MIT", "Apache-2.0", "GPL-3.0-only", "BSD-3-Clause"} + for _, lic := range commonLicenses { + if _, ok := spdxLicenses[lic]; !ok { + t.Errorf("Common license %q not found in SPDX data", lic) + } + } +} diff --git a/pkg/appview/licenses/template_example_test.go b/pkg/appview/licenses/template_example_test.go new file mode 100644 index 0000000..23232d2 --- /dev/null +++ b/pkg/appview/licenses/template_example_test.go @@ -0,0 +1,134 @@ +package licenses_test + +import ( + "html/template" + "strings" + "testing" + + "atcr.io/pkg/appview/licenses" +) + +// TestRepositoryPageTemplate demonstrates how the license badges will render +// in the actual repository.html template +func TestRepositoryPageTemplate(t *testing.T) { + funcMap := template.FuncMap{ + "parseLicenses": func(licensesStr string) []licenses.LicenseInfo { + return licenses.ParseLicenses(licensesStr) + }, + } + + // This is the exact template structure from repository.html + tmplStr := `{{ if .Licenses }}` + + `{{ range parseLicenses .Licenses }}` + + `{{ if .IsValid }}` + + `{{ .SPDXID }}` + + `{{ else }}` + + `{{ .Name }}` + + `{{ end }}` + + `{{ end }}` + + `{{ end }}` + + tmpl := template.Must(template.New("test").Funcs(funcMap).Parse(tmplStr)) + + tests := []struct { + name string + licenses string + wantContain []string + wantNotContain []string + }{ + { + name: "MIT license", + licenses: "MIT", + wantContain: []string{ + `MIT`, + }, + }, + { + name: "Multiple valid licenses", + licenses: "MIT, Apache-2.0, GPL-3.0-only", + wantContain: []string{ + `https://spdx.org/licenses/MIT.html`, + `https://spdx.org/licenses/Apache-2.0.html`, + `https://spdx.org/licenses/GPL-3.0-only.html`, + `>MIT`, + `>Apache-2.0`, + `>GPL-3.0-only`, + }, + }, + { + name: "Custom license", + licenses: "CustomProprietary", + wantContain: []string{ + `CustomProprietary`, + }, + wantNotContain: []string{ + `MIT`, + // Custom license should be a span + `MyCustomLicense`, + }, + }, + { + name: "Apache fuzzy match", + licenses: "Apache 2.0", + wantContain: []string{ + `https://spdx.org/licenses/Apache-2.0.html`, + `>Apache-2.0`, + }, + }, + { + name: "Empty licenses", + licenses: "", + wantNotContain: []string{ + ` {{ if .Repository.Licenses }} - + {{ range parseLicenses .Repository.Licenses }} + {{ if .IsValid }} + + {{ .SPDXID }} + + {{ else }} + + {{ .Name }} + + {{ end }} + {{ end }} {{ end }} {{ if .Repository.SourceURL }} @@ -129,7 +139,10 @@
- {{ .Tag.Digest }} +
+ {{ .Tag.Digest }} + +
{{ if .Platforms }}
{{ range .Platforms }} @@ -183,7 +196,10 @@ {{ else if not .Reachable }} ⚠️ Offline {{ end }} - {{ .Manifest.Digest }} +
+ {{ .Manifest.Digest }} + +
- {{ .Digest }} +
+ {{ .Digest }} + +