diff --git a/cmd/export.go b/cmd/export.go new file mode 100644 index 00000000..4b9b5df6 --- /dev/null +++ b/cmd/export.go @@ -0,0 +1,123 @@ +// ------------------------------------------------------------------------ +// SPDX-FileCopyrightText: Copyright © 2024 bomctl authors +// SPDX-FileName: cmd/export.go +// SPDX-FileType: SOURCE +// SPDX-License-Identifier: Apache-2.0 +// ------------------------------------------------------------------------ +// 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 ( + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/bomctl/bomctl/internal/pkg/db" + "github.com/bomctl/bomctl/internal/pkg/export" + "github.com/bomctl/bomctl/internal/pkg/utils" + "github.com/bomctl/bomctl/internal/pkg/utils/format" +) + +func exportCmd() *cobra.Command { + documentIDs := []string{} + opts := &export.ExportOptions{ + Logger: utils.NewLogger("export"), + } + + outputFile := OutputFileValue("") + formatString := FormatStringValue(format.DefaultFormatString()) + formatEncoding := FormatEncodingValue(format.DefaultEncoding()) + + exportCmd := &cobra.Command{ + Use: "export [flags] SBOM_URL...", + Args: cobra.MinimumNArgs(1), + Short: "Export SBOM file(s) from Storage", + Long: "Export SBOM file(s) from Storage", + PreRun: func(_ *cobra.Command, args []string) { + documentIDs = append(documentIDs, args...) + }, + Run: func(cmd *cobra.Command, _ []string) { + cfgFile, err := cmd.Flags().GetString("config") + cobra.CheckErr(err) + + initOpts(opts, cfgFile, string(formatString), string(formatEncoding)) + backend := initBackend(opts) + + if string(outputFile) != "" { + if len(documentIDs) > 1 { + opts.Logger.Fatal("The --output-file option cannot be used when more than one SBOM is provided.") + } + + out, err := os.Create(string(outputFile)) + if err != nil { + opts.Logger.Fatal("error creating output file", "outputFile", outputFile) + } + + opts.OutputFile = out + + defer opts.OutputFile.Close() + } + Export(documentIDs, opts, backend) + }, + } + + exportCmd.Flags().VarP( + &outputFile, + "output-file", + "o", + "Path to output file", + ) + exportCmd.Flags().VarP( + &formatString, + "format", + "f", + format.FormatStringOptions) + exportCmd.Flags().VarP( + &formatEncoding, + "encoding", + "e", + "the output encoding [spdx: [text, json] cyclonedx: [json]") + + return exportCmd +} + +func Export(documentIDs []string, opts *export.ExportOptions, backend *db.Backend) { + for _, id := range documentIDs { + if err := export.Export(id, opts, backend); err != nil { + opts.Logger.Fatal(err) + } + } +} + +func initOpts(opts *export.ExportOptions, cfgFile, formatString, formatEncoding string) { + opts.CacheDir = viper.GetString("cache_dir") + opts.ConfigFile = cfgFile + opts.FormatString = formatString + opts.Encoding = formatEncoding +} + +func initBackend(opts *export.ExportOptions) *db.Backend { + backend := db.NewBackend(func(b *db.Backend) { + b.Options.DatabaseFile = filepath.Join(opts.CacheDir, db.DatabaseFile) + b.Logger = utils.NewLogger("export") + }) + + if err := backend.InitClient(); err != nil { + backend.Logger.Fatalf("failed to initialize backend client: %v", err) + } + + return backend +} diff --git a/cmd/options.go b/cmd/options.go index e48e336c..b612fceb 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -31,10 +31,13 @@ type ( DirectoryValue string ExistingFileValue string OutputFileValue string + FormatStringValue string + FormatEncodingValue string URLValue string DirectorySliceValue []string FileSliceValue []string URLSliceValue []string + SBOMIDSliceValue []string ) var ( @@ -63,8 +66,11 @@ func (dsv *DirectorySliceValue) String() string { return fmt.Sprintf("%v", *dsv) func (efv *ExistingFileValue) String() string { return fmt.Sprintf("%v", *efv) } func (fsv *FileSliceValue) String() string { return fmt.Sprintf("%v", *fsv) } func (ofv *OutputFileValue) String() string { return fmt.Sprintf("%v", *ofv) } -func (uv *URLValue) String() string { return fmt.Sprintf("%v", *uv) } -func (usv *URLSliceValue) String() string { return fmt.Sprintf("%v", *usv) } +func (fstv *FormatStringValue) String() string { return fmt.Sprintf("%v", *fstv) } +func (fev *FormatEncodingValue) String() string { return fmt.Sprintf("%v", *fev) } + +func (uv *URLValue) String() string { return fmt.Sprintf("%v", *uv) } +func (usv *URLSliceValue) String() string { return fmt.Sprintf("%v", *usv) } func (dv *DirectoryValue) Set(value string) error { checkDirectory(value) @@ -100,6 +106,18 @@ func (ofv *OutputFileValue) Set(value string) error { return nil } +func (fstv *FormatStringValue) Set(value string) error { + *fstv = FormatStringValue(value) + + return nil +} + +func (fev *FormatEncodingValue) Set(value string) error { + *fev = FormatEncodingValue(value) + + return nil +} + func (uv *URLValue) Set(value string) error { *uv = URLValue(value) @@ -113,9 +131,10 @@ func (usv *URLSliceValue) Set(value string) error { } const ( - valueTypeDir string = "DIRECTORY" - valueTypeFile string = "FILE" - valueTypeURL string = "URL" + valueTypeDir string = "DIRECTORY" + valueTypeFile string = "FILE" + valueTypeURL string = "URL" + valueTypeString string = "STRING" ) func (dv *DirectoryValue) Type() string { return valueTypeDir } @@ -123,5 +142,7 @@ func (dsv *DirectorySliceValue) Type() string { return valueTypeDir } func (efv *ExistingFileValue) Type() string { return valueTypeFile } func (fsv *FileSliceValue) Type() string { return valueTypeFile } func (ofv *OutputFileValue) Type() string { return valueTypeFile } +func (fstv *FormatStringValue) Type() string { return valueTypeString } +func (fev *FormatEncodingValue) Type() string { return valueTypeString } func (uv *URLValue) Type() string { return valueTypeURL } func (usv *URLSliceValue) Type() string { return valueTypeURL } diff --git a/cmd/root.go b/cmd/root.go index e9a233fb..bb1cd161 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -110,6 +110,7 @@ func rootCmd() *cobra.Command { cobra.CheckErr(viper.BindPFlag("cache_dir", rootCmd.PersistentFlags().Lookup("cache-dir"))) rootCmd.AddCommand(fetchCmd()) + rootCmd.AddCommand(exportCmd()) rootCmd.AddCommand(listCmd()) rootCmd.AddCommand(versionCmd()) diff --git a/go.mod b/go.mod index 44803f46..db04b2a2 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/charmbracelet/lipgloss v0.9.1 github.com/charmbracelet/log v0.3.1 github.com/go-git/go-git/v5 v5.11.0 + github.com/google/go-cmp v0.6.0 github.com/jdx/go-netrc v1.0.0 github.com/opencontainers/image-spec v1.1.0 github.com/protobom/protobom v0.4.2 @@ -29,6 +30,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cloudflare/circl v1.3.7 // indirect + github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -40,7 +42,6 @@ require ( github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-openapi/inflect v0.19.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/google/go-cmp v0.6.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl/v2 v2.13.0 // indirect diff --git a/go.sum b/go.sum index 5b9aedcd..e4309b16 100644 --- a/go.sum +++ b/go.sum @@ -86,6 +86,8 @@ github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBS github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ= +github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= diff --git a/internal/pkg/export/export.go b/internal/pkg/export/export.go new file mode 100644 index 00000000..ef2c376c --- /dev/null +++ b/internal/pkg/export/export.go @@ -0,0 +1,76 @@ +// ------------------------------------------------------------------------ +// SPDX-FileCopyrightText: Copyright © 2024 bomctl authors +// SPDX-FileName: internal/pkg/export/export.go +// SPDX-FileType: SOURCE +// SPDX-License-Identifier: Apache-2.0 +// ------------------------------------------------------------------------ +// 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 export + +import ( + "fmt" + "os" + + "github.com/charmbracelet/log" + "github.com/protobom/protobom/pkg/writer" + + "github.com/bomctl/bomctl/internal/pkg/db" + "github.com/bomctl/bomctl/internal/pkg/utils" + "github.com/bomctl/bomctl/internal/pkg/utils/format" +) + +type ( + ExportOptions struct { + Logger *log.Logger + OutputFile *os.File + FormatString string + Encoding string + CacheDir string + ConfigFile string + } +) + +func Export(sbomID string, opts *ExportOptions, backend *db.Backend) error { + logger := utils.NewLogger("export") + + logger.Info(fmt.Sprintf("Exporting %s SBOM ID", sbomID)) + + parsedFormat, err := format.Parse(opts.FormatString, opts.Encoding) + if err != nil { + return fmt.Errorf("%w", err) + } + + wr := writer.New( + writer.WithFormat(parsedFormat), + ) + + document, err := backend.GetDocumentByID(sbomID) + if err != nil { + return fmt.Errorf("%w", err) + } + + if opts.OutputFile != nil { + // Write the SBOM document bytes to file. + if err := wr.WriteFile(document, opts.OutputFile.Name()); err != nil { + return fmt.Errorf("%w", err) + } + } else { + // Write the SBOM document bytes to stdout. + if err := wr.WriteStream(document, os.Stdout); err != nil { + return fmt.Errorf("%w", err) + } + } + + return nil +} diff --git a/internal/pkg/utils/format/format.go b/internal/pkg/utils/format/format.go new file mode 100644 index 00000000..01a59424 --- /dev/null +++ b/internal/pkg/utils/format/format.go @@ -0,0 +1,124 @@ +package format + +import ( + "errors" + "strings" + + "github.com/protobom/protobom/pkg/formats" +) + +var ( + errUnknownEncoding = errors.New("unknown encoding") + errUnknownFormat = errors.New("unknown format") +) + +const ( + FormatStringOptions = `Output Format: + - spdx, + - spdx-2.3, + - cyclonedx, + - cyclonedx-1.0,cyclonedx-1.1, + - cyclonedx-1.2, + - cyclonedx-1.3, + - cyclonedx-1.4, + - cyclonedx-1.5 + ` +) + +func DefaultSPDXJSONVersion() formats.Format { + return formats.SPDX23JSON +} + +func DefaultSPDXTVVersion() formats.Format { + return formats.SPDX23TV +} + +func DefaultCycloneDXVersion() formats.Format { + return formats.CDX15JSON +} + +func DefaultEncoding() string { + return formats.JSON +} + +func DefaultFormatString() string { + return "cyclonedx-1.5" +} + +func JSONFormatMap() map[string]formats.Format { + return map[string]formats.Format{ + "spdx": formats.SPDXFORMAT, + "spdx-2.2": formats.SPDX22JSON, + "spdx-2.3": formats.SPDX23JSON, + + "cyclonedx": formats.CDXFORMAT, + "cyclonedx-1.0": formats.CDX10JSON, + "cyclonedx-1.1": formats.CDX11JSON, + "cyclonedx-1.2": formats.CDX12JSON, + "cyclonedx-1.3": formats.CDX13JSON, + "cyclonedx-1.4": formats.CDX14JSON, + "cyclonedx-1.5": formats.CDX15JSON, + } +} + +func TVFormatMap() map[string]formats.Format { + return map[string]formats.Format{ + "spdx": formats.SPDXFORMAT, + "spdx-2.2": formats.SPDX22TV, + "spdx-2.3": formats.SPDX23TV, + } +} + +func EncodingMap() map[string]string { + return map[string]string{ + "json": formats.JSON, + "xml": formats.XML, + "text": formats.TEXT, + } +} + +func XMLFormatMap() map[string]formats.Format { + return map[string]formats.Format{} +} + +type Format struct { + formats.Format +} + +// Parse parses the format string into a formats.Format. +func Parse(fs, encoding string) (formats.Format, error) { + if fs == "" { + return formats.EmptyFormat, errUnknownFormat + } + + var fm map[string]formats.Format + + switch encoding { + case formats.JSON: + fm = JSONFormatMap() + case formats.TEXT: + fm = TVFormatMap() + case formats.XML: + fm = XMLFormatMap() + default: + return formats.EmptyFormat, + errUnknownEncoding + } + + return DefaultVersion(fm, fs, encoding) +} + +func DefaultVersion(fm map[string]formats.Format, fs, encoding string) (formats.Format, error) { + switch f, ok := fm[strings.ToLower(fs)]; { + case !ok: + return formats.EmptyFormat, errUnknownFormat + case f == formats.SPDXFORMAT && encoding == formats.JSON: + return DefaultSPDXJSONVersion(), nil + case f == formats.SPDXFORMAT && encoding == formats.TEXT: + return DefaultSPDXTVVersion(), nil + case f == formats.CDXFORMAT: + return DefaultCycloneDXVersion(), nil + default: + return f, nil + } +} diff --git a/internal/pkg/utils/format/format_test.go b/internal/pkg/utils/format/format_test.go new file mode 100644 index 00000000..aff4bc3b --- /dev/null +++ b/internal/pkg/utils/format/format_test.go @@ -0,0 +1,60 @@ +package format_test + +import ( + "testing" + + "github.com/protobom/protobom/pkg/formats" + + "github.com/bomctl/bomctl/internal/pkg/utils/format" +) + +func Test_Parse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fs string + encoding string + want formats.Format + wantErr bool + }{ + { + name: "Parse spdx-2.2 json format", + fs: "spdx-2.2", + encoding: formats.JSON, + want: formats.SPDX22JSON, + wantErr: false, + }, + { + name: "Parse spdx-2.3 json format", + fs: "spdx-2.3", + encoding: formats.JSON, + want: formats.SPDX23JSON, + wantErr: false, + }, + { + name: "Parse spdx json format", + fs: "spdx", + encoding: formats.JSON, + want: format.DefaultSPDXJSONVersion(), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := format.Parse(tt.fs, tt.encoding) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + + return + } + + if string(got) != string(tt.want) { + t.Errorf("Parse() = %v, want %v", got, tt.want) + } + }) + } +}