diff --git a/cmd/bom/cmd/document.go b/cmd/bom/cmd/document.go index 8e79a3f4..174760ef 100644 --- a/cmd/bom/cmd/document.go +++ b/cmd/bom/cmd/document.go @@ -31,5 +31,6 @@ func AddDocument(parent *cobra.Command) { AddOutline(documentCmd) AddQuery(documentCmd) + AddToDot(documentCmd) parent.AddCommand(documentCmd) } diff --git a/cmd/bom/cmd/document_todot.go b/cmd/bom/cmd/document_todot.go new file mode 100644 index 00000000..5de62bec --- /dev/null +++ b/cmd/bom/cmd/document_todot.go @@ -0,0 +1,86 @@ +/* +Copyright 2023 The Kubernetes Authors. + +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. +*/ + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "sigs.k8s.io/bom/pkg/spdx" +) + +func AddToDot(parent *cobra.Command) { + toDotOpts := &spdx.ToDotOptions{} + toDotCmd := &cobra.Command{ + PersistentPreRunE: initLogging, + Short: "bom document todot -> dump the SPDX document as dotlang.", + Long: `bom document todot -> dump the SPDX document as dotlang. + +This Subcommand translates the graph like structure of an spdx document into dotlang, +An abstract grammar used to represent graphs https://graphviz.org/doc/info/lang.html. + +This is printed to stdout but can easily be piped to a file like so. + +bom document todot file.spdx > file.dot + +The output can also be filtered by depth, (--depth), inverse dependencies (--find) +or subgraph (--subgraph) to aid with visualisation by tools like Graphviz + +bom will try to add useful information to dotlangs tooltip node attribute. +`, + Use: "todot SPDX_FILE|URL", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, args []string) error { + if len(args) == 0 { + args = append(args, "") + } + doc, err := spdx.OpenDoc(args[0]) + if err != nil { + return fmt.Errorf("opening doc: %w", err) + } + if toDotOpts.Find != "" { + doc.FilterReverseDependencies(toDotOpts.Find, toDotOpts.Depth) + } + fmt.Println(doc.ToDot(toDotOpts)) + return nil + }, + } + toDotCmd.PersistentFlags().StringVarP( + &toDotOpts.Find, + "find", + "f", + "", + "Find node in DAG", + ) + toDotCmd.PersistentFlags().IntVarP( + &toDotOpts.Depth, + "depth", + "d", + -1, + "Depth to traverse", + ) + toDotCmd.PersistentFlags().StringVarP( + &toDotOpts.SubGraphRoot, + "subgraph", + "s", + "", + "SPDXID of the root node for the subgraph", + ) + parent.AddCommand(toDotCmd) +} diff --git a/pkg/spdx/document.go b/pkg/spdx/document.go index 52cccfea..ae32d373 100644 --- a/pkg/spdx/document.go +++ b/pkg/spdx/document.go @@ -120,6 +120,12 @@ type ExternalRef struct { Locator string // unique string with no spaces } +type ToDotOptions struct { + Find string + Depth int + SubGraphRoot string +} + type DrawingOptions struct { Width int Height int @@ -276,6 +282,30 @@ func (d *Document) Render() (doc string, err error) { return doc, err } +func (d *Document) ToDot(o *ToDotOptions) string { + out := "" + if o.SubGraphRoot == "" { + out = escape(d.Name) + ";\n" + } + seenFilter := &map[string]struct{}{} + var ok bool + for _, p := range d.Packages { + if o.SubGraphRoot != "" { + p, ok = recursiveIDSearch(o.SubGraphRoot, p, &map[string]struct{}{}).(*Package) + if p == nil { + continue + } + if !ok { + log.Fatal("Interface object is not of expected type Package") + } + } else { + out += escape(d.Name) + " -> " + escape(p.SPDXID()) + ";\n" + } + out += toDot(p, o.Depth, seenFilter) + } + return fmt.Sprintf("digraph {\n%s\n}\n", out) +} + // AddFile adds a file contained in the package. func (d *Document) AddFile(file *File) error { if d.Files == nil { diff --git a/pkg/spdx/file.go b/pkg/spdx/file.go index ffa58754..1600322c 100644 --- a/pkg/spdx/file.go +++ b/pkg/spdx/file.go @@ -61,6 +61,10 @@ type File struct { LicenseInfoInFile string // GPL-3.0-or-later } +func (f *File) ToDot() string { + return fmt.Sprintf("%q", f.SPDXID()) +} + func NewFile() (f *File) { f = &File{} f.Opts = &ObjectOptions{} diff --git a/pkg/spdx/json/document/document.go b/pkg/spdx/json/document/document.go index d75f35e6..14b597be 100644 --- a/pkg/spdx/json/document/document.go +++ b/pkg/spdx/json/document/document.go @@ -64,6 +64,8 @@ type Package interface { GetCopyrightText() string GetLicenseConcluded() string GetFilesAnalyzed() bool + GetVendorInfo() string + GetSourceInfo() string GetLicenseDeclared() string GetVersion() string GetVerificationCode() PackageVerificationCode diff --git a/pkg/spdx/json/v2.2/types.go b/pkg/spdx/json/v2.2/types.go index 6ad3d687..e1583355 100644 --- a/pkg/spdx/json/v2.2/types.go +++ b/pkg/spdx/json/v2.2/types.go @@ -99,6 +99,7 @@ type Package struct { Originator string `json:"originator,omitempty"` Supplier string `json:"supplier,omitempty"` SourceInfo string `json:"sourceInfo,omitempty"` + VendorInfo string `json:"vendorInfo,omitempty"` CopyrightText string `json:"copyrightText"` HasFiles []string `json:"hasFiles,omitempty"` LicenseInfoFromFiles []string `json:"licenseInfoFromFiles,omitempty"` @@ -117,6 +118,8 @@ func (p *Package) GetLicenseDeclared() string { return p.LicenseDeclared } func (p *Package) GetVersion() string { return p.Version } func (p *Package) GetPrimaryPurpose() string { return "" } func (p *Package) GetSupplier() string { return p.Supplier } +func (p *Package) GetVendorInfo() string { return p.VendorInfo } +func (p *Package) GetSourceInfo() string { return p.SourceInfo } func (p *Package) GetOriginator() string { return p.Originator } func (p *Package) GetVerificationCode() document.PackageVerificationCode { diff --git a/pkg/spdx/json/v2.3/types.go b/pkg/spdx/json/v2.3/types.go index 74054bcd..aa389270 100644 --- a/pkg/spdx/json/v2.3/types.go +++ b/pkg/spdx/json/v2.3/types.go @@ -99,6 +99,7 @@ type Package struct { Originator string `json:"originator,omitempty"` Supplier string `json:"supplier,omitempty"` SourceInfo string `json:"sourceInfo,omitempty"` + VendorInfo string `json:"vendorInfo,omitempty"` CopyrightText string `json:"copyrightText"` PrimaryPurpose string `json:"primaryPackagePurpose,omitempty"` HasFiles []string `json:"hasFiles,omitempty"` @@ -118,6 +119,8 @@ func (p *Package) GetLicenseDeclared() string { return p.LicenseDeclared } func (p *Package) GetVersion() string { return p.Version } func (p *Package) GetPrimaryPurpose() string { return p.PrimaryPurpose } func (p *Package) GetSupplier() string { return p.Supplier } +func (p *Package) GetVendorInfo() string { return p.VendorInfo } +func (p *Package) GetSourceInfo() string { return p.SourceInfo } func (p *Package) GetOriginator() string { return p.Originator } func (p *Package) GetVerificationCode() document.PackageVerificationCode { diff --git a/pkg/spdx/object.go b/pkg/spdx/object.go index c88cec76..2544ebd9 100644 --- a/pkg/spdx/object.go +++ b/pkg/spdx/object.go @@ -51,6 +51,7 @@ type Object interface { getProvenanceSubjects(opts *ProvenanceOptions, seen *map[string]struct{}) []intoto.Subject GetElementByID(string) Object GetName() string + ToDot() string } type Entity struct { diff --git a/pkg/spdx/package.go b/pkg/spdx/package.go index 53384617..c222d820 100644 --- a/pkg/spdx/package.go +++ b/pkg/spdx/package.go @@ -98,6 +98,8 @@ type Package struct { Comment string // a place for the SPDX document creator to record any general comments HomePage string // A web site that serves as the package home page PrimaryPurpose string // Estimate of the most likely package usage + VendorInfo string + SourceInfo string // Supplier: the actual distribution source for the package/directory Supplier struct { @@ -136,6 +138,29 @@ func NewPackage() (p *Package) { return p } +// ToDot returns a representation of the package as a dotlang node. +func (p *Package) ToDot() string { + packageData := structToString(p, true, "RWMutex", "Opts", "Relationships") + + sURL := "" + if url := p.Purl(); url != nil { + sURL = fmt.Sprintf(`URL: %s\n`, url.ToString()) + } + + name := p.Name + if p.Version != "" { + name = name + "@" + p.Version + } + + return fmt.Sprintf( + `%q [label=%q tooltip="%s%s" fontname="monospace"]`, + p.SPDXID(), + name, + packageData, + sURL, + ) +} + // AddFile adds a file contained in the package. func (p *Package) AddFile(file *File) error { p.Lock() diff --git a/pkg/spdx/parser.go b/pkg/spdx/parser.go index 3a73f9ab..ec9821a8 100644 --- a/pkg/spdx/parser.go +++ b/pkg/spdx/parser.go @@ -251,6 +251,8 @@ func parseJSON(file *os.File) (doc *Document, err error) { LicenseInfoFromFiles: []string{}, LicenseDeclared: pData.GetLicenseDeclared(), Version: pData.GetVersion(), + VendorInfo: pData.GetVendorInfo(), + SourceInfo: pData.GetSourceInfo(), VerificationCode: pData.GetVerificationCode().GetValue(), // Comment: pData.Comment, // HomePage: pData.HomePage, diff --git a/pkg/spdx/spdx.go b/pkg/spdx/spdx.go index ee14a413..c485e863 100644 --- a/pkg/spdx/spdx.go +++ b/pkg/spdx/spdx.go @@ -22,7 +22,9 @@ import ( "fmt" "os" "path/filepath" + "reflect" "regexp" + "slices" "strings" "unicode/utf8" @@ -264,6 +266,44 @@ func Banner() string { return string(d) } +func safeLen(v any) int { + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Array, reflect.Chan, reflect.Map, reflect.Slice, reflect.String: + return rv.Len() + default: + return -1 + } +} + +func structToString(s any, ignoreNils bool, ignore ...string) string { + v := reflect.ValueOf(s) + if v.Kind() == reflect.Pointer { + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return "" + } + typeOf := v.Type() + out := "" + for i := range v.NumField() { + fieldName := typeOf.Field(i).Name + if slices.Contains(ignore, fieldName) { + continue + } + fieldValue := v.Field(i).Interface() + if ignoreNils && safeLen(fieldValue) == 0 { + continue + } + if reflect.TypeOf(fieldValue).Kind() == reflect.Struct { + out += structToString(fieldValue, ignoreNils, ignore...) + } else { + out += fmt.Sprintf(`%s: %v\n`, fieldName, fieldValue) + } + } + return out +} + // recursiveNameFilter is a function that recursivley filters an objects peers inplace // to include only those that are on a direct path to another object with the queried name. // If one or more path is found it returns true. @@ -290,6 +330,37 @@ func recursiveNameFilter(name string, o Object, depth int, seen *map[string]bool return out } +func escape(s string) string { + return fmt.Sprintf("%q", s) +} + +// toDot traverses an objects relationships to return a representation +// of the object and all its peers as a string of valid dotlang. +// +//nolint:gocritic // seen is a pointer recursively populated +func toDot(o Object, depth int, seen *map[string]struct{}) string { + if _, ok := (*seen)[o.SPDXID()]; ok { + return "" + } + (*seen)[o.SPDXID()] = struct{}{} + s := o.ToDot() + ";\n" + if depth == 1 { + return s + } + rels := *o.GetRelationships() + if rels == nil { + return s + } + for _, rel := range rels { + if rel.Peer == nil { + continue + } + s += escape(o.SPDXID()) + " -> " + escape(rel.Peer.SPDXID()) + ";\n" + s += toDot(rel.Peer, depth-1, seen) + } + return s +} + // recursiveIDSearch is a function that recursively searches an object's peers // to find the specified SPDX ID. If found, returns a copy of the object. // diff --git a/pkg/spdx/spdx_unit_test.go b/pkg/spdx/spdx_unit_test.go index 905e42f9..4f306d06 100644 --- a/pkg/spdx/spdx_unit_test.go +++ b/pkg/spdx/spdx_unit_test.go @@ -25,6 +25,7 @@ import ( "io" "os" "path/filepath" + "slices" "strings" "testing" @@ -417,6 +418,88 @@ func TestIgnorePatterns(t *testing.T) { require.Len(t, p, 4) } +func TestStructToString(t *testing.T) { + testStruct := struct { + A string + B int + C struct { + D string + E int + F struct { + G bool + } + } + }{ + "hello", 1, struct { + D string + E int + F struct { + G bool + } + }{"", 2, struct { + G bool + }{false}}, + } + require.Empty(t, structToString(1, false)) + require.Equal(t, `A: hello\nB: 1\nD: \nE: 2\nG: false\n`, structToString(testStruct, false)) + require.Equal(t, `A: hello\nB: 1\nE: 2\nG: false\n`, structToString(testStruct, true)) + require.Equal(t, `B: 1\nE: 2\n`, structToString(testStruct, true, "A", "G")) +} + +func TestToDot(t *testing.T) { + /* + create the following package structure + + root + | + ----------- + | | + node-1 node-2 + | | + ----------- + | + leaf + */ + + packageIDs := []string{"root", "node-1", "node-2", "leaf"} + edges := []struct { + p string + c string + }{ + {"root", "node-1"}, + {"root", "node-2"}, + {"node-1", "leaf"}, + {"node-2", "leaf"}, + } + packages := map[string]*Package{} + for _, id := range packageIDs { + p := NewPackage() + p.SetSPDXID(id) + p.Name = id + packages[id] = p + } + + for _, edge := range edges { + require.NoError(t, packages[edge.p].AddPackage(packages[edge.c])) + } + + // split and sort by line since order here is not deterministic. + expectedDot := strings.Split(`"root" [label="root" tooltip="ID: root\nName: root\nFilesAnalyzed: false\n" fontname="monospace"]; +"root" -> "node-1"; +"node-1" [label="node-1" tooltip="ID: node-1\nName: node-1\nFilesAnalyzed: false\n" fontname="monospace"]; +"node-1" -> "leaf"; +"leaf" [label="leaf" tooltip="ID: leaf\nName: leaf\nFilesAnalyzed: false\n" fontname="monospace"]; +"root" -> "node-2"; +"node-2" [label="node-2" tooltip="ID: node-2\nName: node-2\nFilesAnalyzed: false\n" fontname="monospace"]; +"node-2" -> "leaf"; +`, "\n") + // run function + actualDot := strings.Split(toDot(packages["root"], -1, &map[string]struct{}{}), "\n") + slices.Sort(expectedDot) + slices.Sort(actualDot) + require.Equal(t, expectedDot, actualDot) +} + func TestRecursiveNameFilter(t *testing.T) { /* create the starting package structure