diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e71d414..cdf93db 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -28,7 +28,9 @@ version triggers a warning against `BuildInfo.ENGINE_VERSION`. `libhegel` resolves from `$HEGEL_LIBHEGEL_PATH` (explicit override), else the OS's standard shared-library search path (`LD_LIBRARY_PATH` on Linux, `DYLD_LIBRARY_PATH` on macOS), else the -native bundled in the jar for the host OS/arch (unpacked to a per-user cache). The bundled libraries are fetched at build time by +native bundled in the jar for the host OS/arch (unpacked to a per-user cache; the cache is +best-effort — if it cannot be read or written, e.g. under a sandbox that denies writes to the user +cache dir, the native is extracted to a fresh directory under the system temp dir instead). The bundled libraries are fetched at build time by `scripts/fetch_natives.py` (wired into Maven's `generate-resources` phase), which discovers whatever shared objects the pinned `` release publishes — no runtime download. The fetch is **strict** — because the bundled native is the only way end users get the engine (no runtime-download diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..b6c1d86 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,3 @@ +RELEASE_TYPE: patch + +Fix error when cache directory for libhegel could not be written to, for example inside of a sandbox. diff --git a/src/main/java/dev/hegel/LibraryLoader.java b/src/main/java/dev/hegel/LibraryLoader.java index 7ca86ee..ce91a4c 100644 --- a/src/main/java/dev/hegel/LibraryLoader.java +++ b/src/main/java/dev/hegel/LibraryLoader.java @@ -27,23 +27,44 @@ * *

The bundled libraries are placed on the classpath at build time (see {@code * scripts/fetch_natives.py}), so the shipped jar is self-contained and nothing is downloaded at - * runtime. Configuration (environment, cache dir, OS/arch, and the resource opener) is injected so - * the resolver is fully unit-testable, including the unpack path. + * runtime. The per-user cache is purely a performance optimization: when it cannot be read or + * written (e.g. a sandbox that denies access to the user cache dir), the library is extracted to a + * fresh directory under the system temp dir instead. Configuration (environment, cache dir, + * OS/arch, the resource opener, and the temp-dir supplier) is injected so the resolver is fully + * unit-testable, including the unpack path. */ final class LibraryLoader { + /** Creates a fresh private directory for the temp-dir fallback; injected for testability. */ + @FunctionalInterface + interface TempDirSupplier { + Path create() throws IOException; + } + private final Map env; private final Path cacheDir; private final String os; private final String arch; private final Function resources; + private final TempDirSupplier tempDirs; LibraryLoader( Map env, Path cacheDir, String os, String arch, Function resources) { + this(env, cacheDir, os, arch, resources, () -> Files.createTempDirectory("hegel-java-libhegel")); + } + + LibraryLoader( + Map env, + Path cacheDir, + String os, + String arch, + Function resources, + TempDirSupplier tempDirs) { this.env = env; this.cacheDir = cacheDir; this.os = os; this.arch = arch; this.resources = resources; + this.tempDirs = tempDirs; } /** @@ -170,9 +191,10 @@ Path searchLibraryPath() { } /** - * Unpack the bundled native for this OS/arch to the cache and return its path, or {@code null} if - * no native is bundled for this platform. The cache entry is keyed by the library's content hash, - * so it is reused across runs and never collides between engine versions. + * Unpack the bundled native for this OS/arch and return its path, or {@code null} if no native + * is bundled for this platform. The per-user cache is tried first; on any cache failure the + * library is extracted to a fresh directory under the system temp dir instead. Unpacking fails + * only when both paths fail, with an error reporting both causes. */ Path unpackBundled() { InputStream in = resources.apply(resourcePath()); @@ -185,23 +207,53 @@ Path unpackBundled() { } catch (IOException e) { throw new HegelException("Failed to read bundled libhegel resource " + resourcePath(), e); } + IOException cacheFailure; + try { + return cachedLibrary(bytes); + } catch (IOException e) { + cacheFailure = e; + } + try { + return tempLibrary(bytes); + } catch (IOException e) { + e.addSuppressed(cacheFailure); + throw new HegelException( + "Failed to unpack bundled libhegel to a temp dir (cache also unusable: " + cacheFailure + ")", e); + } + } + + /** + * The per-user cached copy of the library, written on first use. The cache entry is keyed by + * the library's content hash, so it is reused across runs and never collides between engine + * versions. + */ + private Path cachedLibrary(byte[] bytes) throws IOException { Path dir = cacheDir.resolve(sha256Hex(bytes)); Path target = dir.resolve(libFileName()); if (Files.isRegularFile(target) && target.toFile().length() == bytes.length) { return target; } + Files.createDirectories(dir); + return installLibrary(dir, bytes); + } + + /** + * Extract the library to a fresh private directory under the system temp dir. + */ + private Path tempLibrary(byte[] bytes) throws IOException { + return installLibrary(tempDirs.create(), bytes); + } + + /** Write the library into {@code dir} atomically (temp file + rename), marked executable. */ + private Path installLibrary(Path dir, byte[] bytes) throws IOException { + Path target = dir.resolve(libFileName()); + Path tmp = Files.createTempFile(dir, "libhegel", ".part"); try { - Files.createDirectories(dir); - Path tmp = Files.createTempFile(dir, "libhegel", ".part"); - try { - Files.write(tmp, bytes); - tmp.toFile().setExecutable(true, false); - Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } finally { - Files.deleteIfExists(tmp); - } - } catch (IOException e) { - throw new HegelException("Failed to unpack bundled libhegel to " + target, e); + Files.write(tmp, bytes); + tmp.toFile().setExecutable(true, false); + Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(tmp); } return target; } diff --git a/src/test/java/dev/hegel/LibraryLoaderTest.java b/src/test/java/dev/hegel/LibraryLoaderTest.java index 3fdf829..797c5d4 100644 --- a/src/test/java/dev/hegel/LibraryLoaderTest.java +++ b/src/test/java/dev/hegel/LibraryLoaderTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -240,12 +241,30 @@ private static String warnOutput(FakeLibhegel lib, String expected) { } @Test - void unpackIoErrorFails(@TempDir Path dir) throws IOException { - byte[] payload = "x".getBytes(StandardCharsets.UTF_8); + void unusableCacheFallsBackToTempDir(@TempDir Path dir) throws IOException { + byte[] payload = "ELF-ish-bytes".getBytes(StandardCharsets.UTF_8); Path cacheAsFile = dir.resolve("cache-is-a-file"); Files.writeString(cacheAsFile, "occupied"); // createDirectories under it must fail LibraryLoader l = loader(Map.of(), cacheAsFile, bundled(LINUX_RESOURCE, payload)); + Path got = l.resolve(); // the default supplier extracts under the system temp dir + assertFalse(got.startsWith(cacheAsFile)); + assertArrayEquals(payload, Files.readAllBytes(got)); + } + + @Test + void cacheAndTempDirBothUnusableFails(@TempDir Path dir) throws IOException { + byte[] payload = "x".getBytes(StandardCharsets.UTF_8); + Path cacheAsFile = dir.resolve("cache-is-a-file"); + Files.writeString(cacheAsFile, "occupied"); + LibraryLoader l = + new LibraryLoader(Map.of(), cacheAsFile, "linux", "amd64", bundled(LINUX_RESOURCE, payload), () -> { + throw new IOException("temp denied"); + }); HegelException e = assertThrows(HegelException.class, l::resolve); - assertTrue(e.getMessage().contains("Failed to unpack bundled libhegel")); + // The terminal error reports both causes: the temp failure as the cause, the cache failure + // in the message (and suppressed on the cause, so both stack traces survive). + assertTrue(e.getCause().getMessage().contains("temp denied")); + assertTrue(e.getMessage().contains("cache also unusable")); + assertTrue(e.getMessage().contains("cache-is-a-file")); } }