Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
69 changes: 35 additions & 34 deletions src/main/java/org/grimmory/magic4j/Magic.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
*/
Expand All @@ -242,8 +235,6 @@ public void close() {
}
}

// -- Private helpers --

private static MemorySegment openCookie(int flags) {
try {
MemorySegment c = (MemorySegment) MagicBindings.MAGIC_OPEN.invokeExact(flags);
Expand All @@ -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));
}
Expand All @@ -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(
Expand All @@ -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);
}
Expand Down
38 changes: 27 additions & 11 deletions src/main/java/org/grimmory/magic4j/internal/MagicBindings.java
Original file line number Diff line number Diff line change
@@ -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;

/**
Expand Down Expand Up @@ -33,19 +32,21 @@ 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
* colon-separated list of file paths, or the compiled-in default when {@code filename} is {@code
* 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)}
Expand All @@ -54,43 +55,58 @@ 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
* describing the type of the named file. Returns {@code NULL} on error; the string is owned by
* 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
* string describing the type of the given memory buffer. Returns {@code NULL} on error; the
* 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.
* Returns -1 on systems that don't support {@code utime(3)} when {@link
* 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));
}
6 changes: 3 additions & 3 deletions src/main/java/org/grimmory/magic4j/internal/NativeLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 2 additions & 3 deletions src/test/java/org/grimmory/magic4j/FormatDetectionTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down