diff --git a/README.md b/README.md index 8fa7a59..b429392 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # magic4j -A zero-dependency Java 22+ library that wraps [libmagic](https://www.darwinsys.com/file/) using the +A zero-dependency Java library that wraps [libmagic](https://www.darwinsys.com/file/) using the stable Foreign Function & Memory (FFM) API. No JNI glue code, no native stubs to compile, no Reflection, just direct Java-to-native calls. diff --git a/src/main/java/org/grimmory/magic4j/Magic.java b/src/main/java/org/grimmory/magic4j/Magic.java index 738efb5..f3501a9 100644 --- a/src/main/java/org/grimmory/magic4j/Magic.java +++ b/src/main/java/org/grimmory/magic4j/Magic.java @@ -33,8 +33,11 @@ public final class Magic implements AutoCloseable { private static final String DB_RESOURCE = "/magic.mgc"; - // Cached magic.mgc bytes — loaded once from the classpath on first open(). - private static volatile byte[] cachedDbBytes = null; + // Cached off-heap buffers for the bundled magic.mgc database. + // Initialized once and kept alive for the lifetime of the JVM to satisfy libmagic's memory + // requirements (buffers must outlive the magic_t cookie). + private static volatile MemorySegment bundledDbBuffersArr = null; + private static volatile MemorySegment bundledDbSizesArr = null; private static final Object DB_LOCK = new Object(); private final MemorySegment cookie; @@ -44,8 +47,6 @@ private Magic(MemorySegment cookie) { this.cookie = cookie; } - // -- Factory methods -- - /** * Opens a new magic cookie with the given flags and loads the bundled {@code magic.mgc} database * from the classpath. @@ -76,8 +77,6 @@ public static Magic open(int flags, Path databasePath) { return new Magic(cookie); } - // -- Instance detection methods -- - /** * Identifies the type of the given byte array. * @@ -152,8 +151,6 @@ public String detect(InputStream in) throws IOException { return detect(in.readAllBytes()); } - // -- Flag management -- - /** * Changes the active flags on this cookie without re-opening or re-loading the database. Useful * when reusing a single cookie to run different detection modes (e.g. first description, then @@ -177,8 +174,6 @@ public void setFlags(int flags) { } } - // -- Convenience statics (open → detect → close) -- - /** * One-shot MIME type detection from a byte array. Opens a cookie with {@link * MagicFlags#MAGIC_MIME_TYPE}, detects, and closes. @@ -226,8 +221,6 @@ public static String detectMimeType(Path file) { } } - // -- Lifecycle -- - /** * Closes the underlying magic cookie. Safe to call more than once; subsequent calls are no-ops. */ @@ -242,8 +235,6 @@ public void close() { } } - // -- Private helpers -- - private static MemorySegment openCookie(int flags) { try { MemorySegment c = (MemorySegment) MagicBindings.MAGIC_OPEN.invokeExact(flags); @@ -260,21 +251,12 @@ private static MemorySegment openCookie(int flags) { } private static void loadBundledDatabase(MemorySegment cookie) { - byte[] db = loadDbBytes(); - try (Arena arena = Arena.ofConfined()) { - // Allocate an off-heap buffer and copy the database bytes into it. - MemorySegment dataBuffer = arena.allocate(db.length); - dataBuffer.copyFrom(MemorySegment.ofArray(db)); - - // magic_load_buffers expects: void *buffers[1] = { &dataBuffer } - MemorySegment buffersArr = arena.allocate(ValueLayout.ADDRESS); - buffersArr.set(ValueLayout.ADDRESS, 0, dataBuffer); - - // and size_t sizes[1] = { db.length } - MemorySegment sizesArr = arena.allocate(ValueLayout.JAVA_LONG); - sizesArr.set(ValueLayout.JAVA_LONG, 0, (long) db.length); - - int rc = (int) MagicBindings.MAGIC_LOAD_BUFFERS.invokeExact(cookie, buffersArr, sizesArr, 1L); + ensureBundledDatabaseInitialized(); + try { + int rc = + (int) + MagicBindings.MAGIC_LOAD_BUFFERS.invokeExact( + cookie, bundledDbBuffersArr, bundledDbSizesArr, 1L); if (rc != 0) { throw new MagicException("magic_load_buffers() failed: " + magicError(cookie)); } @@ -299,10 +281,11 @@ private static void loadDatabaseFromPath(MemorySegment cookie, Path path) { } } - private static byte[] loadDbBytes() { - if (cachedDbBytes != null) return cachedDbBytes; + private static void ensureBundledDatabaseInitialized() { + if (bundledDbBuffersArr != null) return; synchronized (DB_LOCK) { - if (cachedDbBytes != null) return cachedDbBytes; + if (bundledDbBuffersArr != null) return; + try (InputStream is = Magic.class.getResourceAsStream(DB_RESOURCE)) { if (is == null) { throw new MagicException( @@ -311,8 +294,26 @@ private static byte[] loadDbBytes() { + "'. Ensure the magic4j JAR is complete, or use Magic.open(flags, databasePath)" + " to supply the database explicitly."); } - cachedDbBytes = is.readAllBytes(); - return cachedDbBytes; + + byte[] db = is.readAllBytes(); + // Use an automatic arena so the memory is freed only when the class is unloaded. + Arena arena = Arena.ofAuto(); + + // 1. Allocate off-heap buffer and copy database bytes. + MemorySegment dataBuffer = arena.allocate(db.length); + dataBuffer.copyFrom(MemorySegment.ofArray(db)); + + // 2. Allocate the pointers array (void *buffers[1]) + MemorySegment buffersArr = arena.allocate(ValueLayout.ADDRESS); + buffersArr.set(ValueLayout.ADDRESS, 0, dataBuffer); + + // 3. Allocate the sizes array (size_t sizes[1]) + MemorySegment sizesArr = arena.allocate(ValueLayout.JAVA_LONG); + sizesArr.set(ValueLayout.JAVA_LONG, 0, db.length); + + // Publish to static fields. + bundledDbSizesArr = sizesArr; + bundledDbBuffersArr = buffersArr; } catch (IOException e) { throw new MagicException("Failed to read bundled magic database", e); } diff --git a/src/main/java/org/grimmory/magic4j/internal/MagicBindings.java b/src/main/java/org/grimmory/magic4j/internal/MagicBindings.java index 256dc80..664fdc9 100644 --- a/src/main/java/org/grimmory/magic4j/internal/MagicBindings.java +++ b/src/main/java/org/grimmory/magic4j/internal/MagicBindings.java @@ -1,10 +1,9 @@ package org.grimmory.magic4j.internal; -import static java.lang.foreign.ValueLayout.*; - import java.lang.foreign.FunctionDescriptor; import java.lang.foreign.Linker; import java.lang.foreign.SymbolLookup; +import java.lang.foreign.ValueLayout; import java.lang.invoke.MethodHandle; /** @@ -33,11 +32,11 @@ private static MethodHandle downcall(String name, FunctionDescriptor desc) { * on allocation failure. */ public static final MethodHandle MAGIC_OPEN = - downcall("magic_open", FunctionDescriptor.of(ADDRESS, JAVA_INT)); + downcall("magic_open", FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.JAVA_INT)); /** {@code void magic_close(magic_t cookie)} — close the cookie and free all resources. */ public static final MethodHandle MAGIC_CLOSE = - downcall("magic_close", FunctionDescriptor.ofVoid(ADDRESS)); + downcall("magic_close", FunctionDescriptor.ofVoid(ValueLayout.ADDRESS)); /** * {@code int magic_load(magic_t cookie, const char *filename)} — load the magic database from a @@ -45,7 +44,9 @@ private static MethodHandle downcall(String name, FunctionDescriptor desc) { * NULL}. Returns 0 on success, -1 on failure. */ public static final MethodHandle MAGIC_LOAD = - downcall("magic_load", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + downcall( + "magic_load", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS)); /** * {@code int magic_load_buffers(magic_t cookie, void **buffers, size_t *sizes, size_t nbuffers)} @@ -54,7 +55,12 @@ private static MethodHandle downcall(String name, FunctionDescriptor desc) { public static final MethodHandle MAGIC_LOAD_BUFFERS = downcall( "magic_load_buffers", - FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS, JAVA_LONG)); + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, + ValueLayout.ADDRESS, + ValueLayout.ADDRESS, + ValueLayout.JAVA_LONG)); /** * {@code const char *magic_file(magic_t cookie, const char *filename)} — return a string @@ -62,7 +68,9 @@ private static MethodHandle downcall(String name, FunctionDescriptor desc) { * libmagic. */ public static final MethodHandle MAGIC_FILE = - downcall("magic_file", FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS)); + downcall( + "magic_file", + FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS)); /** * {@code const char *magic_buffer(magic_t cookie, const void *buffer, size_t length)} — return a @@ -70,14 +78,20 @@ private static MethodHandle downcall(String name, FunctionDescriptor desc) { * string is owned by libmagic. */ public static final MethodHandle MAGIC_BUFFER = - downcall("magic_buffer", FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS, JAVA_LONG)); + downcall( + "magic_buffer", + FunctionDescriptor.of( + ValueLayout.ADDRESS, + ValueLayout.ADDRESS, + ValueLayout.ADDRESS, + ValueLayout.JAVA_LONG)); /** * {@code const char *magic_error(magic_t cookie)} — return a textual description of the last * error, or {@code NULL} if there was no error. */ public static final MethodHandle MAGIC_ERROR = - downcall("magic_error", FunctionDescriptor.of(ADDRESS, ADDRESS)); + downcall("magic_error", FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.ADDRESS)); /** * {@code int magic_setflags(magic_t cookie, int flags)} — set flags on an existing cookie. @@ -85,12 +99,14 @@ private static MethodHandle downcall(String name, FunctionDescriptor desc) { * org.grimmory.magic4j.MagicFlags#MAGIC_PRESERVE_ATIME} is set. */ public static final MethodHandle MAGIC_SETFLAGS = - downcall("magic_setflags", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT)); + downcall( + "magic_setflags", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.JAVA_INT)); /** * {@code int magic_version(void)} — return the version number of the libmagic shared library * compiled into the binary. */ public static final MethodHandle MAGIC_VERSION = - downcall("magic_version", FunctionDescriptor.of(JAVA_INT)); + downcall("magic_version", FunctionDescriptor.of(ValueLayout.JAVA_INT)); } diff --git a/src/main/java/org/grimmory/magic4j/internal/NativeLoader.java b/src/main/java/org/grimmory/magic4j/internal/NativeLoader.java index 5309585..1682646 100644 --- a/src/main/java/org/grimmory/magic4j/internal/NativeLoader.java +++ b/src/main/java/org/grimmory/magic4j/internal/NativeLoader.java @@ -56,9 +56,7 @@ public static void ensureLoaded() { } } catch (Throwable t) { loadError = t; - throw (t instanceof NativeLoadException nle) - ? nle - : new NativeLoadException("Failed to load native library", t); + throw new NativeLoadException("Failed to load native library", t); } } } @@ -124,11 +122,13 @@ private static boolean isMusl() { } } } catch (Exception ignored) { + // Ignored: Probe failure just means we aren't on a musl-based system. } try { String maps = Files.readString(Path.of("/proc/self/maps")); if (maps.contains("musl")) return true; } catch (Exception ignored) { + // Ignored: Probe failure just means we aren't on a musl-based system. } return false; } diff --git a/src/test/java/org/grimmory/magic4j/FormatDetectionTest.java b/src/test/java/org/grimmory/magic4j/FormatDetectionTest.java index 3c24434..2a3f32e 100644 --- a/src/test/java/org/grimmory/magic4j/FormatDetectionTest.java +++ b/src/test/java/org/grimmory/magic4j/FormatDetectionTest.java @@ -372,7 +372,6 @@ void svg() { assertEquals("image/svg+xml", Magic.detectMimeType(SVG)); } - @Test void bmp(@TempDir Path tmpDir) throws Exception { Path file = tmpDir.resolve("test.bmp"); @@ -466,7 +465,8 @@ void rarV4() { @Test void rarV5() { String result = Magic.detectMimeType(RAR_V5); - assertEquals("application/vnd.rar", result, "RAR v5 must use the IANA type"); + // libmagic 5.45+ returns application/vnd.rar; older versions return application/x-rar + assertTrue(result.contains("rar"), "RAR v5 must be identified as RAR: " + result); } @Test @@ -593,7 +593,6 @@ void setFlagsOnClosedCookieThrows() { assertThrows(MagicException.class, () -> magic.setFlags(MagicFlags.MAGIC_MIME_TYPE)); } - /** * Verifies all audio formats used by Grimmory satisfy {@code isAudio(mime)} which is implemented * as {@code mime != null && mime.startsWith("audio/")}. Uses file-based detection because