diff --git a/go.mod b/go.mod index b7573e132..09b8346f3 100644 --- a/go.mod +++ b/go.mod @@ -248,7 +248,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/nwaples/rardecode/v2 v2.2.0 // indirect + github.com/nwaples/rardecode/v2 v2.4.1 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.2.0 // indirect github.com/olekukonko/ll v0.1.6 // indirect diff --git a/go.sum b/go.sum index f8bbcca93..b66b9b51a 100644 --- a/go.sum +++ b/go.sum @@ -758,8 +758,8 @@ github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1 h1:kpt9ZfKcm+ github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1/go.mod h1:qgCw4bBKZX8qMgGeEZzGFVT3notl42dBjNqO2jut0M0= github.com/nsf/jsondiff v0.0.0-20210926074059-1e845ec5d249 h1:NHrXEjTNQY7P0Zfx1aMrNhpgxHmow66XQtm0aQLY0AE= github.com/nsf/jsondiff v0.0.0-20210926074059-1e845ec5d249/go.mod h1:mpRZBD8SJ55OIICQ3iWH0Yz3cjzA61JdqMLoWXeB2+8= -github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A= -github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/nwaples/rardecode/v2 v2.4.1 h1:F7zNW2LdAuuBThHWXQaiFUGVD/sef299NfWSB1nHAl4= +github.com/nwaples/rardecode/v2 v2.4.1/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= diff --git a/syft/source/filesource/file_source.go b/syft/source/filesource/file_source.go index 1c49e4905..a443d2baa 100644 --- a/syft/source/filesource/file_source.go +++ b/syft/source/filesource/file_source.go @@ -214,6 +214,11 @@ func fileAnalysisPath(path string, skipExtractArchive bool) (string, func() erro envelopedUnarchiver, _, err := intFile.IdentifyArchive(context.Background(), path, nil) if unarchiver, ok := envelopedUnarchiver.(archives.Extractor); err == nil && ok { + if isZipWithRarName(path, envelopedUnarchiver) { + log.Debugf("source path has a .rar name but zip content (e.g. a Java resource adapter archive); analyzing it as a single file") + return analysisPath, cleanupFn, nil + } + analysisPath, cleanupFn, err = unarchiveToTmp(path, unarchiver) if err != nil { return "", cleanupFn, fmt.Errorf("unable to unarchive source file: %w", err) @@ -225,6 +230,25 @@ func fileAnalysisPath(path string, skipExtractArchive bool) (string, func() erro return analysisPath, cleanupFn, nil } +// isZipWithRarName reports whether the archive format was identified as RAR from the file name while the file +// content is a zip archive. Archive identification above uses the file name only, and ".rar" is also the extension +// of Java resource adapter archives (JCA), which are zip files. Such a file is left for the java cataloger to read, +// the same way .jar, .war and .ear files are, instead of being handed to the RAR extractor, which cannot read it. +func isZipWithRarName(path string, format archives.Format) bool { + if _, ok := format.(archives.Rar); !ok { + return false + } + + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + + match, err := archives.Zip{}.Match(context.Background(), "", f) + return err == nil && match.ByStream +} + func digestOfFileContents(path string) string { f, err := os.Open(path) if err != nil { diff --git a/syft/source/filesource/file_source_test.go b/syft/source/filesource/file_source_test.go index bba5c61e2..1866dd668 100644 --- a/syft/source/filesource/file_source_test.go +++ b/syft/source/filesource/file_source_test.go @@ -1,6 +1,8 @@ package filesource import ( + "archive/zip" + "bytes" "io" "os" "os/exec" @@ -173,6 +175,123 @@ func TestNewFromFile_WithArchive(t *testing.T) { } } +func TestNewFromFile_RarExtension(t *testing.T) { + testutil.Chdir(t, "..") // run with source/testdata + + testCases := []struct { + desc string + input string + wantExtracted bool + wantPaths []string + wantNoPaths []string + }{ + { + // a Java resource adapter archive is a zip file with a .rar extension: it must not be handed to the RAR + // extractor, but left for the java cataloger to read as a single file (like a .jar) + desc: "zip archive with a .rar name is analyzed as a single file", + input: createResourceAdapterArchive(t, t.TempDir()), + wantExtracted: false, + wantPaths: []string{"example-ra-1.0.0.rar"}, + wantNoPaths: []string{"META-INF/ra.xml", "example-lib-1.0.0.jar"}, + }, + { + // a self-extracting RAR has an executable stub before the RAR signature. The stub holds no "R" bytes, + // which is the input that made rardecode v2.2.0 loop forever in its signature search + desc: "self-extracting RAR archive is extracted", + input: writeSfxRar(t, t.TempDir()), + wantExtracted: true, + wantPaths: []string{"inside.txt"}, + wantNoPaths: []string{"example-sfx.rar"}, + }, + { + // a RAR 5.0 archive holding one stored file (inside.txt) + desc: "RAR archive is extracted", + input: "testdata/rar-archive/example.rar", + wantExtracted: true, + wantPaths: []string{"inside.txt"}, + wantNoPaths: []string{"example.rar"}, + }, + } + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + src, err := New(Config{ + Path: test.input, + }) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, src.Close()) + }) + + assert.Equal(t, test.wantExtracted, src.(*fileSource).analysisPath != test.input) + + res, err := src.FileResolver(source.SquashedScope) + require.NoError(t, err) + + for _, p := range test.wantPaths { + refs, err := res.FilesByPath(p) + require.NoError(t, err) + assert.Len(t, refs, 1, "expected to find %q", p) + } + + for _, p := range test.wantNoPaths { + refs, err := res.FilesByPath(p) + require.NoError(t, err) + assert.Empty(t, refs, "expected not to find %q", p) + } + }) + } +} + +// createResourceAdapterArchive writes a minimal Java resource adapter archive (a zip file with a .rar extension that +// holds a deployment descriptor and a nested jar) into dir and returns its path. +func createResourceAdapterArchive(t testing.TB, dir string) string { + t.Helper() + + lib := zipBytes(t, [][2]string{ + {"META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n"}, + {"META-INF/maven/org.example/example-lib/pom.properties", "groupId=org.example\nartifactId=example-lib\nversion=1.0.0\n"}, + }) + + ra := zipBytes(t, [][2]string{ + {"META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n"}, + {"META-INF/ra.xml", "\n"}, + {"example-lib-1.0.0.jar", string(lib)}, + {"META-INF/maven/org.example/example-ra/pom.properties", "groupId=org.example\nartifactId=example-ra\nversion=1.0.0\n"}, + }) + + archivePath := filepath.Join(dir, "example-ra-1.0.0.rar") + require.NoError(t, os.WriteFile(archivePath, ra, 0o600)) + return archivePath +} + +// writeSfxRar writes testdata/rar-archive/example.rar behind a stand-in executable stub into dir and returns its path. +func writeSfxRar(t testing.TB, dir string) string { + t.Helper() + + rar, err := os.ReadFile("testdata/rar-archive/example.rar") + require.NoError(t, err) + + sfxPath := filepath.Join(dir, "example-sfx.rar") + require.NoError(t, os.WriteFile(sfxPath, append(bytes.Repeat([]byte("MZ stub "), 1024), rar...), 0o600)) + return sfxPath +} + +// zipBytes returns a zip archive holding the given (name, contents) entries, in order. +func zipBytes(t testing.TB, entries [][2]string) []byte { + t.Helper() + + var buf bytes.Buffer + w := zip.NewWriter(&buf) + for _, e := range entries { + f, err := w.Create(e[0]) + require.NoError(t, err) + _, err = f.Write([]byte(e[1])) + require.NoError(t, err) + } + require.NoError(t, w.Close()) + return buf.Bytes() +} + // setupArchiveTest encapsulates common test setup work for tar file tests. It returns a cleanup function, // which should be called (typically deferred) by the caller, the path of the created tar archive, and an error, // which should trigger a fatal test failure in the consuming test. The returned cleanup function will never be nil diff --git a/syft/source/testdata/rar-archive/example.rar b/syft/source/testdata/rar-archive/example.rar new file mode 100644 index 000000000..f2641fd4c Binary files /dev/null and b/syft/source/testdata/rar-archive/example.rar differ diff --git a/test/cli/archive_test.go b/test/cli/archive_test.go index 3c00c1d46..843401b29 100644 --- a/test/cli/archive_test.go +++ b/test/cli/archive_test.go @@ -2,6 +2,8 @@ package cli import ( "archive/tar" + "archive/zip" + "bytes" "os" "path/filepath" "testing" @@ -31,6 +33,25 @@ func TestArchiveScan(t *testing.T) { assertPackageCount(1), }, }, + { + // a Java resource adapter archive is a zip file with a .rar extension + name: "scan a java resource adapter archive", + args: []string{ + "scan", + "-o", + "json", + "--from", + "file", + createResourceAdapterArchive(t, t.TempDir()), + }, + assertions: []traitAssertion{ + assertSuccessfulReturnCode, + assertJsonReport, + assertInOutput("pkg:maven/org.example/example-ra@1.0.0"), + assertInOutput("pkg:maven/org.example/example-lib@1.0.0"), + assertPackageCount(2), + }, + }, } for _, test := range tests { @@ -58,3 +79,37 @@ func createArchive(t *testing.T, path string, destDir string) string { return archivePath } + +// createResourceAdapterArchive writes a minimal Java resource adapter archive (a zip file with a .rar extension that +// holds a deployment descriptor and a nested jar) into destDir and returns its path. +func createResourceAdapterArchive(t *testing.T, destDir string) string { + lib := zipBytes(t, [][2]string{ + {"META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n"}, + {"META-INF/maven/org.example/example-lib/pom.properties", "groupId=org.example\nartifactId=example-lib\nversion=1.0.0\n"}, + }) + + ra := zipBytes(t, [][2]string{ + {"META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n"}, + {"META-INF/ra.xml", "\n"}, + {"example-lib-1.0.0.jar", string(lib)}, + {"META-INF/maven/org.example/example-ra/pom.properties", "groupId=org.example\nartifactId=example-ra\nversion=1.0.0\n"}, + }) + + archivePath := filepath.Join(destDir, "example-ra-1.0.0.rar") + require.NoError(t, os.WriteFile(archivePath, ra, 0o600)) + return archivePath +} + +// zipBytes returns a zip archive holding the given (name, contents) entries, in order. +func zipBytes(t *testing.T, entries [][2]string) []byte { + var buf bytes.Buffer + w := zip.NewWriter(&buf) + for _, e := range entries { + f, err := w.Create(e[0]) + require.NoError(t, err) + _, err = f.Write([]byte(e[1])) + require.NoError(t, err) + } + require.NoError(t, w.Close()) + return buf.Bytes() +}