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
104 changes: 87 additions & 17 deletions internal/sitegen/simple.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"strings"

"github.com/clean-dependency-project/cdprun/internal/config"
"github.com/clean-dependency-project/cdprun/internal/endoflife"
"github.com/clean-dependency-project/cdprun/internal/storage"
"log/slog"
)
Expand All @@ -31,6 +30,21 @@ type SimpleArtifactEntry struct {
Version string `json:"version"`
}

// UnsupportedEntry is a single item in the "unsupported" JSON key.
// Kind distinguishes two roles:
//
// - "line" — the entire version line is EOL (e.g. version "3.9" covers all Python 3.9.x).
// Clients should block every artifact whose version starts with this prefix.
// - "artifact" — this specific version is present in the artifact store and is EOL.
// Clients should remove or quarantine that specific artifact.
type UnsupportedEntry struct {
Version string `json:"version"`
EOL string `json:"eol,omitempty"`
Notes string `json:"notes,omitempty"`
Supported bool `json:"supported"` // always false
Kind string `json:"kind"` // "line" | "artifact"
}

func artifactTypeFromFilename(filename string) string {
lower := strings.ToLower(filename)
// Multi-part extensions first.
Expand Down Expand Up @@ -269,10 +283,9 @@ func renderVersionPage(runtime RuntimeModel, major int, unsupported config.Unsup
// Render JSON index: artifact entries + unsupported list filtered to this major.
artifactIndex := collectArtifactIndexByMajor(runtime, major)
allUnsupported := expandUnsupportedVersions(runtime, unsupported)
var majorUnsupported []endoflife.PolicyVersion
majorUnsupported := []UnsupportedEntry{}
for _, pv := range allUnsupported {
parsed, _, _, err := storage.ParseSemver(pv.Version)
if err == nil && parsed == major {
if versionBelongsToMajor(pv.Version, major) {
majorUnsupported = append(majorUnsupported, pv)
}
}
Expand Down Expand Up @@ -524,7 +537,7 @@ func renderSimpleRootIndex(model *SiteModel, unsupported config.UnsupportedConfi
for k, v := range allIndex {
out[k] = v
}
unsupportedByRuntime := make(map[string][]endoflife.PolicyVersion, len(model.Runtimes))
unsupportedByRuntime := make(map[string][]UnsupportedEntry, len(model.Runtimes))
for _, rt := range model.Runtimes {
unsupportedByRuntime[rt.Name] = expandUnsupportedVersions(rt, unsupported)
}
Expand Down Expand Up @@ -610,47 +623,104 @@ func collectAllArtifactIndex(model *SiteModel) SimpleRootIndex {
return index
}

// versionBelongsToMajor reports whether version string v has the given major version.
// Handles 1-component prefixes (e.g. "16"), 2-component (e.g. "3.9"), and full semver.
func versionBelongsToMajor(v string, major int) bool {
parsedMajor, _, _, err := storage.ParseSemver(v)
if err == nil {
return parsedMajor == major
}
// Single-component prefix like "16"
var m int
if n, scanErr := fmt.Sscanf(v, "%d", &m); n == 1 && scanErr == nil {
return m == major
}
return false
}

// parseSemverFull parses any version string (1, 2, or 3 components) into a
// comparable numeric tuple. Single-component strings like "8" or "16" return
// (major, 0, 0). This is used by the sort in expandUnsupportedVersions so that
// single-digit major prefixes ("8") sort correctly before double-digit ones ("10").
func parseSemverFull(v string) (major, minor, patch int, err error) {
maj, min, pat, e := storage.ParseSemver(v)
if e == nil {
return maj, min, pat, nil
}
// Single-component (e.g. "8", "16")
var m int
if n, scanErr := fmt.Sscanf(v, "%d", &m); n == 1 && scanErr == nil {
return m, 0, 0, nil
}
return 0, 0, 0, fmt.Errorf("cannot parse version %q", v)
}

// expandUnsupportedVersions builds the list of concrete unsupported versions for a runtime
// by walking every version present in the model and prefix-matching against unsupported rules.
// For each rule that matches at least one DB version, the rule's prefix (e.g. "3.9", "16") is
// also included so downstream clients can block the entire version line, not just the specific
// patches present in this artifact store.
// Duplicate concrete versions across platforms are deduplicated before matching.
func expandUnsupportedVersions(rt RuntimeModel, uc config.UnsupportedConfig) []endoflife.PolicyVersion {
// Always returns a non-nil slice so the JSON output is [] rather than null.
func expandUnsupportedVersions(rt RuntimeModel, uc config.UnsupportedConfig) []UnsupportedEntry {
result := []UnsupportedEntry{}
if len(uc) == 0 {
return nil
return result
}

seen := make(map[string]struct{})
var result []endoflife.PolicyVersion
seenConcrete := make(map[string]struct{})
seenPrefix := make(map[string]struct{})

for _, platform := range rt.Platforms {
for _, version := range platform.Versions {
v := version.Version
if _, already := seen[v]; already {
if _, already := seenConcrete[v]; already {
continue
}
seen[v] = struct{}{}
seenConcrete[v] = struct{}{}

rule := uc.FindMatchingRule(rt.Name, v)
if rule == nil {
continue
}

pv := endoflife.PolicyVersion{
// Emit the rule prefix once (e.g. "3.9", "16") so clients can block
// the entire version line regardless of which patches they have installed.
if _, prefixSeen := seenPrefix[rule.Version]; !prefixSeen && rule.Version != v {
seenPrefix[rule.Version] = struct{}{}
entry := UnsupportedEntry{
Version: rule.Version,
Supported: false,
Kind: "line",
}
if rule.EOLDate != "" {
entry.EOL = rule.EOLDate
}
if rule.Reason != "" {
entry.Notes = rule.Reason
}
result = append(result, entry)
}

// Emit the concrete patch version (e.g. "3.9.25") present in the artifact store.
entry := UnsupportedEntry{
Version: v,
Supported: false,
Kind: "artifact",
}
if rule.EOLDate != "" {
pv.EOL = rule.EOLDate
entry.EOL = rule.EOLDate
}
if rule.Reason != "" {
pv.Notes = rule.Reason
entry.Notes = rule.Reason
}
result = append(result, pv)
result = append(result, entry)
}
}

sort.Slice(result, func(i, j int) bool {
iMaj, iMin, iPat, iErr := storage.ParseSemver(result[i].Version)
jMaj, jMin, jPat, jErr := storage.ParseSemver(result[j].Version)
iMaj, iMin, iPat, iErr := parseSemverFull(result[i].Version)
jMaj, jMin, jPat, jErr := parseSemverFull(result[j].Version)
if iErr != nil || jErr != nil {
return result[i].Version < result[j].Version
}
Expand Down
98 changes: 70 additions & 28 deletions internal/sitegen/sitegen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"time"

"github.com/clean-dependency-project/cdprun/internal/config"
"github.com/clean-dependency-project/cdprun/internal/endoflife"
"github.com/clean-dependency-project/cdprun/internal/storage"
)

Expand Down Expand Up @@ -1474,7 +1473,7 @@ func TestRenderSimpleIndex_UnsupportedVersions(t *testing.T) {
t.Fatal("simple/index.json missing 'unsupported' key")
}

var unsupportedRoot map[string][]endoflife.PolicyVersion
var unsupportedRoot map[string][]UnsupportedEntry
if err := json.Unmarshal(unsupportedRootRaw, &unsupportedRoot); err != nil {
t.Fatalf("parse root unsupported map: %v", err)
}
Expand All @@ -1483,19 +1482,28 @@ func TestRenderSimpleIndex_UnsupportedVersions(t *testing.T) {
if !ok {
t.Fatal("root unsupported missing 'nodejs' key")
}
if len(nodejsUnsupported) != 1 {
t.Fatalf("root unsupported nodejs count = %d, want 1", len(nodejsUnsupported))
// Expect prefix "16" + concrete "16.20.2" = 2 entries
if len(nodejsUnsupported) != 2 {
t.Fatalf("root unsupported nodejs count = %d, want 2 (prefix + concrete)", len(nodejsUnsupported))
}
if nodejsUnsupported[0].Version != "16.20.2" {
t.Errorf("root unsupported nodejs[0].version = %q, want %q", nodejsUnsupported[0].Version, "16.20.2")
if nodejsUnsupported[0].Version != "16" {
t.Errorf("root unsupported nodejs[0].version = %q, want \"16\" (prefix)", nodejsUnsupported[0].Version)
}
if nodejsUnsupported[0].Supported {
t.Error("root unsupported nodejs[0].supported should be false")
if nodejsUnsupported[1].Version != "16.20.2" {
t.Errorf("root unsupported nodejs[1].version = %q, want \"16.20.2\" (concrete)", nodejsUnsupported[1].Version)
}
if nodejsUnsupported[0].Supported || nodejsUnsupported[1].Supported {
t.Error("root unsupported nodejs entries should have supported=false")
}
if nodejsUnsupported[0].EOL != "2023-09-11" {
t.Errorf("root unsupported nodejs[0].eol = %q, want %q", nodejsUnsupported[0].EOL, "2023-09-11")
}

if nodejsUnsupported[0].Kind != "line" {
t.Errorf("root unsupported nodejs[0].kind = %q, want \"line\"", nodejsUnsupported[0].Kind)
}
if nodejsUnsupported[1].Kind != "artifact" {
t.Errorf("root unsupported nodejs[1].kind = %q, want \"artifact\"", nodejsUnsupported[1].Kind)
}
// ── 2. Runtime simple/nodejs/index.json ───────────────────────────────────
rtContent, err := os.ReadFile(filepath.Join(tempDir, "simple", "nodejs", "index.json"))
if err != nil {
Expand All @@ -1512,16 +1520,20 @@ func TestRenderSimpleIndex_UnsupportedVersions(t *testing.T) {
t.Fatal("simple/nodejs/index.json missing 'unsupported' key")
}

var unsupportedRt []endoflife.PolicyVersion
var unsupportedRt []UnsupportedEntry
if err := json.Unmarshal(unsupportedRtRaw, &unsupportedRt); err != nil {
t.Fatalf("parse runtime unsupported list: %v", err)
}

if len(unsupportedRt) != 1 {
t.Fatalf("runtime unsupported count = %d, want 1", len(unsupportedRt))
// Expect prefix "16" + concrete "16.20.2" = 2 entries
if len(unsupportedRt) != 2 {
t.Fatalf("runtime unsupported count = %d, want 2 (prefix + concrete)", len(unsupportedRt))
}
if unsupportedRt[0].Version != "16.20.2" {
t.Errorf("runtime unsupported[0].version = %q, want %q", unsupportedRt[0].Version, "16.20.2")
if unsupportedRt[0].Version != "16" {
t.Errorf("runtime unsupported[0].version = %q, want \"16\" (prefix)", unsupportedRt[0].Version)
}
if unsupportedRt[1].Version != "16.20.2" {
t.Errorf("runtime unsupported[1].version = %q, want \"16.20.2\" (concrete)", unsupportedRt[1].Version)
}

// ── 3. Major-version simple/nodejs/v16/index.json ─────────────────────────
Expand All @@ -1540,16 +1552,20 @@ func TestRenderSimpleIndex_UnsupportedVersions(t *testing.T) {
t.Fatal("simple/nodejs/v16/index.json missing 'unsupported' key")
}

var unsupportedV16 []endoflife.PolicyVersion
var unsupportedV16 []UnsupportedEntry
if err := json.Unmarshal(unsupportedV16Raw, &unsupportedV16); err != nil {
t.Fatalf("parse v16 unsupported list: %v", err)
}

if len(unsupportedV16) != 1 {
t.Fatalf("v16 unsupported count = %d, want 1", len(unsupportedV16))
// Expect prefix "16" + concrete "16.20.2" = 2 entries
if len(unsupportedV16) != 2 {
t.Fatalf("v16 unsupported count = %d, want 2 (prefix + concrete)", len(unsupportedV16))
}
if unsupportedV16[0].Version != "16" {
t.Errorf("v16 unsupported[0].version = %q, want \"16\" (prefix)", unsupportedV16[0].Version)
}
if unsupportedV16[0].Version != "16.20.2" {
t.Errorf("v16 unsupported[0].version = %q, want %q", unsupportedV16[0].Version, "16.20.2")
if unsupportedV16[1].Version != "16.20.2" {
t.Errorf("v16 unsupported[1].version = %q, want \"16.20.2\" (concrete)", unsupportedV16[1].Version)
}

// ── 4. Supported version (v22) must NOT appear in its major-version unsupported list ─
Expand All @@ -1568,7 +1584,7 @@ func TestRenderSimpleIndex_UnsupportedVersions(t *testing.T) {
t.Fatal("simple/nodejs/v22/index.json missing 'unsupported' key")
}

var unsupportedV22 []endoflife.PolicyVersion
var unsupportedV22 []UnsupportedEntry
if err := json.Unmarshal(unsupportedV22Raw, &unsupportedV22); err != nil {
t.Fatalf("parse v22 unsupported list: %v", err)
}
Expand All @@ -1586,6 +1602,8 @@ func TestExpandUnsupportedVersions(t *testing.T) {
{
OS: "linux",
Versions: []VersionModel{
{Version: "8.17.0", Major: 8},
{Version: "10.24.1", Major: 10},
{Version: "16.20.2", Major: 16},
{Version: "16.20.1", Major: 16},
{Version: "18.20.0", Major: 18},
Expand All @@ -1596,6 +1614,7 @@ func TestExpandUnsupportedVersions(t *testing.T) {
{
OS: "windows",
Versions: []VersionModel{
{Version: "8.17.0", Major: 8},
{Version: "16.20.2", Major: 16},
{Version: "18.20.0", Major: 18},
},
Expand All @@ -1610,16 +1629,17 @@ func TestExpandUnsupportedVersions(t *testing.T) {
wantNoDuplicate bool
}{
{
name: "empty config returns nil",
name: "empty config returns empty non-nil slice",
uc: config.UnsupportedConfig{},
wantVersions: nil,
},
{
name: "prefix 16 expands to all 16.x.y versions without duplicates",
name: "prefix 16 includes prefix entry and all 16.x.y concrete versions without duplicates",
uc: config.UnsupportedConfig{
"nodejs": {{Version: "16", Reason: "EOL", EOLDate: "2023-09-11"}},
},
wantVersions: []string{"16.20.1", "16.20.2"},
// prefix "16" + concrete "16.20.1", "16.20.2"
wantVersions: []string{"16", "16.20.1", "16.20.2"},
wantNoDuplicate: true,
},
{
Expand All @@ -1631,14 +1651,27 @@ func TestExpandUnsupportedVersions(t *testing.T) {
{Version: "22", Reason: "EOL"},
},
},
// 16.20.1, 16.20.2, 18.20.0, 22.15.0 — numeric order, not lexicographic
wantVersions: []string{"16.20.1", "16.20.2", "18.20.0", "22.15.0"},
// prefixes first, then concretes, all in numeric order
wantVersions: []string{"16", "16.20.1", "16.20.2", "18", "18.20.0", "22", "22.15.0"},
},
{
name: "exact version match",
name: "single-digit prefix sorts before double-digit prefix (key regression guard)",
uc: config.UnsupportedConfig{
"nodejs": {
{Version: "8", Reason: "EOL", EOLDate: "2019-12-31"},
{Version: "10", Reason: "EOL", EOLDate: "2021-04-30"},
{Version: "16", Reason: "EOL", EOLDate: "2023-09-11"},
},
},
// Lexicographic would give: "10","10.24.1","16","16.20.1","16.20.2","8","8.17.0"
// Correct numeric: "8","8.17.0","10","10.24.1","16","16.20.1","16.20.2"
wantVersions: []string{"8", "8.17.0", "10", "10.24.1", "16", "16.20.1", "16.20.2"},
}, {
name: "exact version match emits only the concrete version (no prefix duplicate)",
uc: config.UnsupportedConfig{
"nodejs": {{Version: "18.20.0", Reason: "EOL", EOLDate: "2024-04-30"}},
},
// rule.Version == concrete version — no separate prefix entry
wantVersions: []string{"18.20.0"},
},
{
Expand All @@ -1650,7 +1683,7 @@ func TestExpandUnsupportedVersions(t *testing.T) {
wantVersions: nil,
},
{
name: "unknown runtime returns nil",
name: "unknown runtime returns empty",
uc: config.UnsupportedConfig{
"temurin": {{Version: "16", Reason: "EOL"}},
},
Expand All @@ -1662,6 +1695,12 @@ func TestExpandUnsupportedVersions(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
got := expandUnsupportedVersions(rt, tc.uc)

// expandUnsupportedVersions must never return nil — callers rely on []
// marshaling as JSON [] not null.
if got == nil {
t.Fatal("expandUnsupportedVersions returned nil; want non-nil slice (may be empty)")
}

if len(tc.wantVersions) == 0 {
if len(got) != 0 {
t.Errorf("got %d entries, want 0: %v", len(got), got)
Expand Down Expand Up @@ -1693,11 +1732,14 @@ func TestExpandUnsupportedVersions(t *testing.T) {
}
}

// Verify all returned entries have Supported=false.
// Verify all returned entries have Supported=false and a non-empty Kind.
for _, pv := range got {
if pv.Supported {
t.Errorf("version %q has Supported=true, want false", pv.Version)
}
if pv.Kind != "line" && pv.Kind != "artifact" {
t.Errorf("version %q has Kind=%q, want \"line\" or \"artifact\"", pv.Version, pv.Kind)
}
}
})
}
Expand Down
Loading