implement spdx license check for manifests, clean up generators

This commit is contained in:
Evan Jarrett
2025-10-21 12:02:46 -05:00
parent 1f72d90726
commit 7cde02bf02
12 changed files with 587 additions and 15 deletions
+1
View File
@@ -6,6 +6,7 @@ version: 2
before:
hooks:
- go mod tidy
- go generate ./...
builds:
# Credential helper - cross-platform native binary distribution
+2
View File
@@ -0,0 +1,2 @@
# Generated SPDX license data
spdx-licenses.json
+64
View File
@@ -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)
}
})
}
}
+166
View File
@@ -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
}
+125
View File
@@ -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)
}
}
}
@@ -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 }}` +
`<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" class="metadata-badge license-badge" title="{{ .Name }}">{{ .SPDXID }}</a>` +
`{{ else }}` +
`<span class="metadata-badge license-badge" title="Custom license: {{ .Name }}">{{ .Name }}</span>` +
`{{ 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{
`<a href="https://spdx.org/licenses/MIT.html"`,
`class="metadata-badge license-badge"`,
`title="MIT License"`,
`>MIT</a>`,
},
},
{
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</a>`,
`>Apache-2.0</a>`,
`>GPL-3.0-only</a>`,
},
},
{
name: "Custom license",
licenses: "CustomProprietary",
wantContain: []string{
`<span class="metadata-badge license-badge"`,
`title="Custom license: CustomProprietary"`,
`>CustomProprietary</span>`,
},
wantNotContain: []string{
`<a href=`,
`https://spdx.org`,
},
},
{
name: "Mixed valid and custom",
licenses: "MIT, MyCustomLicense",
wantContain: []string{
// Valid license (MIT) should be a link
`<a href="https://spdx.org/licenses/MIT.html"`,
`>MIT</a>`,
// Custom license should be a span
`<span class="metadata-badge license-badge"`,
`title="Custom license: MyCustomLicense"`,
`>MyCustomLicense</span>`,
},
},
{
name: "Apache fuzzy match",
licenses: "Apache 2.0",
wantContain: []string{
`https://spdx.org/licenses/Apache-2.0.html`,
`>Apache-2.0</a>`,
},
},
{
name: "Empty licenses",
licenses: "",
wantNotContain: []string{
`<a `,
`<span`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := struct{ Licenses string }{Licenses: tt.licenses}
var buf strings.Builder
err := tmpl.Execute(&buf, data)
if err != nil {
t.Fatalf("Template execution failed: %v", err)
}
output := buf.String()
// Check for expected content
for _, want := range tt.wantContain {
if !strings.Contains(output, want) {
t.Errorf("Output missing expected content:\nWant: %s\nGot: %s", want, output)
}
}
// Check for unexpected content
for _, notWant := range tt.wantNotContain {
if strings.Contains(output, notWant) {
t.Errorf("Output contains unexpected content:\nDon't want: %s\nGot: %s", notWant, output)
}
}
t.Logf("Template output:\n%s", output)
})
}
}
+57 -9
View File
@@ -338,6 +338,45 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
background: var(--code-bg);
padding: 0.1rem 0.3rem;
border-radius: 3px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
vertical-align: middle;
position: relative;
}
/* Digest with copy button container */
.digest-container {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
/* Digest tooltip on hover - using title attribute for native browser tooltip */
.digest {
cursor: default;
}
/* Digest copy button */
.digest-copy-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--secondary);
padding: 0.1rem 0.4rem;
font-size: 0.75rem;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
}
.digest-copy-btn:hover {
background: var(--hover-bg);
border-color: var(--primary);
color: var(--primary);
}
.separator {
@@ -491,6 +530,21 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
border: 1px solid #90caf9;
}
/* Clickable license badges */
a.license-badge {
text-decoration: none;
cursor: pointer;
transition: all 0.2s ease;
}
a.license-badge:hover {
background: var(--primary);
color: var(--bg);
border-color: var(--primary);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.repo-description {
color: var(--border-dark);
font-size: 0.95rem;
@@ -559,13 +613,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
color: var(--border-dark);
}
.tag-digest, .manifest-digest {
font-family: 'Monaco', 'Courier New', monospace;
font-size: 0.85rem;
background: var(--code-bg);
padding: 0.1rem 0.3rem;
border-radius: 3px;
}
/* Note: .tag-digest and .manifest-digest styling now handled by .digest class above */
/* Settings Page */
.settings-page {
@@ -813,7 +861,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
/* Repository Page */
.repository-page {
max-width: 1000px;
/* Let container's max-width (1200px) control page width */
margin: 0 auto;
}
@@ -1643,7 +1691,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
/* README and Repository Layout */
.repo-content-layout {
display: grid;
grid-template-columns: 1fr 400px;
grid-template-columns: 1fr 450px;
gap: 2rem;
margin-top: 2rem;
}
+19 -3
View File
@@ -46,7 +46,17 @@
{{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL }}
<div class="repo-metadata">
{{ if .Repository.Licenses }}
<span class="metadata-badge license-badge">{{ .Repository.Licenses }}</span>
{{ range parseLicenses .Repository.Licenses }}
{{ if .IsValid }}
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" class="metadata-badge license-badge" title="{{ .Name }}">
{{ .SPDXID }}
</a>
{{ else }}
<span class="metadata-badge license-badge" title="Custom license: {{ .Name }}">
{{ .Name }}
</span>
{{ end }}
{{ end }}
{{ end }}
{{ if .Repository.SourceURL }}
<a href="{{ .Repository.SourceURL }}" target="_blank" class="metadata-link">
@@ -129,7 +139,10 @@
</div>
<div class="tag-item-details">
<div style="display: flex; justify-content: space-between; align-items: center;">
<code class="digest">{{ .Tag.Digest }}</code>
<div class="digest-container">
<code class="digest" title="{{ .Tag.Digest }}">{{ .Tag.Digest }}</code>
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Tag.Digest }}')">📋</button>
</div>
{{ if .Platforms }}
<div class="platforms-inline">
{{ range .Platforms }}
@@ -183,7 +196,10 @@
{{ else if not .Reachable }}
<span class="offline-badge">⚠️ Offline</span>
{{ end }}
<code class="manifest-digest">{{ .Manifest.Digest }}</code>
<div class="digest-container">
<code class="digest manifest-digest" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code>
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Manifest.Digest }}')">📋</button>
</div>
</div>
<div style="display: flex; gap: 1rem; align-items: center;">
<time datetime="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
@@ -33,7 +33,10 @@
</div>
<div class="push-details">
<code class="digest" title="{{ .Digest }}">{{ .Digest }}</code>
<div class="digest-container">
<code class="digest" title="{{ .Digest }}">{{ .Digest }}</code>
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Digest }}')">📋</button>
</div>
<span class="separator"></span>
<time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
{{ timeAgo .CreatedAt }}
+6
View File
@@ -8,6 +8,8 @@ import (
"net/http"
"strings"
"time"
"atcr.io/pkg/appview/licenses"
)
//go:embed templates/**/*.html
@@ -84,6 +86,10 @@ func Templates() (*template.Template, error) {
// e.g., "sha256:abc123" becomes "sha256-abc123"
return strings.ReplaceAll(s, ":", "-")
},
"parseLicenses": func(licensesStr string) []licenses.LicenseInfo {
return licenses.ParseLicenses(licensesStr)
},
}
tmpl := template.New("").Funcs(funcMap)
+7 -2
View File
@@ -1,3 +1,6 @@
//go:build ignore
// +build ignore
package main
// CBOR Code Generator
@@ -5,10 +8,12 @@ package main
// This generates optimized CBOR marshaling code for ATProto records.
//
// Usage:
// go run gen/main.go
// go generate ./pkg/atproto/...
//
// This creates pkg/atproto/cbor_gen.go which should be committed to git.
// Only re-run when you modify types in pkg/atproto/types.go
//
// The //go:generate directive is in lexicon.go
import (
"fmt"
@@ -21,7 +26,7 @@ import (
func main() {
// Generate map-style encoders for CrewRecord and CaptainRecord
if err := cbg.WriteMapEncodersToFile("pkg/atproto/cbor_gen.go", "atproto",
if err := cbg.WriteMapEncodersToFile("cbor_gen.go", "atproto",
atproto.CrewRecord{},
atproto.CaptainRecord{},
); err != nil {
+2
View File
@@ -1,5 +1,7 @@
package atproto
//go:generate go run generate.go
import (
"encoding/base64"
"encoding/json"