From a2657d9777cef49699352ef02c87b7b3d918934c Mon Sep 17 00:00:00 2001 From: nscuro Date: Fri, 25 Feb 2022 22:07:16 +0100 Subject: [PATCH 1/4] incorporate bom link; add test files for components merge Signed-off-by: nscuro --- cyclonedx.go | 69 ++++ cyclonedx_test.go | 21 + go.mod | 4 + go.sum | 12 + link.go | 99 +++++ link_test.go | 15 + merge.go | 382 ++++++++++++++++++ merge_test.go | 82 ++++ roundtrip_test.go | 24 +- testdata/merge/components-bom-a.json | 40 ++ testdata/merge/components-bom-b.json | 40 ++ .../merge/components-result-flat-subject.json | 82 ++++ testdata/merge/components-result-flat.json | 67 +++ ...omponents-result-hierarchical-subject.json | 86 ++++ .../merge/components-result-hierarchical.json | 71 ++++ .../merge/components-result-link-subject.json | 54 +++ testdata/merge/components-result-link.json | 31 ++ testdata/merge/components-subject.json | 13 + 18 files changed, 1174 insertions(+), 18 deletions(-) create mode 100644 link.go create mode 100644 link_test.go create mode 100644 merge.go create mode 100644 merge_test.go create mode 100644 testdata/merge/components-bom-a.json create mode 100644 testdata/merge/components-bom-b.json create mode 100644 testdata/merge/components-result-flat-subject.json create mode 100644 testdata/merge/components-result-flat.json create mode 100644 testdata/merge/components-result-hierarchical-subject.json create mode 100644 testdata/merge/components-result-hierarchical.json create mode 100644 testdata/merge/components-result-link-subject.json create mode 100644 testdata/merge/components-result-link.json create mode 100644 testdata/merge/components-subject.json diff --git a/cyclonedx.go b/cyclonedx.go index 5ebc7c0..b43c35e 100644 --- a/cyclonedx.go +++ b/cyclonedx.go @@ -23,6 +23,9 @@ import ( "errors" "fmt" "io" + + "github.com/google/uuid" + "github.com/gowebpki/jcs" ) const ( @@ -164,6 +167,37 @@ type Component struct { ReleaseNotes *ReleaseNotes `json:"releaseNotes,omitempty" xml:"releaseNotes,omitempty"` } +func (c Component) bomReference() string { + return c.BOMRef +} + +func (c *Component) setBOMReference(ref string) { + c.BOMRef = ref +} + +// TODO: Can we solve this more elegantly? +type componentRefSeed Component + +func (c componentRefSeed) MarshalJSON() ([]byte, error) { + c.BOMRef = "" + + componentJSON, err := json.Marshal(Component(c)) + if err != nil { + return nil, err + } + + return jcs.Transform(componentJSON) +} + +func (c Component) generateBOMReference() (string, error) { + componentJSON, err := json.Marshal(componentRefSeed(c)) + if err != nil { + return "", err + } + + return uuid.NewSHA1(uuid.MustParse("369fac08-d4a0-452e-b4b1-de87a0f376c6"), componentJSON).String(), nil +} + type Composition struct { Aggregate CompositionAggregate `json:"aggregate" xml:"aggregate"` Assemblies *[]BOMReference `json:"assemblies,omitempty" xml:"assemblies>assembly,omitempty"` @@ -520,6 +554,17 @@ type Property struct { Value string `json:"value" xml:",innerxml"` } +// referrer is an internal utility interface that is used +// to address bom elements that have a BOM reference. +type referrer interface { + bomReference() string + setBOMReference(ref string) + + // generateBOMReference returns a new value intended to be used as BOM reference. + // Given the same state of the referrer, generateBOMReference must return the same result. + generateBOMReference() (string, error) +} + type ReleaseNotes struct { Type string `json:"type" xml:"type"` Title string `json:"title,omitempty" xml:"title,omitempty"` @@ -570,6 +615,18 @@ type Service struct { ReleaseNotes *ReleaseNotes `json:"releaseNotes,omitempty" xml:"releaseNotes,omitempty"` } +func (s Service) bomReference() string { + return s.BOMRef +} + +func (s *Service) setBOMReference(ref string) { + s.BOMRef = ref +} + +func (s Service) generateBOMReference() (string, error) { + return "", nil +} + type Severity string const ( @@ -625,6 +682,18 @@ type Vulnerability struct { Affects *[]Affects `json:"affects,omitempty" xml:"affects>target,omitempty"` } +func (v Vulnerability) bomReference() string { + return v.BOMRef +} + +func (v *Vulnerability) setBOMReference(ref string) { + v.BOMRef = ref +} + +func (v Vulnerability) generateBOMReference() (string, error) { + return "", nil +} + type VulnerabilityAnalysis struct { State ImpactAnalysisState `json:"state,omitempty" xml:"state,omitempty"` Justification ImpactAnalysisJustification `json:"justification,omitempty" xml:"justification,omitempty"` diff --git a/cyclonedx_test.go b/cyclonedx_test.go index 3ced188..3422f01 100644 --- a/cyclonedx_test.go +++ b/cyclonedx_test.go @@ -20,6 +20,8 @@ package cyclonedx import ( "encoding/json" "encoding/xml" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -217,3 +219,22 @@ func TestLicenses_UnmarshalXML(t *testing.T) { err = xml.Unmarshal([]byte("expressionValue"), licenses) assert.Error(t, err) } + +func readTestBOM(t *testing.T, filePath string) *BOM { + format := BOMFileFormatJSON + if filepath.Ext(filePath) == ".xml" { + format = BOMFileFormatXML + } + + file, err := os.Open(filePath) + require.NoError(t, err) + defer func() { + _ = file.Close() + }() + + var bom BOM + err = NewBOMDecoder(file, format).Decode(&bom) + require.NoError(t, err) + + return &bom +} diff --git a/go.mod b/go.mod index 45f13b8..c30aa76 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,9 @@ go 1.15 require ( github.com/bradleyjkemp/cupaloy/v2 v2.7.0 + github.com/google/go-cmp v0.5.7 + github.com/google/uuid v1.3.0 + github.com/gowebpki/jcs v1.0.0 + github.com/mitchellh/copystructure v1.2.0 github.com/stretchr/testify v1.7.0 ) diff --git a/go.sum b/go.sum index 6eb52c0..aff3340 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,16 @@ github.com/bradleyjkemp/cupaloy/v2 v2.7.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1l github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gowebpki/jcs v1.0.0 h1:0pZtOgGetfH/L7yXb4KWcJqIyZNA43WXFyMd7ftZACw= +github.com/gowebpki/jcs v1.0.0/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -10,6 +20,8 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= diff --git a/link.go b/link.go new file mode 100644 index 0000000..45c9f9a --- /dev/null +++ b/link.go @@ -0,0 +1,99 @@ +package cyclonedx + +import ( + "fmt" + "net/url" + "regexp" + "strconv" + + "github.com/google/uuid" +) + +var bomLinkRegex = regexp.MustCompile(`^urn:cdx:(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/(?P[0-9]+)(?:#(?P[0-9a-zA-Z\-._~%!$&'()*+,;=:@\/?]+))?$`) + +// IsBOMLink TODO +func IsBOMLink(s string) bool { + return bomLinkRegex.MatchString(s) +} + +// BOMLink TODO +type BOMLink struct { + SerialNumber uuid.UUID // Serial number of the linked BOM + Version int // Version of the linked BOM + Reference string // Reference of the linked element +} + +// NewBOMLink TODO +func NewBOMLink(bom *BOM, elem referrer) (*BOMLink, error) { + if bom == nil { + return nil, fmt.Errorf("bom is nil") + } + if bom.SerialNumber == "" { + return nil, fmt.Errorf("missing serial number") + } + if bom.Version < 1 { + return nil, fmt.Errorf("versions below 1 are not allowed") + } + + serial, err := uuid.Parse(bom.SerialNumber) + if err != nil { + return nil, fmt.Errorf("invalid serial number: %w", err) + } + + if elem == nil { + return &BOMLink{ + SerialNumber: serial, + Version: bom.Version, + }, nil + } + + return &BOMLink{ + SerialNumber: serial, + Version: bom.Version, + Reference: elem.bomReference(), + }, nil +} + +// String TODO +func (b BOMLink) String() string { + if b.Reference == "" { + return fmt.Sprintf("urn:cdx:%s/%d", b.SerialNumber, b.Version) + } + + return fmt.Sprintf("urn:cdx:%s/%d#%s", b.SerialNumber, b.Version, url.QueryEscape(b.Reference)) +} + +// ParseBOMLink TODO +func ParseBOMLink(s string) (*BOMLink, error) { + matches := bomLinkRegex.FindStringSubmatch(s) + if len(matches) < 3 || len(matches) > 4 { + return nil, fmt.Errorf("") + } + + serial, err := uuid.Parse(matches[1]) + if err != nil { + return nil, fmt.Errorf("invalid serial number: %w", err) + } + version, err := strconv.Atoi(matches[2]) + if err != nil { + return nil, fmt.Errorf("invalid version: %w", err) + } + + if len(matches) == 4 { + bomRef, err := url.QueryUnescape(matches[3]) + if err != nil { + return nil, fmt.Errorf("invalid reference: %w", err) + } + + return &BOMLink{ + SerialNumber: serial, + Version: version, + Reference: bomRef, + }, nil + } + + return &BOMLink{ + SerialNumber: serial, + Version: version, + }, nil +} diff --git a/link_test.go b/link_test.go new file mode 100644 index 0000000..1b9173c --- /dev/null +++ b/link_test.go @@ -0,0 +1,15 @@ +package cyclonedx + +import "testing" + +func TestIsBOMLink(t *testing.T) { + // TODO +} + +func TestNewBOMLink(t *testing.T) { + // TODO +} + +func TestParseBOMLink(t *testing.T) { + // TODO +} diff --git a/merge.go b/merge.go new file mode 100644 index 0000000..9cf8372 --- /dev/null +++ b/merge.go @@ -0,0 +1,382 @@ +package cyclonedx + +import ( + "fmt" + + "github.com/google/uuid" + "github.com/mitchellh/copystructure" +) + +func MergeFlat(subject *Component, boms ...*BOM) (*BOM, error) { + if len(boms) < 2 { + return nil, fmt.Errorf("merging requires at least two boms, but got %d", len(boms)) + } + + if subject != nil && subject.BOMRef == "" { + bomRef, err := subject.generateBOMReference() + if err != nil { + return nil, fmt.Errorf("failed to generate bom ref for subject: %w", err) + } + + subject.setBOMReference(bomRef) + } + + serialsSeen := make(map[string]int) + for i, bom := range boms { + if bom == nil { + return nil, fmt.Errorf("bom #%d is nil", i) + } + if bom.SerialNumber == "" { + return nil, fmt.Errorf("bom #%d is missing a serial number", i) + } + if _, err := uuid.Parse(bom.SerialNumber); err != nil { + return nil, fmt.Errorf("bom #%d has an invalid serial number: %w", i, err) + } + if seenAt, seen := serialsSeen[bom.SerialNumber]; seen { + return nil, fmt.Errorf("bom #%d has the same serial number as bom #%d", i, seenAt) + } else { + serialsSeen[bom.SerialNumber] = i + } + } + + var ( + tools []Tool + metadataProperties []Property + components []Component + services []Service + vulnerabilities []Vulnerability + dependencies []Dependency + compositions []Composition + properties []Property + subjectDependencies []Dependency + ) + + // During the merging process, BOM refs will be replaced with + // BOM links. Because BOM refs may be referenced in multiple places + // throughout the BOM, we need to keep track of the replacements + // we made. + replacedRefs := make(map[string]string) + + for i := range boms { + bom, err := copyBOM(boms[i]) + if err != nil { + return nil, fmt.Errorf("failed to copy bom #%d", i) + } + + if bom.Metadata != nil { + if bom.Metadata.Tools != nil { + tools = append(tools, *bom.Metadata.Tools...) + } + if bom.Metadata.Component != nil { + err = bomRefsToBomLinks(bom.Metadata.Component, bom, replacedRefs) + if err != nil { + return nil, fmt.Errorf("failed to convert refs to links for main component of bom #%d: %w", i, err) + } + + components = append(components, *bom.Metadata.Component) + subjectDependencies = append(subjectDependencies, Dependency{Ref: bom.Metadata.Component.BOMRef}) + } + if bom.Metadata.Properties != nil { + metadataProperties = append(metadataProperties, *bom.Metadata.Properties...) + } + } + + if bom.Components != nil { + for j := range *bom.Components { + err = bomRefsToBomLinks(&(*bom.Components)[j], bom, replacedRefs) + if err != nil { + return nil, fmt.Errorf("failed to convert refs to links for component #%d of bom #%d: %w", j, i, err) + } + + components = append(components, (*bom.Components)[j]) + } + } + + if bom.Services != nil { + for j := range *bom.Services { + err = bomRefsToBomLinks(&(*bom.Services)[j], bom, replacedRefs) + if err != nil { + return nil, fmt.Errorf("failed to convert refs to links for service #%d of bom #%d: %w", j, i, err) + } + + services = append(services, (*bom.Services)[j]) + } + } + + if bom.Vulnerabilities != nil { + for j, vulnerability := range *bom.Vulnerabilities { + err = bomRefsToBomLinks(&(*bom.Vulnerabilities)[j], bom, replacedRefs) + if err != nil { + return nil, fmt.Errorf("failed to convert refs to links for vulnerability #%d of bom #%d: %w", j, i, err) + } + + if vulnerability.Affects != nil { + // Update BOM refs of affected elements + for k, affects := range *(*bom.Vulnerabilities)[j].Affects { + if replacement, replaced := replacedRefs[affects.Ref]; replaced { + (*(*bom.Vulnerabilities)[j].Affects)[k].Ref = replacement + } + } + } + + vulnerabilities = append(vulnerabilities, (*bom.Vulnerabilities)[j]) + } + } + + if bom.Dependencies != nil { + updateDependencyRefs(*bom.Dependencies, replacedRefs) + dependencies = append(dependencies, *bom.Dependencies...) + } + + if bom.Compositions != nil { + for j, composition := range *bom.Compositions { + if composition.Assemblies != nil { + // Update assembly BOM refs + for k, ref := range *(*bom.Compositions)[j].Assemblies { + if replacement, replaced := replacedRefs[string(ref)]; replaced { + (*(*bom.Compositions)[j].Assemblies)[k] = BOMReference(replacement) + } + } + } + if composition.Dependencies != nil { + // Update dependency BOM refs + for k, ref := range *(*bom.Compositions)[j].Dependencies { + if replacement, replaced := replacedRefs[string(ref)]; replaced { + (*(*bom.Compositions)[j].Dependencies)[k] = BOMReference(replacement) + } + } + } + + compositions = append(compositions, (*bom.Compositions)[j]) + } + } + } + + if subject != nil { + // Ensure that dependency relationships of + // the subject are always on top + dependencies = append([]Dependency{ + { + Ref: subject.BOMRef, + Dependencies: &subjectDependencies, + }, + }, dependencies...) + } + + bom := NewBOM() + + var metadata Metadata + if len(tools) > 0 { + metadata.Tools = &tools + } + if subject != nil { + metadata.Component = subject + } + if len(metadataProperties) > 0 { + metadata.Properties = &metadataProperties + } + if metadata != (Metadata{}) { + bom.Metadata = &metadata + } + + if len(components) > 0 { + bom.Components = &components + } + if len(services) > 0 { + bom.Services = &services + } + if len(vulnerabilities) > 0 { + bom.Vulnerabilities = &vulnerabilities + } + if len(dependencies) > 0 { + bom.Dependencies = &dependencies + } + if len(compositions) > 0 { + bom.Compositions = &compositions + } + if len(properties) > 0 { + bom.Properties = &properties + } + + return bom, nil +} + +func MergeLink(subject *Component, boms ...*BOM) (*BOM, error) { + if len(boms) < 2 { + return nil, fmt.Errorf("merging requires at least two boms, but got %d", len(boms)) + } + + if subject != nil && subject.BOMRef == "" { + bomRef, err := subject.generateBOMReference() + if err != nil { + return nil, fmt.Errorf("failed to generate bom ref for subject: %w", err) + } + + subject.setBOMReference(bomRef) + } + + var ( + components []Component + dependencies []Dependency + subjectDependencies []Dependency + ) + + serialsSeen := make(map[string]int) + for i, bom := range boms { + if bom == nil { + return nil, fmt.Errorf("bom #%d is nil", i) + } + if bom.SerialNumber == "" { + return nil, fmt.Errorf("bom #%d is missing a serial number", i) + } + if _, err := uuid.Parse(bom.SerialNumber); err != nil { + return nil, fmt.Errorf("bom #%d has an invalid serial number: %w", i, err) + } + if seenAt, seen := serialsSeen[bom.SerialNumber]; seen { + return nil, fmt.Errorf("bom #%d has the same serial number as bom #%d", i, seenAt) + } else { + serialsSeen[bom.SerialNumber] = i + } + if bom.Metadata == nil || bom.Metadata.Component == nil { + return nil, fmt.Errorf("bom #%s is missing a main component", bom.SerialNumber) + } + + bomLink, err := NewBOMLink(bom, nil) + if err != nil { + return nil, fmt.Errorf("failed to link bom #%d: %w", i, err) + } + + component := Component{ + BOMRef: bom.Metadata.Component.BOMRef, + Type: bom.Metadata.Component.Type, + Group: bom.Metadata.Component.Group, + Name: bom.Metadata.Component.Name, + Version: bom.Metadata.Component.Version, + ExternalReferences: &[]ExternalReference{ + { + Type: ERTypeBOM, + URL: bomLink.String(), + }, + }, + } + + if component.BOMRef == "" { + component.BOMRef, err = component.generateBOMReference() + if err != nil { + return nil, fmt.Errorf("failed to generate bom ref for main component of bom #%d: %w", i, err) + } + } + + err = bomRefsToBomLinks(&component, bom, make(map[string]string)) + if err != nil { + return nil, fmt.Errorf("failed to convert refs to links for main component of bom #%d: %w", i, err) + } + + components = append(components, component) + + if subject != nil { + subjectDependencies = append(subjectDependencies, Dependency{ + Ref: component.BOMRef, + }) + } + } + + if subject != nil { + // Ensure that dependency relationships of + // the subject are always on top + dependencies = append([]Dependency{ + { + Ref: subject.BOMRef, + Dependencies: &subjectDependencies, + }, + }, subjectDependencies...) + } + + bom := NewBOM() + if subject != nil { + bom.Metadata = &Metadata{ + Component: subject, + } + } + if len(components) > 0 { + bom.Components = &components + } + if len(dependencies) > 0 { + bom.Dependencies = &dependencies + } + + return bom, nil +} + +func copyBOM(bom *BOM) (*BOM, error) { + bomCopy, err := copystructure.Copy(bom) + if err != nil { + return nil, err + } + + return bomCopy.(*BOM), nil +} + +func bomRefsToBomLinks(ref referrer, bom *BOM, replacedRefs map[string]string) error { + if ref == nil { + return nil + } + + if ref.bomReference() == "" { + bomRef, err := ref.generateBOMReference() + if err != nil { + return fmt.Errorf("failed to generate bom reference: %w", err) + } + + ref.setBOMReference(bomRef) + } + + if !IsBOMLink(ref.bomReference()) { + link, err := NewBOMLink(bom, ref) + if err != nil { + return fmt.Errorf("failed to create bom link: %w", err) + } + + replacedRefs[ref.bomReference()] = link.String() + ref.setBOMReference(link.String()) + } + + switch elemType := ref.(type) { + case *Component: + if elemType.Components != nil { + for i := range *elemType.Components { + err := bomRefsToBomLinks(&(*elemType.Components)[i], bom, replacedRefs) + if err != nil { + return err + } + } + } + case *Service: + if elemType.Services != nil { + for i := range *elemType.Services { + err := bomRefsToBomLinks(&(*elemType.Services)[i], bom, replacedRefs) + if err != nil { + return err + } + } + } + case *Vulnerability: + break // There are not sub-vulnerabilities + default: + return fmt.Errorf("can't handle element of type %T", elemType) + } + + return nil +} + +func updateDependencyRefs(dependencies []Dependency, replacedRefs map[string]string) { + for i, dependency := range dependencies { + if replacement, replaced := replacedRefs[dependency.Ref]; replaced { + dependencies[i].Ref = replacement + } + + if dependency.Dependencies != nil { + updateDependencyRefs(*dependencies[i].Dependencies, replacedRefs) + } + } +} diff --git a/merge_test.go b/merge_test.go new file mode 100644 index 0000000..740f061 --- /dev/null +++ b/merge_test.go @@ -0,0 +1,82 @@ +package cyclonedx + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" +) + +func TestMergeFlat(t *testing.T) { + t.Run("Components", func(t *testing.T) { + t.Run("WithoutSubject", func(t *testing.T) { + var ( + bomA = readTestBOM(t, "./testdata/merge/components-bom-a.json") + bomB = readTestBOM(t, "./testdata/merge/components-bom-b.json") + resultExpected = readTestBOM(t, "./testdata/merge/components-result-flat.json") + ) + + result, err := MergeFlat(nil, bomA, bomB) + require.NoError(t, err) + + if !cmp.Equal(result, resultExpected, filterXMLNS) { + require.FailNow(t, "unexpected merge result", cmp.Diff(result, resultExpected, filterXMLNS)) + } + }) + + t.Run("WithSubject", func(t *testing.T) { + var ( + bomA = readTestBOM(t, "./testdata/merge/components-bom-a.json") + bomB = readTestBOM(t, "./testdata/merge/components-bom-b.json") + subject = readTestBOM(t, "./testdata/merge/components-subject.json").Metadata.Component + resultExpected = readTestBOM(t, "./testdata/merge/components-result-flat-subject.json") + ) + + result, err := MergeFlat(subject, bomA, bomB) + require.NoError(t, err) + + if !cmp.Equal(result, resultExpected, filterXMLNS) { + require.FailNow(t, "unexpected merge result", cmp.Diff(result, resultExpected, filterXMLNS)) + } + }) + }) +} + +func TestMergeLink(t *testing.T) { + t.Run("Components", func(t *testing.T) { + t.Run("WithoutSubject", func(t *testing.T) { + var ( + bomA = readTestBOM(t, "./testdata/merge/components-bom-a.json") + bomB = readTestBOM(t, "./testdata/merge/components-bom-b.json") + resultExpected = readTestBOM(t, "./testdata/merge/components-result-link.json") + ) + + result, err := MergeLink(nil, bomA, bomB) + require.NoError(t, err) + + if !cmp.Equal(result, resultExpected, filterXMLNS) { + require.FailNow(t, "unexpected merge result", cmp.Diff(result, resultExpected, filterXMLNS)) + } + }) + + t.Run("WithSubject", func(t *testing.T) { + var ( + bomA = readTestBOM(t, "./testdata/merge/components-bom-a.json") + bomB = readTestBOM(t, "./testdata/merge/components-bom-b.json") + subject = readTestBOM(t, "./testdata/merge/components-subject.json").Metadata.Component + resultExpected = readTestBOM(t, "./testdata/merge/components-result-link-subject.json") + ) + + result, err := MergeLink(subject, bomA, bomB) + require.NoError(t, err) + + if !cmp.Equal(result, resultExpected, filterXMLNS) { + require.FailNow(t, "unexpected merge result", cmp.Diff(result, resultExpected, filterXMLNS)) + } + }) + }) +} + +var filterXMLNS = cmp.FilterPath(func(path cmp.Path) bool { + return path.String() == "XMLNS" +}, cmp.Ignore()) diff --git a/roundtrip_test.go b/roundtrip_test.go index a5d648e..e063b59 100644 --- a/roundtrip_test.go +++ b/roundtrip_test.go @@ -45,13 +45,7 @@ func TestRoundTripJSON(t *testing.T) { for _, bomFilePath := range bomFilePaths { t.Run(filepath.Base(bomFilePath), func(t *testing.T) { // Read original BOM JSON - bomFile, err := os.Open(bomFilePath) - require.NoError(t, err) - - // Decode BOM - bom := new(BOM) - require.NoError(t, NewBOMDecoder(bomFile, BOMFileFormatJSON).Decode(bom)) - bomFile.Close() + bom := readTestBOM(t, bomFilePath) // Encode BOM again buf := new(bytes.Buffer) @@ -61,11 +55,11 @@ func TestRoundTripJSON(t *testing.T) { encoder := NewBOMEncoder(io.MultiWriter(buf, tempFile), BOMFileFormatJSON) encoder.SetPretty(true) require.NoError(t, encoder.Encode(bom)) - tempFile.Close() // Required for CLI to be able to access the file + _ = tempFile.Close() // Required for CLI to be able to access the file // Sanity checks: BOM has to be valid assertValidBOM(t, tempFile.Name()) - os.Remove(tempFile.Name()) + _ = os.Remove(tempFile.Name()) // Compare with snapshot assert.NoError(t, roundTripSnapshotter.SnapshotMulti(filepath.Base(bomFilePath), buf.String())) @@ -80,13 +74,7 @@ func TestRoundTripXML(t *testing.T) { for _, bomFilePath := range bomFilePaths { t.Run(filepath.Base(bomFilePath), func(t *testing.T) { // Read original BOM XML - bomFile, err := os.Open(bomFilePath) - require.NoError(t, err) - - // Decode BOM - bom := new(BOM) - require.NoError(t, NewBOMDecoder(bomFile, BOMFileFormatXML).Decode(bom)) - bomFile.Close() + bom := readTestBOM(t, bomFilePath) // Encode BOM again buf := new(bytes.Buffer) @@ -96,11 +84,11 @@ func TestRoundTripXML(t *testing.T) { encoder := NewBOMEncoder(io.MultiWriter(buf, tempFile), BOMFileFormatXML) encoder.SetPretty(true) require.NoError(t, encoder.Encode(bom)) - tempFile.Close() // Required for CLI to be able to access the file + _ = tempFile.Close() // Required for CLI to be able to access the file // Sanity check: BOM has to be valid assertValidBOM(t, tempFile.Name()) - os.Remove(tempFile.Name()) + _ = os.Remove(tempFile.Name()) // Compare with snapshot assert.NoError(t, roundTripSnapshotter.SnapshotMulti(filepath.Base(bomFilePath), buf.String())) diff --git a/testdata/merge/components-bom-a.json b/testdata/merge/components-bom-a.json new file mode 100644 index 0000000..7a9d5de --- /dev/null +++ b/testdata/merge/components-bom-a.json @@ -0,0 +1,40 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "serialNumber": "urn:uuid:b3b9a427-329d-4478-8787-a368c05e1b8b", + "metadata": { + "component": { + "bom-ref": "acme-app-a@1.0.0", + "type": "application", + "name": "acme-app-a", + "version": "1.0.0" + } + }, + "components": [ + { + "bom-ref": "pkg:maven/com.acme/acme-lib-a@1.2.3", + "type": "library", + "group": "com.acme", + "name": "acme-lib-a", + "version": "1.2.3", + "components": [ + { + "type": "file", + "name": "blob-a.tar.gz" + } + ] + } + ], + "dependencies": [ + { + "ref": "acme-app-a@1.0.0", + "dependsOn": [ + "pkg:maven/com.acme/acme-lib-a@1.2.3" + ] + }, + { + "ref": "pkg:maven/com.acme/acme-lib-a@1.2.3" + } + ] +} \ No newline at end of file diff --git a/testdata/merge/components-bom-b.json b/testdata/merge/components-bom-b.json new file mode 100644 index 0000000..0942a28 --- /dev/null +++ b/testdata/merge/components-bom-b.json @@ -0,0 +1,40 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 2, + "serialNumber": "urn:uuid:dfd8724c-d1ea-4717-b240-d95af1545240", + "metadata": { + "component": { + "bom-ref": "acme-app-b@2.0.0", + "type": "application", + "name": "acme-app-b", + "version": "2.0.0" + } + }, + "components": [ + { + "bom-ref": "pkg:maven/com.acme/acme-lib-b@2.1.0", + "type": "library", + "group": "com.acme", + "name": "acme-lib-b", + "version": "2.1.0", + "components": [ + { + "type": "file", + "name": "blob-b.tar.gz" + } + ] + } + ], + "dependencies": [ + { + "ref": "acme-app-b@2.0.0", + "dependsOn": [ + "pkg:maven/com.acme/acme-lib-b@2.1.0" + ] + }, + { + "ref": "pkg:maven/com.acme/acme-lib-b@2.1.0" + } + ] +} \ No newline at end of file diff --git a/testdata/merge/components-result-flat-subject.json b/testdata/merge/components-result-flat-subject.json new file mode 100644 index 0000000..e624d25 --- /dev/null +++ b/testdata/merge/components-result-flat-subject.json @@ -0,0 +1,82 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "component": { + "bom-ref": "acme-product@1.0.0", + "type": "application", + "name": "acme-product", + "version": "1.0.0" + } + }, + "components": [ + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0", + "type": "application", + "name": "acme-app-a", + "version": "1.0.0" + }, + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#pkg%3Amaven%2Fcom.acme%2Facme-lib-a%401.2.3", + "type": "library", + "group": "com.acme", + "name": "acme-lib-a", + "version": "1.2.3", + "components": [ + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#ee6758ea-6932-5a2d-86af-2bebaa3fa001", + "type": "file", + "name": "blob-a.tar.gz" + } + ] + }, + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0", + "type": "application", + "name": "acme-app-b", + "version": "2.0.0" + }, + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#pkg%3Amaven%2Fcom.acme%2Facme-lib-b%402.1.0", + "type": "library", + "group": "com.acme", + "name": "acme-lib-b", + "version": "2.1.0", + "components": [ + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#927391ab-6b52-54b9-a2c8-290c7100fe56", + "type": "file", + "name": "blob-b.tar.gz" + } + ] + } + ], + "dependencies": [ + { + "ref": "acme-product@1.0.0", + "dependsOn": [ + "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0", + "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0" + ] + }, + { + "ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0", + "dependsOn": [ + "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#pkg%3Amaven%2Fcom.acme%2Facme-lib-a%401.2.3" + ] + }, + { + "ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#pkg%3Amaven%2Fcom.acme%2Facme-lib-a%401.2.3" + }, + { + "ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0", + "dependsOn": [ + "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#pkg%3Amaven%2Fcom.acme%2Facme-lib-b%402.1.0" + ] + }, + { + "ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#pkg%3Amaven%2Fcom.acme%2Facme-lib-b%402.1.0" + } + ] +} \ No newline at end of file diff --git a/testdata/merge/components-result-flat.json b/testdata/merge/components-result-flat.json new file mode 100644 index 0000000..7b46e0a --- /dev/null +++ b/testdata/merge/components-result-flat.json @@ -0,0 +1,67 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "components": [ + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0", + "type": "application", + "name": "acme-app-a", + "version": "1.0.0" + }, + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#pkg%3Amaven%2Fcom.acme%2Facme-lib-a%401.2.3", + "type": "library", + "group": "com.acme", + "name": "acme-lib-a", + "version": "1.2.3", + "components": [ + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#ee6758ea-6932-5a2d-86af-2bebaa3fa001", + "type": "file", + "name": "blob-a.tar.gz" + } + ] + }, + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0", + "type": "application", + "name": "acme-app-b", + "version": "2.0.0" + }, + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#pkg%3Amaven%2Fcom.acme%2Facme-lib-b%402.1.0", + "type": "library", + "group": "com.acme", + "name": "acme-lib-b", + "version": "2.1.0", + "components": [ + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#927391ab-6b52-54b9-a2c8-290c7100fe56", + "type": "file", + "name": "blob-b.tar.gz" + } + ] + } + ], + "dependencies": [ + { + "ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0", + "dependsOn": [ + "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#pkg%3Amaven%2Fcom.acme%2Facme-lib-a%401.2.3" + ] + }, + { + "ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#pkg%3Amaven%2Fcom.acme%2Facme-lib-a%401.2.3" + }, + { + "ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0", + "dependsOn": [ + "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#pkg%3Amaven%2Fcom.acme%2Facme-lib-b%402.1.0" + ] + }, + { + "ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#pkg%3Amaven%2Fcom.acme%2Facme-lib-b%402.1.0" + } + ] +} \ No newline at end of file diff --git a/testdata/merge/components-result-hierarchical-subject.json b/testdata/merge/components-result-hierarchical-subject.json new file mode 100644 index 0000000..f93479d --- /dev/null +++ b/testdata/merge/components-result-hierarchical-subject.json @@ -0,0 +1,86 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "component": { + "bom-ref": "acme-product@1.0.0", + "type": "application", + "name": "acme-product", + "version": "1.0.0" + } + }, + "components": [ + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0", + "type": "application", + "name": "acme-app-a", + "version": "1.0.0", + "components": [ + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#pkg%3Amaven%2Fcom.acme%2Facme-lib-a%401.2.3", + "type": "library", + "group": "com.acme", + "name": "acme-lib-a", + "version": "1.2.3", + "components": [ + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#ee6758ea-6932-5a2d-86af-2bebaa3fa001", + "type": "file", + "name": "blob-a.tar.gz" + } + ] + } + ] + }, + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0", + "type": "application", + "name": "acme-app-b", + "version": "2.0.0", + "components": [ + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#pkg%3Amaven%2Fcom.acme%2Facme-lib-b%402.1.0", + "type": "library", + "group": "com.acme", + "name": "acme-lib-b", + "version": "2.1.0", + "components": [ + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#927391ab-6b52-54b9-a2c8-290c7100fe56", + "type": "file", + "name": "blob-b.tar.gz" + } + ] + } + ] + } + ], + "dependencies": [ + { + "ref": "acme-product@1.0.0", + "dependsOn": [ + "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0", + "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0" + ] + }, + { + "ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0", + "dependsOn": [ + "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#pkg%3Amaven%2Fcom.acme%2Facme-lib-a%401.2.3" + ] + }, + { + "ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#pkg%3Amaven%2Fcom.acme%2Facme-lib-a%401.2.3" + }, + { + "ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0", + "dependsOn": [ + "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#pkg%3Amaven%2Fcom.acme%2Facme-lib-b%402.1.0" + ] + }, + { + "ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#pkg%3Amaven%2Fcom.acme%2Facme-lib-b%402.1.0" + } + ] +} \ No newline at end of file diff --git a/testdata/merge/components-result-hierarchical.json b/testdata/merge/components-result-hierarchical.json new file mode 100644 index 0000000..3ad398c --- /dev/null +++ b/testdata/merge/components-result-hierarchical.json @@ -0,0 +1,71 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "components": [ + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0", + "type": "application", + "name": "acme-app-a", + "version": "1.0.0", + "components": [ + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#pkg%3Amaven%2Fcom.acme%2Facme-lib-a%401.2.3", + "type": "library", + "group": "com.acme", + "name": "acme-lib-a", + "version": "1.2.3", + "components": [ + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#ee6758ea-6932-5a2d-86af-2bebaa3fa001", + "type": "file", + "name": "blob-a.tar.gz" + } + ] + } + ] + }, + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0", + "type": "application", + "name": "acme-app-b", + "version": "2.0.0", + "components": [ + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#pkg%3Amaven%2Fcom.acme%2Facme-lib-b%402.1.0", + "type": "library", + "group": "com.acme", + "name": "acme-lib-b", + "version": "2.1.0", + "components": [ + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#927391ab-6b52-54b9-a2c8-290c7100fe56", + "type": "file", + "name": "blob-b.tar.gz" + } + ] + } + ] + } + ], + "dependencies": [ + { + "ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0", + "dependsOn": [ + "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#pkg%3Amaven%2Fcom.acme%2Facme-lib-a%401.2.3" + ] + }, + { + "ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#pkg%3Amaven%2Fcom.acme%2Facme-lib-a%401.2.3" + }, + { + "ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0", + "dependsOn": [ + "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#pkg%3Amaven%2Fcom.acme%2Facme-lib-b%402.1.0" + ] + }, + { + "ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#pkg%3Amaven%2Fcom.acme%2Facme-lib-b%402.1.0" + } + ] +} \ No newline at end of file diff --git a/testdata/merge/components-result-link-subject.json b/testdata/merge/components-result-link-subject.json new file mode 100644 index 0000000..4c2cd52 --- /dev/null +++ b/testdata/merge/components-result-link-subject.json @@ -0,0 +1,54 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "component": { + "bom-ref": "acme-product@1.0.0", + "type": "application", + "name": "acme-product", + "version": "1.0.0" + } + }, + "components": [ + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0", + "type": "application", + "name": "acme-app-a", + "version": "1.0.0", + "externalReferences": [ + { + "type": "bom", + "url": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1" + } + ] + }, + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0", + "type": "application", + "name": "acme-app-b", + "version": "2.0.0", + "externalReferences": [ + { + "type": "bom", + "url": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2" + } + ] + } + ], + "dependencies": [ + { + "ref": "acme-product@1.0.0", + "dependsOn": [ + "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0", + "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0" + ] + }, + { + "ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0" + }, + { + "ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0" + } + ] +} \ No newline at end of file diff --git a/testdata/merge/components-result-link.json b/testdata/merge/components-result-link.json new file mode 100644 index 0000000..33976c7 --- /dev/null +++ b/testdata/merge/components-result-link.json @@ -0,0 +1,31 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "components": [ + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0", + "type": "application", + "name": "acme-app-a", + "version": "1.0.0", + "externalReferences": [ + { + "type": "bom", + "url": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1" + } + ] + }, + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0", + "type": "application", + "name": "acme-app-b", + "version": "2.0.0", + "externalReferences": [ + { + "type": "bom", + "url": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2" + } + ] + } + ] +} \ No newline at end of file diff --git a/testdata/merge/components-subject.json b/testdata/merge/components-subject.json new file mode 100644 index 0000000..f83e72e --- /dev/null +++ b/testdata/merge/components-subject.json @@ -0,0 +1,13 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "component": { + "bom-ref": "acme-product@1.0.0", + "type": "application", + "name": "acme-product", + "version": "1.0.0" + } + } +} \ No newline at end of file From 8c5a696ed59cc3fbbce49bc24cd6761c8b1db176 Mon Sep 17 00:00:00 2001 From: nscuro Date: Fri, 25 Feb 2022 23:20:51 +0100 Subject: [PATCH 2/4] gotta love them license headers Signed-off-by: nscuro --- link.go | 17 +++++++++++++++++ link_test.go | 17 +++++++++++++++++ merge.go | 17 +++++++++++++++++ merge_test.go | 17 +++++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/link.go b/link.go index 45c9f9a..26a71c0 100644 --- a/link.go +++ b/link.go @@ -1,3 +1,20 @@ +// This file is part of CycloneDX Go +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + package cyclonedx import ( diff --git a/link_test.go b/link_test.go index 1b9173c..0b6524a 100644 --- a/link_test.go +++ b/link_test.go @@ -1,3 +1,20 @@ +// This file is part of CycloneDX Go +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + package cyclonedx import "testing" diff --git a/merge.go b/merge.go index 9cf8372..b5ae745 100644 --- a/merge.go +++ b/merge.go @@ -1,3 +1,20 @@ +// This file is part of CycloneDX Go +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + package cyclonedx import ( diff --git a/merge_test.go b/merge_test.go index 740f061..bda72c3 100644 --- a/merge_test.go +++ b/merge_test.go @@ -1,3 +1,20 @@ +// This file is part of CycloneDX Go +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + package cyclonedx import ( From 9228dea83b4a6f28fdce7dac41bc2d3f54a09746 Mon Sep 17 00:00:00 2001 From: nscuro Date: Sat, 26 Feb 2022 19:53:58 +0100 Subject: [PATCH 3/4] add hierarchical merge again; add properties tests Signed-off-by: nscuro --- cyclonedx.go | 31 ++- link.go | 6 +- link_test.go | 31 ++- merge.go | 260 +++++++++++++++--- merge_test.go | 96 ++++++- testdata/merge/properties-bom-a.json | 34 +++ testdata/merge/properties-bom-b.json | 34 +++ testdata/merge/properties-result-flat.json | 57 ++++ .../merge/properties-result-hierarchical.json | 57 ++++ testdata/merge/properties-result-link.json | 31 +++ 10 files changed, 569 insertions(+), 68 deletions(-) create mode 100644 testdata/merge/properties-bom-a.json create mode 100644 testdata/merge/properties-bom-b.json create mode 100644 testdata/merge/properties-result-flat.json create mode 100644 testdata/merge/properties-result-hierarchical.json create mode 100644 testdata/merge/properties-result-link.json diff --git a/cyclonedx.go b/cyclonedx.go index b43c35e..1fd8c6f 100644 --- a/cyclonedx.go +++ b/cyclonedx.go @@ -119,6 +119,17 @@ func (b *BOMReference) UnmarshalXML(d *xml.Decoder, start xml.StartElement) erro return nil } +// bomReferrer is an internal utility interface that is used +// to address bom elements that have a BOM reference. +type bomReferrer interface { + bomReference() string + setBOMReference(ref string) + + // generateBOMReference returns a new value intended to be used as BOM reference. + // Given the same state of the bomReferrer, generateBOMReference must return the same result. + generateBOMReference() (string, error) +} + type ComponentType string const ( @@ -167,10 +178,12 @@ type Component struct { ReleaseNotes *ReleaseNotes `json:"releaseNotes,omitempty" xml:"releaseNotes,omitempty"` } +// bomReference implements the bomReferrer interface. func (c Component) bomReference() string { return c.BOMRef } +// setBOMReference implements the bomReferrer interface. func (c *Component) setBOMReference(ref string) { c.BOMRef = ref } @@ -189,6 +202,7 @@ func (c componentRefSeed) MarshalJSON() ([]byte, error) { return jcs.Transform(componentJSON) } +// generateBOMReference implements the bomReferrer interface. func (c Component) generateBOMReference() (string, error) { componentJSON, err := json.Marshal(componentRefSeed(c)) if err != nil { @@ -554,17 +568,6 @@ type Property struct { Value string `json:"value" xml:",innerxml"` } -// referrer is an internal utility interface that is used -// to address bom elements that have a BOM reference. -type referrer interface { - bomReference() string - setBOMReference(ref string) - - // generateBOMReference returns a new value intended to be used as BOM reference. - // Given the same state of the referrer, generateBOMReference must return the same result. - generateBOMReference() (string, error) -} - type ReleaseNotes struct { Type string `json:"type" xml:"type"` Title string `json:"title,omitempty" xml:"title,omitempty"` @@ -615,14 +618,17 @@ type Service struct { ReleaseNotes *ReleaseNotes `json:"releaseNotes,omitempty" xml:"releaseNotes,omitempty"` } +// bomReference implements the bomReferrer interface. func (s Service) bomReference() string { return s.BOMRef } +// setBOMReference implements the bomReferrer interface. func (s *Service) setBOMReference(ref string) { s.BOMRef = ref } +// generateBOMReference implements the bomReferrer interface. func (s Service) generateBOMReference() (string, error) { return "", nil } @@ -682,14 +688,17 @@ type Vulnerability struct { Affects *[]Affects `json:"affects,omitempty" xml:"affects>target,omitempty"` } +// bomReference implements the bomReferrer interface. func (v Vulnerability) bomReference() string { return v.BOMRef } +// setBOMReference implements the bomReferrer interface. func (v *Vulnerability) setBOMReference(ref string) { v.BOMRef = ref } +// generateBOMReference implements the bomReferrer interface. func (v Vulnerability) generateBOMReference() (string, error) { return "", nil } diff --git a/link.go b/link.go index 26a71c0..9787bf4 100644 --- a/link.go +++ b/link.go @@ -41,7 +41,7 @@ type BOMLink struct { } // NewBOMLink TODO -func NewBOMLink(bom *BOM, elem referrer) (*BOMLink, error) { +func NewBOMLink(bom *BOM, ref bomReferrer) (*BOMLink, error) { if bom == nil { return nil, fmt.Errorf("bom is nil") } @@ -57,7 +57,7 @@ func NewBOMLink(bom *BOM, elem referrer) (*BOMLink, error) { return nil, fmt.Errorf("invalid serial number: %w", err) } - if elem == nil { + if ref == nil { return &BOMLink{ SerialNumber: serial, Version: bom.Version, @@ -67,7 +67,7 @@ func NewBOMLink(bom *BOM, elem referrer) (*BOMLink, error) { return &BOMLink{ SerialNumber: serial, Version: bom.Version, - Reference: elem.bomReference(), + Reference: ref.bomReference(), }, nil } diff --git a/link_test.go b/link_test.go index 0b6524a..0b6f5bc 100644 --- a/link_test.go +++ b/link_test.go @@ -17,10 +17,37 @@ package cyclonedx -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func TestIsBOMLink(t *testing.T) { - // TODO + t.Run("Valid", func(t *testing.T) { + for _, link := range []string{ + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b/1", + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b/111", + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b/1#ref", + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b/1#r%2Fe%2Ff", + } { + assert.True(t, IsBOMLink(link)) + } + }) + + t.Run("Invalid", func(t *testing.T) { + for _, invalidLink := range []string{ + "urn", + "urn:cdx", + "urn:cdx:foo-bar", + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b", + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b#ref", + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b/#ref", + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b/1#", + } { + assert.False(t, IsBOMLink(invalidLink), invalidLink) + } + }) } func TestNewBOMLink(t *testing.T) { diff --git a/merge.go b/merge.go index b5ae745..50d6293 100644 --- a/merge.go +++ b/merge.go @@ -25,8 +25,9 @@ import ( ) func MergeFlat(subject *Component, boms ...*BOM) (*BOM, error) { - if len(boms) < 2 { - return nil, fmt.Errorf("merging requires at least two boms, but got %d", len(boms)) + err := validateBOMsForMerge(boms) + if err != nil { + return nil, err } if subject != nil && subject.BOMRef == "" { @@ -38,24 +39,6 @@ func MergeFlat(subject *Component, boms ...*BOM) (*BOM, error) { subject.setBOMReference(bomRef) } - serialsSeen := make(map[string]int) - for i, bom := range boms { - if bom == nil { - return nil, fmt.Errorf("bom #%d is nil", i) - } - if bom.SerialNumber == "" { - return nil, fmt.Errorf("bom #%d is missing a serial number", i) - } - if _, err := uuid.Parse(bom.SerialNumber); err != nil { - return nil, fmt.Errorf("bom #%d has an invalid serial number: %w", i, err) - } - if seenAt, seen := serialsSeen[bom.SerialNumber]; seen { - return nil, fmt.Errorf("bom #%d has the same serial number as bom #%d", i, seenAt) - } else { - serialsSeen[bom.SerialNumber] = i - } - } - var ( tools []Tool metadataProperties []Property @@ -167,11 +150,180 @@ func MergeFlat(subject *Component, boms ...*BOM) (*BOM, error) { compositions = append(compositions, (*bom.Compositions)[j]) } } + + if bom.Properties != nil { + properties = append(properties, *bom.Properties...) + } + } + + if subject != nil { + dependencies = append([]Dependency{ + { + Ref: subject.BOMRef, + Dependencies: &subjectDependencies, + }, + }, dependencies...) + } + + bom := NewBOM() + + var metadata Metadata + if len(tools) > 0 { + metadata.Tools = &tools + } + if subject != nil { + metadata.Component = subject + } + if len(metadataProperties) > 0 { + metadata.Properties = &metadataProperties + } + if metadata != (Metadata{}) { + bom.Metadata = &metadata + } + + if len(components) > 0 { + bom.Components = &components + } + if len(services) > 0 { + bom.Services = &services + } + if len(vulnerabilities) > 0 { + bom.Vulnerabilities = &vulnerabilities + } + if len(dependencies) > 0 { + bom.Dependencies = &dependencies + } + if len(compositions) > 0 { + bom.Compositions = &compositions + } + if len(properties) > 0 { + bom.Properties = &properties + } + + return bom, nil +} + +func MergeHierarchical(subject *Component, boms ...*BOM) (*BOM, error) { + err := validateBOMsForMerge(boms, func(i int, bom *BOM) error { + if bom.Metadata == nil || bom.Metadata.Component == nil { + return fmt.Errorf("bom #%d is missing a main component", i) + } + return nil + }) + if err != nil { + return nil, err + } + + var ( + tools []Tool + metadataProperties []Property + components []Component + services []Service + vulnerabilities []Vulnerability + dependencies []Dependency + compositions []Composition + properties []Property + subjectDependencies []Dependency + ) + + replacedRefs := make(map[string]string) + + for i := range boms { + bom, err := copyBOM(boms[i]) + if err != nil { + return nil, fmt.Errorf("failed to copy bom #%d", i) + } + + if bom.Metadata.Tools != nil { + tools = append(tools, *bom.Metadata.Tools...) + } + if bom.Metadata.Properties != nil { + metadataProperties = append(metadataProperties, *bom.Metadata.Properties...) + } + + { + main := bom.Metadata.Component + if bom.Components != nil { + if main.Components == nil { + main.Components = bom.Components + } else { + *main.Components = append(*main.Components, *bom.Components...) + } + } + + err = bomRefsToBomLinks(main, bom, replacedRefs) + if err != nil { + return nil, fmt.Errorf("failed to convert refs to links for main component of bom #%d: %w", i, err) + } + + components = append(components, *main) + if subject != nil { + subjectDependencies = append(subjectDependencies, Dependency{Ref: main.BOMRef}) + } + } + + if bom.Services != nil { + for j := range *bom.Services { + err = bomRefsToBomLinks(&(*bom.Services)[j], bom, replacedRefs) + if err != nil { + return nil, fmt.Errorf("failed to convert refs to links for service #%d of bom #%d: %w", j, i, err) + } + + services = append(services, (*bom.Services)[j]) + } + } + + if bom.Vulnerabilities != nil { + for j, vulnerability := range *bom.Vulnerabilities { + err = bomRefsToBomLinks(&(*bom.Vulnerabilities)[j], bom, replacedRefs) + if err != nil { + return nil, fmt.Errorf("failed to convert refs to links for vulnerability #%d of bom #%d: %w", j, i, err) + } + + if vulnerability.Affects != nil { + for k, affects := range *(*bom.Vulnerabilities)[j].Affects { + if replacement, replaced := replacedRefs[affects.Ref]; replaced { + (*(*bom.Vulnerabilities)[j].Affects)[k].Ref = replacement + } + } + } + + vulnerabilities = append(vulnerabilities, (*bom.Vulnerabilities)[j]) + } + } + + if bom.Dependencies != nil { + updateDependencyRefs(*bom.Dependencies, replacedRefs) + dependencies = append(dependencies, *bom.Dependencies...) + } + + if bom.Compositions != nil { + for j, composition := range *bom.Compositions { + if composition.Assemblies != nil { + for k, ref := range *(*bom.Compositions)[j].Assemblies { + if replacement, replaced := replacedRefs[string(ref)]; replaced { + (*(*bom.Compositions)[j].Assemblies)[k] = BOMReference(replacement) + } + } + } + if composition.Dependencies != nil { + for k, ref := range *(*bom.Compositions)[j].Dependencies { + if replacement, replaced := replacedRefs[string(ref)]; replaced { + (*(*bom.Compositions)[j].Dependencies)[k] = BOMReference(replacement) + } + } + } + + compositions = append(compositions, (*bom.Compositions)[j]) + } + } + + if bom.Properties != nil { + properties = append(properties, *bom.Properties...) + } } if subject != nil { - // Ensure that dependency relationships of - // the subject are always on top dependencies = append([]Dependency{ { Ref: subject.BOMRef, @@ -219,8 +371,14 @@ func MergeFlat(subject *Component, boms ...*BOM) (*BOM, error) { } func MergeLink(subject *Component, boms ...*BOM) (*BOM, error) { - if len(boms) < 2 { - return nil, fmt.Errorf("merging requires at least two boms, but got %d", len(boms)) + err := validateBOMsForMerge(boms, func(i int, bom *BOM) error { + if bom.Metadata == nil || bom.Metadata.Component == nil { + return fmt.Errorf("bom #%d is missing a main component", i) + } + return nil + }) + if err != nil { + return nil, err } if subject != nil && subject.BOMRef == "" { @@ -238,26 +396,7 @@ func MergeLink(subject *Component, boms ...*BOM) (*BOM, error) { subjectDependencies []Dependency ) - serialsSeen := make(map[string]int) for i, bom := range boms { - if bom == nil { - return nil, fmt.Errorf("bom #%d is nil", i) - } - if bom.SerialNumber == "" { - return nil, fmt.Errorf("bom #%d is missing a serial number", i) - } - if _, err := uuid.Parse(bom.SerialNumber); err != nil { - return nil, fmt.Errorf("bom #%d has an invalid serial number: %w", i, err) - } - if seenAt, seen := serialsSeen[bom.SerialNumber]; seen { - return nil, fmt.Errorf("bom #%d has the same serial number as bom #%d", i, seenAt) - } else { - serialsSeen[bom.SerialNumber] = i - } - if bom.Metadata == nil || bom.Metadata.Component == nil { - return nil, fmt.Errorf("bom #%s is missing a main component", bom.SerialNumber) - } - bomLink, err := NewBOMLink(bom, nil) if err != nil { return nil, fmt.Errorf("failed to link bom #%d: %w", i, err) @@ -325,6 +464,39 @@ func MergeLink(subject *Component, boms ...*BOM) (*BOM, error) { return bom, nil } +func validateBOMsForMerge(boms []*BOM, addChecks ...func(int, *BOM) error) error { + if len(boms) < 2 { + return fmt.Errorf("merging requires at least two boms, but got %d", len(boms)) + } + + serialsSeen := make(map[string]int) + for i, bom := range boms { + if bom == nil { + return fmt.Errorf("bom #%d is nil", i) + } + if bom.SerialNumber == "" { + return fmt.Errorf("bom #%d is missing a serial number", i) + } + if _, err := uuid.Parse(bom.SerialNumber); err != nil { + return fmt.Errorf("bom #%d has an invalid serial number: %w", i, err) + } + if seenAt, seen := serialsSeen[bom.SerialNumber]; seen { + return fmt.Errorf("bom #%d has the same serial number as bom #%d", i, seenAt) + } else { + serialsSeen[bom.SerialNumber] = i + } + + for _, check := range addChecks { + err := check(i, boms[i]) + if err != nil { + return err + } + } + } + + return nil +} + func copyBOM(bom *BOM) (*BOM, error) { bomCopy, err := copystructure.Copy(bom) if err != nil { @@ -334,7 +506,7 @@ func copyBOM(bom *BOM) (*BOM, error) { return bomCopy.(*BOM), nil } -func bomRefsToBomLinks(ref referrer, bom *BOM, replacedRefs map[string]string) error { +func bomRefsToBomLinks(ref bomReferrer, bom *BOM, replacedRefs map[string]string) error { if ref == nil { return nil } diff --git a/merge_test.go b/merge_test.go index bda72c3..c663da2 100644 --- a/merge_test.go +++ b/merge_test.go @@ -36,8 +36,8 @@ func TestMergeFlat(t *testing.T) { result, err := MergeFlat(nil, bomA, bomB) require.NoError(t, err) - if !cmp.Equal(result, resultExpected, filterXMLNS) { - require.FailNow(t, "unexpected merge result", cmp.Diff(result, resultExpected, filterXMLNS)) + if !cmp.Equal(resultExpected, result, filterXMLNS) { + require.FailNow(t, "unexpected merge result", cmp.Diff(resultExpected, result, filterXMLNS)) } }) @@ -52,11 +52,76 @@ func TestMergeFlat(t *testing.T) { result, err := MergeFlat(subject, bomA, bomB) require.NoError(t, err) - if !cmp.Equal(result, resultExpected, filterXMLNS) { - require.FailNow(t, "unexpected merge result", cmp.Diff(result, resultExpected, filterXMLNS)) + if !cmp.Equal(resultExpected, result, filterXMLNS) { + require.FailNow(t, "unexpected merge result", cmp.Diff(resultExpected, result, filterXMLNS)) } }) }) + + t.Run("Properties", func(t *testing.T) { + var ( + bomA = readTestBOM(t, "./testdata/merge/properties-bom-a.json") + bomB = readTestBOM(t, "./testdata/merge/properties-bom-b.json") + resultExpected = readTestBOM(t, "./testdata/merge/properties-result-flat.json") + ) + + result, err := MergeFlat(nil, bomA, bomB) + require.NoError(t, err) + + if !cmp.Equal(resultExpected, result, filterXMLNS) { + require.FailNow(t, "unexpected merge result", cmp.Diff(resultExpected, result, filterXMLNS)) + } + }) +} + +func TestMergeHierarchical(t *testing.T) { + t.Run("Components", func(t *testing.T) { + t.Run("WithoutSubject", func(t *testing.T) { + var ( + bomA = readTestBOM(t, "./testdata/merge/components-bom-a.json") + bomB = readTestBOM(t, "./testdata/merge/components-bom-b.json") + resultExpected = readTestBOM(t, "./testdata/merge/components-result-hierarchical.json") + ) + + result, err := MergeHierarchical(nil, bomA, bomB) + require.NoError(t, err) + + if !cmp.Equal(resultExpected, result, filterXMLNS) { + require.FailNow(t, "unexpected merge result", cmp.Diff(resultExpected, result, filterXMLNS)) + } + }) + + t.Run("WithSubject", func(t *testing.T) { + var ( + bomA = readTestBOM(t, "./testdata/merge/components-bom-a.json") + bomB = readTestBOM(t, "./testdata/merge/components-bom-b.json") + subject = readTestBOM(t, "./testdata/merge/components-subject.json").Metadata.Component + resultExpected = readTestBOM(t, "./testdata/merge/components-result-hierarchical-subject.json") + ) + + result, err := MergeHierarchical(subject, bomA, bomB) + require.NoError(t, err) + + if !cmp.Equal(resultExpected, result, filterXMLNS) { + require.FailNow(t, "unexpected merge result", cmp.Diff(resultExpected, result, filterXMLNS)) + } + }) + }) + + t.Run("Properties", func(t *testing.T) { + var ( + bomA = readTestBOM(t, "./testdata/merge/properties-bom-a.json") + bomB = readTestBOM(t, "./testdata/merge/properties-bom-b.json") + resultExpected = readTestBOM(t, "./testdata/merge/properties-result-hierarchical.json") + ) + + result, err := MergeHierarchical(nil, bomA, bomB) + require.NoError(t, err) + + if !cmp.Equal(resultExpected, result, filterXMLNS) { + require.FailNow(t, "unexpected merge result", cmp.Diff(resultExpected, result, filterXMLNS)) + } + }) } func TestMergeLink(t *testing.T) { @@ -71,8 +136,8 @@ func TestMergeLink(t *testing.T) { result, err := MergeLink(nil, bomA, bomB) require.NoError(t, err) - if !cmp.Equal(result, resultExpected, filterXMLNS) { - require.FailNow(t, "unexpected merge result", cmp.Diff(result, resultExpected, filterXMLNS)) + if !cmp.Equal(resultExpected, result, filterXMLNS) { + require.FailNow(t, "unexpected merge result", cmp.Diff(resultExpected, result, filterXMLNS)) } }) @@ -87,11 +152,26 @@ func TestMergeLink(t *testing.T) { result, err := MergeLink(subject, bomA, bomB) require.NoError(t, err) - if !cmp.Equal(result, resultExpected, filterXMLNS) { - require.FailNow(t, "unexpected merge result", cmp.Diff(result, resultExpected, filterXMLNS)) + if !cmp.Equal(resultExpected, result, filterXMLNS) { + require.FailNow(t, "unexpected merge result", cmp.Diff(resultExpected, result, filterXMLNS)) } }) }) + + t.Run("Properties", func(t *testing.T) { + var ( + bomA = readTestBOM(t, "./testdata/merge/properties-bom-a.json") + bomB = readTestBOM(t, "./testdata/merge/properties-bom-b.json") + resultExpected = readTestBOM(t, "./testdata/merge/properties-result-link.json") + ) + + result, err := MergeLink(nil, bomA, bomB) + require.NoError(t, err) + + if !cmp.Equal(resultExpected, result, filterXMLNS) { + require.FailNow(t, "unexpected merge result", cmp.Diff(resultExpected, result, filterXMLNS)) + } + }) } var filterXMLNS = cmp.FilterPath(func(path cmp.Path) bool { diff --git a/testdata/merge/properties-bom-a.json b/testdata/merge/properties-bom-a.json new file mode 100644 index 0000000..d37c0d2 --- /dev/null +++ b/testdata/merge/properties-bom-a.json @@ -0,0 +1,34 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "serialNumber": "urn:uuid:b3b9a427-329d-4478-8787-a368c05e1b8b", + "metadata": { + "component": { + "bom-ref": "acme-app-a@1.0.0", + "type": "application", + "name": "acme-app-a", + "version": "1.0.0" + }, + "properties": [ + { + "name": "internal:some-id", + "value": "bom-a" + }, + { + "name": "internal:some-id", + "value": "common-value" + } + ] + }, + "properties": [ + { + "name": "internal:some-id", + "value": "bom-a" + }, + { + "name": "internal:some-id", + "value": "common-value" + } + ] +} \ No newline at end of file diff --git a/testdata/merge/properties-bom-b.json b/testdata/merge/properties-bom-b.json new file mode 100644 index 0000000..21a1948 --- /dev/null +++ b/testdata/merge/properties-bom-b.json @@ -0,0 +1,34 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 2, + "serialNumber": "urn:uuid:dfd8724c-d1ea-4717-b240-d95af1545240", + "metadata": { + "component": { + "bom-ref": "acme-app-b@2.0.0", + "type": "application", + "name": "acme-app-b", + "version": "2.0.0" + }, + "properties": [ + { + "name": "internal:some-id", + "value": "bom-b" + }, + { + "name": "internal:some-id", + "value": "common-value" + } + ] + }, + "properties": [ + { + "name": "internal:some-id", + "value": "bom-b" + }, + { + "name": "internal:some-id", + "value": "common-value" + } + ] +} \ No newline at end of file diff --git a/testdata/merge/properties-result-flat.json b/testdata/merge/properties-result-flat.json new file mode 100644 index 0000000..8700a9f --- /dev/null +++ b/testdata/merge/properties-result-flat.json @@ -0,0 +1,57 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "properties": [ + { + "name": "internal:some-id", + "value": "bom-a" + }, + { + "name": "internal:some-id", + "value": "common-value" + }, + { + "name": "internal:some-id", + "value": "bom-b" + }, + { + "name": "internal:some-id", + "value": "common-value" + } + ] + }, + "components": [ + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0", + "type": "application", + "name": "acme-app-a", + "version": "1.0.0" + }, + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0", + "type": "application", + "name": "acme-app-b", + "version": "2.0.0" + } + ], + "properties": [ + { + "name": "internal:some-id", + "value": "bom-a" + }, + { + "name": "internal:some-id", + "value": "common-value" + }, + { + "name": "internal:some-id", + "value": "bom-b" + }, + { + "name": "internal:some-id", + "value": "common-value" + } + ] +} \ No newline at end of file diff --git a/testdata/merge/properties-result-hierarchical.json b/testdata/merge/properties-result-hierarchical.json new file mode 100644 index 0000000..8700a9f --- /dev/null +++ b/testdata/merge/properties-result-hierarchical.json @@ -0,0 +1,57 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "properties": [ + { + "name": "internal:some-id", + "value": "bom-a" + }, + { + "name": "internal:some-id", + "value": "common-value" + }, + { + "name": "internal:some-id", + "value": "bom-b" + }, + { + "name": "internal:some-id", + "value": "common-value" + } + ] + }, + "components": [ + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0", + "type": "application", + "name": "acme-app-a", + "version": "1.0.0" + }, + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0", + "type": "application", + "name": "acme-app-b", + "version": "2.0.0" + } + ], + "properties": [ + { + "name": "internal:some-id", + "value": "bom-a" + }, + { + "name": "internal:some-id", + "value": "common-value" + }, + { + "name": "internal:some-id", + "value": "bom-b" + }, + { + "name": "internal:some-id", + "value": "common-value" + } + ] +} \ No newline at end of file diff --git a/testdata/merge/properties-result-link.json b/testdata/merge/properties-result-link.json new file mode 100644 index 0000000..33976c7 --- /dev/null +++ b/testdata/merge/properties-result-link.json @@ -0,0 +1,31 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "components": [ + { + "bom-ref": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1#acme-app-a%401.0.0", + "type": "application", + "name": "acme-app-a", + "version": "1.0.0", + "externalReferences": [ + { + "type": "bom", + "url": "urn:cdx:b3b9a427-329d-4478-8787-a368c05e1b8b/1" + } + ] + }, + { + "bom-ref": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2#acme-app-b%402.0.0", + "type": "application", + "name": "acme-app-b", + "version": "2.0.0", + "externalReferences": [ + { + "type": "bom", + "url": "urn:cdx:dfd8724c-d1ea-4717-b240-d95af1545240/2" + } + ] + } + ] +} \ No newline at end of file From 865f0a934b17919bd48ac7dfefba8877fc62e3e6 Mon Sep 17 00:00:00 2001 From: nscuro Date: Sat, 26 Feb 2022 22:12:41 +0100 Subject: [PATCH 4/4] more link tests Signed-off-by: nscuro --- link_test.go | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/link_test.go b/link_test.go index 0b6f5bc..8081ea3 100644 --- a/link_test.go +++ b/link_test.go @@ -20,7 +20,9 @@ package cyclonedx import ( "testing" + "github.com/google/uuid" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIsBOMLink(t *testing.T) { @@ -51,9 +53,81 @@ func TestIsBOMLink(t *testing.T) { } func TestNewBOMLink(t *testing.T) { - // TODO + bom := NewBOM() + bom.SerialNumber = uuid.MustParse("50b69bf2-fd4f-400e-9522-43badebb14ca").URN() + bom.Version = 6 + + t.Run("Component", func(t *testing.T) { + c := Component{BOMRef: "ref"} + + link, err := NewBOMLink(bom, &c) + require.NoError(t, err) + require.Equal(t, "50b69bf2-fd4f-400e-9522-43badebb14ca", link.SerialNumber.String()) + require.Equal(t, 6, link.Version) + require.Equal(t, "ref", link.Reference) + }) + + t.Run("Service", func(t *testing.T) { + s := Service{BOMRef: "ref"} + + link, err := NewBOMLink(bom, &s) + require.NoError(t, err) + require.Equal(t, "50b69bf2-fd4f-400e-9522-43badebb14ca", link.SerialNumber.String()) + require.Equal(t, 6, link.Version) + require.Equal(t, "ref", link.Reference) + }) + + t.Run("Vulnerability", func(t *testing.T) { + v := Vulnerability{BOMRef: "ref"} + + link, err := NewBOMLink(bom, &v) + require.NoError(t, err) + require.Equal(t, "50b69bf2-fd4f-400e-9522-43badebb14ca", link.SerialNumber.String()) + require.Equal(t, 6, link.Version) + require.Equal(t, "ref", link.Reference) + }) +} + +func TestBOMLink_String(t *testing.T) { + t.Run("WithReference", func(t *testing.T) { + link := BOMLink{ + SerialNumber: uuid.MustParse("50b69bf2-fd4f-400e-9522-43badebb14ca"), + Version: 6, + Reference: "r/e/f@1.2.3", + } + require.Equal(t, "urn:cdx:50b69bf2-fd4f-400e-9522-43badebb14ca/6#r%2Fe%2Ff%401.2.3", link.String()) + }) + + t.Run("WithoutReference", func(t *testing.T) { + link := BOMLink{ + SerialNumber: uuid.MustParse("50b69bf2-fd4f-400e-9522-43badebb14ca"), + Version: 6, + Reference: "", + } + require.Equal(t, "urn:cdx:50b69bf2-fd4f-400e-9522-43badebb14ca/6", link.String()) + }) } func TestParseBOMLink(t *testing.T) { - // TODO + t.Run("WithReference", func(t *testing.T) { + link, err := ParseBOMLink("urn:cdx:50b69bf2-fd4f-400e-9522-43badebb14ca/6#r%2Fe%2Ff%401.2.3") + require.NoError(t, err) + require.Equal(t, uuid.MustParse("50b69bf2-fd4f-400e-9522-43badebb14ca"), link.SerialNumber) + require.Equal(t, 6, link.Version) + require.Equal(t, "r/e/f@1.2.3", link.Reference) + }) + + t.Run("WithoutReference", func(t *testing.T) { + link, err := ParseBOMLink("urn:cdx:50b69bf2-fd4f-400e-9522-43badebb14ca/6") + require.NoError(t, err) + require.Equal(t, uuid.MustParse("50b69bf2-fd4f-400e-9522-43badebb14ca"), link.SerialNumber) + require.Equal(t, 6, link.Version) + require.Equal(t, "", link.Reference) + }) + + t.Run("Invalid", func(t *testing.T) { + link, err := ParseBOMLink("urn:uuid:50b69bf2-fd4f-400e-9522-43badebb14ca") + require.Error(t, err) + require.Nil(t, link) + }) }