From 6c9bb732dd06db54b35f434f4bbfcfada564e2fc Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Mon, 5 Oct 2026 10:41:03 -0400 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 * ci: pull the test fixture cache for windows unit tests Signed-off-by: Alex Goodman * 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 * 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 * incorporate windows path fix Signed-off-by: Alex Goodman * incorporate go-rpmdb close fix Signed-off-by: Alex Goodman * 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 * temp: surface windows binary cataloger diagnostics Signed-off-by: Alex Goodman * 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 * keep addSymlinkToIndex under the funlen limit Signed-off-by: Alex Goodman * drop windows skips covered by the stereoscope and go-rpmdb bumps Signed-off-by: Alex Goodman --------- Signed-off-by: Alex Goodman --- .github/workflows/validations.yaml | 14 ++++++-- .make/main.go | 29 ++++++----------- cmd/syft/internal/commands/convert.go | 4 +++ cmd/syft/internal/commands/scan.go | 5 +++ cmd/syft/internal/options/cache_test.go | 13 ++++++-- cmd/syft/internal/options/writer.go | 32 ++++++++++++++++--- cmd/syft/internal/options/writer_test.go | 4 +-- go.mod | 4 +-- go.sum | 12 +++---- internal/cache/filesystem_test.go | 4 +++ .../generate/discover_catalogers.go | 2 +- .../generate/discover_catalogers_test.go | 3 +- internal/file/squashfs.go | 7 ++-- internal/file/squashfs_test.go | 2 ++ internal/file/tar_file_traversal.go | 3 +- internal/os/feature_detection_test.go | 2 ++ internal/testutils/docker.go | 30 +++++++++++++++++ internal/tmpdir/tmpdir_test.go | 3 +- .../cataloger/filedigest/cataloger_test.go | 3 ++ .../cataloger/filemetadata/cataloger_test.go | 3 ++ .../internal/all_regular_files_test.go | 16 +++++++--- syft/format/github/encoder_test.go | 5 +++ syft/format/internal/testutil/image_input.go | 2 ++ syft/format/syftjson/encoder_test.go | 5 +++ syft/format/text/encoder_test.go | 5 +++ syft/internal/fileresolver/chroot_context.go | 9 ++++-- .../chroot_context_windows_test.go | 17 ++++++++++ .../container_image_all_layers_test.go | 9 ++++++ .../container_image_deep_squash_test.go | 9 ++++++ .../container_image_squash_test.go | 10 ++++++ .../fileresolver/directory_indexer.go | 28 ++++++++-------- .../fileresolver/filetree_resolver.go | 16 ++++++++++ .../cataloger/ai/parse_safetensors_test.go | 10 +++--- syft/pkg/cataloger/arch/parse_alpm_db.go | 6 ++-- .../binary/classifier_cataloger_test.go | 2 ++ .../internal/config/binary_from_image_test.go | 9 +++--- .../internal/manager/internal/list_entries.go | 3 +- .../manager/internal/list_entries_test.go | 10 +++--- .../internal/manager/internal/utils_test.go | 3 +- .../manager/testutil/snippet_or_binary.go | 20 +++++++----- syft/pkg/cataloger/bitnami/cataloger.go | 4 +-- syft/pkg/cataloger/bitnami/package.go | 3 +- .../pkg/cataloger/bitnami/parse_components.go | 4 +-- .../cataloger/golang/license_finder_test.go | 30 +++++++++++++++-- .../cataloger/golang/parse_go_binary_test.go | 3 ++ syft/pkg/cataloger/golang/parse_go_mod.go | 8 ++++- syft/pkg/cataloger/golang/upx_test.go | 2 ++ .../cataloger/internal/dotnet/pe/pe_test.go | 2 ++ .../internal/pkgtest/test_generic_parser.go | 6 +++- .../pkg/cataloger/java/archive_parser_test.go | 8 ++++- .../java/internal/maven/config_test.go | 2 ++ .../java/internal/maven/test/mock_repo.go | 2 +- syft/pkg/cataloger/perl/cataloger_test.go | 2 ++ syft/pkg/cataloger/python/parse_wheel_egg.go | 15 ++++----- .../python/parse_wheel_egg_metadata.go | 22 ++++++------- .../python/parse_wheel_egg_record.go | 5 ++- .../swift/parse_package_resolved_test.go | 3 +- syft/source/snapsource/snap_source_test.go | 18 +++++++---- syft/source/snapsource/snap_test.go | 12 +++++-- .../stereoscopesource/image_source_test.go | 2 ++ 60 files changed, 391 insertions(+), 135 deletions(-) create mode 100644 internal/testutils/docker.go create mode 100644 syft/internal/fileresolver/chroot_context_windows_test.go diff --git a/.github/workflows/validations.yaml b/.github/workflows/validations.yaml index 0186c5ace..942ab1eb2 100644 --- a/.github/workflows/validations.yaml +++ b/.github/workflows/validations.yaml @@ -96,16 +96,24 @@ jobs: with: persist-credentials: false - # go-make's setup only (not ./.github/actions/bootstrap): no apt, binny tools, or fixture cache are needed + # go-make's setup only (not ./.github/actions/bootstrap): binny ships no windows release, so binny tools + # are unavailable here (`make unit` skips the fixture refresh on windows) - name: Setup go + go-make tooling uses: anchore/go-make/.github/actions/setup@fa5421b3bf24c9d18f11a736c5eb4172f2b7d683 # v0.8.1 with: # keep in sync with the go-version default in ./.github/actions/bootstrap go-version: "1.26.2" - # the full unit suite leans on linux docker fixtures, so this runs the windows-relevant subset + # the binny-free version of `make download-test-fixture-cache` (keep the oras version in sync with .binny.yaml) + - name: Download test fixture cache + shell: bash + run: | + curl -sSfL https://github.com/oras-project/oras/releases/download/v1.3.4/oras_1.3.4_windows_amd64.zip -o "$RUNNER_TEMP/oras.zip" + unzip -q "$RUNNER_TEMP/oras.zip" oras.exe -d "$RUNNER_TEMP" + "$RUNNER_TEMP/oras.exe" pull ghcr.io/anchore/oss-cache/syft-test-fixture-cache:v2 + - name: Run unit tests - run: make unit:windows + run: make unit Integration-Test: # Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline diff --git a/.make/main.go b/.make/main.go index 58db41a96..dc18a31d7 100644 --- a/.make/main.go +++ b/.make/main.go @@ -1,7 +1,6 @@ package main import ( - "os" "path/filepath" "runtime" "strconv" @@ -97,23 +96,6 @@ func main() { }, }, - // windows unit tests: deliberately not hooked into "test" (no RunsOn). The full unit suite leans on - // linux docker fixture images and posix-only assumptions, so this covers the packages that own - // host path handling (where windows bugs live) and skips the tests that need docker image fixtures. - // ponytail: curated package list, widen it as more of the suite is made windows-safe. - Task{ - Name: "unit:windows", - Description: "run the windows-relevant subset of unit tests", - Run: func() { - Run("go test -count=1"+ - ` -skip "Image|Squash|AllLayers|MixFileTypes|GivenCoordinates"`+ - " ./syft/internal/windows/... ./syft/internal/fileresolver/... ./syft/source/directorysource/... ./syft/source/filesource/..."+ - " ./syft/file ./syft/file/cataloger/filedigest/... ./syft/file/cataloger/filecontent/...", - run.Stdout(os.Stderr), - ) - }, - }, - // default validation pipeline (replaces Taskfile `default`/`pr-validations`/`validations`). Task{ Name: "default", @@ -157,7 +139,7 @@ func main() { Task{ Name: "unit:syft", RunsOn: lang.List("unit"), - Dependencies: Deps("refresh-fixtures"), + Dependencies: unitFixtureDeps(), }, Task{ Name: "clean:syft", @@ -185,6 +167,15 @@ func raceEnabled() bool { return config.CI && !config.Windows } +// unitFixtureDeps skips the fixture cache refresh on windows: it needs binny-managed tools (binny ships +// no windows release) and linux docker, so fixture-backed tests there have to skip themselves instead. +func unitFixtureDeps() []string { + if config.Windows { + return nil + } + return Deps("refresh-fixtures") +} + // race applies raceEnabled() to a gotest suite. gotest exposes no functional option // for the race detector, but gotest.Config.Race is exported. func race() gotest.Option { diff --git a/cmd/syft/internal/commands/convert.go b/cmd/syft/internal/commands/convert.go index a6189b847..83cba62c5 100644 --- a/cmd/syft/internal/commands/convert.go +++ b/cmd/syft/internal/commands/convert.go @@ -114,6 +114,10 @@ func RunConvert(ctx context.Context, opts *ConvertOptions, userInput string) err 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 { diff --git a/cmd/syft/internal/commands/scan.go b/cmd/syft/internal/commands/scan.go index 7db17fb7d..7f92491ca 100644 --- a/cmd/syft/internal/commands/scan.go +++ b/cmd/syft/internal/commands/scan.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "os" "reflect" "strings" @@ -181,6 +182,10 @@ func runScan(ctx context.Context, id clio.Identification, opts *scanOptions, use 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() }() + } sources := opts.From if len(sources) == 0 { diff --git a/cmd/syft/internal/options/cache_test.go b/cmd/syft/internal/options/cache_test.go index f92f658d0..a42f5f665 100644 --- a/cmd/syft/internal/options/cache_test.go +++ b/cmd/syft/internal/options/cache_test.go @@ -4,6 +4,7 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -22,9 +23,10 @@ func Test_defaultDir(t *testing.T) { homeDir := filepath.Join(tmpDir, "fake-home") tests := []struct { - name string - env map[string]string - expected string + name string + env map[string]string + expected string + skipOnWindows bool }{ { name: "no-xdg", @@ -32,6 +34,8 @@ func Test_defaultDir(t *testing.T) { "HOME": homeDir, }, expected: homeDir, + // xdg falls back to LOCALAPPDATA on windows, not HOME + skipOnWindows: true, }, { name: "xdg-cache", @@ -59,6 +63,9 @@ func Test_defaultDir(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { + if test.skipOnWindows && runtime.GOOS == "windows" { + t.Skip("posix-only cache dir fallback") + } restoreCache(t) for k, v := range test.env { diff --git a/cmd/syft/internal/options/writer.go b/cmd/syft/internal/options/writer.go index d53d3bfcc..f82530732 100644 --- a/cmd/syft/internal/options/writer.go +++ b/cmd/syft/internal/options/writer.go @@ -2,10 +2,11 @@ package options import ( "bytes" + "errors" "fmt" "io" "os" - "path" + "path/filepath" "sort" "strings" @@ -20,7 +21,10 @@ import ( "github.com/anchore/syft/syft/sbom" ) -var _ sbom.Writer = (*sbomMultiWriter)(nil) +var _ interface { + io.Closer + sbom.Writer +} = (*sbomMultiWriter)(nil) var _ interface { io.Closer @@ -164,6 +168,12 @@ func newSBOMMultiWriter(options ...sbomWriterDescription) (_ *sbomMultiWriter, e } out := &sbomMultiWriter{} + defer func() { + // don't leak the files already opened when a later output fails + if err != nil { + _ = out.Close() + } + }() for _, option := range options { switch len(option.Path) { @@ -172,8 +182,8 @@ func newSBOMMultiWriter(options ...sbomWriterDescription) (_ *sbomMultiWriter, e format: option.Format, }) default: - // create any missing subdirectories - dir := path.Dir(option.Path) + // create any missing subdirectories (option.Path is a host path, so this must be filepath) + dir := filepath.Dir(option.Path) if dir != "" { s, err := os.Stat(dir) if err != nil { @@ -210,6 +220,20 @@ func (m *sbomMultiWriter) Write(s sbom.SBOM) (errs error) { return errs } +// Close releases any files held by the writers. Write already closes each file it writes to, so this matters +// when the SBOM is never written (e.g. an error after the writer was created). +func (m *sbomMultiWriter) Close() error { + var errs error + for _, w := range m.writers { + if c, ok := w.(io.Closer); ok { + if err := c.Close(); err != nil && !errors.Is(err, os.ErrClosed) { + errs = multierror.Append(errs, err) + } + } + } + return errs +} + // sbomStreamWriter implements sbom.Writer for a given format and io.Writer, also providing a close function for cleanup type sbomStreamWriter struct { format sbom.FormatEncoder diff --git a/cmd/syft/internal/options/writer_test.go b/cmd/syft/internal/options/writer_test.go index 44d2dc213..b0ce9ad1b 100644 --- a/cmd/syft/internal/options/writer_test.go +++ b/cmd/syft/internal/options/writer_test.go @@ -202,9 +202,9 @@ func Test_newSBOMMultiWriter(t *testing.T) { if test.err { assert.Error(t, err) return - } else { - assert.NoError(t, err) } + require.NoError(t, err) + t.Cleanup(func() { _ = mw.Close() }) assert.Len(t, mw.writers, len(test.expected)) diff --git a/go.mod b/go.mod index 0099ae96f..996aa4ad6 100644 --- a/go.mod +++ b/go.mod @@ -18,11 +18,11 @@ require ( github.com/anchore/go-homedir v0.1.1 github.com/anchore/go-logger v0.2.0 github.com/anchore/go-macholibre v0.1.1 - github.com/anchore/go-rpmdb v0.2.0 + github.com/anchore/go-rpmdb v0.2.2-0.20261002172926-a488674d6c87 github.com/anchore/go-sync v0.1.2 github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b github.com/anchore/packageurl-go v0.2.0 - github.com/anchore/stereoscope v0.3.3 + github.com/anchore/stereoscope v0.3.4-0.20261002170525-59d3a69590a6 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be github.com/aquasecurity/go-pep440-version v0.0.1 github.com/bitnami/go-version v0.0.0-20250131085805-b1f57a8634ef diff --git a/go.sum b/go.sum index 02cee8380..b82ae0667 100644 --- a/go.sum +++ b/go.sum @@ -140,8 +140,8 @@ github.com/anchore/go-lzo v0.1.1 h1:IwL/fvkdtlIrYIXck6WxZ3nb8WjjHziYYmGxlooyOnM= github.com/anchore/go-lzo v0.1.1/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= github.com/anchore/go-macholibre v0.1.1 h1:sTBHi3S1WVIOCamc6NyT8Sn9mBmD30DCTfjfVm4k6/M= github.com/anchore/go-macholibre v0.1.1/go.mod h1:YNq2610RlvGCK0Za+Klz/OCMtUaEnwwAGA/VfUAQaro= -github.com/anchore/go-rpmdb v0.2.0 h1:ixcyMg/guhBL9XjBVwhTv9CG48xO07Qkg2uDX2+zx/c= -github.com/anchore/go-rpmdb v0.2.0/go.mod h1:ATsRlpCXstnoYfzqBfhwGw0U2dolx1BBQ9rAU8jWBAw= +github.com/anchore/go-rpmdb v0.2.2-0.20261002172926-a488674d6c87 h1:aY7DcEkjgzMRbCCJsGHu8eQeL5I88V8G9ISEWtfrlCU= +github.com/anchore/go-rpmdb v0.2.2-0.20261002172926-a488674d6c87/go.mod h1:Y64EbetdqhryMjtk8IzXkdCQw/aNDx+feZCT9aj6rjs= github.com/anchore/go-struct-converter v0.2.0-rc2 h1:q+859fW2/jbHJHB2etbNfRlFwYpknyvbqqk1hUdamQ4= github.com/anchore/go-struct-converter v0.2.0-rc2/go.mod h1:cDBA5vhcR62nXWo8QH9/Kk2807o65ISaHPNPX66L+Uw= github.com/anchore/go-sync v0.1.2 h1:RDD5RCanrWqhbtBulqPSZpERadUlgcYjpybYzYd3Uk8= @@ -150,8 +150,8 @@ github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b h1:e1bmaoJfZV github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b/go.mod h1:Bkc+JYWjMCF8OyZ340IMSIi2Ebf3uwByOk6ho4wne1E= github.com/anchore/packageurl-go v0.2.0 h1:CkrM4RMUwrEGAiE1OVlxaZNzWj0TuHRey7o4T/EAErk= github.com/anchore/packageurl-go v0.2.0/go.mod h1:2JCgOQMIsqZ7TmliXG4PnUthPJAKE3mWQbsW2XHjAOE= -github.com/anchore/stereoscope v0.3.3 h1:sur8G2THAOnOrSXDjIc1SXzQwco8hjFBtGFq1jFvNVA= -github.com/anchore/stereoscope v0.3.3/go.mod h1:LxL4iOFtB8XMbA1MykUUTkmtQmDuYdbr4+TkBkCfljU= +github.com/anchore/stereoscope v0.3.4-0.20261002170525-59d3a69590a6 h1:6l3YwREGEXX/w4tBhRcPb7efYWo4pf8+fmAy0mND4EY= +github.com/anchore/stereoscope v0.3.4-0.20261002170525-59d3a69590a6/go.mod h1:LxL4iOFtB8XMbA1MykUUTkmtQmDuYdbr4+TkBkCfljU= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= @@ -428,8 +428,8 @@ github.com/gkampitakis/ciinfo v0.3.4 h1:5eBSibVuSMbb/H6Elc0IIEFbkzCJi3lm94n0+U7Z github.com/gkampitakis/ciinfo v0.3.4/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-snaps v0.5.23 h1:okh5QR48zpUjpWtu65AtqxdCY8huJq+dEDuUzd1PuKg= github.com/gkampitakis/go-snaps v0.5.23/go.mod h1:uy3lVzCCRRsAwYqSocyw5fY8xRLCYEfqoOJNxr8HonM= -github.com/glebarez/go-sqlite v1.20.3 h1:89BkqGOXR9oRmG58ZrzgoY/Fhy5x0M+/WV48U5zVrZ4= -github.com/glebarez/go-sqlite v1.20.3/go.mod h1:u3N6D/wftiAzIOJtZl6BmedqxmmkDfH3q+ihjqxC9u0= +github.com/glebarez/go-sqlite v1.23.0 h1:FyhIq4jqmgphQAUlY79zPldYGwISEZikaDfhiGWkkaI= +github.com/glebarez/go-sqlite v1.23.0/go.mod h1:IIYrOH3L0rHY3jb4IXOHoWdklNajSGUN2eJcvK8WrnI= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= diff --git a/internal/cache/filesystem_test.go b/internal/cache/filesystem_test.go index 47c693042..046341434 100644 --- a/internal/cache/filesystem_test.go +++ b/internal/cache/filesystem_test.go @@ -6,6 +6,7 @@ import ( "net/url" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -95,6 +96,9 @@ func Test_makeDiskKey(t *testing.T) { } func Test_errors(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("windows ignores unix permission bits on directories") + } tmp := t.TempDir() cache := filepath.Join(tmp, "cache") // make a non-writable directory diff --git a/internal/capabilities/generate/discover_catalogers.go b/internal/capabilities/generate/discover_catalogers.go index 3dc5419c7..789d7dd4e 100644 --- a/internal/capabilities/generate/discover_catalogers.go +++ b/internal/capabilities/generate/discover_catalogers.go @@ -212,7 +212,7 @@ func parseGenericCatalogerFunction(funcDecl *ast.FuncDecl, filePath, repoRoot st // extractPackageNameFromPath extracts the package name from a cataloger source file path // e.g., "syft/pkg/cataloger/swift/cataloger.go" -> "swift" func extractPackageNameFromPath(filePath string) string { - parts := strings.Split(filePath, string(filepath.Separator)) + parts := strings.Split(filepath.ToSlash(filePath), "/") // find the LAST occurrence of "cataloger" in the path // (to handle test fixtures with multiple "cataloger" segments) diff --git a/internal/capabilities/generate/discover_catalogers_test.go b/internal/capabilities/generate/discover_catalogers_test.go index 0672e9f6f..e953be131 100644 --- a/internal/capabilities/generate/discover_catalogers_test.go +++ b/internal/capabilities/generate/discover_catalogers_test.go @@ -4,6 +4,7 @@ import ( "go/ast" "go/parser" "go/token" + "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -337,7 +338,7 @@ func TestResolveImportPath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := resolveImportPath(tt.importPath, testRepoRoot) - require.Equal(t, tt.want, got) + require.Equal(t, filepath.FromSlash(tt.want), got) }) } } diff --git a/internal/file/squashfs.go b/internal/file/squashfs.go index 303dde993..7aef361dc 100644 --- a/internal/file/squashfs.go +++ b/internal/file/squashfs.go @@ -4,7 +4,7 @@ import ( "errors" "io/fs" "os" - "path/filepath" + "path" "strings" "github.com/diskfs/go-diskfs/filesystem" @@ -25,7 +25,8 @@ func WalkDiskDir(fsys filesystem.FileSystem, root string, fn WalkDiskDirFunc) er } for _, info := range infos { - p := filepath.Join(root, info.Name()) + // squashfs paths are posix regardless of host os + p := path.Join(root, info.Name()) err = walkDiskDir(fsys, p, info, fn) if err != nil { if errors.Is(err, fs.SkipDir) { @@ -74,7 +75,7 @@ func walkDiskDir(fsys filesystem.FileSystem, name string, d os.FileInfo, walkDir } for _, d1 := range dirs { - name1 := filepath.Join(name, d1.Name()) + name1 := path.Join(name, d1.Name()) if err := walkDiskDir(fsys, name1, d1, walkDirFn); err != nil { if errors.Is(err, fs.SkipDir) { break diff --git a/internal/file/squashfs_test.go b/internal/file/squashfs_test.go index a2be519dc..265919187 100644 --- a/internal/file/squashfs_test.go +++ b/internal/file/squashfs_test.go @@ -19,6 +19,8 @@ func createTestFS(t *testing.T) filesystem.FileSystem { filename := "test.squashfs" f, err := os.Create(filepath.Join(dir, filename)) require.NoError(t, err) + // windows can't remove the temp dir while the backing file is open + t.Cleanup(func() { _ = f.Close() }) b := file.New(f, false) fsys, err := squashfs.Create(b, 0, 0, 4096) diff --git a/internal/file/tar_file_traversal.go b/internal/file/tar_file_traversal.go index d3c9656a7..54f10448d 100644 --- a/internal/file/tar_file_traversal.go +++ b/internal/file/tar_file_traversal.go @@ -84,7 +84,8 @@ func ExtractGlobsFromTarToUniqueTempFile(ctx context.Context, archivePath, dir s func matchesAnyGlob(name string, globs ...string) bool { for _, glob := range globs { - if matches, err := doublestar.PathMatch(glob, name); err == nil && matches { + // tar entry names are always posix, so don't match with the host separator (PathMatch) + if matches, err := doublestar.Match(glob, name); err == nil && matches { return true } } diff --git a/internal/os/feature_detection_test.go b/internal/os/feature_detection_test.go index b4f2ed38d..15a8f6b44 100644 --- a/internal/os/feature_detection_test.go +++ b/internal/os/feature_detection_test.go @@ -16,6 +16,7 @@ import ( "github.com/anchore/syft/internal/os" "github.com/anchore/syft/internal/sbomsync" "github.com/anchore/syft/internal/task" + "github.com/anchore/syft/internal/testutils" "github.com/anchore/syft/syft" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/linux" @@ -84,6 +85,7 @@ func Test_EnvironmentTask(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) tarPath := imagetest.GetFixtureImageTarPath(t, test.name) // get the source diff --git a/internal/testutils/docker.go b/internal/testutils/docker.go new file mode 100644 index 000000000..8279a7723 --- /dev/null +++ b/internal/testutils/docker.go @@ -0,0 +1,30 @@ +package testutils + +import ( + "os/exec" + "runtime" + "strings" + "sync" + "testing" +) + +var dockerServerOS = sync.OnceValue(func() string { + out, err := exec.Command("docker", "version", "--format", "{{.Server.Os}}").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +}) + +// SkipWithoutLinuxContainers skips tests whose fixtures are linux container images (built or run via docker). +// This only kicks in on windows, where the docker daemon is often running windows containers (or is absent), +// so the linux fixture images can't be built. Everywhere else the fixture tooling is left to fail loudly. +func SkipWithoutLinuxContainers(t testing.TB) { + t.Helper() + if runtime.GOOS != "windows" { + return + } + if os := dockerServerOS(); os != "linux" { + t.Skipf("linux container fixtures unavailable (docker server os=%q)", os) + } +} diff --git a/internal/tmpdir/tmpdir_test.go b/internal/tmpdir/tmpdir_test.go index aeb208403..81142dfe5 100644 --- a/internal/tmpdir/tmpdir_test.go +++ b/internal/tmpdir/tmpdir_test.go @@ -265,8 +265,9 @@ func TestLazyCreation(t *testing.T) { // root dir is not created until needed assert.Equal(t, "", td.root) - _, _, err := td.NewFile("trigger-*") + f, _, err := td.NewFile("trigger-*") require.NoError(t, err) + require.NoError(t, f.Close()) assert.NotEmpty(t, td.root) diff --git a/syft/file/cataloger/filedigest/cataloger_test.go b/syft/file/cataloger/filedigest/cataloger_test.go index 6188bd066..2c63d3bdb 100644 --- a/syft/file/cataloger/filedigest/cataloger_test.go +++ b/syft/file/cataloger/filedigest/cataloger_test.go @@ -15,6 +15,7 @@ import ( stereoscopeFile "github.com/anchore/stereoscope/pkg/file" "github.com/anchore/stereoscope/pkg/imagetest" intFile "github.com/anchore/syft/internal/file" + "github.com/anchore/syft/internal/testutils" "github.com/anchore/syft/internal/unknown" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/source" @@ -92,6 +93,7 @@ func TestDigestsCataloger(t *testing.T) { func TestDigestsCataloger_MixFileTypes(t *testing.T) { testImage := "image-file-type-mix" + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", testImage) src := stereoscopesource.New(img, stereoscopesource.ImageConfig{ @@ -162,6 +164,7 @@ func TestDigestsCataloger_MixFileTypes(t *testing.T) { func TestFileDigestCataloger_GivenCoordinates(t *testing.T) { testImage := "image-file-type-mix" + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", testImage) c := NewCataloger([]crypto.Hash{crypto.SHA256}) diff --git a/syft/file/cataloger/filemetadata/cataloger_test.go b/syft/file/cataloger/filemetadata/cataloger_test.go index fee17c0b1..3547a043a 100644 --- a/syft/file/cataloger/filemetadata/cataloger_test.go +++ b/syft/file/cataloger/filemetadata/cataloger_test.go @@ -10,6 +10,7 @@ import ( stereoscopeFile "github.com/anchore/stereoscope/pkg/file" "github.com/anchore/stereoscope/pkg/imagetest" + "github.com/anchore/syft/internal/testutils" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/source" "github.com/anchore/syft/syft/source/stereoscopesource" @@ -18,6 +19,7 @@ import ( func TestFileMetadataCataloger(t *testing.T) { testImage := "image-file-type-mix" + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", testImage) c := NewCataloger() @@ -157,6 +159,7 @@ func TestFileMetadataCataloger(t *testing.T) { func TestFileMetadataCataloger_GivenCoordinates(t *testing.T) { testImage := "image-file-type-mix" + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", testImage) c := NewCataloger() diff --git a/syft/file/cataloger/internal/all_regular_files_test.go b/syft/file/cataloger/internal/all_regular_files_test.go index cec05bf61..f421ba9c1 100644 --- a/syft/file/cataloger/internal/all_regular_files_test.go +++ b/syft/file/cataloger/internal/all_regular_files_test.go @@ -2,6 +2,7 @@ package internal import ( "context" + "runtime" "testing" "github.com/google/go-cmp/cmp" @@ -10,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/anchore/stereoscope/pkg/imagetest" + "github.com/anchore/syft/internal/testutils" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/source" "github.com/anchore/syft/syft/source/directorysource" @@ -19,15 +21,16 @@ import ( func Test_allRegularFiles(t *testing.T) { tests := []struct { name string - setup func() file.Resolver + setup func(t *testing.T) file.Resolver wantRealPaths *strset.Set wantAccessPaths *strset.Set }{ { name: "image", - setup: func() file.Resolver { + setup: func(t *testing.T) file.Resolver { testImage := "image-file-type-mix" + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", testImage) s := stereoscopesource.New(img, stereoscopesource.ImageConfig{ @@ -44,7 +47,12 @@ func Test_allRegularFiles(t *testing.T) { }, { name: "directory", - setup: func() file.Resolver { + setup: func(t *testing.T) file.Resolver { + if runtime.GOOS == "windows" { + // file symlinks from a git checkout don't index as access paths on windows yet (same gap the + // fileresolver symlink fixtures skip for) + t.Skip("fixture relies on symlinks") + } s, err := directorysource.NewFromPath("testdata/symlinked-root/nested/link-root") require.NoError(t, err) r, err := s.FileResolver(source.SquashedScope) @@ -57,7 +65,7 @@ func Test_allRegularFiles(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - resolver := tt.setup() + resolver := tt.setup(t) locations := AllRegularFiles(context.Background(), resolver) realLocations := strset.New() virtualLocations := strset.New() diff --git a/syft/format/github/encoder_test.go b/syft/format/github/encoder_test.go index 865cce073..ce63d881c 100644 --- a/syft/format/github/encoder_test.go +++ b/syft/format/github/encoder_test.go @@ -2,6 +2,7 @@ package github import ( "flag" + "runtime" "testing" "github.com/anchore/syft/syft/format/internal/testutil" @@ -11,6 +12,10 @@ var updateSnapshot = flag.Bool("update-github", false, "update the *.golden file var updateImage = flag.Bool("update-image", false, "update the golden image used for image encoder testing") func TestGithubDirectoryEncoder(t *testing.T) { + if runtime.GOOS == "windows" { + // the golden snapshot embeds the posix source path (and the source ID derived from it) + t.Skip("directory snapshot is posix path specific") + } dir := t.TempDir() testutil.AssertEncoderAgainstGoldenSnapshot(t, testutil.EncoderSnapshotTestConfig{ diff --git a/syft/format/internal/testutil/image_input.go b/syft/format/internal/testutil/image_input.go index 881c0952a..08306a024 100644 --- a/syft/format/internal/testutil/image_input.go +++ b/syft/format/internal/testutil/image_input.go @@ -11,6 +11,7 @@ import ( "github.com/anchore/stereoscope/pkg/filetree" "github.com/anchore/stereoscope/pkg/image" "github.com/anchore/stereoscope/pkg/imagetest" + "github.com/anchore/syft/internal/testutils" "github.com/anchore/syft/syft/cpe" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/linux" @@ -35,6 +36,7 @@ func ImageInput(t testing.TB, testImage string, options ...ImageOption) sbom.SBO case true: img = imagetest.GetGoldenFixtureImage(t, testImage) default: + testutils.SkipWithoutLinuxContainers(t) img = imagetest.GetFixtureImage(t, "docker-archive", testImage) } diff --git a/syft/format/syftjson/encoder_test.go b/syft/format/syftjson/encoder_test.go index c07581884..2415e5640 100644 --- a/syft/format/syftjson/encoder_test.go +++ b/syft/format/syftjson/encoder_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "flag" + "runtime" "strings" "testing" @@ -90,6 +91,10 @@ func TestEscapeHTML(t *testing.T) { } func TestDirectoryEncoder(t *testing.T) { + if runtime.GOOS == "windows" { + // the golden snapshot embeds the posix source path (and the source ID derived from it) + t.Skip("directory snapshot is posix path specific") + } cfg := DefaultEncoderConfig() cfg.Pretty = true enc, err := NewFormatEncoderWithConfig(cfg) diff --git a/syft/format/text/encoder_test.go b/syft/format/text/encoder_test.go index 2a11f1eb4..6ad39a31d 100644 --- a/syft/format/text/encoder_test.go +++ b/syft/format/text/encoder_test.go @@ -2,6 +2,7 @@ package text import ( "flag" + "runtime" "testing" "github.com/anchore/syft/syft/format/internal/testutil" @@ -11,6 +12,10 @@ var updateSnapshot = flag.Bool("update-text", false, "update the *.golden files var updateImage = flag.Bool("update-image", false, "update the golden image used for image encoder testing") func TestTextDirectoryEncoder(t *testing.T) { + if runtime.GOOS == "windows" { + // the golden snapshot embeds the posix source path (and the source ID derived from it) + t.Skip("directory snapshot is posix path specific") + } dir := t.TempDir() testutil.AssertEncoderAgainstGoldenSnapshot(t, testutil.EncoderSnapshotTestConfig{ diff --git a/syft/internal/fileresolver/chroot_context.go b/syft/internal/fileresolver/chroot_context.go index deb8cd000..8f7eef399 100644 --- a/syft/internal/fileresolver/chroot_context.go +++ b/syft/internal/fileresolver/chroot_context.go @@ -210,8 +210,13 @@ func (r ChrootContext) ToChrootPath(nativePath string) string { prefix = r.base } else { // we need to account for the cwd relative to the running process and the given root for the directory resolver - prefix = filepath.Clean(filepath.Join(r.cwd, r.cwdRelativeToRoot)) - prefix += string(filepath.Separator) + prefix = r.cwdRelativeToRoot + // on windows an absolute root stays absolute here (see ChangeDirectory), and joining it onto the cwd + // would nest one volume inside another (D:\cwd\C:\root) + if !filepath.IsAbs(prefix) { + prefix = filepath.Join(r.cwd, prefix) + } + prefix = filepath.Clean(prefix) + string(filepath.Separator) } responsePath = strings.TrimPrefix(responsePath, prefix) } diff --git a/syft/internal/fileresolver/chroot_context_windows_test.go b/syft/internal/fileresolver/chroot_context_windows_test.go new file mode 100644 index 000000000..1884c4219 --- /dev/null +++ b/syft/internal/fileresolver/chroot_context_windows_test.go @@ -0,0 +1,17 @@ +package fileresolver + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// windows CI runs from D: while t.TempDir() lives on C:, so the root and cwd don't share a volume +func TestChrootContext_ToChrootPath_rootOnAnotherVolume(t *testing.T) { + ctx := ChrootContext{ + root: `C:\scan\root`, + cwd: `D:\work`, + cwdRelativeToRoot: `C:\scan\root`, + } + assert.Equal(t, "dir/file.txt", ctx.ToChrootPath("/c/scan/root/dir/file.txt")) +} diff --git a/syft/internal/fileresolver/container_image_all_layers_test.go b/syft/internal/fileresolver/container_image_all_layers_test.go index 7d36baa8b..e8cc2750c 100644 --- a/syft/internal/fileresolver/container_image_all_layers_test.go +++ b/syft/internal/fileresolver/container_image_all_layers_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/anchore/stereoscope/pkg/imagetest" + "github.com/anchore/syft/internal/testutils" "github.com/anchore/syft/syft/file" ) @@ -91,6 +92,7 @@ func TestAllLayersResolver_FilesByPath(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") resolver, err := NewFromContainerImageAllLayers(img) @@ -201,6 +203,7 @@ func TestAllLayersResolver_FilesByGlob(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") resolver, err := NewFromContainerImageAllLayers(img) @@ -249,6 +252,7 @@ func Test_imageAllLayersResolver_FilesByMIMEType(t *testing.T) { } for _, test := range tests { t.Run(test.fixtureName, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", test.fixtureName) resolver, err := NewFromContainerImageAllLayers(img) @@ -266,6 +270,7 @@ func Test_imageAllLayersResolver_FilesByMIMEType(t *testing.T) { } func Test_imageAllLayersResolver_hasFilesystemIDInLocation(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-duplicate-path") resolver, err := NewFromContainerImageAllLayers(img) @@ -326,6 +331,7 @@ func TestAllLayersImageResolver_FilesContents(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") resolver, err := NewFromContainerImageAllLayers(img) @@ -353,6 +359,7 @@ func TestAllLayersImageResolver_FilesContents(t *testing.T) { func TestAllLayersImageResolver_FilesContents_errorOnDirRequest(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") resolver, err := NewFromContainerImageAllLayers(img) @@ -500,6 +507,7 @@ func Test_imageAllLayersResolver_resolvesLinks(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") resolver, err := NewFromContainerImageAllLayers(img) @@ -514,6 +522,7 @@ func Test_imageAllLayersResolver_resolvesLinks(t *testing.T) { } func TestAllLayersResolver_AllLocations(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-files-deleted") resolver, err := NewFromContainerImageAllLayers(img) diff --git a/syft/internal/fileresolver/container_image_deep_squash_test.go b/syft/internal/fileresolver/container_image_deep_squash_test.go index c1a6945a3..f88afdf13 100644 --- a/syft/internal/fileresolver/container_image_deep_squash_test.go +++ b/syft/internal/fileresolver/container_image_deep_squash_test.go @@ -16,6 +16,7 @@ import ( "go.uber.org/goleak" "github.com/anchore/stereoscope/pkg/imagetest" + "github.com/anchore/syft/internal/testutils" "github.com/anchore/syft/syft/file" ) @@ -115,6 +116,7 @@ func Test_ContainerImageDeepSquash_FilesByPath(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") resolver, err := NewFromContainerImageDeepSquash(img) @@ -231,6 +233,7 @@ func Test_ContainerImageDeepSquash_FilesByGlob(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") resolver, err := NewFromContainerImageDeepSquash(img) @@ -288,6 +291,7 @@ func Test_ContainerImageDeepSquash_FilesByMIMEType(t *testing.T) { for _, test := range tests { t.Run(test.fixtureName, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", test.fixtureName) resolver, err := NewFromContainerImageDeepSquash(img) @@ -305,6 +309,7 @@ func Test_ContainerImageDeepSquash_FilesByMIMEType(t *testing.T) { } func Test_ContainerImageDeepSquash_hasFilesystemIDInLocation(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-duplicate-path") resolver, err := NewFromContainerImageDeepSquash(img) @@ -365,6 +370,7 @@ func Test_ContainerImageDeepSquash_FilesContents(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") resolver, err := NewFromContainerImageDeepSquash(img) @@ -389,6 +395,7 @@ func Test_ContainerImageDeepSquash_FilesContents(t *testing.T) { } func Test_ContainerImageDeepSquash_FilesContents_errorOnDirRequest(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") resolver, err := NewFromContainerImageDeepSquash(img) @@ -557,6 +564,7 @@ func Test_ContainerImageDeepSquash_resolvesLinks(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") resolver, err := NewFromContainerImageDeepSquash(img) @@ -571,6 +579,7 @@ func Test_ContainerImageDeepSquash_resolvesLinks(t *testing.T) { } func Test_ContainerImageDeepSquash_AllLocations(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-files-deleted") resolver, err := NewFromContainerImageDeepSquash(img) diff --git a/syft/internal/fileresolver/container_image_squash_test.go b/syft/internal/fileresolver/container_image_squash_test.go index b1ceaca0d..f9c8c45e8 100644 --- a/syft/internal/fileresolver/container_image_squash_test.go +++ b/syft/internal/fileresolver/container_image_squash_test.go @@ -16,6 +16,7 @@ import ( stereoscopeFile "github.com/anchore/stereoscope/pkg/file" "github.com/anchore/stereoscope/pkg/imagetest" + "github.com/anchore/syft/internal/testutils" "github.com/anchore/syft/syft/file" ) @@ -75,6 +76,7 @@ func TestImageSquashResolver_FilesByPath(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") resolver, err := NewFromContainerImageSquash(img) @@ -184,6 +186,7 @@ func TestImageSquashResolver_FilesByGlob(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") resolver, err := NewFromContainerImageSquash(img) @@ -241,6 +244,7 @@ func Test_imageSquashResolver_FilesByMIMEType(t *testing.T) { for _, test := range tests { t.Run(test.fixtureName, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", test.fixtureName) resolver, err := NewFromContainerImageSquash(img) @@ -258,6 +262,7 @@ func Test_imageSquashResolver_FilesByMIMEType(t *testing.T) { } func Test_imageSquashResolver_hasFilesystemIDInLocation(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-duplicate-path") resolver, err := NewFromContainerImageSquash(img) @@ -316,6 +321,7 @@ func TestSquashImageResolver_FilesContents(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") resolver, err := NewFromContainerImageSquash(img) @@ -341,6 +347,7 @@ func TestSquashImageResolver_FilesContents(t *testing.T) { func TestSquashImageResolver_FilesContents_errorOnDirRequest(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") resolver, err := NewFromContainerImageSquash(img) @@ -499,6 +506,7 @@ func Test_imageSquashResolver_resolvesLinks(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-symlinks") resolver, err := NewFromContainerImageSquash(img) @@ -545,6 +553,7 @@ func locationSorter(a, b file.Location) int { } func TestSquashResolver_AllLocations(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-files-deleted") resolver, err := NewFromContainerImageSquash(img) @@ -580,6 +589,7 @@ func TestSquashResolver_AllLocations(t *testing.T) { // content and metadata), so that image results are in parity with directory results (which cannot distinguish a // hardlink from a regular file). this must hold for both the squashed and all-layers resolvers. func TestImageResolvers_Hardlinks(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-hardlinks") resolvers := map[string]file.Resolver{} diff --git a/syft/internal/fileresolver/directory_indexer.go b/syft/internal/fileresolver/directory_indexer.go index e748a5015..3f6216d73 100644 --- a/syft/internal/fileresolver/directory_indexer.go +++ b/syft/internal/fileresolver/directory_indexer.go @@ -351,17 +351,12 @@ func (r directoryIndexer) addFileToIndex(p string, info os.FileInfo) error { func (r directoryIndexer) addSymlinkToIndex(p string, info os.FileInfo) (string, error) { linkTarget, err := os.Readlink(p) if err != nil { - isOnWindows := windows.HostRunningOnWindows() - if isOnWindows { - p = windows.FromPosix(p) - } - - linkTarget, err = filepath.EvalSymlinks(p) - - if isOnWindows { - p = windows.ToPosix(p) + nativePath := p + if windows.HostRunningOnWindows() { + nativePath = windows.FromPosix(p) } + linkTarget, err = filepath.EvalSymlinks(nativePath) if err != nil { return "", fmt.Errorf("unable to readlink for path=%q: %w", p, err) } @@ -414,16 +409,21 @@ func (r directoryIndexer) addSymlinkToIndex(p string, info os.FileInfo) (string, } } - ref, err := r.tree.AddSymLink(file.Path(p), file.Path(linkTarget)) - if err != nil { - return "", err - } - targetAbsPath := linkTarget if !filepath.IsAbs(targetAbsPath) { targetAbsPath = filepath.Clean(filepath.Join(path.Dir(p), linkTarget)) } + // the tree is posix (like p), but the joins above produce native separators on windows + if windows.HostRunningOnWindows() { + linkTarget = windows.ToPosix(linkTarget) + } + + ref, err := r.tree.AddSymLink(file.Path(p), file.Path(linkTarget)) + if err != nil { + return "", err + } + metadata := NewMetadataFromPath(p, info) metadata.LinkDestination = linkTarget r.index.Add(*ref, metadata) diff --git a/syft/internal/fileresolver/filetree_resolver.go b/syft/internal/fileresolver/filetree_resolver.go index 20c60b57a..d8a5f71d3 100644 --- a/syft/internal/fileresolver/filetree_resolver.go +++ b/syft/internal/fileresolver/filetree_resolver.go @@ -35,9 +35,19 @@ func nativeOSFileOpener(ref stereoscopeFile.Reference) (io.ReadCloser, error) { return stereoscopeFile.NewLazyReadCloser(filePath), nil } +// virtual reports whether the tree is not backed by the host filesystem (e.g. a squashfs), in which case there is +// no chroot and request/response paths are already the posix tree paths. Mapping them through the host (as the +// chroot does) would root them on the current volume on windows (/payload.txt -> /d/payload.txt). +func (r FiletreeResolver) virtual() bool { + return r.Chroot == ChrootContext{} +} + // requestPath converts a user (chroot) path into the path the file tree is keyed on. The tree is always posix // (windows paths are volume-encoded, e.g. /c/some/path), while ToNativePath yields a native host path. func (r *FiletreeResolver) requestPath(userPath string) (string, error) { + if r.virtual() { + return userPath, nil + } nativePath, err := r.Chroot.ToNativePath(userPath) if err != nil { return "", err @@ -50,6 +60,9 @@ func (r *FiletreeResolver) requestPath(userPath string) (string, error) { // responsePath takes a path from the underlying fs domain and converts it to a path that is relative to the root of the file resolver. func (r FiletreeResolver) responsePath(path string) string { + if r.virtual() { + return path + } return r.Chroot.ToChrootPath(path) } @@ -111,6 +124,9 @@ func (r FiletreeResolver) FilesByPath(userPaths ...string) ([]file.Location, err // requestGlob is the glob equivalent of requestPath. func (r FiletreeResolver) requestGlob(pattern string) (string, error) { + if r.virtual() { + return pattern, nil + } glob, err := r.Chroot.ToNativeGlob(pattern) if err != nil { return "", err diff --git a/syft/pkg/cataloger/ai/parse_safetensors_test.go b/syft/pkg/cataloger/ai/parse_safetensors_test.go index c5d4457cb..0fbdd5508 100644 --- a/syft/pkg/cataloger/ai/parse_safetensors_test.go +++ b/syft/pkg/cataloger/ai/parse_safetensors_test.go @@ -8,6 +8,7 @@ import ( "io" "math" "os" + "path" "path/filepath" "slices" "strings" @@ -522,14 +523,15 @@ func TestSafeTensorsMergeProcessor(t *testing.T) { t.Run("dir scan: config.json _name_or_path beats the parent directory fallback", func(t *testing.T) { // When a sibling config.json carries _name_or_path - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), + // (location paths are posix, so keep the temp dir slash-separated for windows) + dir := filepath.ToSlash(t.TempDir()) + require.NoError(t, os.WriteFile(path.Join(dir, "config.json"), []byte(`{"_name_or_path":"org/preferred-name"}`), 0o644)) - stPath := filepath.Join(dir, "weights.safetensors") + stPath := path.Join(dir, "weights.safetensors") p := dirPkg(stPath, pkg.SafeTensorsModelInfo{ Format: "safetensors", TensorCount: 1, MetadataHash: "abc", }) - resolver := file.NewMockResolverForPaths(filepath.Join(dir, "config.json")) + resolver := file.NewMockResolverForPaths(path.Join(dir, "config.json")) out, _, err := safeTensorsMergeProcessor(context.Background(), resolver, []pkg.Package{p}, nil, nil) require.NoError(t, err) require.Len(t, out, 1) diff --git a/syft/pkg/cataloger/arch/parse_alpm_db.go b/syft/pkg/cataloger/arch/parse_alpm_db.go index db7505be6..ac63a4141 100644 --- a/syft/pkg/cataloger/arch/parse_alpm_db.go +++ b/syft/pkg/cataloger/arch/parse_alpm_db.go @@ -114,7 +114,7 @@ func fetchPkgFiles(base string, resolver file.Resolver) ([]pkg.AlpmFileRecord, f func fetchBackupFiles(base string, resolver file.Resolver) ([]pkg.AlpmFileRecord, file.Location, error) { // We only really do this to get any backup database entries from the files database - target := filepath.Join(base, "files") + target := path.Join(base, "files") loc, err := getLocation(target, resolver) if err != nil { @@ -395,7 +395,9 @@ func parseMtreeWithLimits(r io.Reader, maxSize int64, maxLines int) ([]pkg.AlpmF if ok := ignoredFiles[f.Name]; ok { continue } - path := fmt.Sprintf("/%s", f.Name) + // go-mtree runs filepath.Clean on entry names, which turns them into host paths on windows. + // ponytail: a vis-encoded backslash (\134) in a name gets mangled on windows only, unvis first if that matters + path := fmt.Sprintf("/%s", filepath.ToSlash(f.Name)) fileFields["path"] = path for _, kv := range f.Keywords { kw := string(kv.Keyword()) diff --git a/syft/pkg/cataloger/binary/classifier_cataloger_test.go b/syft/pkg/cataloger/binary/classifier_cataloger_test.go index 52768213c..a9d0a676e 100644 --- a/syft/pkg/cataloger/binary/classifier_cataloger_test.go +++ b/syft/pkg/cataloger/binary/classifier_cataloger_test.go @@ -17,6 +17,7 @@ import ( "github.com/anchore/packageurl-go" "github.com/anchore/stereoscope/pkg/imagetest" + "github.com/anchore/syft/internal/testutils" "github.com/anchore/syft/syft/cpe" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/pkg" @@ -3169,6 +3170,7 @@ func Test_Cataloger_DefaultClassifiers_PositiveCases_Image(t *testing.T) { t.Run(test.name, func(t *testing.T) { c := NewClassifierCataloger(DefaultClassifierCatalogerConfig()) + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", test.fixtureImage) src := stereoscopesource.New(img, stereoscopesource.ImageConfig{ Reference: test.fixtureImage, diff --git a/syft/pkg/cataloger/binary/internal/manager/internal/config/binary_from_image_test.go b/syft/pkg/cataloger/binary/internal/manager/internal/config/binary_from_image_test.go index 55bb3ee40..8d7153e16 100644 --- a/syft/pkg/cataloger/binary/internal/manager/internal/config/binary_from_image_test.go +++ b/syft/pkg/cataloger/binary/internal/manager/internal/config/binary_from_image_test.go @@ -1,6 +1,7 @@ package config import ( + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -123,10 +124,10 @@ func TestAllStorePaths(t *testing.T) { }, dest: "dest", expected: []string{ - "dest/test/1.0/platform1/test1", - "dest/test/1.0/platform1/test2", - "dest/test/1.0/platform2/test1", - "dest/test/1.0/platform2/test2", + filepath.FromSlash("dest/test/1.0/platform1/test1"), + filepath.FromSlash("dest/test/1.0/platform1/test2"), + filepath.FromSlash("dest/test/1.0/platform2/test1"), + filepath.FromSlash("dest/test/1.0/platform2/test2"), }, }, } diff --git a/syft/pkg/cataloger/binary/internal/manager/internal/list_entries.go b/syft/pkg/cataloger/binary/internal/manager/internal/list_entries.go index de35b3731..ce54e936e 100644 --- a/syft/pkg/cataloger/binary/internal/manager/internal/list_entries.go +++ b/syft/pkg/cataloger/binary/internal/manager/internal/list_entries.go @@ -4,6 +4,7 @@ import ( "fmt" "iter" "os" + "path" "path/filepath" "slices" "sort" @@ -167,7 +168,7 @@ func getLogicalKey(managedBinaryPath string) (*LogicalEntryKey, error) { OrgName: items[0], Version: items[1], Platform: items[2], - Filename: filepath.Join(items[3:]...), + Filename: path.Join(items[3:]...), // posix, since this is part of the logical key }, nil } diff --git a/syft/pkg/cataloger/binary/internal/manager/internal/list_entries_test.go b/syft/pkg/cataloger/binary/internal/manager/internal/list_entries_test.go index 82d430346..3294c19c2 100644 --- a/syft/pkg/cataloger/binary/internal/manager/internal/list_entries_test.go +++ b/syft/pkg/cataloger/binary/internal/manager/internal/list_entries_test.go @@ -64,8 +64,8 @@ func TestListAllBinaries(t *testing.T) { assert.Equal(t, Entries{ - LogicalEntryKey{OrgName: "busybox", Version: "1.3.6", Platform: "linux-amd64", Filename: "busybox"}: EntryInfo{IsConfigured: true, BinaryPath: "testdata/bin/busybox/1.3.6/linux-amd64/busybox", SnippetPath: ""}, - LogicalEntryKey{OrgName: "busybox", Version: "1.3.6", Platform: "linux-arm64", Filename: "busybox"}: EntryInfo{IsConfigured: false, BinaryPath: "testdata/bin/busybox/1.3.6/linux-arm64/busybox", SnippetPath: ""}, + LogicalEntryKey{OrgName: "busybox", Version: "1.3.6", Platform: "linux-amd64", Filename: "busybox"}: EntryInfo{IsConfigured: true, BinaryPath: filepath.FromSlash("testdata/bin/busybox/1.3.6/linux-amd64/busybox"), SnippetPath: ""}, + LogicalEntryKey{OrgName: "busybox", Version: "1.3.6", Platform: "linux-arm64", Filename: "busybox"}: EntryInfo{IsConfigured: false, BinaryPath: filepath.FromSlash("testdata/bin/busybox/1.3.6/linux-arm64/busybox"), SnippetPath: ""}, }, entries, ) @@ -105,10 +105,10 @@ func TestListAllEntries(t *testing.T) { assert.Equal(t, Entries{ - LogicalEntryKey{OrgName: "busybox", Version: "1.3.6", Platform: "linux-amd64", Filename: "busybox"}: EntryInfo{IsConfigured: true, BinaryPath: "testdata/bin/busybox/1.3.6/linux-amd64/busybox", SnippetPath: "testdata/snippets/busybox/1.3.6/linux-amd64/busybox"}, - LogicalEntryKey{OrgName: "busybox", Version: "1.3.6", Platform: "linux-arm64", Filename: "busybox"}: EntryInfo{IsConfigured: false, BinaryPath: "testdata/bin/busybox/1.3.6/linux-arm64/busybox", SnippetPath: "testdata/snippets/busybox/1.3.6/linux-arm64/busybox"}, + LogicalEntryKey{OrgName: "busybox", Version: "1.3.6", Platform: "linux-amd64", Filename: "busybox"}: EntryInfo{IsConfigured: true, BinaryPath: filepath.FromSlash("testdata/bin/busybox/1.3.6/linux-amd64/busybox"), SnippetPath: filepath.FromSlash("testdata/snippets/busybox/1.3.6/linux-amd64/busybox")}, + LogicalEntryKey{OrgName: "busybox", Version: "1.3.6", Platform: "linux-arm64", Filename: "busybox"}: EntryInfo{IsConfigured: false, BinaryPath: filepath.FromSlash("testdata/bin/busybox/1.3.6/linux-arm64/busybox"), SnippetPath: filepath.FromSlash("testdata/snippets/busybox/1.3.6/linux-arm64/busybox")}, // note the standalone snippet! - LogicalEntryKey{OrgName: "postgres", Version: "9.6.10", Platform: "linux-amd64", Filename: "postgres"}: EntryInfo{IsConfigured: false, BinaryPath: "", SnippetPath: "testdata/snippets/postgres/9.6.10/linux-amd64/postgres"}, + LogicalEntryKey{OrgName: "postgres", Version: "9.6.10", Platform: "linux-amd64", Filename: "postgres"}: EntryInfo{IsConfigured: false, BinaryPath: "", SnippetPath: filepath.FromSlash("testdata/snippets/postgres/9.6.10/linux-amd64/postgres")}, }, entries, ) diff --git a/syft/pkg/cataloger/binary/internal/manager/internal/utils_test.go b/syft/pkg/cataloger/binary/internal/manager/internal/utils_test.go index ab8d7a111..51f5f8e54 100644 --- a/syft/pkg/cataloger/binary/internal/manager/internal/utils_test.go +++ b/syft/pkg/cataloger/binary/internal/manager/internal/utils_test.go @@ -1,6 +1,7 @@ package internal import ( + "path/filepath" "strings" "testing" @@ -13,7 +14,7 @@ func TestSplitFilepath(t *testing.T) { path string expected []string }{ - {path: "a/b/c", expected: []string{"a", "b", "c"}}, + {path: filepath.Join("a", "b", "c"), expected: []string{"a", "b", "c"}}, } for _, test := range tests { diff --git a/syft/pkg/cataloger/binary/internal/manager/testutil/snippet_or_binary.go b/syft/pkg/cataloger/binary/internal/manager/testutil/snippet_or_binary.go index 9a4ef20fc..9d81f2847 100644 --- a/syft/pkg/cataloger/binary/internal/manager/testutil/snippet_or_binary.go +++ b/syft/pkg/cataloger/binary/internal/manager/testutil/snippet_or_binary.go @@ -3,7 +3,9 @@ package testutil import ( "fmt" "os" + "path" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" @@ -18,10 +20,11 @@ import ( // not specify the "bin" or "snippets" parent path... this is determined logically [snippets > binary unless told // otherwise]). Path should also be to the directory containing the binary or snippets of interest (not the binaries // or snippets itself). -func SnippetOrBinary(t *testing.T, path string, requireBinary bool) string { +func SnippetOrBinary(t *testing.T, logicalPath string, requireBinary bool) string { t.Helper() - require.Len(t, internal.SplitFilepath(path), 3, "path must be a in the form //") + // logicalPath is a posix key, not a host path + require.Len(t, strings.Split(logicalPath, "/"), 3, "path must be a in the form //") // cd to testdata directory and load the config @@ -43,11 +46,11 @@ func SnippetOrBinary(t *testing.T, path string, requireBinary bool) string { var fixturePath string for k, v := range entries.Sorted() { - if filepath.Dir(k.Path()) == path { + if path.Dir(k.Path()) == logicalPath { // prefer the snippet over the binary if !requireBinary { if v.SnippetPath != "" { - t.Logf("using snippet for %q", path) + t.Logf("using snippet for %q", logicalPath) require.NoError(t, validateSnippet(v.BinaryPath, v.SnippetPath)) fixturePath = v.SnippetPath break @@ -56,10 +59,10 @@ func SnippetOrBinary(t *testing.T, path string, requireBinary bool) string { fixturePath = v.BinaryPath break } - t.Fatalf("no binary or snippet found for %q", path) + t.Fatalf("no binary or snippet found for %q", logicalPath) } if v.BinaryPath != "" { - t.Logf("forcing the use of the original binary for %q", path) + t.Logf("forcing the use of the original binary for %q", logicalPath) fixturePath = v.BinaryPath break } @@ -68,12 +71,12 @@ func SnippetOrBinary(t *testing.T, path string, requireBinary bool) string { t.Skip("no binary found, but is covered by a snippet. Please add this case to the 'binary/testdata/config.yaml' and recreate the snippet") } - t.Fatalf("no binary found for %q", path) + t.Fatalf("no binary found for %q", logicalPath) } } if fixturePath == "" { - t.Fatalf("no fixture found for %q", path) + t.Fatalf("no fixture found for %q", logicalPath) } // this should be relative to the tests-fixtures directory and should be the directory containing the binary or @@ -101,6 +104,7 @@ func validateSnippet(binaryPath, snippetPath string) error { if err != nil { return err } + defer f.Close() expected, err := internal.Sha256SumFile(f) if err != nil { diff --git a/syft/pkg/cataloger/bitnami/cataloger.go b/syft/pkg/cataloger/bitnami/cataloger.go index 71f6518e2..2d845d6d1 100644 --- a/syft/pkg/cataloger/bitnami/cataloger.go +++ b/syft/pkg/cataloger/bitnami/cataloger.go @@ -5,7 +5,7 @@ package bitnami import ( "context" - "path/filepath" + "path" "strings" "github.com/anchore/syft/internal/log" @@ -73,7 +73,7 @@ func parseSBOM(_ context.Context, resolver file.Resolver, _ *generic.Environment // Bitnami packages reported in a SPDX file are shipped under the same directory // as the SPDX file itself. - metadata.Path = filepath.Dir(reader.RealPath) + metadata.Path = path.Dir(reader.RealPath) if p.ID() != mainPkgID { metadata.Files = packageFiles(s.Relationships, p, metadata.Path) secondaryPkgsFiles = append(secondaryPkgsFiles, metadata.Files...) diff --git a/syft/pkg/cataloger/bitnami/package.go b/syft/pkg/cataloger/bitnami/package.go index db29aec48..cb46d5453 100644 --- a/syft/pkg/cataloger/bitnami/package.go +++ b/syft/pkg/cataloger/bitnami/package.go @@ -3,7 +3,6 @@ package bitnami import ( "fmt" "path" - "path/filepath" "slices" "strings" @@ -73,7 +72,7 @@ func packageFiles(relationships []artifact.Relationship, p pkg.Package, baseDire // mainPkgFiles returns the files owned by the main package in the SPDX file. func mainPkgFiles(resolver file.Resolver, spdxFilePath string, secondaryPkgsFiles []string) ([]string, error) { - ownedPathGlob := fmt.Sprintf("%s/**", filepath.Dir(spdxFilePath)) + ownedPathGlob := fmt.Sprintf("%s/**", path.Dir(spdxFilePath)) ownedLocations, err := resolver.FilesByGlob(ownedPathGlob) if err != nil { return nil, err diff --git a/syft/pkg/cataloger/bitnami/parse_components.go b/syft/pkg/cataloger/bitnami/parse_components.go index 155a89831..d2eb6d592 100644 --- a/syft/pkg/cataloger/bitnami/parse_components.go +++ b/syft/pkg/cataloger/bitnami/parse_components.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "fmt" - "path/filepath" + "path" "sort" "strings" @@ -67,7 +67,7 @@ func parseComponentsJSON(_ context.Context, _ file.Resolver, _ *generic.Environm Revision: revision, Architecture: entry.Arch, Distro: entry.Distro, - Path: filepath.Join(filepath.Dir(reader.RealPath), name), + Path: path.Join(path.Dir(reader.RealPath), name), } p := pkg.Package{ diff --git a/syft/pkg/cataloger/golang/license_finder_test.go b/syft/pkg/cataloger/golang/license_finder_test.go index 8be2f8d1d..81976bbe9 100644 --- a/syft/pkg/cataloger/golang/license_finder_test.go +++ b/syft/pkg/cataloger/golang/license_finder_test.go @@ -1,6 +1,9 @@ package golang import ( + "path/filepath" + "runtime" + "strings" "testing" "github.com/spf13/afero" @@ -202,10 +205,23 @@ func TestFindAllLicenseCandidatesUpwards(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Create in-memory filesystem fs := afero.NewMemMapFs() - tt.setupFS(fs) + setupFS, startDir, stopAt, expectedFiles := afero.Fs(fs), tt.startDir, tt.stopAt, tt.expectedFiles + if runtime.GOOS == "windows" { + // the finder wants native absolute paths, so root the posix-style cases on a drive + setupFS = afero.NewBasePathFs(fs, `C:\`) + startDir, stopAt = winAbs(startDir), winAbs(stopAt) + expectedFiles = nil + if tt.expectedFiles != nil { + expectedFiles = []string{} + } + for _, f := range tt.expectedFiles { + expectedFiles = append(expectedFiles, winAbs(f)) + } + } + tt.setupFS(setupFS) // Run the function - result, err := findAllLicenseCandidatesUpwards(tt.startDir, tt.stopAt, fs) + result, err := findAllLicenseCandidatesUpwards(startDir, stopAt, fs) // Check error expectation if tt.expectedError { @@ -214,7 +230,15 @@ func TestFindAllLicenseCandidatesUpwards(t *testing.T) { } require.NoError(t, err, tt.description) - assert.Equal(t, tt.expectedFiles, result, tt.description) + assert.Equal(t, expectedFiles, result, tt.description) }) } } + +// winAbs roots posix absolute paths on C:\, leaving relative ones relative (some cases assert they're rejected) +func winAbs(p string) string { + if !strings.HasPrefix(p, "/") { + return p + } + return filepath.Join(`C:\`, filepath.FromSlash(p)) +} diff --git a/syft/pkg/cataloger/golang/parse_go_binary_test.go b/syft/pkg/cataloger/golang/parse_go_binary_test.go index 3f704c5d3..7e33b0155 100644 --- a/syft/pkg/cataloger/golang/parse_go_binary_test.go +++ b/syft/pkg/cataloger/golang/parse_go_binary_test.go @@ -20,6 +20,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/anchore/syft/internal/testutils" "github.com/anchore/syft/syft/cataloging" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/internal/fileresolver" @@ -30,6 +31,8 @@ import ( // make will run the default make target for the given test fixture path func runMakeTarget(t *testing.T, fixtureName string) { + // the fixture binaries are built inside linux containers + testutils.SkipWithoutLinuxContainers(t) cwd, err := os.Getwd() require.NoError(t, err) fixtureDir := filepath.Join(cwd, "testdata/", fixtureName) diff --git a/syft/pkg/cataloger/golang/parse_go_mod.go b/syft/pkg/cataloger/golang/parse_go_mod.go index e9d021729..c4a727987 100644 --- a/syft/pkg/cataloger/golang/parse_go_mod.go +++ b/syft/pkg/cataloger/golang/parse_go_mod.go @@ -20,6 +20,7 @@ import ( "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/internal/fileresolver" + "github.com/anchore/syft/syft/internal/windows" "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg/cataloger/generic" ) @@ -38,7 +39,12 @@ func newGoModCataloger(opts CatalogerConfig) *goModCataloger { // parseGoModFile takes a go.mod and tries to resolve and lists all packages discovered. func (c *goModCataloger) parseGoModFile(ctx context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) (pkgs []pkg.Package, relationships []artifact.Relationship, err error) { - modDir := filepath.Dir(string(reader.Location.Reference().RealPath)) + realPath := string(reader.Location.Reference().RealPath) + if windows.HostRunningOnWindows() { + // directory resolvers hand back volume-encoded posix paths (/c/...), the go toolchain needs the native path + realPath = windows.FromPosix(realPath) + } + modDir := filepath.Dir(realPath) digests, err := parseGoSumFile(resolver, reader) if err != nil { log.Debugf("unable to get go.sum: %v", err) diff --git a/syft/pkg/cataloger/golang/upx_test.go b/syft/pkg/cataloger/golang/upx_test.go index 05df518e2..928211874 100644 --- a/syft/pkg/cataloger/golang/upx_test.go +++ b/syft/pkg/cataloger/golang/upx_test.go @@ -10,6 +10,7 @@ import ( stereoscopeFile "github.com/anchore/stereoscope/pkg/file" "github.com/anchore/stereoscope/pkg/imagetest" + "github.com/anchore/syft/internal/testutils" ) // every case here is one the magic scan must reject, asserted on parseUPXInfo's errNotUPX result. @@ -114,6 +115,7 @@ func TestParseUPXInfo_ValidHeader(t *testing.T) { // ignores that padding if there is some. A build that needs none passes either way, which is how the bug // went unnoticed. The case without padding is covered by TestDecompressUPX_TailExtentsArePlacedPastTheLoader. func TestImageSmallUPXNeedsLoaderPadding(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", "image-small-upx") rc, err := img.OpenPathFromSquash(stereoscopeFile.Path("/run-me")) require.NoError(t, err) diff --git a/syft/pkg/cataloger/internal/dotnet/pe/pe_test.go b/syft/pkg/cataloger/internal/dotnet/pe/pe_test.go index b612e7bc0..ca518ac03 100644 --- a/syft/pkg/cataloger/internal/dotnet/pe/pe_test.go +++ b/syft/pkg/cataloger/internal/dotnet/pe/pe_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/anchore/stereoscope/pkg/imagetest" + "github.com/anchore/syft/internal/testutils" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/source" "github.com/anchore/syft/syft/source/stereoscopesource" @@ -199,6 +200,7 @@ func fixtureResolvers(t *testing.T, fixtures ...string) map[string]file.Resolver continue } + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", fixture) s := stereoscopesource.New(img, stereoscopesource.ImageConfig{ diff --git a/syft/pkg/cataloger/internal/pkgtest/test_generic_parser.go b/syft/pkg/cataloger/internal/pkgtest/test_generic_parser.go index e74a3a06e..22042fcf1 100644 --- a/syft/pkg/cataloger/internal/pkgtest/test_generic_parser.go +++ b/syft/pkg/cataloger/internal/pkgtest/test_generic_parser.go @@ -24,6 +24,7 @@ import ( "github.com/anchore/syft/internal/cmptest" "github.com/anchore/syft/internal/licenses" "github.com/anchore/syft/internal/relationship" + "github.com/anchore/syft/internal/testutils" "github.com/anchore/syft/internal/tmpdir" "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/file" @@ -150,9 +151,11 @@ func (p *CatalogTester) FromFile(t *testing.T, path string) *CatalogTester { fixture, err := os.Open(path) require.NoError(t, err) + // parsers don't own the reader (the generic cataloger closes it), so close it here + t.Cleanup(func() { _ = fixture.Close() }) p.reader = file.LocationReadCloser{ - Location: file.NewVirtualLocationFromDirectory(fixture.Name(), fixture.Name(), *stereofile.NewFileReference(stereofile.Path(absPath))), + Location: file.NewVirtualLocationFromDirectory(filepath.ToSlash(fixture.Name()), filepath.ToSlash(fixture.Name()), *stereofile.NewFileReference(stereofile.Path(absPath))), ReadCloser: fixture, } return p @@ -201,6 +204,7 @@ func (p *CatalogTester) WithImageResolver(t *testing.T, fixtureName string) *Cat return p } + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", fixtureName) s := stereoscopesource.New(img, stereoscopesource.ImageConfig{ diff --git a/syft/pkg/cataloger/java/archive_parser_test.go b/syft/pkg/cataloger/java/archive_parser_test.go index 84167c5ef..c586a421e 100644 --- a/syft/pkg/cataloger/java/archive_parser_test.go +++ b/syft/pkg/cataloger/java/archive_parser_test.go @@ -6,6 +6,7 @@ import ( "io" "os" "os/exec" + "path" "path/filepath" "strings" "syscall" @@ -18,6 +19,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/anchore/syft/internal/testutils" "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/license" @@ -1605,6 +1607,9 @@ func generateJavaBuildFixture(t *testing.T, fixturePath string) { return } + // the fixtures are built with maven/gradle inside linux containers + testutils.SkipWithoutLinuxContainers(t) + makeTask := strings.TrimPrefix(fixturePath, "testdata/java-builds/") t.Log(color.Bold.Sprintf("Generating Fixture from 'make %s'", makeTask)) @@ -1630,7 +1635,8 @@ func generateJavaMetadataJarFixture(t *testing.T, fixtureName string, fileExtens return fixturePath } - makeTask := filepath.Join("cache", fixtureName+"."+fileExtension) + // make targets are posix paths regardless of host + makeTask := path.Join("cache", fixtureName+"."+fileExtension) t.Log(color.Bold.Sprintf("Generating Fixture from 'make %s'", makeTask)) cwd, err := os.Getwd() diff --git a/syft/pkg/cataloger/java/internal/maven/config_test.go b/syft/pkg/cataloger/java/internal/maven/config_test.go index 1a6ab4c50..80de474bd 100644 --- a/syft/pkg/cataloger/java/internal/maven/config_test.go +++ b/syft/pkg/cataloger/java/internal/maven/config_test.go @@ -47,6 +47,8 @@ func Test_defaultMavenLocalRepoDir(t *testing.T) { home, err := filepath.Abs(test.home) require.NoError(t, err) t.Setenv("HOME", home) + // os.UserHomeDir reads USERPROFILE on windows + t.Setenv("USERPROFILE", home) } got := defaultMavenLocalRepoDir() require.Equal(t, test.expected, got) diff --git a/syft/pkg/cataloger/java/internal/maven/test/mock_repo.go b/syft/pkg/cataloger/java/internal/maven/test/mock_repo.go index 6ab462f6b..a2ed64895 100644 --- a/syft/pkg/cataloger/java/internal/maven/test/mock_repo.go +++ b/syft/pkg/cataloger/java/internal/maven/test/mock_repo.go @@ -31,7 +31,7 @@ func MockRepo(t *testing.T, dir string) (url string) { t.Cleanup(server.Close) - matches, err := doublestar.Glob(os.DirFS(dir), filepath.Join("**", "*.pom")) + matches, err := doublestar.Glob(os.DirFS(dir), "**/*.pom") require.NoError(t, err) for _, match := range matches { diff --git a/syft/pkg/cataloger/perl/cataloger_test.go b/syft/pkg/cataloger/perl/cataloger_test.go index 78f9551b7..6fad8926a 100644 --- a/syft/pkg/cataloger/perl/cataloger_test.go +++ b/syft/pkg/cataloger/perl/cataloger_test.go @@ -8,6 +8,7 @@ import ( "github.com/anchore/stereoscope/pkg/imagetest" "github.com/anchore/syft/internal/packagemetadata" + "github.com/anchore/syft/internal/testutils" "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/pkg" @@ -523,6 +524,7 @@ func TestCpanCatalogers_metadataTypes(t *testing.T) { func imageResolver(t *testing.T, fixture string) file.Resolver { t.Helper() + testutils.SkipWithoutLinuxContainers(t) img := imagetest.GetFixtureImage(t, "docker-archive", fixture) resolver, err := stereoscopesource.New(img, stereoscopesource.ImageConfig{Reference: fixture}). FileResolver(source.SquashedScope) diff --git a/syft/pkg/cataloger/python/parse_wheel_egg.go b/syft/pkg/cataloger/python/parse_wheel_egg.go index 0c4cf1563..33d2d0161 100644 --- a/syft/pkg/cataloger/python/parse_wheel_egg.go +++ b/syft/pkg/cataloger/python/parse_wheel_egg.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "path" - "path/filepath" "strings" "github.com/scylladb/go-set/strset" @@ -53,7 +52,7 @@ func fetchInstalledFiles(resolver file.Resolver, metadataLocation file.Location, // to reconcile the installed-files.txt path to the same layer (or the next adjacent lower layer). // find the installed-files.txt file relative to the directory where the METADATA file resides (in path AND layer structure) - installedFilesPath := filepath.Join(filepath.Dir(metadataLocation.RealPath), "installed-files.txt") + installedFilesPath := path.Join(path.Dir(metadataLocation.RealPath), "installed-files.txt") installedFilesRef := resolver.RelativeFileByPath(metadataLocation, installedFilesPath) if installedFilesRef != nil { @@ -83,7 +82,7 @@ func fetchRecordFiles(resolver file.Resolver, metadataLocation file.Location) (f // to reconcile the RECORD path to the same layer (or the next adjacent lower layer). // find the RECORD file relative to the directory where the METADATA file resides (in path AND layer structure) - recordPath := filepath.Join(filepath.Dir(metadataLocation.RealPath), "RECORD") + recordPath := path.Join(path.Dir(metadataLocation.RealPath), "RECORD") recordRef := resolver.RelativeFileByPath(metadataLocation, recordPath) if recordRef != nil { @@ -107,8 +106,8 @@ func fetchRecordFiles(resolver file.Resolver, metadataLocation file.Location) (f // fetchTopLevelPackages finds a corresponding top_level.txt file for the given python package metadata file and returns the set of package names contained. func fetchTopLevelPackages(resolver file.Resolver, metadataLocation file.Location) (pkgs []string, sources []file.Location, err error) { // a top_level.txt file specifies the python top-level packages (provided by this python package) installed into site-packages - parentDir := filepath.Dir(metadataLocation.RealPath) - topLevelPath := filepath.Join(parentDir, "top_level.txt") + parentDir := path.Dir(metadataLocation.RealPath) + topLevelPath := path.Join(parentDir, "top_level.txt") topLevelLocation := resolver.RelativeFileByPath(metadataLocation, topLevelPath) if topLevelLocation == nil { @@ -157,8 +156,8 @@ type vcsInfo struct { } func fetchDirectURLData(resolver file.Resolver, metadataLocation file.Location) (d *pkg.PythonDirectURLOriginInfo, sources []file.Location, err error) { - parentDir := filepath.Dir(metadataLocation.RealPath) - directURLPath := filepath.Join(parentDir, "direct_url.json") + parentDir := path.Dir(metadataLocation.RealPath) + directURLPath := path.Join(parentDir, "direct_url.json") directURLLocation := resolver.RelativeFileByPath(metadataLocation, directURLPath) if directURLLocation == nil { @@ -269,7 +268,7 @@ func findLicenses(ctx context.Context, resolver file.Resolver, m parsedData) pkg continue } - if licenses.IsLicenseFile(filepath.Base(f.Path)) { + if licenses.IsLicenseFile(path.Base(f.Path)) { candidatePaths.Add(path.Join(m.SitePackagesRootPath, f.Path)) } } diff --git a/syft/pkg/cataloger/python/parse_wheel_egg_metadata.go b/syft/pkg/cataloger/python/parse_wheel_egg_metadata.go index 5b0572e3a..05c49f5f4 100644 --- a/syft/pkg/cataloger/python/parse_wheel_egg_metadata.go +++ b/syft/pkg/cataloger/python/parse_wheel_egg_metadata.go @@ -2,7 +2,7 @@ package python import ( "fmt" - "path/filepath" + "path" "strings" "github.com/bmatcuk/doublestar/v4" @@ -48,13 +48,13 @@ func parseWheelOrEggMetadata(locationReader file.LocationReadCloser) (parsedData } // add additional metadata not stored in the egg/wheel metadata file - path := locationReader.Path() + metadataPath := locationReader.Path() - pd.SitePackagesRootPath = determineSitePackagesRootPath(path) + pd.SitePackagesRootPath = determineSitePackagesRootPath(metadataPath) if pd.Licenses != "" || pd.LicenseExpression != "" { - pd.LicenseFilePath = path + pd.LicenseFilePath = metadataPath } else if pd.LicenseFile != "" { - pd.LicenseFilePath = filepath.Join(filepath.Dir(path), pd.LicenseFile) + pd.LicenseFilePath = path.Join(path.Dir(metadataPath), pd.LicenseFile) } pd.DistInfoLocation = locationReader.Location @@ -144,18 +144,18 @@ func getFieldType(key, in string) any { // isEggRegularFile determines if the specified path is the regular file variant // of egg metadata (as opposed to a directory that contains more metadata // files). -func isEggRegularFile(path string) bool { - return doublestar.MatchUnvalidated(eggInfoGlob, path) +func isEggRegularFile(p string) bool { + return doublestar.MatchUnvalidated(eggInfoGlob, p) } // determineSitePackagesRootPath returns the path of the site packages root, // given the egg metadata file or directory specified in the path. -func determineSitePackagesRootPath(path string) string { - if isEggRegularFile(path) { - return filepath.Clean(filepath.Dir(path)) +func determineSitePackagesRootPath(p string) string { + if isEggRegularFile(p) { + return path.Clean(path.Dir(p)) } - return filepath.Clean(filepath.Dir(filepath.Dir(path))) + return path.Clean(path.Dir(path.Dir(p))) } // handleFieldBodyContinuation returns the updated value for the specified field after processing the specified line. diff --git a/syft/pkg/cataloger/python/parse_wheel_egg_record.go b/syft/pkg/cataloger/python/parse_wheel_egg_record.go index 94982f10b..12c983673 100644 --- a/syft/pkg/cataloger/python/parse_wheel_egg_record.go +++ b/syft/pkg/cataloger/python/parse_wheel_egg_record.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "path" "path/filepath" "strings" @@ -87,11 +88,13 @@ func parseInstalledFiles(reader io.Reader, location, sitePackagesRootPath string } if location != "" && sitePackagesRootPath != "" { - joinedPath := filepath.Join(filepath.Dir(location), line) + joinedPath := path.Join(path.Dir(location), line) + // the path package has no Rel, and filepath.Rel hands back host separators on windows line, err = filepath.Rel(sitePackagesRootPath, joinedPath) if err != nil { return nil, err } + line = filepath.ToSlash(line) } installedFile := pkg.PythonFileRecord{ diff --git a/syft/pkg/cataloger/swift/parse_package_resolved_test.go b/syft/pkg/cataloger/swift/parse_package_resolved_test.go index 494a164c1..98803e43d 100644 --- a/syft/pkg/cataloger/swift/parse_package_resolved_test.go +++ b/syft/pkg/cataloger/swift/parse_package_resolved_test.go @@ -122,8 +122,7 @@ func TestParsePackageResolved_empty(t *testing.T) { dir := t.TempDir() fixture = filepath.Join(dir, "Package.resolved") - _, err := os.Create(fixture) - require.NoError(t, err) + require.NoError(t, os.WriteFile(fixture, nil, 0600)) pkgtest.TestFileParser(t, fixture, parsePackageResolved, nil, nil) } diff --git a/syft/source/snapsource/snap_source_test.go b/syft/source/snapsource/snap_source_test.go index 6a4b23e4e..bf03a59a4 100644 --- a/syft/source/snapsource/snap_source_test.go +++ b/syft/source/snapsource/snap_source_test.go @@ -30,6 +30,10 @@ import ( ) func TestNewFromLocal(t *testing.T) { + // the request is resolved with filepath.Abs, which adds a volume on windows + localSnap, err := filepath.Abs("/test/local.snap") + require.NoError(t, err) + tests := []struct { name string cfg Config @@ -40,13 +44,13 @@ func TestNewFromLocal(t *testing.T) { { name: "local file exists", cfg: Config{ - Request: "/test/local.snap", + Request: localSnap, DigestAlgorithms: []crypto.Hash{crypto.SHA256}, }, setup: func(fs afero.Fs) { - require.NoError(t, createMockSquashfsFile(fs, "/test/local.snap")) + require.NoError(t, createMockSquashfsFile(fs, localSnap)) }, - wantRequest: "/test/local.snap", + wantRequest: localSnap, }, { name: "resolve home dir exists", @@ -65,18 +69,18 @@ func TestNewFromLocal(t *testing.T) { { name: "local file with architecture specified", cfg: Config{ - Request: "/test/local.snap", + Request: localSnap, Platform: &image.Platform{ Architecture: "arm64", }, }, setup: func(fs afero.Fs) { - require.NoError(t, createMockSquashfsFile(fs, "/test/local.snap")) + require.NoError(t, createMockSquashfsFile(fs, localSnap)) }, wantErr: func(t assert.TestingT, err error, msgAndArgs ...any) bool { return assert.Error(t, err, msgAndArgs...) && assert.Contains(t, err.Error(), "architecture cannot be specified for local snap files", msgAndArgs...) }, - wantRequest: "/test/local.snap", + wantRequest: localSnap, }, } for _, tt := range tests { @@ -288,6 +292,8 @@ func writeSquashfs(t *testing.T, manifest string) string { require.NoError(t, err) _, err = w.Write([]byte(contents)) require.NoError(t, err) + // close before finalizing: windows directory listings can report a stale (zero) size for open files + require.NoError(t, w.Close()) } // note: go-diskfs wants paths relative to the image root, without a leading slash diff --git a/syft/source/snapsource/snap_test.go b/syft/source/snapsource/snap_test.go index e6b3ac450..a55d4e664 100644 --- a/syft/source/snapsource/snap_test.go +++ b/syft/source/snapsource/snap_test.go @@ -145,7 +145,9 @@ func TestNewSnapFromFile(t *testing.T) { DigestAlgorithms: []crypto.Hash{crypto.SHA256}, }, setup: func() string { - path := "/test/valid.snap" + // newSnapFromFile resolves the request with filepath.Abs, which adds a volume on windows + path, err := filepath.Abs("/test/valid.snap") + require.NoError(t, err) require.NoError(t, createMockSquashfsFile(fs, path)) return path }, @@ -179,7 +181,9 @@ func TestNewSnapFromFile(t *testing.T) { name: "path is directory", cfg: Config{}, setup: func() string { - path := "/test/directory" + // newSnapFromFile resolves the request with filepath.Abs, which adds a volume on windows + path, err := filepath.Abs("/test/directory") + require.NoError(t, err) require.NoError(t, fs.MkdirAll(path, 0755)) return path }, @@ -471,7 +475,9 @@ func TestGetSnapFileInfo(t *testing.T) { { name: "path is directory", setup: func() string { - path := "/test/directory" + // newSnapFromFile resolves the request with filepath.Abs, which adds a volume on windows + path, err := filepath.Abs("/test/directory") + require.NoError(t, err) require.NoError(t, fs.MkdirAll(path, 0755)) return path }, diff --git a/syft/source/stereoscopesource/image_source_test.go b/syft/source/stereoscopesource/image_source_test.go index 4b8f709af..fc42d12a4 100644 --- a/syft/source/stereoscopesource/image_source_test.go +++ b/syft/source/stereoscopesource/image_source_test.go @@ -10,6 +10,7 @@ import ( "github.com/anchore/stereoscope" "github.com/anchore/stereoscope/pkg/imagetest" + "github.com/anchore/syft/internal/testutils" "github.com/anchore/syft/syft/internal/testutil" "github.com/anchore/syft/syft/source" ) @@ -78,6 +79,7 @@ func Test_StereoscopeImage_Exclusions(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { + testutils.SkipWithoutLinuxContainers(t) imageName := strings.SplitN(imagetest.PrepareFixtureImage(t, "docker-archive", test.input), ":", 2)[1] img, err := stereoscope.GetImage(context.TODO(), imageName)