diff --git a/bench_test.go b/bench_test.go index d850d19..eb570af 100644 --- a/bench_test.go +++ b/bench_test.go @@ -1,10 +1,19 @@ package sbom import ( + "encoding/json" "fmt" + "io" "testing" ) +const benchmarkPackageCount = 840 + +var ( + benchmarkDocument *SBOM + benchmarkError error +) + var benchFixtures = []struct { name string path string @@ -21,21 +30,88 @@ var benchFixtures = []struct { func BenchmarkParse(b *testing.B) { for _, f := range benchFixtures { - data := readFixture(b, f.path) - doc, err := Parse(data) - if err != nil { - b.Fatalf("%s: %v", f.path, err) - } - pkgs := len(doc.Packages) - b.Run(f.name, func(b *testing.B) { + benchmarkParse(b, f.name, readFixture(b, f.path)) + } +} + +func BenchmarkParseLarge(b *testing.B) { + benchmarkParse(b, "CycloneDX/840-packages", readFixture(b, "cyclonedx/juice-shop.cdx.json")) + benchmarkParse(b, "SPDX/large", readFixture(b, "spdx/nginx.spdx.json")) +} + +func BenchmarkParseCycloneDXComponents(b *testing.B) { + benchmarkParse(b, "flat", benchmarkCycloneDX(b, benchmarkPackageCount, false, 0, 0)) + benchmarkParse(b, "nested", benchmarkCycloneDX(b, benchmarkPackageCount, true, 0, 0)) +} + +func BenchmarkParseDenseDependencyGraph(b *testing.B) { + benchmarkParse(b, "CycloneDX/256-by-32", benchmarkCycloneDX(b, 256, false, 32, 0)) +} + +func BenchmarkParseSPDXEnvelopes(b *testing.B) { + data := readFixture(b, "spdx/nginx.spdx.json") + github := append(append([]byte(`{"sbom":`), data...), '}') + intoto := append([]byte(`{"predicateType":"https://spdx.dev/Document","predicate":`), data...) + intoto = append(intoto, '}') + benchmarkParse(b, "GitHub", github) + benchmarkParse(b, "in-toto", intoto) +} + +func BenchmarkParseDuplicatePackageIdentities(b *testing.B) { + benchmarkParse(b, "CycloneDX", benchmarkCycloneDX(b, benchmarkPackageCount, false, 0, 2)) + benchmarkParse(b, "SPDX", benchmarkSPDX(b, benchmarkPackageCount, 2)) +} + +func BenchmarkEncodeLarge(b *testing.B) { + benchmarks := []struct { + name string + path string + format Format + }{ + {"CycloneDX/JSON", "cyclonedx/juice-shop.cdx.json", FormatCycloneDXJSON}, + {"CycloneDX/XML", "cyclonedx/juice-shop.cdx.json", FormatCycloneDXXML}, + {"SPDX/JSON", "spdx/nginx.spdx.json", FormatSPDXJSON}, + } + for _, benchmark := range benchmarks { + doc := mustParseBenchmark(b, readFixture(b, benchmark.path)) + b.Run(benchmark.name, func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + b.ReportMetric(float64(len(doc.Packages)), "pkgs") + for range b.N { + benchmarkError = Encode(io.Discard, doc, benchmark.format) + } + if benchmarkError != nil { + b.Fatal(benchmarkError) + } + }) + } +} + +func BenchmarkParseThenEncode(b *testing.B) { + benchmarks := []struct { + name string + path string + format Format + }{ + {"CycloneDX/JSON", "cyclonedx/juice-shop.cdx.json", FormatCycloneDXJSON}, + {"SPDX/JSON", "spdx/nginx.spdx.json", FormatSPDXJSON}, + } + for _, benchmark := range benchmarks { + data := readFixture(b, benchmark.path) + b.Run(benchmark.name, func(b *testing.B) { b.ReportAllocs() b.SetBytes(int64(len(data))) - b.ReportMetric(float64(pkgs), "pkgs") - for i := 0; i < b.N; i++ { - if _, err := Parse(data); err != nil { - b.Fatal(err) + b.ResetTimer() + for range b.N { + benchmarkDocument, benchmarkError = Parse(data) + if benchmarkError == nil { + benchmarkError = Encode(io.Discard, benchmarkDocument, benchmark.format) } } + if benchmarkError != nil { + b.Fatal(benchmarkError) + } }) } } @@ -45,7 +121,8 @@ func BenchmarkDetect(b *testing.B) { data := readFixture(b, f.path) b.Run(f.name, func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + b.ResetTimer() + for range b.N { _ = Detect(data) } }) @@ -53,13 +130,174 @@ func BenchmarkDetect(b *testing.B) { } func BenchmarkPackagePURL(b *testing.B) { - doc, _ := Parse(readFixture(b, "cyclonedx/juice-shop.cdx.json")) + doc := mustParseBenchmark(b, readFixture(b, "cyclonedx/juice-shop.cdx.json")) b.Run(fmt.Sprintf("%d-pkgs", len(doc.Packages)), func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + b.ResetTimer() + for range b.N { for j := range doc.Packages { _ = doc.Packages[j].PURL() } } }) } + +func benchmarkParse(b *testing.B, name string, data []byte) { + b.Helper() + doc := mustParseBenchmark(b, data) + b.Run(name, func(b *testing.B) { + b.ReportAllocs() + b.SetBytes(int64(len(data))) + b.ResetTimer() + b.ReportMetric(float64(len(doc.Packages)), "pkgs") + b.ReportMetric(float64(len(doc.Relationships)), "rels") + for range b.N { + benchmarkDocument, benchmarkError = Parse(data) + } + if benchmarkError != nil { + b.Fatal(benchmarkError) + } + }) +} + +func mustParseBenchmark(b testing.TB, data []byte) *SBOM { + b.Helper() + doc, err := Parse(data) + if err != nil { + b.Fatal(err) + } + return doc +} + +func benchmarkCycloneDX(b testing.TB, packageCount int, nested bool, dependencyWidth, identities int) []byte { + b.Helper() + bom := cdxBOM{ + BOMFormat: "CycloneDX", + SpecVersion: "1.6", + BOMVersion: 1, + Metadata: &cdxMetadata{ + Timestamp: "2026-01-01T00:00:00Z", + Component: &cdxComponent{Type: "application", Name: "benchmark", Version: "1.0.0"}, + }, + } + component := func(i int) cdxComponent { + identity := i + if identities > 0 { + identity %= packageCount / identities + } + return cdxComponent{ + BOMRef: fmt.Sprintf("component-%d", i), + Type: "library", + Name: fmt.Sprintf("package-%d", identity), + Version: fmt.Sprintf("1.%d.0", identity%20), + Description: "A package with representative CycloneDX metadata", + Copyright: "Copyright 2026 Example", + Author: "Example Maintainer", + PURL: fmt.Sprintf("pkg:generic/package-%d@1.%d.0", identity, identity%20), + Supplier: &cdxOrgEntity{Name: "Example Org"}, + Hashes: []cdxHash{ + {Alg: "SHA-256", Content: "c314ca2bdaf4317fb92300e22b3ff9a4494a6e8d4d3c80baab6ad441bf52c390"}, + {Alg: "SHA-512", Content: "950319f7d7b8339c29ee29ff9ac69f1ea1453ffc967928c3d36df20477d8b817"}, + }, + Licenses: []cdxLicense{{License: &cdxLicenseID{ID: "MIT"}}}, + ExternalReferences: []cdxExtRef{ + {Type: "website", URL: "https://example.com/package"}, + {Type: "vcs", URL: "https://example.com/package.git"}, + }, + Properties: []cdxProperty{ + {Name: "benchmark:source", Value: "registry"}, + {Name: "benchmark:scope", Value: "runtime"}, + }, + } + } + + if nested { + const roots = 20 + bom.Components = make([]cdxComponent, roots) + for root := range roots { + index := root * (packageCount / roots) + bom.Components[root] = component(index) + current := &bom.Components[root] + for offset := 1; offset < packageCount/roots; offset++ { + current.Components = []cdxComponent{component(index + offset)} + current = ¤t.Components[0] + } + } + } else { + bom.Components = make([]cdxComponent, packageCount) + for i := range packageCount { + bom.Components[i] = component(i) + } + } + + if dependencyWidth > 0 { + bom.Dependencies = make([]cdxDependency, packageCount) + for i := range packageCount { + dependency := cdxDependency{ + Ref: fmt.Sprintf("component-%d", i), + DependsOn: make([]string, dependencyWidth), + } + for j := range dependencyWidth { + dependency.DependsOn[j] = fmt.Sprintf("component-%d", (i+j+1)%packageCount) + } + bom.Dependencies[i] = dependency + } + } + return marshalBenchmarkJSON(b, &bom) +} + +func benchmarkSPDX(b testing.TB, packageCount, identities int) []byte { + b.Helper() + doc := spdxDoc{ + SPDXVersion: "SPDX-2.3", + SPDXID: spdxDocID, + Name: "benchmark", + DataLicense: "CC0-1.0", + DocumentNamespace: "https://example.com/benchmark", + CreationInfo: &spdxCreationInfo{ + Created: "2026-01-01T00:00:00Z", + Creators: []string{"Tool: benchmark", "Organization: Example Org"}, + }, + Packages: make([]spdxPackage, packageCount), + } + for i := range packageCount { + identity := i + if identities > 0 { + identity %= packageCount / identities + } + doc.Packages[i] = spdxPackage{ + SPDXID: fmt.Sprintf("SPDXRef-Package-%d", i), + Name: fmt.Sprintf("package-%d", identity), + VersionInfo: fmt.Sprintf("1.%d.0", identity%20), + DownloadLocation: "https://example.com/package.tar.gz", + Homepage: "https://example.com/package", + PackageFileName: "package.tar.gz", + LicenseConcluded: "MIT", + LicenseDeclared: "MIT", + CopyrightText: "Copyright 2026 Example", + Description: "A package with representative SPDX metadata", + Supplier: "Organization: Example Org", + Originator: "Person: Example Maintainer", + PrimaryPackagePurpose: "LIBRARY", + Checksums: []spdxChecksum{{ + Algorithm: "SHA256", + Value: "c314ca2bdaf4317fb92300e22b3ff9a4494a6e8d4d3c80baab6ad441bf52c390", + }}, + ExternalRefs: []spdxExtRef{{ + Category: "PACKAGE-MANAGER", + Type: "purl", + Locator: fmt.Sprintf("pkg:generic/package-%d@1.%d.0", identity, identity%20), + }}, + } + } + return marshalBenchmarkJSON(b, &doc) +} + +func marshalBenchmarkJSON(b testing.TB, value any) []byte { + b.Helper() + data, err := json.Marshal(value) + if err != nil { + b.Fatal(err) + } + return data +} diff --git a/cyclonedx.go b/cyclonedx.go index 581faed..8be2755 100644 --- a/cyclonedx.go +++ b/cyclonedx.go @@ -7,6 +7,7 @@ import ( ) const ( + cdxBOMFormat = "CycloneDX" cdxXMLNS = "http://cyclonedx.org/schema/bom/1.5" cdxSpecVersion = "1.5" cdxDefaultCompType = "library" @@ -244,11 +245,24 @@ func parseCycloneDX(data []byte) (*SBOM, error) { if err := json.Unmarshal(data, &bom); err != nil { return nil, wrapErr("cyclonedx json", err) } - if bom.BOMFormat != "CycloneDX" { + if bom.BOMFormat != cdxBOMFormat { return nil, ErrUnrecognized } - s := newSBOM(TypeCycloneDX) + packageCount := len(bom.Components) + nestedRelationshipCount := 0 + for i := range bom.Components { + if len(bom.Components[i].Components) > 0 { + packageCount, nestedRelationshipCount = cdxComponentStats(bom.Components) + break + } + } + relationshipCount := nestedRelationshipCount + for i := range bom.Dependencies { + relationshipCount += len(bom.Dependencies[i].DependsOn) + } + + s := newSizedSBOM(TypeCycloneDX, packageCount, relationshipCount) s.SpecVersion = bom.SpecVersion s.Document = Document{ ID: bom.SerialNumber, @@ -275,7 +289,8 @@ func parseCycloneDX(data []byte) (*SBOM, error) { cdxWalkComponents(s, bom.Components, "") - for _, d := range bom.Dependencies { + for i := range bom.Dependencies { + d := &bom.Dependencies[i] for _, t := range d.DependsOn { s.Relationships = append(s.Relationships, Relationship{ SourceID: d.Ref, TargetID: t, Type: RelDependsOn, @@ -286,6 +301,18 @@ func parseCycloneDX(data []byte) (*SBOM, error) { return s, nil } +func cdxComponentStats(components []cdxComponent) (packages, relationships int) { + packages = len(components) + for i := range components { + children := components[i].Components + relationships += len(children) + childPackages, childRelationships := cdxComponentStats(children) + packages += childPackages + relationships += childRelationships + } + return packages, relationships +} + func cdxWalkComponents(s *SBOM, comps []cdxComponent, parent string) { for i := range comps { c := &comps[i] @@ -315,6 +342,15 @@ func cdxPackage(c *cdxComponent) Package { Description: c.Description, Copyright: c.Copyright, } + if len(c.Hashes) > 0 { + p.Checksums = make([]Checksum, len(c.Hashes)) + for i := range c.Hashes { + p.Checksums[i] = Checksum{ + Algorithm: normalizeChecksumAlgorithm(c.Hashes[i].Alg), + Value: c.Hashes[i].Content, + } + } + } if c.Supplier != nil && c.Supplier.Name != "" { p.Supplier = c.Supplier.Name p.SupplierType = SupplierOrganization @@ -323,34 +359,63 @@ func cdxPackage(c *cdxComponent) Package { p.Originator = c.Author p.OriginatorType = SupplierPerson } - for _, h := range c.Hashes { - p.Checksums = append(p.Checksums, Checksum{ - Algorithm: strings.ReplaceAll(h.Alg, "-", ""), - Value: h.Content, - }) - } for _, l := range c.Licenses { if id := l.value(); id != "" { p.LicenseConcluded = id p.LicenseDeclared = id } } + externalReferenceCount := len(c.ExternalReferences) if c.PURL != "" { - p.ExternalRefs = append(p.ExternalRefs, ExternalRef{ - Category: "PACKAGE_MANAGER", Type: "purl", Locator: c.PURL, - }) + externalReferenceCount++ } - for _, r := range c.ExternalReferences { - p.ExternalRefs = append(p.ExternalRefs, ExternalRef{ - Category: r.Type, Type: r.Type, Locator: r.URL, - }) + if externalReferenceCount > 0 { + p.ExternalRefs = make([]ExternalRef, externalReferenceCount) + next := 0 + if c.PURL != "" { + p.ExternalRefs[0] = ExternalRef{ + Category: "PACKAGE_MANAGER", Type: purlExternalReferenceType, Locator: c.PURL, + } + next = 1 + } + for i := range c.ExternalReferences { + r := &c.ExternalReferences[i] + p.ExternalRefs[next+i] = ExternalRef{ + Category: r.Type, Type: r.Type, Locator: r.URL, + } + } } - for _, pr := range c.Properties { - p.Properties = append(p.Properties, Property(pr)) + if len(c.Properties) > 0 { + p.Properties = make([]Property, len(c.Properties)) + for i := range c.Properties { + p.Properties[i] = Property(c.Properties[i]) + } } return p } +func normalizeChecksumAlgorithm(algorithm string) string { + switch algorithm { + case "MD-5": + return "MD5" + case "SHA-1": + return "SHA1" + case "SHA-256": + return "SHA256" + case "SHA-384": + return "SHA384" + case "SHA-512": + return "SHA512" + case "SHA3-256": + return "SHA3256" + case "SHA3-384": + return "SHA3384" + case "SHA3-512": + return "SHA3512" + } + return strings.ReplaceAll(algorithm, "-", "") +} + func (l cdxLicense) value() string { if l.Expression != "" { return l.Expression @@ -365,5 +430,34 @@ func (l cdxLicense) value() string { } func normalizePackageType(t string) string { - return strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(t), "_", "-")) + t = strings.TrimSpace(t) + switch t { + case "application": + return "APPLICATION" + case "container": + return "CONTAINER" + case "data": + return "DATA" + case "device": + return "DEVICE" + case "device-driver": + return "DEVICE-DRIVER" + case "file": + return "FILE" + case "firmware": + return "FIRMWARE" + case "framework": + return "FRAMEWORK" + case "library": + return "LIBRARY" + case "machine-learning-model": + return "MACHINE-LEARNING-MODEL" + case "operating-system": + return "OPERATING-SYSTEM" + case "platform": + return "PLATFORM" + case "cryptographic-asset": + return "CRYPTOGRAPHIC-ASSET" + } + return strings.ToUpper(strings.ReplaceAll(t, "_", "-")) } diff --git a/cyclonedx_test.go b/cyclonedx_test.go index 7344497..08d7f48 100644 --- a/cyclonedx_test.go +++ b/cyclonedx_test.go @@ -1,6 +1,9 @@ package sbom -import "testing" +import ( + "reflect" + "testing" +) func TestCycloneDXLicenseShapes(t *testing.T) { in := `{ @@ -50,6 +53,19 @@ func TestCycloneDXNestedComponents(t *testing.T) { } } +func TestCycloneDXEmptyDocumentPreservesNilSlices(t *testing.T) { + doc, err := Parse([]byte(`{"bomFormat":"CycloneDX","specVersion":"1.6"}`)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if doc.Packages != nil { + t.Errorf("Packages = %#v, want nil", doc.Packages) + } + if doc.Relationships != nil { + t.Errorf("Relationships = %#v, want nil", doc.Relationships) + } +} + func TestCycloneDXDependencies(t *testing.T) { in := `{ "bomFormat":"CycloneDX","specVersion":"1.5", @@ -68,3 +84,45 @@ func TestCycloneDXDependencies(t *testing.T) { t.Errorf("relationship = %+v", r) } } + +func TestCycloneDXPackageMetadata(t *testing.T) { + in := `{ + "bomFormat":"CycloneDX","specVersion":"1.6", + "components":[{ + "bom-ref":"pkg","type":"device_driver","name":"driver","version":"1.0.0", + "author":"Jane Doe","supplier":{"name":"Acme"}, + "hashes":[{"alg":"SHA-256","content":"abc"},{"alg":"BLAKE2b-256","content":"def"}], + "purl":"pkg:generic/driver@1.0.0", + "externalReferences":[{"type":"website","url":"https://example.com"}], + "properties":[{"name":"scope","value":"runtime"}] + }] + }` + doc, err := Parse([]byte(in)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + p := doc.Packages[0] + if p.Type != "DEVICE-DRIVER" { + t.Errorf("Type = %q", p.Type) + } + if p.SupplierType != SupplierOrganization || p.Supplier != "Acme" { + t.Errorf("supplier = %q/%q", p.SupplierType, p.Supplier) + } + if p.OriginatorType != SupplierPerson || p.Originator != "Jane Doe" { + t.Errorf("originator = %q/%q", p.OriginatorType, p.Originator) + } + wantChecksums := []Checksum{{Algorithm: "SHA256", Value: "abc"}, {Algorithm: "BLAKE2b256", Value: "def"}} + if !reflect.DeepEqual(p.Checksums, wantChecksums) { + t.Errorf("Checksums = %#v, want %#v", p.Checksums, wantChecksums) + } + wantReferences := []ExternalRef{ + {Category: "PACKAGE_MANAGER", Type: "purl", Locator: "pkg:generic/driver@1.0.0"}, + {Category: "website", Type: "website", Locator: "https://example.com"}, + } + if !reflect.DeepEqual(p.ExternalRefs, wantReferences) { + t.Errorf("ExternalRefs = %#v, want %#v", p.ExternalRefs, wantReferences) + } + if !reflect.DeepEqual(p.Properties, []Property{{Name: "scope", Value: "runtime"}}) { + t.Errorf("Properties = %#v", p.Properties) + } +} diff --git a/encode.go b/encode.go index 04cc67a..9f8e464 100644 --- a/encode.go +++ b/encode.go @@ -6,6 +6,7 @@ import ( "encoding/xml" "fmt" "io" + "strconv" "strings" "time" ) @@ -56,7 +57,7 @@ func nowUTC() string { return time.Now().UTC().Format(time.RFC3339) } func buildCycloneDX(s *SBOM) *cdxBOM { bom := &cdxBOM{ - BOMFormat: "CycloneDX", + BOMFormat: cdxBOMFormat, SpecVersion: firstNonEmpty(s.SpecVersion, cdxSpecVersion), BOMVersion: 1, SerialNumber: s.Document.ID, @@ -70,11 +71,18 @@ func buildCycloneDX(s *SBOM) *cdxBOM { Licenses: componentLicensesToCDX(c), } } - for _, c := range s.Document.Creators { - bom.Metadata.Tools = append(bom.Metadata.Tools, cdxTool{Vendor: c.Type, Name: c.Name}) + if len(s.Document.Creators) > 0 { + bom.Metadata.Tools = make([]cdxTool, len(s.Document.Creators)) + for i := range s.Document.Creators { + creator := &s.Document.Creators[i] + bom.Metadata.Tools[i] = cdxTool{Vendor: creator.Type, Name: creator.Name} + } } - for i := range s.Packages { - bom.Components = append(bom.Components, packageToCDX(&s.Packages[i])) + if len(s.Packages) > 0 { + bom.Components = make([]cdxComponent, len(s.Packages)) + for i := range s.Packages { + bom.Components[i] = packageToCDX(&s.Packages[i]) + } } return bom } @@ -83,7 +91,7 @@ func packageToCDX(p *Package) cdxComponent { purl := p.PURL() c := cdxComponent{ BOMRef: firstNonEmpty(p.ID, purl), - Type: firstNonEmpty(strings.ToLower(p.Type), cdxDefaultCompType), + Type: cdxPackageType(p.Type), Name: p.Name, Version: p.Version, Description: p.Description, @@ -95,6 +103,40 @@ func packageToCDX(p *Package) cdxComponent { return c } +func cdxPackageType(packageType string) string { + switch packageType { + case "APPLICATION": + return "application" + case "CONTAINER": + return "container" + case "DATA": + return "data" + case "DEVICE": + return "device" + case "DEVICE-DRIVER": + return "device-driver" + case "FILE": + return "file" + case "FIRMWARE": + return "firmware" + case "FRAMEWORK": + return "framework" + case "LIBRARY": + return "library" + case "MACHINE-LEARNING-MODEL": + return "machine-learning-model" + case "OPERATING-SYSTEM": + return "operating-system" + case "PLATFORM": + return "platform" + case "CRYPTOGRAPHIC-ASSET": + return "cryptographic-asset" + case "": + return cdxDefaultCompType + } + return strings.ToLower(packageType) +} + func buildSPDX(s *SBOM) *spdxDoc { doc := &spdxDoc{ SPDXVersion: firstNonEmpty(s.SpecVersion, spdxSpecVersion), @@ -106,12 +148,20 @@ func buildSPDX(s *SBOM) *spdxDoc { Created: firstNonEmpty(s.Document.Created, nowUTC()), }, } - for _, c := range s.Document.Creators { - doc.CreationInfo.Creators = append(doc.CreationInfo.Creators, c.Type+": "+c.Name) - } + creatorCount := len(s.Document.Creators) if s.Document.Supplier != "" { - doc.CreationInfo.Creators = append(doc.CreationInfo.Creators, - SupplierOrganization+": "+s.Document.Supplier) + creatorCount++ + } + if creatorCount > 0 { + doc.CreationInfo.Creators = make([]string, 0, creatorCount) + for i := range s.Document.Creators { + creator := &s.Document.Creators[i] + doc.CreationInfo.Creators = append(doc.CreationInfo.Creators, creator.Type+": "+creator.Name) + } + if s.Document.Supplier != "" { + doc.CreationInfo.Creators = append(doc.CreationInfo.Creators, + SupplierOrganization+": "+s.Document.Supplier) + } } root := spdxPackage{ @@ -119,15 +169,16 @@ func buildSPDX(s *SBOM) *spdxDoc { VersionInfo: s.Document.Component.Version, DownloadLocation: spdxNoAssertion, } root.LicenseDeclared, doc.ExtractedLicensingInfos = componentLicensesToSPDX(s.Document.Component) - doc.Packages = append(doc.Packages, root) - + doc.Packages = make([]spdxPackage, len(s.Packages)+1) + doc.Packages[0] = root + doc.Relationships = make([]spdxRelationship, len(s.Packages)) for i := range s.Packages { sp := packageToSPDX(&s.Packages[i], i) - doc.Packages = append(doc.Packages, sp) - doc.Relationships = append(doc.Relationships, spdxRelationship{ + doc.Packages[i+1] = sp + doc.Relationships[i] = spdxRelationship{ SPDXElementID: spdxRootPkgID, RelationshipType: RelDependsOn, RelatedSPDXElement: sp.SPDXID, - }) + } } return doc } @@ -219,17 +270,20 @@ func joinLicenseExpression(parts []string) string { func packageToSPDX(p *Package, i int) spdxPackage { sp := spdxPackage{ - SPDXID: firstNonEmpty(p.ID, fmt.Sprintf("SPDXRef-Package-%d", i)), + SPDXID: p.ID, Name: p.Name, VersionInfo: p.Version, DownloadLocation: firstNonEmpty(p.DownloadLocation, spdxNoAssertion), LicenseConcluded: firstNonEmpty(p.LicenseConcluded, spdxNoAssertion), LicenseDeclared: firstNonEmpty(p.LicenseDeclared, spdxNoAssertion), } + if sp.SPDXID == "" { + sp.SPDXID = "SPDXRef-Package-" + strconv.Itoa(i) + } if purl := p.PURL(); purl != "" { - sp.ExternalRefs = append(sp.ExternalRefs, spdxExtRef{ - Category: "PACKAGE-MANAGER", Type: "purl", Locator: purl, - }) + sp.ExternalRefs = []spdxExtRef{{ + Category: "PACKAGE-MANAGER", Type: purlExternalReferenceType, Locator: purl, + }} } return sp } diff --git a/encode_test.go b/encode_test.go index 106b80c..1725132 100644 --- a/encode_test.go +++ b/encode_test.go @@ -97,6 +97,29 @@ func TestEncodeSPDXNoEnvelopeFields(t *testing.T) { } } +func TestEncodeSPDXGeneratedPackageIDsAndRelationshipOrder(t *testing.T) { + s := sampleSBOM() + var output bytes.Buffer + if err := Encode(&output, s, FormatSPDXJSON); err != nil { + t.Fatalf("Encode: %v", err) + } + var doc spdxDoc + if err := json.Unmarshal(output.Bytes(), &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + wantIDs := []string{spdxRootPkgID, "SPDXRef-Package-0", "SPDXRef-Package-1"} + for i, want := range wantIDs { + if doc.Packages[i].SPDXID != want { + t.Errorf("Packages[%d].SPDXID = %q, want %q", i, doc.Packages[i].SPDXID, want) + } + } + for i := range doc.Relationships { + if doc.Relationships[i].RelatedSPDXElement != wantIDs[i+1] { + t.Errorf("Relationships[%d] = %#v", i, doc.Relationships[i]) + } + } +} + func TestEncodeComponentLicenseExpression(t *testing.T) { s := sampleSBOM() s.Document.Component.LicenseExpression = "MIT OR Apache-2.0" diff --git a/sbom.go b/sbom.go index 57487bc..da3e063 100644 --- a/sbom.go +++ b/sbom.go @@ -87,6 +87,8 @@ type ExternalRef struct { Locator string } +const purlExternalReferenceType = "purl" + // Checksum is a single hash over a package artefact. type Checksum struct { Algorithm string @@ -128,7 +130,7 @@ type Package struct { // ExternalRefs so this is a simple lookup. func (p *Package) PURL() string { for _, r := range p.ExternalRefs { - if r.Type == "purl" { + if r.Type == purlExternalReferenceType { return r.Locator } } @@ -191,6 +193,20 @@ func New(t Type) *SBOM { func newSBOM(t Type) *SBOM { return New(t) } +func newSizedSBOM(t Type, packages, relationships int) *SBOM { + s := &SBOM{ + Type: t, + pkgIndex: make(map[[2]string]int, packages), + } + if packages > 0 { + s.Packages = make([]Package, 0, packages) + } + if relationships > 0 { + s.Relationships = make([]Relationship, 0, relationships) + } + return s +} + // AddPackage appends p, replacing any existing package with the same // (Name, Version) pair. func (s *SBOM) AddPackage(p Package) { s.addPackage(p) } @@ -288,14 +304,15 @@ func (s *SBOM) addPackage(p Package) { // Parse sniffs the SBOM format from content and parses it. Only JSON // serialisations are supported. func Parse(data []byte) (*SBOM, error) { - switch Detect(data) { + detected := detect(data) + switch detected.typ { case TypeCycloneDX: return parseCycloneDX(data) case TypeSPDX: - return parseSPDX(data) + return parseSPDX(data, detected.spdxEnvelope) } // Fall back to trying both, mirroring Parser#try_both_parsers. - if doc, err := parseSPDX(data); err == nil && len(doc.Packages) > 0 { + if doc, err := parseSPDX(data, false); err == nil && len(doc.Packages) > 0 { return doc, nil } if doc, err := parseCycloneDX(data); err == nil && len(doc.Packages) > 0 { @@ -311,46 +328,55 @@ func Parse(data []byte) (*SBOM, error) { // skipped without allocation so detection cost is independent of document // size once a discriminator is found. func Detect(data []byte) Type { + return detect(data).typ +} + +type detectedFormat struct { + typ Type + spdxEnvelope bool +} + +func detect(data []byte) detectedFormat { data = bytes.TrimSpace(data) if len(data) == 0 || data[0] != '{' { - return TypeUnknown + return detectedFormat{} } dec := json.NewDecoder(bytes.NewReader(data)) if _, err := dec.Token(); err != nil { // opening '{' - return TypeUnknown + return detectedFormat{} } for dec.More() { tok, err := dec.Token() if err != nil { - return TypeUnknown + return detectedFormat{} } key, ok := tok.(string) if !ok { - return TypeUnknown + return detectedFormat{} } switch key { case "bomFormat": var v string - if dec.Decode(&v) == nil && v == "CycloneDX" { - return TypeCycloneDX + if dec.Decode(&v) == nil && v == cdxBOMFormat { + return detectedFormat{typ: TypeCycloneDX} } - return TypeUnknown + return detectedFormat{} case "spdxVersion", "SPDXID": - return TypeSPDX + return detectedFormat{typ: TypeSPDX} case "sbom": - return TypeSPDX + return detectedFormat{typ: TypeSPDX, spdxEnvelope: true} case "predicateType": var v string if dec.Decode(&v) == nil && strings.Contains(v, "spdx") { - return TypeSPDX + return detectedFormat{typ: TypeSPDX, spdxEnvelope: true} } default: if err := skipValue(dec); err != nil { - return TypeUnknown + return detectedFormat{} } } } - return TypeUnknown + return detectedFormat{} } // skipValue advances dec past the next JSON value without decoding it. diff --git a/spdx.go b/spdx.go index 0e6bc7e..9c279d2 100644 --- a/spdx.go +++ b/spdx.go @@ -15,12 +15,12 @@ type spdxDoc struct { Packages []spdxPackage `json:"packages"` Relationships []spdxRelationship `json:"relationships,omitempty"` ExtractedLicensingInfos []spdxExtractedLicenseInfo `json:"hasExtractedLicensingInfos,omitempty"` +} - // Envelope unwrapping: GitHub's dependency-graph API nests under - // "sbom", and in-toto attestations nest under "predicate". - SBOM json.RawMessage `json:"sbom,omitempty"` - Predicate json.RawMessage `json:"predicate,omitempty"` - PredicateType string `json:"predicateType,omitempty"` +type spdxEnvelope struct { + SBOM json.RawMessage `json:"sbom"` + Predicate json.RawMessage `json:"predicate"` + PredicateType string `json:"predicateType"` } type spdxExtractedLicenseInfo struct { @@ -72,28 +72,22 @@ type spdxRelationship struct { const maxEnvelopeDepth = 3 -func parseSPDX(data []byte) (*SBOM, error) { +func parseSPDX(data []byte, envelope bool) (*SBOM, error) { + var err error + data, err = unwrapSPDXEnvelope(data, envelope) + if err != nil { + return nil, err + } + var doc spdxDoc - for range maxEnvelopeDepth { - doc = spdxDoc{} - if err := json.Unmarshal(data, &doc); err != nil { - return nil, wrapErr("spdx json", err) - } - if len(doc.SBOM) > 0 { - data = doc.SBOM - continue - } - if strings.Contains(doc.PredicateType, "spdx") && len(doc.Predicate) > 0 { - data = doc.Predicate - continue - } - break + if err := json.Unmarshal(data, &doc); err != nil { + return nil, wrapErr("spdx json", err) } if doc.SPDXVersion == "" && doc.SPDXID == "" { return nil, ErrUnrecognized } - s := newSBOM(TypeSPDX) + s := newSizedSBOM(TypeSPDX, len(doc.Packages), len(doc.Relationships)) s.SpecVersion = doc.SPDXVersion s.Document = Document{ Name: doc.Name, @@ -103,19 +97,13 @@ func parseSPDX(data []byte) (*SBOM, error) { DataLicense: doc.DataLicense, Namespace: doc.DocumentNamespace, } - if ci := doc.CreationInfo; ci != nil { - s.Document.Created = ci.Created - for _, c := range ci.Creators { - typ, name := splitColon(c) - if typ == SupplierOrganization { - s.Document.Supplier = name - } else { - s.Document.Creators = append(s.Document.Creators, Creator{Type: typ, Name: name}) - } - } - } + applySPDXCreationInfo(s, doc.CreationInfo) - elements := map[string]string{doc.SPDXID: doc.Name} + var elements map[string]string + if len(doc.Relationships) > 0 { + elements = make(map[string]string, len(doc.Packages)+1) + elements[doc.SPDXID] = doc.Name + } for i := range doc.Packages { sp := &doc.Packages[i] p := Package{ @@ -137,17 +125,26 @@ func parseSPDX(data []byte) (*SBOM, error) { if sp.Originator != "" { p.OriginatorType, p.Originator = splitColon(sp.Originator) } - for _, c := range sp.Checksums { - p.Checksums = append(p.Checksums, Checksum(c)) + if len(sp.Checksums) > 0 { + p.Checksums = make([]Checksum, len(sp.Checksums)) + for i := range sp.Checksums { + p.Checksums[i] = Checksum(sp.Checksums[i]) + } + } + if len(sp.ExternalRefs) > 0 { + p.ExternalRefs = make([]ExternalRef, len(sp.ExternalRefs)) + for i := range sp.ExternalRefs { + p.ExternalRefs[i] = ExternalRef(sp.ExternalRefs[i]) + } } - for _, r := range sp.ExternalRefs { - p.ExternalRefs = append(p.ExternalRefs, ExternalRef(r)) + if elements != nil { + elements[sp.SPDXID] = sp.Name } - elements[sp.SPDXID] = sp.Name s.addPackage(p) } - for _, r := range doc.Relationships { + for i := range doc.Relationships { + r := &doc.Relationships[i] s.Relationships = append(s.Relationships, Relationship{ SourceID: r.SPDXElementID, Source: elements[r.SPDXElementID], @@ -160,6 +157,48 @@ func parseSPDX(data []byte) (*SBOM, error) { return s, nil } +func applySPDXCreationInfo(s *SBOM, creationInfo *spdxCreationInfo) { + if creationInfo == nil { + return + } + s.Document.Created = creationInfo.Created + for _, c := range creationInfo.Creators { + typ, name := splitColon(c) + if typ == SupplierOrganization { + s.Document.Supplier = name + } else { + if s.Document.Creators == nil { + s.Document.Creators = make([]Creator, 0, len(creationInfo.Creators)) + } + s.Document.Creators = append(s.Document.Creators, Creator{Type: typ, Name: name}) + } + } +} + +func unwrapSPDXEnvelope(data []byte, envelope bool) ([]byte, error) { + for range maxEnvelopeDepth - 1 { + if !envelope { + break + } + var outer spdxEnvelope + if err := json.Unmarshal(data, &outer); err != nil { + return nil, wrapErr("spdx json", err) + } + if len(outer.SBOM) > 0 { + data = outer.SBOM + envelope = detect(data).spdxEnvelope + continue + } + if strings.Contains(outer.PredicateType, "spdx") && len(outer.Predicate) > 0 { + data = outer.Predicate + envelope = detect(data).spdxEnvelope + continue + } + break + } + return data, nil +} + func splitColon(s string) (typ, name string) { if i := strings.Index(s, ": "); i >= 0 { return s[:i], s[i+2:] diff --git a/spdx_test.go b/spdx_test.go index a5a8346..0527d45 100644 --- a/spdx_test.go +++ b/spdx_test.go @@ -2,6 +2,7 @@ package sbom import ( "errors" + "reflect" "strings" "testing" ) @@ -30,6 +31,74 @@ func TestSPDXExternalRefPURL(t *testing.T) { } } +func TestSPDXEmptyDocumentPreservesNilSlices(t *testing.T) { + doc, err := Parse([]byte(`{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT"}`)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if doc.Packages != nil { + t.Errorf("Packages = %#v, want nil", doc.Packages) + } + if doc.Relationships != nil { + t.Errorf("Relationships = %#v, want nil", doc.Relationships) + } +} + +func TestSPDXEmptyCreatorsPreserveNilSlice(t *testing.T) { + in := `{ + "spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT", + "creationInfo":{"created":"2026-01-01T00:00:00Z","creators":[]} + }` + doc, err := Parse([]byte(in)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if doc.Document.Creators != nil { + t.Errorf("Document.Creators = %#v, want nil", doc.Document.Creators) + } +} + +func TestSPDXOrganizationOnlyCreatorPreservesNilSlice(t *testing.T) { + in := `{ + "spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT", + "creationInfo":{"creators":["Organization: Acme"]} + }` + doc, err := Parse([]byte(in)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if doc.Document.Supplier != "Acme" { + t.Errorf("Document.Supplier = %q, want Acme", doc.Document.Supplier) + } + if doc.Document.Creators != nil { + t.Errorf("Document.Creators = %#v, want nil", doc.Document.Creators) + } +} + +func TestSPDXMixedCreatorsPreserveOrder(t *testing.T) { + in := `{ + "spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT", + "creationInfo":{"creators":[ + "Tool: syft","Organization: Acme","Person: Jane Doe","Tool: scanner" + ]} + }` + doc, err := Parse([]byte(in)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if doc.Document.Supplier != "Acme" { + t.Errorf("Document.Supplier = %q, want Acme", doc.Document.Supplier) + } + want := []Creator{ + {Type: "Tool", Name: "syft"}, + {Type: "Person", Name: "Jane Doe"}, + {Type: "Tool", Name: "scanner"}, + } + if !reflect.DeepEqual(doc.Document.Creators, want) { + t.Errorf("Document.Creators = %#v, want %#v", doc.Document.Creators, want) + } +} + func TestSPDXSupplierOriginator(t *testing.T) { in := `{ "spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT",