Skip to content

Commit

Permalink
feat(spec1-5): handle deprecation of tools
Browse files Browse the repository at this point in the history
closes #115

Signed-off-by: nscuro <nscuro@protonmail.com>
  • Loading branch information
nscuro committed Dec 9, 2023
1 parent f856daa commit c7a84ac
Show file tree
Hide file tree
Showing 29 changed files with 830 additions and 211 deletions.
86 changes: 81 additions & 5 deletions convert.go
Expand Up @@ -68,11 +68,7 @@ func (b *BOM) convert(specVersion SpecVersion) {

recurseComponent(b.Metadata.Component, componentConverter(specVersion))
convertLicenses(b.Metadata.Licenses, specVersion)
if b.Metadata.Tools != nil {
for i := range *b.Metadata.Tools {
convertTool(&(*b.Metadata.Tools)[i], specVersion)
}
}
convertTools(b.Metadata.Tools, specVersion)
}

if b.Components != nil {
Expand Down Expand Up @@ -267,6 +263,8 @@ func convertVulnerabilities(vulns *[]Vulnerability, specVersion SpecVersion) {
for i := range *vulns {
vuln := &(*vulns)[i]

convertTools(vuln.Tools, specVersion)

if specVersion < SpecVersion1_5 {
vuln.ProofOfConcept = nil
vuln.Rejected = ""
Expand Down Expand Up @@ -299,6 +297,50 @@ func serviceConverter(specVersion SpecVersion) func(*Service) {
}
}

// convertTools modifies a ToolsChoice such that it adheres to a given SpecVersion.
func convertTools(tools *ToolsChoice, specVersion SpecVersion) {
if tools == nil {
return
}

if specVersion < SpecVersion1_5 {
convertedTools := make([]Tool, 0)
if tools.Components != nil {
for i := range *tools.Components {
tool := convertComponentToTool((*tools.Components)[i], specVersion)
if tool != nil {
convertedTools = append(convertedTools, *tool)
}
}
tools.Components = nil
}

if tools.Services != nil {
for i := range *tools.Services {
tool := convertServiceToTool((*tools.Services)[i], specVersion)
if tool != nil {
convertedTools = append(convertedTools, *tool)
}
}
tools.Services = nil
}

if len(convertedTools) > 0 {
if tools.Tools == nil {
tools.Tools = &convertedTools
} else {
*tools.Tools = append(*tools.Tools, convertedTools...)
}
}
}

if tools.Tools != nil {
for i := range *tools.Tools {
convertTool(&(*tools.Tools)[i], specVersion)
}
}
}

// convertTool modifies a Tool such that it adheres to a given SpecVersion.
func convertTool(tool *Tool, specVersion SpecVersion) {
if tool == nil {
Expand All @@ -313,6 +355,40 @@ func convertTool(tool *Tool, specVersion SpecVersion) {
convertHashes(tool.Hashes, specVersion)
}

// convertComponentToTool converts a Component to a Tool for use in ToolsChoice.Tools.
func convertComponentToTool(component Component, _ SpecVersion) *Tool {
tool := Tool{
Vendor: component.Author,
Name: component.Name,
Version: component.Version,
Hashes: component.Hashes,
ExternalReferences: component.ExternalReferences,
}

if component.Supplier != nil {
// There is no perfect 1:1 mapping for the Vendor field, but Supplier comes closest.
// https://github.com/CycloneDX/cyclonedx-go/issues/115#issuecomment-1688710539
tool.Vendor = component.Supplier.Name
}

return &tool
}

// convertServiceToTool converts a Service to a Tool for use in ToolsChoice.Tools.
func convertServiceToTool(service Service, _ SpecVersion) *Tool {
tool := Tool{
Name: service.Name,
Version: service.Version,
ExternalReferences: service.ExternalReferences,
}

if service.Provider != nil {
tool.Vendor = service.Provider.Name
}

return &tool
}

func recurseComponent(component *Component, f func(c *Component)) {
if component == nil {
return
Expand Down
19 changes: 17 additions & 2 deletions cyclonedx.go
Expand Up @@ -586,7 +586,7 @@ func (mt MediaType) WithVersion(specVersion SpecVersion) (string, error) {
type Metadata struct {
Timestamp string `json:"timestamp,omitempty" xml:"timestamp,omitempty"`
Lifecycles *[]Lifecycle `json:"lifecycles,omitempty" xml:"lifecycles>lifecycle,omitempty"`
Tools *[]Tool `json:"tools,omitempty" xml:"tools>tool,omitempty"`
Tools *ToolsChoice `json:"tools,omitempty" xml:"tools,omitempty"`
Authors *[]OrganizationalContact `json:"authors,omitempty" xml:"authors>author,omitempty"`
Component *Component `json:"component,omitempty" xml:"component,omitempty"`
Manufacture *OrganizationalEntity `json:"manufacture,omitempty" xml:"manufacture,omitempty"`
Expand Down Expand Up @@ -967,6 +967,7 @@ const (
TaskWorkspaceAccessModeWriteOnly TaskWorkspaceAccessMode = "write-only"
)

// Deprecated: Use Component or Service instead.
type Tool struct {
Vendor string `json:"vendor,omitempty" xml:"vendor,omitempty"`
Name string `json:"name" xml:"name"`
Expand All @@ -975,6 +976,20 @@ type Tool struct {
ExternalReferences *[]ExternalReference `json:"externalReferences,omitempty" xml:"externalReferences>reference,omitempty"`
}

// ToolsChoice represents a union of either Tools (deprecated as of CycloneDX v1.5), and Components or Services.
//
// Encoding or decoding a ToolsChoice with both options present will raise an error.
// When encoding to a SpecVersion lower than SpecVersion1_5, and Components or Services are set,
// they will be automatically converted to legacy Tools.
//
// It is strongly recommended to use Components and Services. However, when consuming BOMs,
// applications should still expect legacy Tools to be present, and handle them accordingly.
type ToolsChoice struct {
Tools *[]Tool `json:"-" xml:"-"` // Deprecated: Use Components and Services instead.
Components *[]Component `json:"-" xml:"-"`
Services *[]Service `json:"-" xml:"-"`
}

type Volume struct {
UID string `json:"uid,omitempty" xml:"uid,omitempty"`
Name string `json:"name,omitempty" xml:"name,omitempty"`
Expand Down Expand Up @@ -1011,7 +1026,7 @@ type Vulnerability struct {
Updated string `json:"updated,omitempty" xml:"updated,omitempty"`
Rejected string `json:"rejected,omitempty" xml:"rejected,omitempty"`
Credits *Credits `json:"credits,omitempty" xml:"credits,omitempty"`
Tools *[]Tool `json:"tools,omitempty" xml:"tools>tool,omitempty"`
Tools *ToolsChoice `json:"tools,omitempty" xml:"tools,omitempty"`
Analysis *VulnerabilityAnalysis `json:"analysis,omitempty" xml:"analysis,omitempty"`
Affects *[]Affects `json:"affects,omitempty" xml:"affects>target,omitempty"`
Properties *[]Property `json:"properties,omitempty" xml:"properties>property,omitempty"`
Expand Down
55 changes: 55 additions & 0 deletions cyclonedx_json.go
Expand Up @@ -20,6 +20,7 @@ package cyclonedx
import (
"encoding/json"
"errors"
"fmt"
)

func (ev EnvironmentVariableChoice) MarshalJSON() ([]byte, error) {
Expand Down Expand Up @@ -130,6 +131,60 @@ func (sv *SpecVersion) UnmarshalJSON(bytes []byte) error {
return nil
}

type toolsChoiceJSON struct {
Components *[]Component `json:"components,omitempty" xml:"-"`
Services *[]Service `json:"services,omitempty" xml:"-"`
}

func (tc ToolsChoice) MarshalJSON() ([]byte, error) {
if tc.Tools != nil && (tc.Components != nil || tc.Services != nil) {
return nil, fmt.Errorf("either a list of tools, or an object holding components and services can be used, but not both")
}

if tc.Tools != nil {
return json.Marshal(tc.Tools)
}

choiceJSON := toolsChoiceJSON{
Components: tc.Components,
Services: tc.Services,
}
if choiceJSON.Components != nil || choiceJSON.Services != nil {
return json.Marshal(choiceJSON)
}

return []byte(nil), nil
}

func (tc *ToolsChoice) UnmarshalJSON(bytes []byte) error {
var choiceJSON toolsChoiceJSON
err := json.Unmarshal(bytes, &choiceJSON)
if err != nil {
var typeErr *json.UnmarshalTypeError
if !errors.As(err, &typeErr) || typeErr.Value != "array" {
return err
}

var legacyTools []Tool
err = json.Unmarshal(bytes, &legacyTools)
if err != nil {
return err
}

*tc = ToolsChoice{Tools: &legacyTools}
return nil
}

if choiceJSON.Components != nil || choiceJSON.Services != nil {
*tc = ToolsChoice{
Components: choiceJSON.Components,
Services: choiceJSON.Services,
}
}

return nil
}

var jsonSchemas = map[SpecVersion]string{
SpecVersion1_0: "",
SpecVersion1_1: "",
Expand Down
105 changes: 105 additions & 0 deletions cyclonedx_xml.go
Expand Up @@ -299,6 +299,111 @@ func (sv *SpecVersion) UnmarshalXML(d *xml.Decoder, start xml.StartElement) erro
return nil
}

// toolsChoiceMarshalXML is a helper struct for marshalling ToolsChoice.
type toolsChoiceMarshalXML struct {
LegacyTools *[]Tool `json:"-" xml:"tool,omitempty"`
Components *[]Component `json:"-" xml:"components>component,omitempty"`
Services *[]Service `json:"-" xml:"services>service,omitempty"`
}

// toolsChoiceUnmarshalXML is a helper struct for unmarshalling tools represented
// as components and / or services. It is intended to be used with the streaming XML API.
//
// <components> <-- cursor should be here when unmarshalling this!
// <component>
// <name>foo</name>
// </component>
// </components>
type toolsChoiceUnmarshalXML struct {
Components *[]Component `json:"-" xml:"component,omitempty"`
Services *[]Service `json:"-" xml:"service,omitempty"`
}

func (tc ToolsChoice) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if tc.Tools != nil && (tc.Components != nil || tc.Services != nil) {
return fmt.Errorf("either a list of tools, or an object holding components and services can be used, but not both")
}

if tc.Tools != nil {
return e.EncodeElement(toolsChoiceMarshalXML{LegacyTools: tc.Tools}, start)
}

tools := toolsChoiceMarshalXML{
Components: tc.Components,
Services: tc.Services,
}
if tools.Components != nil || tools.Services != nil {
return e.EncodeElement(tools, start)
}

return nil
}

func (tc *ToolsChoice) UnmarshalXML(d *xml.Decoder, _ xml.StartElement) error {
var components []Component
var services []Service
legacyTools := make([]Tool, 0)

for {
token, err := d.Token()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return err
}

switch tokenType := token.(type) {
case xml.StartElement:
if tokenType.Name.Local == "tool" {
var tool Tool
if err = d.DecodeElement(&tool, &tokenType); err != nil {
return err
}
legacyTools = append(legacyTools, tool)
} else if tokenType.Name.Local == "components" {
var foo toolsChoiceUnmarshalXML
if err = d.DecodeElement(&foo, &tokenType); err != nil {
return err
}
if foo.Components != nil {
components = *foo.Components
}
} else if tokenType.Name.Local == "services" {
var foo toolsChoiceUnmarshalXML
if err = d.DecodeElement(&foo, &tokenType); err != nil {
return err
}
if foo.Services != nil {
services = *foo.Services
}
} else {
return fmt.Errorf("unknown element: %s", tokenType.Name.Local)
}
}
}

choice := ToolsChoice{}
if len(legacyTools) > 0 && (len(components) > 0 || len(services) > 0) {
return fmt.Errorf("either a list of tools, or an object holding components and services can be used, but not both")
}
if len(components) > 0 {
choice.Components = &components
}
if len(services) > 0 {
choice.Services = &services
}
if len(legacyTools) > 0 {
choice.Tools = &legacyTools
}

if choice.Tools != nil || choice.Components != nil || choice.Services != nil {
*tc = choice
}

return nil
}

var xmlNamespaces = map[SpecVersion]string{
SpecVersion1_0: "http://cyclonedx.org/schema/bom/1.0",
SpecVersion1_1: "http://cyclonedx.org/schema/bom/1.1",
Expand Down
2 changes: 1 addition & 1 deletion example_test.go
Expand Up @@ -133,7 +133,7 @@ func Example_decode() {
}

fmt.Printf("Successfully decoded BOM of %s\n", bom.Metadata.Component.PackageURL)
fmt.Printf("- Generated: %s with %s\n", bom.Metadata.Timestamp, (*bom.Metadata.Tools)[0].Name)
fmt.Printf("- Generated: %s with %s\n", bom.Metadata.Timestamp, (*bom.Metadata.Tools.Tools)[0].Name)
fmt.Printf("- Components: %d\n", len(*bom.Components))

// Output:
Expand Down

0 comments on commit c7a84ac

Please sign in to comment.