Files
syft/cmd/syft/internal/commands/convert.go
T
Alex Goodman 6c9bb732dd ci: run the full unit suite on windows (#5370)
* ci: run the full unit suite on windows via make unit

Drops the curated `unit:windows` subset; the windows job now bootstraps like linux and runs `make unit`. Exceptions get added as failures show up.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* ci: skip fixture refresh for windows unit tests

binny has no windows release, so the bootstrap action can't install the tools the fixture refresh needs.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* test: skip linux container fixtures when docker can't run them

Windows CI runners only run windows containers, so image fixtures go through `testutils.SkipWithoutLinuxContainers`. No behavior change on linux/mac.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix: close leaked file handles and posix squashfs paths on windows

- output writers are now closed on early returns in `scan`/`convert` (open handles block cleanup on windows)
- `newSBOMMultiWriter` used `path.Dir` on host paths, so output subdirectories were never created on windows
- squashfs walk paths were built with `filepath.Join`
- skip a few posix-only test cases (dir perms, xdg fallback, go-rpmdb ndb handle leak upstream)

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix(golang): resolve go source module dir natively on windows

- `parseGoModFile` passed the resolver's posix (`/c/...`) path to the go toolchain, so go source resolution silently found nothing on windows
- skip container-built fixtures (go archs, java builds) and golden image tarballs on windows; stereoscope's layer cache filenames contain `:`, which windows rejects

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix: keep posix paths posix on windows

- `ChrootContext.ToChrootPath` nested volumes (`D:\cwd\C:\root`) when the scan root was on another drive, so the prefix was never trimmed
- bitnami, python wheel/egg, and alpm parsers used `filepath` on posix location paths
- alpm backup-file lookups silently missed on windows

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* ci: pull the test fixture cache for windows unit tests

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix: don't map virtual file tree paths through the host on windows

`FiletreeResolver` without a chroot (snaps) ran request paths through `filepath.Abs`, rooting them on the current volume (`/payload.txt` -> `/d/payload.txt`), so snap scans on windows found nothing.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix: match tar entry globs as posix on windows

- `matchesAnyGlob` used `doublestar.PathMatch` (host separator) on tar entry names, so tar-wrapped java archives silently produced nothing on windows
- test helpers now treat binary snippet keys and `FromFile` locations as posix

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* incorporate windows path fix

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* incorporate go-rpmdb close fix

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* close squashfs fixture writers before finalizing

- windows dir listings can report a zero size for files still open, so the manifest was packed empty

(also adds a temporary windows diagnostic for the binary cataloger)

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* temp: surface windows binary cataloger diagnostics

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix: keep directory symlink targets posix on windows

- the directory indexer joined symlink targets with native separators, so links in the posix file tree pointed nowhere and globs on link names found nothing
- binary fixture logical keys used native separators, so nested snippets were picked as the fixture dir on windows

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* keep addSymlinkToIndex under the funlen limit

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* drop windows skips covered by the stereoscope and go-rpmdb bumps

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
2026-10-05 10:41:03 -04:00

320 lines
11 KiB
Go

package commands
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/anchore/clio"
"github.com/anchore/go-homedir"
"github.com/anchore/syft/cmd/syft/internal/options"
"github.com/anchore/syft/cmd/syft/internal/ui"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/spillbuf"
"github.com/anchore/syft/internal/tmpdir"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/format/syftjson"
"github.com/anchore/syft/syft/sbom"
)
const (
convertExample = ` {{.appName}} {{.command}} img.syft.json -o spdx-json convert a syft SBOM to spdx-json, output goes to stdout
{{.appName}} {{.command}} img.syft.json -o cyclonedx-json=img.cdx.json convert a syft SBOM to CycloneDX, output is written to the file "img.cdx.json"
{{.appName}} {{.command}} - -o spdx-json convert an SBOM from STDIN to spdx-json
SYFT_CONVERT_PASSTHROUGH_EXACT_FORMAT=true {{.appName}} {{.command}} img.syft.json -o syft-json=out.syft.json copy the input to "out.syft.json" unchanged if it is already syft-json at the current schema version
`
)
type ConvertOptions struct {
options.Config `yaml:",inline" mapstructure:",squash"`
options.Output `yaml:",inline" mapstructure:",squash"`
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
Convert options.Convert `yaml:"convert" json:"convert" mapstructure:"convert"`
}
func Convert(app clio.Application) *cobra.Command {
id := app.ID()
opts := &ConvertOptions{
UpdateCheck: options.DefaultUpdateCheck(),
Output: options.DefaultOutput(),
Convert: options.DefaultConvert(),
}
return app.SetupCommand(&cobra.Command{
Use: "convert [SOURCE-SBOM] -o [FORMAT]",
Short: "Convert between SBOM formats",
Long: "[Experimental] Convert SBOM files to, and from, SPDX, CycloneDX and Syft's format. For more info about data loss between formats see https://github.com/anchore/syft/wiki/format-conversion",
Example: internal.Tprintf(convertExample, map[string]any{
"appName": id.Name,
"command": "convert",
}),
Args: validateConvertArgs,
PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
RunE: func(cmd *cobra.Command, args []string) error {
restoreStdout := ui.CaptureStdoutToTraceLog()
defer restoreStdout()
return RunConvert(cmd.Context(), opts, args[0])
},
}, opts)
}
func validateConvertArgs(cmd *cobra.Command, args []string) error {
return validateArgs(cmd, args, "an SBOM argument is required")
}
func RunConvert(ctx context.Context, opts *ConvertOptions, userInput string) error {
log.Warn("convert is an experimental feature, run `syft convert -h` for help")
// a temp dir is only needed when STDIN is a pipe and has to be spilled to disk; it is created lazily on
// first use (under the user's TMPDIR), so a file input never touches it. Mirrors syft.CreateSBOM, which
// also honors a TempDir already attached to the context.
td := tmpdir.FromContext(ctx)
if td == nil {
_, td = tmpdir.Root(ctx, "syft-convert")
defer func() {
_ = td.Cleanup()
}()
}
reader, inputInfo, err := openConvertInput(userInput, td)
if err != nil {
return err
}
defer func() {
_ = reader.Close()
}()
outputs := opts.Outputs
var unchanged []string
if opts.Convert.PassthroughExactFormat {
log.Warn("convert.passthrough-exact-format is enabled: syft-json input that already exactly matches a requested output format and schema version will be copied to that output unchanged (this does not apply to any other input format)")
unchanged, outputs, err = partitionOutputsBySourceFormat(opts.Output, reader)
if err != nil {
return err
}
}
// resolve (and validate) every output that needs converting before writing anything, so that
// a bad -o value fails the command without leaving partial results behind
var writer sbom.Writer
if len(outputs) > 0 {
outputOpts := opts.Output
outputOpts.Outputs = outputs
writer, err = outputOpts.SBOMWriter()
if err != nil {
return err
}
// output files are opened up front; release them if we bail out before writing
if c, ok := writer.(io.Closer); ok {
defer func() { _ = c.Close() }()
}
}
for _, output := range unchanged {
if err := writeUnchanged(output, opts.LegacyFile, inputInfo, reader); err != nil {
return err
}
}
if writer == nil {
return nil
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("unable to seek to start of SBOM: %w", err)
}
s, _, _, err := format.Decode(reader)
if err != nil {
return fmt.Errorf("failed to decode SBOM: %w", err)
}
if s == nil {
return fmt.Errorf("no SBOM produced")
}
if err := writer.Write(*s); err != nil {
return fmt.Errorf("failed to write SBOM: %w", err)
}
return nil
}
// openConvertInput opens the SBOM document at the given path, or STDIN when "-". The returned reader is seekable so
// the document can be identified, copied, and decoded in turn without being loaded into memory. The returned
// os.FileInfo describes the file the document is being read from, so that writing back over it can be recognized;
// it is nil when the input is not a file (a pipe or a terminal on STDIN).
func openConvertInput(userInput string, td *tmpdir.TempDir) (io.ReadSeekCloser, os.FileInfo, error) {
if userInput == "-" {
return openStdin(td)
}
f, err := os.Open(userInput)
if err != nil {
return nil, nil, fmt.Errorf("failed to open SBOM file: %w", err)
}
info, err := f.Stat()
if err != nil {
// the document is readable, we just cannot recognize it as an output destination
log.WithFields("error", err, "path", userInput).Debug("unable to stat SBOM file")
}
return f, info, nil
}
// openStdin returns a seekable view of STDIN. When STDIN is a regular file (such as a shell redirect) it is read in
// place. A pipe or terminal cannot seek (you will get errors such as "seek /dev/stdin: illegal seek"), so its
// contents are drained into a spill buffer: a small head stays in memory and everything beyond it goes to a temp
// file under td, so an arbitrarily large piped SBOM never has to be held in memory to be made seekable.
func openStdin(td *tmpdir.TempDir) (io.ReadSeekCloser, os.FileInfo, error) {
if info, err := os.Stdin.Stat(); err == nil && info.Mode().IsRegular() {
if start, err := os.Stdin.Seek(0, io.SeekCurrent); err == nil {
// a redirect reads the file in place, so it is just as much an input file as a path argument is
return readSeekNopCloser{io.NewSectionReader(os.Stdin, start, info.Size()-start)}, info, nil
}
}
buf := spillbuf.New(td)
if _, err := io.Copy(io.NewOffsetWriter(buf, 0), os.Stdin); err != nil {
_ = buf.Close()
return nil, nil, fmt.Errorf("failed to read SBOM from STDIN: %w", err)
}
return &spilledReader{
SectionReader: io.NewSectionReader(buf, 0, buf.Size()),
buf: buf,
}, nil, nil
}
type readSeekNopCloser struct {
io.ReadSeeker
}
func (readSeekNopCloser) Close() error {
return nil
}
// spilledReader is a seekable view over a spill buffer; closing it releases the buffer's memory and spill file.
type spilledReader struct {
*io.SectionReader
buf *spillbuf.Buffer
}
func (r *spilledReader) Close() error {
return r.buf.Close()
}
// partitionOutputsBySourceFormat splits the requested outputs (in "<format>[@<version>][=<path>]" form) into those
// that exactly match the format and schema version of a syft-json input document (which can be written out
// unchanged) and those that still require conversion. Input in any other format is always converted. Outputs that
// cannot be resolved to an encoder are left for the writer to report on.
func partitionOutputsBySourceFormat(output options.Output, reader io.ReadSeeker) (unchanged []string, toConvert []string, err error) {
if _, err := reader.Seek(0, io.SeekStart); err != nil {
return nil, nil, fmt.Errorf("unable to seek to start of SBOM: %w", err)
}
id, version := format.Identify(reader)
if id == "" || version == "" {
// let decoding surface the appropriate error
return nil, output.Outputs, nil
}
if id != syftjson.ID {
log.WithFields("format", id, "version", version).Debug("input is not syft-json, passthrough does not apply")
return nil, output.Outputs, nil
}
encoders, err := output.Encoders()
if err != nil {
return nil, nil, err
}
collection := format.NewEncoderCollection(encoders...)
for _, out := range output.Outputs {
name, _, _ := strings.Cut(strings.TrimSpace(out), "=")
enc := collection.GetByString(name)
if enc != nil && enc.ID() == id && enc.Version() == version {
log.WithFields("format", id, "version", version, "output", out).Warn("input already exactly matches the requested output format and schema version, copying it unchanged instead of converting it")
unchanged = append(unchanged, out)
continue
}
toConvert = append(toConvert, out)
}
return unchanged, toConvert, nil
}
// writeUnchanged copies the SBOM document as-is to the destination described by the given "<format>[=<path>]"
// output value, falling back to the (deprecated) --file path, and finally to the report bus (STDOUT). When the
// destination is the input file itself there is nothing to do.
func writeUnchanged(output string, defaultFile string, inputInfo os.FileInfo, reader io.ReadSeeker) error {
if _, err := reader.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("unable to seek to start of SBOM: %w", err)
}
_, path, _ := strings.Cut(strings.TrimSpace(output), "=")
if path == "" {
path = defaultFile
}
if path == "" {
// the report bus only carries strings, so STDOUT output must be materialized in memory
content, err := io.ReadAll(reader) //nolint:gocritic // the whole SBOM is the report; there is no meaningful bound
if err != nil {
return fmt.Errorf("unable to read SBOM: %w", err)
}
bus.Report(string(content))
return nil
}
expandedPath, err := homedir.Expand(path)
if err != nil {
log.Warnf("could not expand given writer output path=%q: %w", path, err)
expandedPath = path
}
if isSameFile(inputInfo, expandedPath) {
log.WithFields("path", expandedPath).Info("output is the input file, leaving it as-is")
return nil
}
if err := os.MkdirAll(filepath.Dir(expandedPath), 0755); err != nil {
return fmt.Errorf("unable to create report directory: %w", err)
}
f, err := os.OpenFile(expandedPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("unable to create report file: %w", err)
}
if _, err := io.Copy(f, reader); err != nil {
_ = f.Close()
return fmt.Errorf("unable to write report file: %w", err)
}
return f.Close()
}
// isSameFile reports whether the given path names the file the input is being read from. Comparing the input's
// os.FileInfo rather than its path covers a shell redirect onto STDIN, which has no path to compare.
func isSameFile(inputInfo os.FileInfo, path string) bool {
if inputInfo == nil {
return false
}
pathInfo, err := os.Stat(path)
if err != nil {
return false
}
return os.SameFile(inputInfo, pathInfo)
}