From 2b5be0e10f2d0c9ec74a9a0e7cd62e3c0083395e Mon Sep 17 00:00:00 2001 From: naveensrinivasan <172697+naveensrinivasan@users.noreply.github.com> Date: Tue, 12 May 2026 14:14:50 -0500 Subject: [PATCH] Fixed the bug which was not listing the major version --- internal/sitegen/simple.go | 104 ++++++++++++++++++++++++++----- internal/sitegen/sitegen_test.go | 98 ++++++++++++++++++++--------- 2 files changed, 157 insertions(+), 45 deletions(-) diff --git a/internal/sitegen/simple.go b/internal/sitegen/simple.go index 34db50d..171d39c 100644 --- a/internal/sitegen/simple.go +++ b/internal/sitegen/simple.go @@ -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" ) @@ -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. @@ -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) } } @@ -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) } @@ -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 } diff --git a/internal/sitegen/sitegen_test.go b/internal/sitegen/sitegen_test.go index ad0a92a..51ab128 100644 --- a/internal/sitegen/sitegen_test.go +++ b/internal/sitegen/sitegen_test.go @@ -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" ) @@ -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) } @@ -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 { @@ -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 ───────────────────────── @@ -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 ─ @@ -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) } @@ -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}, @@ -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}, }, @@ -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, }, { @@ -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"}, }, { @@ -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"}}, }, @@ -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) @@ -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) + } } }) }