|
8 | 8 | package builder_test |
9 | 9 |
|
10 | 10 | import ( |
| 11 | + "bytes" |
11 | 12 | "io" |
12 | 13 | "strings" |
13 | 14 | "testing" |
14 | 15 |
|
15 | 16 | spdxjson "github.com/spdx/tools-golang/json" |
| 17 | + "github.com/spdx/tools-golang/spdx" |
| 18 | + "github.com/spdx/tools-golang/spdx/v2/common" |
16 | 19 | "github.com/stretchr/testify/assert" |
17 | 20 | "github.com/stretchr/testify/require" |
18 | 21 |
|
19 | 22 | "github.com/siderolabs/image-factory/enterprise/spdx/builder" |
| 23 | + ifconstants "github.com/siderolabs/image-factory/pkg/constants" |
20 | 24 | ) |
21 | 25 |
|
22 | 26 | func TestCacheTag(t *testing.T) { |
@@ -100,3 +104,142 @@ func TestBundleToJSON_DocumentNamespace(t *testing.T) { |
100 | 104 | }) |
101 | 105 | } |
102 | 106 | } |
| 107 | + |
| 108 | +// TestBundleToJSON_SingleTalosRoot guards the source-name/version pathway |
| 109 | +// that grype's OpenVEX matcher relies on. Two invariants the test enforces: |
| 110 | +// |
| 111 | +// 1. Exactly one package is the target of a DOCUMENT-DESCRIBES relationship. |
| 112 | +// syft's SPDX importer returns its derived source only when there is a |
| 113 | +// single root (see findRootPackages + extractSource in syft). |
| 114 | +// 2. That root package is named after constants.TalosPURL (always |
| 115 | +// "talos-enterprise" in this enterprise-only package), carries the |
| 116 | +// bundle's TalosVersion, and is marked with PrimaryPackagePurpose=FILE |
| 117 | +// plus the "DocumentRoot-Directory-..." SPDXIdentifier prefix syft uses |
| 118 | +// to map the root onto a syft Source.Description with Name and Version set. |
| 119 | +// |
| 120 | +// Without both, grype's productIdentifiersFromContext returns an empty list, |
| 121 | +// the VEX product `pkg:generic/talos-enterprise@<ver>` never matches any |
| 122 | +// artifact, and the talos-vex statements don't suppress matches. |
| 123 | +func TestBundleToJSON_SingleTalosRoot(t *testing.T) { |
| 124 | + t.Parallel() |
| 125 | + |
| 126 | + sourceDoc := minimalSourceSPDX(t, "talos-amd64", "talos", "v1.13.3") |
| 127 | + |
| 128 | + bundle := &builder.Bundle{ |
| 129 | + SchematicID: "sch", |
| 130 | + TalosVersion: "v1.13.3", |
| 131 | + Arch: "amd64", |
| 132 | + ExternalURL: "https://factory.sidero.dev", |
| 133 | + Files: []builder.File{ |
| 134 | + { |
| 135 | + Filename: "talos-amd64.spdx.json", |
| 136 | + Source: "talos-amd64", |
| 137 | + Content: sourceDoc, |
| 138 | + }, |
| 139 | + }, |
| 140 | + } |
| 141 | + |
| 142 | + r, _, err := builder.BundleToJSON(bundle) |
| 143 | + require.NoError(t, err) |
| 144 | + |
| 145 | + data, err := io.ReadAll(r) |
| 146 | + require.NoError(t, err) |
| 147 | + |
| 148 | + doc, err := spdxjson.Read(bytes.NewReader(data)) |
| 149 | + require.NoError(t, err) |
| 150 | + |
| 151 | + var roots []common.ElementID |
| 152 | + |
| 153 | + for _, rel := range doc.Relationships { |
| 154 | + if rel.RefA.ElementRefID == common.ElementID("DOCUMENT") && |
| 155 | + rel.Relationship == common.TypeRelationshipDescribe { |
| 156 | + roots = append(roots, rel.RefB.ElementRefID) |
| 157 | + } |
| 158 | + } |
| 159 | + |
| 160 | + require.Len(t, roots, 1, "merged document must have exactly one DOCUMENT-DESCRIBES root") |
| 161 | + |
| 162 | + var rootPkg *spdx.Package |
| 163 | + |
| 164 | + for _, p := range doc.Packages { |
| 165 | + if p.PackageSPDXIdentifier == roots[0] { |
| 166 | + rootPkg = p |
| 167 | + |
| 168 | + break |
| 169 | + } |
| 170 | + } |
| 171 | + |
| 172 | + require.NotNil(t, rootPkg, "DESCRIBES target %q must exist in doc.Packages", roots[0]) |
| 173 | + |
| 174 | + wantName := strings.TrimPrefix(ifconstants.TalosPURL, "pkg:generic/") |
| 175 | + assert.Equal(t, wantName, rootPkg.PackageName, |
| 176 | + "root PackageName must match the short name in constants.TalosPURL so grype's derived pkg:generic/<name>@<version> equals the VEX product") |
| 177 | + assert.Equal(t, "v1.13.3", rootPkg.PackageVersion, "root package version feeds syft's Source.Version") |
| 178 | + assert.Equal(t, "FILE", rootPkg.PrimaryPackagePurpose, "FILE purpose routes syft through fileSource()") |
| 179 | + assert.True(t, |
| 180 | + strings.HasPrefix(string(rootPkg.PackageSPDXIdentifier), "DocumentRoot-"), |
| 181 | + "SPDXIdentifier prefix is what triggers syft's directory/file source classification") |
| 182 | + |
| 183 | + // Per-source root must be re-parented under the new talos root via |
| 184 | + // CONTAINS, not via another DOCUMENT-DESCRIBES (which would create a |
| 185 | + // second root and break syft's single-root detection). |
| 186 | + var perSourceContained bool |
| 187 | + |
| 188 | + for _, rel := range doc.Relationships { |
| 189 | + if rel.RefA.ElementRefID == roots[0] && |
| 190 | + rel.Relationship == common.TypeRelationshipContains && |
| 191 | + rel.RefB.ElementRefID == common.ElementID("talos-amd64-DocumentRoot-Directory-talos") { |
| 192 | + perSourceContained = true |
| 193 | + |
| 194 | + break |
| 195 | + } |
| 196 | + } |
| 197 | + |
| 198 | + assert.True(t, perSourceContained, |
| 199 | + "per-source root must be re-parented under the talos root via CONTAINS") |
| 200 | +} |
| 201 | + |
| 202 | +// minimalSourceSPDX produces a syft-shaped SPDX 2.3 JSON document with one |
| 203 | +// DocumentRoot-Directory-<name> root, mirroring what hack/sbom.sh embeds |
| 204 | +// into the Talos initramfs. |
| 205 | +func minimalSourceSPDX(t *testing.T, sourceID, name, version string) []byte { |
| 206 | + t.Helper() |
| 207 | + |
| 208 | + rootID := common.ElementID("DocumentRoot-Directory-" + name) |
| 209 | + |
| 210 | + doc := &spdx.Document{ |
| 211 | + SPDXVersion: spdx.Version, |
| 212 | + DataLicense: spdx.DataLicense, |
| 213 | + SPDXIdentifier: common.ElementID("DOCUMENT"), |
| 214 | + DocumentName: sourceID, |
| 215 | + DocumentNamespace: "https://anchore.com/syft/dir/" + sourceID, |
| 216 | + CreationInfo: &spdx.CreationInfo{ |
| 217 | + Created: "2026-05-30T00:00:00Z", |
| 218 | + Creators: []common.Creator{ |
| 219 | + {CreatorType: "Tool", Creator: "syft-test"}, |
| 220 | + }, |
| 221 | + }, |
| 222 | + Packages: []*spdx.Package{ |
| 223 | + { |
| 224 | + PackageSPDXIdentifier: rootID, |
| 225 | + PackageName: name, |
| 226 | + PackageVersion: version, |
| 227 | + PrimaryPackagePurpose: "FILE", |
| 228 | + PackageDownloadLocation: "NOASSERTION", |
| 229 | + }, |
| 230 | + }, |
| 231 | + Relationships: []*spdx.Relationship{ |
| 232 | + { |
| 233 | + RefA: common.DocElementID{ElementRefID: common.ElementID("DOCUMENT")}, |
| 234 | + RefB: common.DocElementID{ElementRefID: rootID}, |
| 235 | + Relationship: common.TypeRelationshipDescribe, |
| 236 | + }, |
| 237 | + }, |
| 238 | + } |
| 239 | + |
| 240 | + var buf bytes.Buffer |
| 241 | + |
| 242 | + require.NoError(t, spdxjson.Write(doc, &buf)) |
| 243 | + |
| 244 | + return buf.Bytes() |
| 245 | +} |
0 commit comments