Fix hang when scanning a Java resource adapter (.rar) file (#5348)

* fix: scan a Java resource adapter (.rar) given as a file source

The file source picks an extractor for the scan target from its file name
alone. A Java resource adapter archive (JCA) uses the .rar extension but is
a zip file, so it was handed to the RAR extractor. With rardecode v2.2.0 the
RAR extractor does not return on non-RAR input (an infinite loop in its
signature search), so `syft scan --from file adapter.rar` never finished.
The same bytes named .jar scan in under a second.

When the name says RAR but the content is a zip archive, leave the file
unextracted, the same way .jar, .war and .ear scan targets are handled. The
java cataloger already reads .rar files (anchore/syft#4136) and now finds the
resource adapter and its nested jars. Real RAR archives are still extracted.

Signed-off-by: Ismael Donmez <ismael.donmez@chainguard.dev>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(deps): bump github.com/nwaples/rardecode/v2 from 2.2.0 to 2.4.1

rardecode v2.2.0 loops forever in its RAR signature search when the input
does not start with a RAR signature (fixed upstream in nwaples/rardecode
f501ac8, "fix infinite loop in findSig", released in v2.2.2). syft reaches
that search for any file source whose name contains ".rar", so a scan of a
self-extracting RAR, or of any other file with such a name, never finished.
With v2.4.1 a self-extracting RAR is extracted, and a file that is not a RAR
archive fails fast with "rardecode: RAR signature not found", the same way a
file named .zip that is not a zip archive fails today.

github.com/mholt/archives v0.1.5 (the latest release) still requires v2.2.0,
so the newer version is selected here.

Signed-off-by: Ismael Donmez <ismael.donmez@chainguard.dev>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: clarify the .rar zip log line and cover self-extracting RAR sources

- the debug line no longer calls every `.rar`-named zip a resource adapter
- a self-extracting RAR (stub before the signature) given as a file source is
  still extracted

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

---------

Signed-off-by: Ismael Donmez <ismael.donmez@chainguard.dev>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Ismael Dönmez
2026-09-30 15:54:36 +00:00
committed by GitHub
co-authored by Claude Opus 5.5 Alex Goodman
parent f21e439a63
commit 0bbcff1ae3
6 changed files with 201 additions and 3 deletions
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+24
View File
@@ -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 {
+119
View File
@@ -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", "<connector/>\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
Binary file not shown.
+55
View File
@@ -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", "<connector/>\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()
}