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()
+}