diff --git a/README.md b/README.md
index 98d69b9..cec64ba 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# sbom
-Go library for reading and writing Software Bill of Materials documents. Reads CycloneDX and SPDX JSON into a single `Document` / `Package` / `Relationship` model, and writes that model back out as CycloneDX (JSON or XML) or SPDX JSON.
+Go library for reading and writing Software Bill of Materials documents. Reads CycloneDX JSON/XML and SPDX JSON/tag-value into a single `Document` / `Package` / `Relationship` model, and writes that model back out as CycloneDX (JSON or XML) or SPDX JSON.
Ported from [github.com/andrew/sbom](https://github.com/andrew/sbom).
@@ -30,7 +30,9 @@ for _, p := range doc.Packages {
}
```
-`Parse` auto-detects the format. `Detect` returns just the type without a full parse and runs in roughly constant time regardless of document size, since it only token-scans top-level keys until it hits a discriminator.
+`Parse` auto-detects the format. `Detect` returns just the type without a full parse, scanning JSON keys, the XML root, or the tag-value document header.
+
+Packages with different document IDs or PURLs remain separate, even when their names and versions match. `AddPackage` replaces an entry only when its name, version, document ID, and set of PURLs match. `Package.PURL()` returns the first PURL; `Package.ExternalRefs` retains all declared references.
```go
switch sbom.Detect(data) {
@@ -94,10 +96,11 @@ for _, p := range doc.Packages {
| Format | Parse | Encode | Notes |
| --- | --- | --- | --- |
| CycloneDX JSON | yes | yes | nested components flattened, `license.id` / `license.name` / `expression` all read |
-| CycloneDX XML | no | yes | |
+| CycloneDX XML | yes | yes | nested components, dependency references, and package metadata |
| SPDX JSON | yes | yes | `{"sbom": ...}` and in-toto `{"predicate": ...}` envelopes unwrapped on parse |
+| SPDX tag-value | yes | no | document and package fields, external references, relationships, and multiline text |
-XML/YAML/tag-value parsing is not handled yet.
+SPDX file, snippet, annotation, and extracted-license records are not included in the parsed model. YAML parsing is not supported.
## Why not protobom?
diff --git a/cyclonedx.go b/cyclonedx.go
index 8be2755..bdb7fbc 100644
--- a/cyclonedx.go
+++ b/cyclonedx.go
@@ -34,10 +34,11 @@ type cdxBOMXML struct {
}
type cdxMetadataXML struct {
- Timestamp string `xml:"timestamp,omitempty"`
- Tools *cdxToolsXML `xml:"tools,omitempty"`
- Component *cdxComponentXML `xml:"component,omitempty"`
- Supplier *cdxOrgEntity `xml:"supplier,omitempty"`
+ Timestamp string `xml:"timestamp,omitempty"`
+ Tools *cdxToolsXML `xml:"tools,omitempty"`
+ Component *cdxComponentXML `xml:"component,omitempty"`
+ Supplier *cdxOrgEntity `xml:"supplier,omitempty"`
+ Manufacture *cdxOrgEntity `xml:"manufacture,omitempty"`
}
type cdxToolsXML struct {
@@ -245,10 +246,13 @@ func parseCycloneDX(data []byte) (*SBOM, error) {
if err := json.Unmarshal(data, &bom); err != nil {
return nil, wrapErr("cyclonedx json", err)
}
- if bom.BOMFormat != cdxBOMFormat {
+ if bom.BOMFormat != cdxBOMFormat && (bom.BOMFormat != "" || bom.Components == nil) {
return nil, ErrUnrecognized
}
+ return normalizeCycloneDX(&bom), nil
+}
+func normalizeCycloneDX(bom *cdxBOM) *SBOM {
packageCount := len(bom.Components)
nestedRelationshipCount := 0
for i := range bom.Components {
@@ -298,7 +302,7 @@ func parseCycloneDX(data []byte) (*SBOM, error) {
}
}
- return s, nil
+ return s
}
func cdxComponentStats(components []cdxComponent) (packages, relationships int) {
diff --git a/cyclonedx_xml.go b/cyclonedx_xml.go
new file mode 100644
index 0000000..2cbecca
--- /dev/null
+++ b/cyclonedx_xml.go
@@ -0,0 +1,140 @@
+package sbom
+
+import (
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "strings"
+)
+
+const cdxXMLNamespacePrefix = "http://cyclonedx.org/schema/bom/"
+
+type cdxDependencyXML struct {
+ Ref string `xml:"ref,attr"`
+ DependsOn []cdxDependencyXML `xml:"dependency"`
+}
+
+// The dependency targets are ref attributes, unlike the JSON string array.
+type cdxInputXML struct {
+ XMLName xml.Name `xml:"bom"`
+ Version int `xml:"version,attr"`
+ SerialNumber string `xml:"serialNumber,attr"`
+ Metadata *cdxMetadataXML `xml:"metadata"`
+ Components *cdxComponentsXML `xml:"components"`
+ Dependencies []cdxDependencyXML `xml:"dependencies>dependency"`
+}
+
+func detectCycloneDXXML(data []byte) detectedFormat {
+ decoder := xml.NewDecoder(bytes.NewReader(data))
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ return detectedFormat{}
+ }
+ if start, ok := token.(xml.StartElement); ok {
+ if start.Name.Local == "bom" && (start.Name.Space == "" || cycloneDXXMLVersion(start.Name.Space) != "") {
+ return detectedFormat{typ: TypeCycloneDX, xml: true}
+ }
+ return detectedFormat{}
+ }
+ }
+}
+
+func cycloneDXXMLVersion(namespace string) string {
+ version, ok := strings.CutPrefix(namespace, cdxXMLNamespacePrefix)
+ if !ok {
+ return ""
+ }
+ return version
+}
+
+func parseCycloneDXXML(data []byte) (*SBOM, error) {
+ var input cdxInputXML
+ decoder := xml.NewDecoder(bytes.NewReader(data))
+ if err := decoder.Decode(&input); err != nil {
+ return nil, wrapErr("cyclonedx xml", err)
+ }
+ if err := checkXMLEnd(decoder); err != nil {
+ return nil, wrapErr("cyclonedx xml", err)
+ }
+ bom := cdxBOM{
+ BOMFormat: cdxBOMFormat, SpecVersion: cycloneDXXMLVersion(input.XMLName.Space),
+ BOMVersion: input.Version, SerialNumber: input.SerialNumber,
+ Components: componentsFromXML(input.Components),
+ }
+ if input.Metadata != nil {
+ m := input.Metadata
+ bom.Metadata = &cdxMetadata{Timestamp: m.Timestamp, Supplier: m.Supplier, Manufacture: m.Manufacture}
+ if m.Component != nil {
+ component := componentFromXML(m.Component)
+ bom.Metadata.Component = &component
+ }
+ }
+ for _, dependency := range input.Dependencies {
+ d := cdxDependency{Ref: dependency.Ref}
+ for _, target := range dependency.DependsOn {
+ d.DependsOn = append(d.DependsOn, target.Ref)
+ }
+ bom.Dependencies = append(bom.Dependencies, d)
+ }
+ return normalizeCycloneDX(&bom), nil
+}
+
+func checkXMLEnd(decoder *xml.Decoder) error {
+ for {
+ token, err := decoder.Token()
+ if err == io.EOF {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ switch token := token.(type) {
+ case xml.Comment, xml.ProcInst:
+ continue
+ case xml.CharData:
+ if len(bytes.TrimSpace(token)) == 0 {
+ continue
+ }
+ }
+ return fmt.Errorf("unexpected content after bom element")
+ }
+}
+
+func componentsFromXML(input *cdxComponentsXML) []cdxComponent {
+ if input == nil || len(input.Components) == 0 {
+ return nil
+ }
+ components := make([]cdxComponent, len(input.Components))
+ for i := range input.Components {
+ components[i] = componentFromXML(&input.Components[i])
+ }
+ return components
+}
+
+func componentFromXML(input *cdxComponentXML) cdxComponent {
+ c := cdxComponent{
+ BOMRef: input.BOMRef, Type: input.Type, Name: input.Name, Version: input.Version,
+ Description: input.Description, Copyright: input.Copyright, Author: input.Author,
+ PURL: input.PURL, Supplier: input.Supplier, Components: componentsFromXML(input.Components),
+ }
+ if input.Hashes != nil {
+ c.Hashes = input.Hashes.Hashes
+ }
+ if input.ExternalReferences != nil {
+ c.ExternalReferences = input.ExternalReferences.References
+ }
+ if input.Properties != nil {
+ c.Properties = input.Properties.Properties
+ }
+ if input.Licenses != nil {
+ for _, license := range input.Licenses.Licenses {
+ c.Licenses = append(c.Licenses, cdxLicense{License: &cdxLicenseID{ID: license.ID, Name: license.Name}})
+ }
+ if input.Licenses.Expression != "" {
+ c.Licenses = append(c.Licenses, cdxLicense{Expression: input.Licenses.Expression})
+ }
+ }
+ return c
+}
diff --git a/parse_formats_test.go b/parse_formats_test.go
new file mode 100644
index 0000000..ea7eae2
--- /dev/null
+++ b/parse_formats_test.go
@@ -0,0 +1,259 @@
+package sbom
+
+import (
+ "bytes"
+ "errors"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestParseCycloneDXXML(t *testing.T) {
+ data := readFixture(t, "cyclonedx/packages.cdx.xml")
+ if got := Detect(data); got != TypeCycloneDX {
+ t.Fatalf("Detect = %q", got)
+ }
+ doc, err := Parse(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if doc.Type != TypeCycloneDX || doc.SpecVersion != "1.5" || doc.Document.Name != "example" || doc.Document.Supplier != "Acme" || doc.Document.Created != "2026-01-01T00:00:00Z" {
+ t.Fatalf("document = %+v", doc.Document)
+ }
+ if len(doc.Packages) != 3 {
+ t.Fatalf("packages = %+v", doc.Packages)
+ }
+ want := Package{
+ ID: "alpha", Name: "shared", Version: "1.0.0", Type: "LIBRARY",
+ Description: "Shared & useful", Copyright: "Copyright Acme",
+ Supplier: "Acme", SupplierType: SupplierOrganization,
+ Originator: "Jane Doe", OriginatorType: SupplierPerson,
+ LicenseConcluded: "MIT", LicenseDeclared: "MIT",
+ Checksums: []Checksum{{Algorithm: "SHA256", Value: "abc"}},
+ ExternalRefs: []ExternalRef{
+ {Category: "PACKAGE_MANAGER", Type: "purl", Locator: "pkg:npm/%40alpha/shared@1.0.0"},
+ {Category: "website", Type: "website", Locator: "https://example.com"},
+ },
+ Properties: []Property{{Name: "scope", Value: "runtime"}},
+ }
+ if !reflect.DeepEqual(doc.Packages[0], want) {
+ t.Errorf("package = %#v, want %#v", doc.Packages[0], want)
+ }
+ if doc.Packages[2].PURL() != "pkg:npm/%40beta/shared@1.0.0" || doc.Packages[2].LicenseDeclared != "MIT OR Apache-2.0" {
+ t.Errorf("second scoped package = %+v", doc.Packages[2])
+ }
+ wantRelationships := []Relationship{
+ {SourceID: "alpha", TargetID: "child", Type: RelDependsOn},
+ {SourceID: "alpha", TargetID: "beta", Type: RelDependsOn},
+ }
+ if !reflect.DeepEqual(doc.Relationships, wantRelationships) {
+ t.Errorf("relationships = %+v", doc.Relationships)
+ }
+}
+
+func TestParseSPDXTagValue(t *testing.T) {
+ data := readFixture(t, "spdx/packages.spdx")
+ if got := Detect(data); got != TypeSPDX {
+ t.Fatalf("Detect = %q", got)
+ }
+ doc, err := Parse(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ wantDocument := Document{
+ Name: "example", ID: "SPDXRef-DOCUMENT", Type: TypeSPDX, SpecVersion: "SPDX-2.3",
+ DataLicense: "CC0-1.0", Namespace: "https://example.com/sbom/example",
+ Created: "2026-01-01T00:00:00Z", Supplier: "Acme",
+ Creators: []Creator{{Type: "Tool", Name: "example-generator"}},
+ }
+ if !reflect.DeepEqual(doc.Document, wantDocument) {
+ t.Errorf("document = %#v", doc.Document)
+ }
+ if len(doc.Packages) != 2 {
+ t.Fatalf("packages = %+v", doc.Packages)
+ }
+ want := Package{
+ ID: "SPDXRef-alpha", Name: "shared", Version: "1.0.0", Type: "LIBRARY",
+ Filename: "alpha.tgz", DownloadLocation: "https://example.com/alpha.tgz", Homepage: "https://example.com",
+ Description: "Shared & useful\n# This is part of the description.\n Indented text.", Copyright: "Copyright Acme",
+ Supplier: "Acme", SupplierType: SupplierOrganization, Originator: "Jane Doe", OriginatorType: SupplierPerson,
+ LicenseDeclared: "MIT", LicenseConcluded: "MIT",
+ Checksums: []Checksum{{Algorithm: "SHA256", Value: "abc"}},
+ ExternalRefs: []ExternalRef{
+ {Category: "PACKAGE-MANAGER", Type: "purl", Locator: "pkg:npm/%40alpha/shared@1.0.0"},
+ {Category: "PACKAGE-MANAGER", Type: "purl", Locator: "pkg:generic/alpha@1.0.0"},
+ {Category: "SECURITY", Type: "cpe23Type", Locator: "cpe:2.3:a:acme:shared:1.0.0"},
+ },
+ }
+ if !reflect.DeepEqual(doc.Packages[0], want) {
+ t.Errorf("package = %#v, want %#v", doc.Packages[0], want)
+ }
+ if doc.Packages[1].PURL() != "pkg:npm/%40beta/shared@1.0.0" {
+ t.Errorf("second scoped package = %+v", doc.Packages[1])
+ }
+ wantRelationships := []Relationship{
+ {SourceID: "SPDXRef-DOCUMENT", Source: "example", TargetID: "SPDXRef-alpha", Target: "shared", Type: RelDescribes},
+ {SourceID: "SPDXRef-alpha", Source: "shared", TargetID: "SPDXRef-beta", Target: "shared", Type: RelDependsOn},
+ }
+ if !reflect.DeepEqual(doc.Relationships, wantRelationships) {
+ t.Errorf("relationships = %+v", doc.Relationships)
+ }
+}
+
+func TestParsePreservesPackageIdentities(t *testing.T) {
+ for name, data := range map[string]string{
+ "cyclonedx without marker": `{"components":[
+ {"name":"shared","version":"1","purl":"pkg:npm/%40alpha/shared@1"},
+ {"name":"shared","version":"1","purl":"pkg:npm/%40beta/shared@1"}]}`,
+ "XML without namespace": `shared1pkg:npm/%40alpha/shared@1shared1pkg:npm/%40beta/shared@1`,
+ "cyclonedx": `{"bomFormat":"CycloneDX","components":[
+ {"name":"shared","version":"1","purl":"pkg:npm/%40alpha/shared@1"},
+ {"name":"shared","version":"1","purl":"pkg:npm/%40beta/shared@1"}]}`,
+ "spdx": `{"spdxVersion":"SPDX-2.3","packages":[
+ {"name":"shared","versionInfo":"1","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:npm/%40alpha/shared@1"}]},
+ {"name":"shared","versionInfo":"1","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:npm/%40beta/shared@1"}]}]}`,
+ "different IDs": `{"bomFormat":"CycloneDX","components":[{"bom-ref":"a","name":"shared","version":"1"},{"bom-ref":"b","name":"shared","version":"1"}],"dependencies":[{"ref":"a","dependsOn":["b"]}]}`,
+ "multiple PURLs": `{"spdxVersion":"SPDX-2.3","packages":[
+ {"name":"shared","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:generic/shared"},{"referenceType":"purl","referenceLocator":"pkg:npm/%40alpha/shared"}]},
+ {"name":"shared","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:generic/shared"},{"referenceType":"purl","referenceLocator":"pkg:npm/%40beta/shared"}]}]}`,
+ } {
+ t.Run(name, func(t *testing.T) {
+ doc, err := Parse([]byte(data))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(doc.Packages) != 2 {
+ t.Fatalf("packages = %+v; want both identities", doc.Packages)
+ }
+ })
+ }
+}
+
+func TestParseAdditionalFormatErrors(t *testing.T) {
+ for name, data := range map[string]string{
+ "truncated XML": ``,
+ "extra XML root": ``,
+ "trailing XML text": `oops`,
+ "unclosed text": "SPDXVersion: SPDX-2.3\nPackageName: a\nPackageDescription: unfinished",
+ "text suffix": "SPDXVersion: SPDX-2.3\nPackageName: a\nPackageDescription: textoops",
+ "missing colon": "SPDXVersion: SPDX-2.3\nPackageName a",
+ "invalid reference": "SPDXVersion: SPDX-2.3\nPackageName: a\nExternalRef: PACKAGE-MANAGER purl",
+ "invalid relationship": "SPDXVersion: SPDX-2.3\nRelationship: SPDXRef-a DEPENDS_ON",
+ "invalid checksum": "SPDXVersion: SPDX-2.3\nPackageName: a\nPackageChecksum: SHA256",
+ } {
+ t.Run(name, func(t *testing.T) {
+ if doc, err := Parse([]byte(data)); err == nil {
+ t.Fatalf("Parse accepted invalid input: %+v", doc)
+ }
+ })
+ }
+ for _, data := range []string{``, ``, "PackageName: a", "DocumentComment: \nSPDXVersion: SPDX-2.3\n"} {
+ if got := Detect([]byte(data)); got != TypeUnknown {
+ t.Errorf("Detect(%q) = %q", data, got)
+ }
+ if _, err := Parse([]byte(data)); !errors.Is(err, ErrUnrecognized) {
+ t.Errorf("Parse(%q) = %v", data, err)
+ }
+ }
+}
+
+func TestParseSPDXHeaderOrder(t *testing.T) {
+ data := []byte("DataLicense: CC0-1.0\nDocumentComment: Generated inventory\nSPDXVersion: SPDX-2.3\nSPDXID: SPDXRef-DOCUMENT\nPackageName: shared\nSPDXID: SPDXRef-shared\nExternalRef: PACKAGE-MANAGER purl pkg:generic/shared@1")
+ if got := Detect(data); got != TypeSPDX {
+ t.Fatalf("Detect = %q", got)
+ }
+ doc, err := Parse(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if doc.Document.DataLicense != "CC0-1.0" || len(doc.Packages) != 1 || doc.Packages[0].PURL() != "pkg:generic/shared@1" {
+ t.Fatalf("document = %+v", doc)
+ }
+}
+
+func TestParseAdditionalFormatWhitespace(t *testing.T) {
+ for _, fixture := range []string{"cyclonedx/packages.cdx.xml", "spdx/packages.spdx"} {
+ data := readFixture(t, fixture)
+ want, err := Parse(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ data = bytes.ReplaceAll(data, []byte("\n"), []byte("\r\n"))
+ data = append([]byte("\xef\xbb\xbf \r\n"), data...)
+ got, err := Parse(data)
+ if err != nil || !reflect.DeepEqual(got, want) {
+ t.Errorf("Parse %s with BOM and CRLF = %+v, %v", fixture, got, err)
+ }
+ }
+}
+
+func TestParseSPDXLongText(t *testing.T) {
+ text := strings.Repeat("x", 100_000)
+ doc, err := Parse([]byte("SPDXVersion: SPDX-2.3\nPackageName: a\nPackageDescription: " + text + ""))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(doc.Packages) != 1 || doc.Packages[0].Description != text {
+ t.Fatal("long description was not preserved")
+ }
+}
+
+func TestParseSPDXTextWhitespace(t *testing.T) {
+ text := " first line \n\n second line \n"
+ doc, err := Parse([]byte("SPDXVersion: SPDX-2.3\nPackageName: a\nPackageDescription: " + text + ""))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(doc.Packages) != 1 || doc.Packages[0].Description != text {
+ t.Fatalf("multiline whitespace was not preserved: %+v", doc.Packages)
+ }
+}
+
+func TestAddPackagePURLSet(t *testing.T) {
+ s := New(TypeSPDX)
+ alpha := ExternalRef{Type: "purl", Locator: "pkg:npm/%40alpha/shared@1"}
+ generic := ExternalRef{Type: "purl", Locator: "pkg:generic/shared@1"}
+ s.AddPackage(Package{Name: "shared", Version: "1", ExternalRefs: []ExternalRef{alpha, generic}})
+ s.AddPackage(Package{Name: "shared", Version: "1", Description: "updated", ExternalRefs: []ExternalRef{generic, alpha, alpha}})
+ if len(s.Packages) != 1 || s.Packages[0].Description != "updated" {
+ t.Fatalf("reordered PURLs should replace the same identity: %+v", s.Packages)
+ }
+ s.AddPackage(Package{Name: "shared", Version: "1"})
+ if len(s.Packages) != 2 {
+ t.Fatalf("an entry without PURLs must not replace a declared identity: %+v", s.Packages)
+ }
+}
+
+func TestParseEmptyAdditionalFormats(t *testing.T) {
+ for _, data := range []string{``, ``, "SPDXVersion: SPDX-2.3\nSPDXID: SPDXRef-DOCUMENT"} {
+ doc, err := Parse([]byte(data))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if doc.Packages != nil || doc.Relationships != nil {
+ t.Errorf("empty document = %+v", doc)
+ }
+ }
+}
+
+func TestParsePrefixedCycloneDXXML(t *testing.T) {
+ data := `sharedpkg:generic/shared@1Custom license`
+ doc, err := Parse([]byte(data))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if doc.SpecVersion != "1.5" || len(doc.Packages) != 1 || doc.Packages[0].PURL() != "pkg:generic/shared@1" || doc.Packages[0].LicenseDeclared != "Custom license" {
+ t.Fatalf("prefixed document = %+v", doc)
+ }
+}
+
+func TestParseCycloneDXXMLManufacture(t *testing.T) {
+ data := `Acme`
+ doc, err := Parse([]byte(data))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if doc.Document.Supplier != "Acme" {
+ t.Fatalf("supplier = %q, want manufacture name", doc.Document.Supplier)
+ }
+}
diff --git a/sbom.go b/sbom.go
index da3e063..dd7fc7e 100644
--- a/sbom.go
+++ b/sbom.go
@@ -1,5 +1,5 @@
// Package sbom parses Software Bill of Materials documents into a unified
-// data model. It reads CycloneDX and SPDX (JSON serialisations) and
+// data model. It reads CycloneDX JSON/XML and SPDX JSON/tag-value and
// normalises both into the same Document/Package/Relationship shape so
// callers don't need to care which format they were handed.
//
@@ -16,6 +16,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "slices"
"strings"
)
@@ -180,23 +181,23 @@ type SBOM struct {
Packages []Package
Relationships []Relationship
- // pkgIndex keys packages by (name, version) so duplicates collapse the
- // same way the Ruby implementation's @packages[[name, version]] does.
- pkgIndex map[[2]string]int
+ pkgIndex map[packageKey]int
+}
+
+type packageKey struct {
+ name, version, id, purls string
}
// New returns an empty SBOM ready for AddPackage calls. Use this when
// building a document for Encode rather than parsing one.
func New(t Type) *SBOM {
- return &SBOM{Type: t, pkgIndex: map[[2]string]int{}}
+ return &SBOM{Type: t, pkgIndex: map[packageKey]int{}}
}
-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),
+ pkgIndex: make(map[packageKey]int, packages),
}
if packages > 0 {
s.Packages = make([]Package, 0, packages)
@@ -208,7 +209,7 @@ func newSizedSBOM(t Type, packages, relationships int) *SBOM {
}
// AddPackage appends p, replacing any existing package with the same
-// (Name, Version) pair.
+// name, version, document ID, and set of PURLs.
func (s *SBOM) AddPackage(p Package) { s.addPackage(p) }
// FilterProperties walks every package and removes properties whose
@@ -290,9 +291,9 @@ func (s *SBOM) ClassifyScope() map[string]string {
func (s *SBOM) addPackage(p Package) {
if s.pkgIndex == nil {
- s.pkgIndex = map[[2]string]int{}
+ s.pkgIndex = map[packageKey]int{}
}
- key := [2]string{p.Name, p.Version}
+ key := packageKey{p.Name, p.Version, p.ID, packagePURLKey(p.ExternalRefs)}
if i, ok := s.pkgIndex[key]; ok {
s.Packages[i] = p
return
@@ -301,10 +302,27 @@ func (s *SBOM) addPackage(p Package) {
s.Packages = append(s.Packages, p)
}
-// Parse sniffs the SBOM format from content and parses it. Only JSON
-// serialisations are supported.
+func packagePURLKey(refs []ExternalRef) string {
+ var purls []string
+ for _, ref := range refs {
+ if ref.Type == purlExternalReferenceType && ref.Locator != "" {
+ purls = append(purls, ref.Locator)
+ }
+ }
+ slices.Sort(purls)
+ return strings.Join(slices.Compact(purls), "\x00")
+}
+
+// Parse reads CycloneDX JSON/XML or SPDX JSON/tag-value, detecting the format.
func Parse(data []byte) (*SBOM, error) {
+ data = trimDocument(data)
detected := detect(data)
+ if detected.xml {
+ return parseCycloneDXXML(data)
+ }
+ if detected.tagValue {
+ return parseSPDXTagValue(data)
+ }
switch detected.typ {
case TypeCycloneDX:
return parseCycloneDX(data)
@@ -324,9 +342,7 @@ func Parse(data []byte) (*SBOM, error) {
// Detect inspects content and returns the SBOM Type without fully parsing
// it. Returns TypeUnknown if the format can't be determined.
//
-// Only the top-level object keys are scanned; nested arrays and objects are
-// skipped without allocation so detection cost is independent of document
-// size once a discriminator is found.
+// It scans JSON keys, the XML root, or the tag-value document header.
func Detect(data []byte) Type {
return detect(data).typ
}
@@ -334,13 +350,25 @@ func Detect(data []byte) Type {
type detectedFormat struct {
typ Type
spdxEnvelope bool
+ xml bool
+ tagValue bool
+}
+
+func trimDocument(data []byte) []byte {
+ return bytes.TrimSpace(bytes.TrimPrefix(bytes.TrimSpace(data), []byte("\xef\xbb\xbf")))
}
func detect(data []byte) detectedFormat {
- data = bytes.TrimSpace(data)
- if len(data) == 0 || data[0] != '{' {
+ data = trimDocument(data)
+ if len(data) == 0 {
return detectedFormat{}
}
+ if data[0] == '<' {
+ return detectCycloneDXXML(data)
+ }
+ if data[0] != '{' {
+ return detectSPDXTagValue(data)
+ }
dec := json.NewDecoder(bytes.NewReader(data))
if _, err := dec.Token(); err != nil { // opening '{'
return detectedFormat{}
diff --git a/sbom_test.go b/sbom_test.go
index 5357b89..8bc92be 100644
--- a/sbom_test.go
+++ b/sbom_test.go
@@ -26,7 +26,7 @@ func TestDetect(t *testing.T) {
{"github envelope", `{"sbom":{"spdxVersion":"SPDX-2.3"}}`, TypeSPDX},
{"intoto envelope", `{"predicateType":"https://spdx.dev/Document","predicate":{}}`, TypeSPDX},
{"unknown json", `{"foo":1}`, TypeUnknown},
- {"not json", ``, TypeUnknown},
+ {"bare xml bom", ``, TypeCycloneDX},
{"empty", ``, TypeUnknown},
{"whitespace", " \n\t", TypeUnknown},
}
@@ -121,10 +121,10 @@ func TestParseSPDXEnvelope(t *testing.T) {
}
func TestPackageDedup(t *testing.T) {
- s := newSBOM(TypeCycloneDX)
- s.addPackage(Package{Name: "a", Version: "1"})
- s.addPackage(Package{Name: "a", Version: "1", Description: "second"})
- s.addPackage(Package{Name: "a", Version: "2"})
+ s := New(TypeCycloneDX)
+ s.AddPackage(Package{Name: "a", Version: "1"})
+ s.AddPackage(Package{Name: "a", Version: "1", Description: "second"})
+ s.AddPackage(Package{Name: "a", Version: "2"})
if len(s.Packages) != 2 {
t.Fatalf("Packages = %d, want 2", len(s.Packages))
}
diff --git a/spdx.go b/spdx.go
index 9c279d2..8de7f92 100644
--- a/spdx.go
+++ b/spdx.go
@@ -86,7 +86,10 @@ func parseSPDX(data []byte, envelope bool) (*SBOM, error) {
if doc.SPDXVersion == "" && doc.SPDXID == "" {
return nil, ErrUnrecognized
}
+ return normalizeSPDX(&doc), nil
+}
+func normalizeSPDX(doc *spdxDoc) *SBOM {
s := newSizedSBOM(TypeSPDX, len(doc.Packages), len(doc.Relationships))
s.SpecVersion = doc.SPDXVersion
s.Document = Document{
@@ -154,7 +157,7 @@ func parseSPDX(data []byte, envelope bool) (*SBOM, error) {
})
}
- return s, nil
+ return s
}
func applySPDXCreationInfo(s *SBOM, creationInfo *spdxCreationInfo) {
diff --git a/spdx_tagvalue.go b/spdx_tagvalue.go
new file mode 100644
index 0000000..b848338
--- /dev/null
+++ b/spdx_tagvalue.go
@@ -0,0 +1,193 @@
+package sbom
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "strings"
+ "unicode"
+)
+
+const spdxTripleFields = 3
+
+func detectSPDXTagValue(data []byte) detectedFormat {
+ scanner := newSPDXScanner(data)
+ for scanner.Scan() {
+ line := strings.TrimLeftFunc(scanner.Text(), unicode.IsSpace)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ tag, value, ok := strings.Cut(line, ":")
+ if !ok || strings.TrimSpace(tag) == "PackageName" {
+ break
+ }
+ value, err := readSPDXText(scanner, value)
+ if err != nil {
+ break
+ }
+ if strings.TrimSpace(tag) == "SPDXVersion" && strings.HasPrefix(value, "SPDX-") {
+ return detectedFormat{typ: TypeSPDX, tagValue: true}
+ }
+ }
+ return detectedFormat{}
+}
+
+func newSPDXScanner(data []byte) *bufio.Scanner {
+ scanner := bufio.NewScanner(bytes.NewReader(data))
+ scanner.Buffer(nil, len(data)+1)
+ return scanner
+}
+
+type spdxTagParser struct {
+ doc spdxDoc
+ pkg *spdxPackage
+ inDocument bool
+}
+
+func parseSPDXTagValue(data []byte) (*SBOM, error) {
+ p := spdxTagParser{inDocument: true}
+ scanner := newSPDXScanner(data)
+ for scanner.Scan() {
+ line := strings.TrimLeftFunc(scanner.Text(), unicode.IsSpace)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ tag, value, ok := strings.Cut(line, ":")
+ if !ok || strings.TrimSpace(tag) == "" {
+ return nil, wrapErr("spdx tag-value", fmt.Errorf("expected tag: value, got %q", line))
+ }
+ value, err := readSPDXText(scanner, value)
+ if err != nil {
+ return nil, wrapErr("spdx tag-value", err)
+ }
+ if err := p.apply(strings.TrimSpace(tag), value); err != nil {
+ return nil, wrapErr("spdx tag-value", err)
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, wrapErr("spdx tag-value", err)
+ }
+ return normalizeSPDX(&p.doc), nil
+}
+
+func readSPDXText(scanner *bufio.Scanner, value string) (string, error) {
+ text, multiline := strings.CutPrefix(strings.TrimLeftFunc(value, unicode.IsSpace), "")
+ if !multiline {
+ return strings.TrimSpace(value), nil
+ }
+ var b strings.Builder
+ for {
+ before, after, closed := strings.Cut(text, "")
+ b.WriteString(before)
+ if closed {
+ if strings.TrimSpace(after) != "" {
+ return "", fmt.Errorf("unexpected content after ")
+ }
+ return b.String(), nil
+ }
+ if !scanner.Scan() {
+ if err := scanner.Err(); err != nil {
+ return "", err
+ }
+ return "", fmt.Errorf("unterminated value")
+ }
+ b.WriteByte('\n')
+ text = scanner.Text()
+ }
+}
+
+func (p *spdxTagParser) apply(tag, value string) error {
+ switch tag {
+ case "SPDXVersion":
+ p.doc.SPDXVersion = value
+ case "DataLicense":
+ p.doc.DataLicense = value
+ case "DocumentName":
+ p.doc.Name = value
+ case "DocumentNamespace":
+ p.doc.DocumentNamespace = value
+ case "Creator", "Created", "LicenseListVersion":
+ p.creationInfo(tag, value)
+ case "PackageName":
+ p.inDocument = false
+ p.doc.Packages = append(p.doc.Packages, spdxPackage{Name: value})
+ p.pkg = &p.doc.Packages[len(p.doc.Packages)-1]
+ case "FileName", "SnippetSPDXID", "LicenseID", "Annotator", "Reviewer":
+ p.inDocument = false
+ p.pkg = nil
+ case "SPDXID":
+ if p.pkg != nil {
+ p.pkg.SPDXID = value
+ } else if p.inDocument {
+ p.doc.SPDXID = value
+ }
+ case "Relationship":
+ fields := strings.Fields(value)
+ if len(fields) != spdxTripleFields {
+ return fmt.Errorf("invalid Relationship %q", value)
+ }
+ p.doc.Relationships = append(p.doc.Relationships, spdxRelationship{
+ SPDXElementID: fields[0], RelationshipType: fields[1], RelatedSPDXElement: fields[2],
+ })
+ default:
+ if p.pkg != nil {
+ return p.packageField(tag, value)
+ }
+ }
+ return nil
+}
+
+func (p *spdxTagParser) creationInfo(tag, value string) {
+ if p.doc.CreationInfo == nil {
+ p.doc.CreationInfo = &spdxCreationInfo{}
+ }
+ switch tag {
+ case "Creator":
+ p.doc.CreationInfo.Creators = append(p.doc.CreationInfo.Creators, value)
+ case "Created":
+ p.doc.CreationInfo.Created = value
+ case "LicenseListVersion":
+ p.doc.CreationInfo.LicenseListVersion = value
+ }
+}
+
+func (p *spdxTagParser) packageField(tag, value string) error {
+ switch tag {
+ case "PackageVersion":
+ p.pkg.VersionInfo = value
+ case "PackageFileName":
+ p.pkg.PackageFileName = value
+ case "PackageSupplier":
+ p.pkg.Supplier = value
+ case "PackageOriginator":
+ p.pkg.Originator = value
+ case "PackageDownloadLocation":
+ p.pkg.DownloadLocation = value
+ case "PackageHomePage":
+ p.pkg.Homepage = value
+ case "PackageLicenseConcluded":
+ p.pkg.LicenseConcluded = value
+ case "PackageLicenseDeclared":
+ p.pkg.LicenseDeclared = value
+ case "PackageCopyrightText":
+ p.pkg.CopyrightText = value
+ case "PackageDescription":
+ p.pkg.Description = value
+ case "PrimaryPackagePurpose":
+ p.pkg.PrimaryPackagePurpose = value
+ case "PackageChecksum":
+ algorithm, checksum, ok := strings.Cut(value, ":")
+ algorithm, checksum = strings.TrimSpace(algorithm), strings.TrimSpace(checksum)
+ if !ok || algorithm == "" || checksum == "" {
+ return fmt.Errorf("invalid PackageChecksum %q", value)
+ }
+ p.pkg.Checksums = append(p.pkg.Checksums, spdxChecksum{Algorithm: algorithm, Value: checksum})
+ case "ExternalRef":
+ fields := strings.Fields(value)
+ if len(fields) != spdxTripleFields {
+ return fmt.Errorf("invalid ExternalRef %q", value)
+ }
+ p.pkg.ExternalRefs = append(p.pkg.ExternalRefs, spdxExtRef{Category: fields[0], Type: fields[1], Locator: fields[2]})
+ }
+ return nil
+}
diff --git a/testdata/cyclonedx/packages.cdx.xml b/testdata/cyclonedx/packages.cdx.xml
new file mode 100644
index 0000000..ad3b570
--- /dev/null
+++ b/testdata/cyclonedx/packages.cdx.xml
@@ -0,0 +1,29 @@
+
+
+
+ 2026-01-01T00:00:00Z
+ example1.0.0
+ Acme
+
+
+
+ Acme
+ Jane Doe
+ @alphashared1.0.0
+ Shared & useful
+ abc
+ MIT
+ Copyright Acme
+ pkg:npm/%40alpha/shared@1.0.0
+ https://example.com
+ runtime
+ childpkg:npm/child@1.0.0
+
+
+ @betashared1.0.0
+ MIT OR Apache-2.0
+ pkg:npm/%40beta/shared@1.0.0
+
+
+
+
diff --git a/testdata/spdx/packages.spdx b/testdata/spdx/packages.spdx
new file mode 100644
index 0000000..e272c4c
--- /dev/null
+++ b/testdata/spdx/packages.spdx
@@ -0,0 +1,57 @@
+# SPDX package inventory
+SPDXVersion: SPDX-2.3
+DataLicense: CC0-1.0
+SPDXID: SPDXRef-DOCUMENT
+DocumentName: example
+DocumentNamespace: https://example.com/sbom/example
+Creator: Organization: Acme
+Creator: Tool: example-generator
+Created: 2026-01-01T00:00:00Z
+DocumentComment: A document comment.
+PackageName: this is text, not a package
+
+
+PackageName: shared
+SPDXID: SPDXRef-alpha
+PackageVersion: 1.0.0
+PackageFileName: alpha.tgz
+PackageSupplier: Organization: Acme
+PackageOriginator: Person: Jane Doe
+PackageDownloadLocation: https://example.com/alpha.tgz
+FilesAnalyzed: false
+PackageChecksum: SHA256: abc
+PackageHomePage: https://example.com
+PackageLicenseConcluded: MIT
+PackageLicenseDeclared: MIT
+PackageCopyrightText: Copyright Acme
+PackageDescription: Shared & useful
+# This is part of the description.
+ Indented text.
+PrimaryPackagePurpose: LIBRARY
+ExternalRef: PACKAGE-MANAGER purl pkg:npm/%40alpha/shared@1.0.0
+ExternalRef: PACKAGE-MANAGER purl pkg:generic/alpha@1.0.0
+ExternalRef: SECURITY cpe23Type cpe:2.3:a:acme:shared:1.0.0
+
+FileName: ./README.md
+SPDXID: SPDXRef-file
+FileCopyrightText: Copyright File Author
+SnippetSPDXID: SPDXRef-snippet
+SnippetFromFileSPDXID: SPDXRef-file
+SnippetByteRange: 1:20
+
+PackageName: shared
+SPDXID: SPDXRef-beta
+PackageVersion: 1.0.0
+PackageDownloadLocation: NOASSERTION
+FilesAnalyzed: false
+ExternalRef: PACKAGE-MANAGER purl pkg:npm/%40beta/shared@1.0.0
+
+Relationship: SPDXRef-DOCUMENT DESCRIBES SPDXRef-alpha
+Relationship: SPDXRef-alpha DEPENDS_ON SPDXRef-beta
+LicenseID: LicenseRef-example
+ExtractedText: PackageName: not a package
+Annotator: Person: Jane Doe
+AnnotationDate: 2026-01-01T00:00:00Z
+AnnotationType: REVIEW
+SPDXREF: SPDXRef-beta
+AnnotationComment: Reviewed