From 77f68485f9b224fc2d47f2901022424fe29934da Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 21 Oct 2026 11:27:42 +0100 Subject: [PATCH] Detect Java class files by content --- README.md | 10 +++---- magic.go | 46 +++++++++++++++++--------------- signatures.go | 12 +++++++++ signatures_test.go | 40 +++++++++++++++++++++++++++ testdata/InventoryExample.class | Bin 0 -> 216 bytes testdata/InventoryExample.java | 5 ++++ 6 files changed, 86 insertions(+), 27 deletions(-) create mode 100644 testdata/InventoryExample.class create mode 100644 testdata/InventoryExample.java diff --git a/README.md b/README.md index 4185fb0..cedc8bd 100644 --- a/README.md +++ b/README.md @@ -92,16 +92,16 @@ than string literals. The format registry contains: - ZIP, TAR, native PHAR, ar, gzip, bzip2, xz, zstd, PDF, CFBF, PNG, JPEG, and GIF -- ELF, Mach-O (thin and universal), PE/COFF, and WebAssembly +- ELF, Mach-O (thin and universal), PE/COFF, WebAssembly, and Java class files - plain text, Git LFS pointers, JSON, HTML, XML, and SVG Detection uses bytes only. ZIP-based package types such as JAR, wheel, and NuGet remain `zip`, and compressed payloads are not opened. A `CA FE BA BE` prefix is reported as Mach-O only when the following architecture count is -plausible, so Java class files fall through unclassified. PE requires the -`PE\0\0` signature to be reachable within the first 512 bytes. A caller can -combine the result with filename or domain rules when it needs a semantic -type. +plausible. Java class detection checks the major version and a nonzero constant +pool count in the ten-byte header. PE requires the `PE\0\0` signature to be +reachable within the first 512 bytes. A caller can combine the result with +filename or domain rules when it needs a semantic type. Text accepts valid UTF-8 and BOM-marked UTF-16 or UTF-32. Tab, line feed, form feed, carriage return, and escape are the permitted C0 controls. Other C0 controls diff --git a/magic.go b/magic.go index 92fdd8e..4e0a59a 100644 --- a/magic.go +++ b/magic.go @@ -62,34 +62,36 @@ const ( FormatGIF = "gif" FormatELF = "elf" FormatMachO = "mach-o" + FormatJavaClass = "java-class" FormatPE = "pe" FormatWASM = "wasm" FormatAR = "ar" ) const ( - mimeText = "text/plain" - mimeHTML = "text/html" - mimeXML = "text/xml" - mimeSVG = "image/svg+xml" - mimeJSON = "application/json" - mimeZIP = "application/zip" - mimeTAR = "application/x-tar" - mimePHAR = "application/x-phar" - mimeGZIP = "application/gzip" - mimeBZIP2 = "application/x-bzip2" - mimeXZ = "application/x-xz" - mimePDF = "application/pdf" - mimeCFBF = "application/x-ole-storage" - mimePNG = "image/png" - mimeJPEG = "image/jpeg" - mimeGIF = "image/gif" - mimeZstd = "application/zstd" - mimeELF = "application/x-elf" - mimeMachO = "application/x-mach-binary" - mimePE = "application/vnd.microsoft.portable-executable" - mimeWASM = "application/wasm" - mimeAR = "application/x-archive" + mimeText = "text/plain" + mimeHTML = "text/html" + mimeXML = "text/xml" + mimeSVG = "image/svg+xml" + mimeJSON = "application/json" + mimeZIP = "application/zip" + mimeTAR = "application/x-tar" + mimePHAR = "application/x-phar" + mimeGZIP = "application/gzip" + mimeBZIP2 = "application/x-bzip2" + mimeXZ = "application/x-xz" + mimePDF = "application/pdf" + mimeCFBF = "application/x-ole-storage" + mimePNG = "image/png" + mimeJPEG = "image/jpeg" + mimeGIF = "image/gif" + mimeZstd = "application/zstd" + mimeELF = "application/x-elf" + mimeMachO = "application/x-mach-binary" + mimeJavaClass = "application/java-vm" + mimePE = "application/vnd.microsoft.portable-executable" + mimeWASM = "application/wasm" + mimeAR = "application/x-archive" ) // Encoding values reported in Result.Encoding. diff --git a/signatures.go b/signatures.go index 63b0290..841f14e 100644 --- a/signatures.go +++ b/signatures.go @@ -33,6 +33,8 @@ const ( // architectures. machOFatHeaderLen = 8 machOFatArchLimit = 40 + classHeaderLen = 10 + classMinMajor = 45 ) var htmlSignatures = [...]string{ @@ -94,6 +96,8 @@ func binaryFormatState(data []byte) (format, mime string, needMore bool) { return FormatMachO, mimeMachO, false case machOFatHeader(data): return FormatMachO, mimeMachO, false + case javaClassHeader(data): + return FormatJavaClass, mimeJavaClass, false case hasPrefix(data, "\x00asm"): return FormatWASM, mimeWASM, false case hasPrefix(data, "!\n"): @@ -114,6 +118,14 @@ func binaryFormatState(data []byte) (format, mime string, needMore bool) { } } +func javaClassHeader(data []byte) bool { + if len(data) < classHeaderLen || !hasPrefix(data, "\xca\xfe\xba\xbe") { + return false + } + return binary.BigEndian.Uint16(data[6:8]) >= classMinMajor && + binary.BigEndian.Uint16(data[8:classHeaderLen]) > 0 +} + func machOFatHeader(data []byte) bool { if len(data) < machOFatHeaderLen { return false diff --git a/signatures_test.go b/signatures_test.go index ab61600..bb4d3ce 100644 --- a/signatures_test.go +++ b/signatures_test.go @@ -4,6 +4,7 @@ import ( "archive/tar" "bytes" "fmt" + "os" "testing" ) @@ -60,6 +61,45 @@ func TestBinaryFormatRegistry(t *testing.T) { } } +func TestJavaClassDetection(t *testing.T) { + t.Parallel() + class, err := os.ReadFile("testdata/InventoryExample.class") + if err != nil { + t.Fatal(err) + } + for _, detect := range []func([]byte) Result{Detect, DetectPrefix} { + got := detect(class) + if got.Format != FormatJavaClass || got.MIME != mimeJavaClass || got.Kind != KindBinary || got.Reason != ReasonNone { + t.Fatalf("compiled class: %#v", got) + } + } + for length := 0; length < classHeaderLen; length++ { + got := DetectPrefix(class[:length]) + if got.Format == FormatJavaClass || got.Reason != ReasonNeedMore { + t.Fatalf("prefix length %d: %#v", length, got) + } + } + for _, data := range [][]byte{ + []byte("\xca\xfe\xba\xbe\x00\x00\x00\x02\x00\x01"), + []byte("\xca\xfe\xba\xbf\x00\x00\x00\x02\x00\x01"), + []byte("\xca\xfe\xba\xbe\x00\x00\x00\x34\x00\x00"), + []byte("\xca\xfe\xba\xbe\x00\x00\x00\x2c\x00\x01"), + } { + if got := Detect(data); got.Format == FormatJavaClass { + t.Fatalf("non-class header: %#v", got) + } + } + for _, data := range [][]byte{ + []byte("\xca\xfe\xba\xbe\x00\x00\x00\x2d\x00\x01"), + []byte("\xca\xfe\xba\xbe\x00\x03\x00\x2d\x00\x02"), + []byte("\xca\xfe\xba\xbe\xff\xff\x00\x41\x00\x02"), + } { + if got := Detect(data); got.Format != FormatJavaClass { + t.Fatalf("class version: %#v", got) + } + } +} + func TestTruncatedBinarySignaturesDoNotMatch(t *testing.T) { t.Parallel() diff --git a/testdata/InventoryExample.class b/testdata/InventoryExample.class new file mode 100644 index 0000000000000000000000000000000000000000..a74da699ce3d36c68040614561419a11c83b3bf8 GIT binary patch literal 216 zcmX^0Z`VEs1_nn4J}w3(24;2!79Ivx1~x_pfvm)`ME#t^ymWp4q^#8B5=I6#o6Nk- z5<5l)W)00S4h9ZJ2Eoj{vedki{Gv*Qw9JZ<(xOy$1}>mpPnfW4MPhD2PAVe2eL#oG<^_O1eX+L=A~ORC^B#YodE(&42&R$07-5jPY_5m0%=yQ?F@_? ZLGnO_JPf>0X@0OI0|T=V`v$Ni0{|J0DGUGr literal 0 HcmV?d00001 diff --git a/testdata/InventoryExample.java b/testdata/InventoryExample.java new file mode 100644 index 0000000..b788a97 --- /dev/null +++ b/testdata/InventoryExample.java @@ -0,0 +1,5 @@ +public class InventoryExample { + public static String message() { + return "inventory fixture"; + } +}