From e381689474ab9a6a527c2caa65358068bd4fad5a Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:00:05 -0600 Subject: [PATCH 01/12] Match deflater dictionary state checks to HotSpot --- src/natives/zip.ts | 2 ++ test/java/DeflaterState.java | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 test/java/DeflaterState.java diff --git a/src/natives/zip.ts b/src/natives/zip.ts index f1765d4..b23d344 100644 --- a/src/natives/zip.ts +++ b/src/natives/zip.ts @@ -63,6 +63,7 @@ class Deflate { } // the header names the dictionary; compression would also use it setDictionary(d: Uint8Array) { + if (this.wrap && this.headerDone && !this.ended) throw new PendingThrow("java/lang/IllegalArgumentException"); if (this.wrap && !this.headerDone) this.dictId = adler32(1, d) >>> 0; } private put(...bytes: number[]) { @@ -87,6 +88,7 @@ class Deflate { } // flush: 0 none, 2 sync, 3 full, 4 finish. Returns [read, written]. run(input: Uint8Array, output: Uint8Array, flush: number): [number, number] { + if (output.length === 0) return [0, 0]; let read = 0, written = this.drain(output, 0); if (this.pendLen) { this.lastFlush = -1; diff --git a/test/java/DeflaterState.java b/test/java/DeflaterState.java new file mode 100644 index 0000000..a53a959 --- /dev/null +++ b/test/java/DeflaterState.java @@ -0,0 +1,31 @@ +import java.util.zip.Deflater; + +public class DeflaterState { + static void dictionary(Deflater def, String label) { + try { + def.setDictionary(new byte[] { 1, 2, 3 }); + System.out.println(label + ": accepted"); + } catch (IllegalArgumentException e) { + System.out.println(label + ": " + e.getClass().getName()); + } + } + + public static void main(String[] args) { + Deflater def = new Deflater(); + try { + dictionary(def, "before input"); + def.setInput(new byte[] { 1, 2, 3 }); + def.deflate(new byte[0]); + dictionary(def, "after empty output buffer"); + def.deflate(new byte[32]); + dictionary(def, "after output"); + def.finish(); + def.deflate(new byte[32]); + dictionary(def, "after finish"); + def.reset(); + dictionary(def, "after reset"); + } finally { + def.end(); + } + } +} From 95b14533d6923888c413861d7d9ad0ba6cc5ca1b Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:06:46 -0600 Subject: [PATCH 02/12] Cache bootstrap linkage failures and dynamic constants --- src/indy.ts | 13 ++++- src/jit.ts | 2 +- src/jvm.ts | 16 +++++- test/java/LinkageCaching.java | 105 ++++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 test/java/LinkageCaching.java diff --git a/src/indy.ts b/src/indy.ts index 1ced1ff..6764476 100644 --- a/src/indy.ts +++ b/src/indy.ts @@ -465,11 +465,20 @@ export function* constantObject(vm: JVM, t: JThread, cls: JClass, idx: number): case Tag.Class: return vm.mirror(yield* vm.loadClassGen(t, cls.loader, className(cp, idx))); } const cached = cls.ldc[idx]; - if (cached !== undefined && e.tag !== Tag.Dynamic) return cached; + if (cached !== undefined) { + if (e.tag !== Tag.Dynamic) return cached; + const desc = utf8(cp, cp[e.b]!.b); + return isPrim(desc) ? yield* box(t, desc, cached) : cached; + } let v: any; if (e.tag === Tag.MethodType) v = yield* methodType(vm, t, cls.loader, utf8(cp, e.a)); else if (e.tag === Tag.MethodHandle) v = yield* methodHandleConstant(vm, t, cls, cpArg(cls, idx) as MethodHandleRef); - else if (e.tag === Tag.Dynamic) return yield* dynamicConstant(vm, t, cls, idx); + else if (e.tag === Tag.Dynamic) { + v = yield* dynamicConstant(vm, t, cls, idx); + const desc = utf8(cp, cp[e.b]!.b); + cls.ldc[idx] = isPrim(desc) && v !== null ? vm.getField(v, v.cls.name, "value") : v; + return v; + } else throw new PendingThrow("java/lang/InternalError", `bad constant tag ${e.tag}`); cls.ldc[idx] = v; return v; diff --git a/src/jit.ts b/src/jit.ts index 92d69fa..71f350a 100644 --- a/src/jit.ts +++ b/src/jit.ts @@ -884,7 +884,7 @@ function insn(g: Gen, b: Body, pc: number) { } case 0xba: { const site = m.cache?.[pc]; - if (!site || region) { emit(slow(pc, d)); break; } + if (!site || site instanceof PendingThrow || site instanceof JavaThrow || region) { emit(slow(pc, d)); break; } let dd = d; if (site.appendix !== undefined) { emit(`${S(dd)}=${K(site.appendix)};`); dd++; } emit(genInvoke(g, 0xb8, pc, next, dd, site.m, cls, d)); diff --git a/src/jvm.ts b/src/jvm.ts index 8b5dafa..d297de2 100644 --- a/src/jvm.ts +++ b/src/jvm.ts @@ -454,9 +454,23 @@ export class JVM { const cache = f.m.cache ??= []; const pc = f.pc; const site = cache[pc]; + if (site instanceof PendingThrow || site instanceof JavaThrow) throw site; if (site !== undefined) return site; t.pushNative((function* (vm: JVM) { - cache[pc] = yield* linkCallSite(vm, t, f.m.cls, idx); + try { + cache[pc] = yield* linkCallSite(vm, t, f.m.cls, idx); + } catch (e) { + if (e instanceof JavaThrow && vm.isInstance(e.obj, "java/lang/LinkageError")) { + const cls = e.obj.cls; + const detail = vm.getField(e.obj, "java/lang/Throwable", "detailMessage"); + // HotSpot recreates cached bootstrap errors without their original cause. + cache[pc] = vm.loadBoot(cls.name) === cls + ? new PendingThrow(cls.name, detail === null ? null : vm.jsString(detail)) : e; + } else if (e instanceof PendingThrow && vm.loadBoot(e.cls)?.isAssignableTo(vm.loadBootOrFail("java/lang/LinkageError"))) { + cache[pc] = e; + } + throw e; + } })(this), null, "V", true); return undefined; } diff --git a/test/java/LinkageCaching.java b/test/java/LinkageCaching.java new file mode 100644 index 0000000..be1a574 --- /dev/null +++ b/test/java/LinkageCaching.java @@ -0,0 +1,105 @@ +import java.lang.invoke.*; +import java.lang.reflect.*; +import java.lang.classfile.ClassFile; +import java.lang.constant.*; +import static java.lang.classfile.ClassFile.*; +import static java.lang.constant.ConstantDescs.*; + +public class LinkageCaching { + static int bootstrapCalls; + static int constantCalls; + static int errorCalls; + + public static CallSite bootstrap(MethodHandles.Lookup lookup, String name, MethodType type) { + if (++bootstrapCalls == 1) throw new IllegalStateException("first attempt"); + return new ConstantCallSite(MethodHandles.constant(String.class, "linked")); + } + + public static Object constant(MethodHandles.Lookup lookup, String name, Class type) { + if (type == long.class) { + constantCalls++; + return 9007199254740993L; + } + return "value" + ++constantCalls; + } + + public static CallSite error(MethodHandles.Lookup lookup, String name, MethodType type) { + errorCalls++; + throw new AssertionError("bootstrap assertion"); + } + + public static CallSite withArgument(MethodHandles.Lookup lookup, String name, MethodType type, Object value) { + return new ConstantCallSite(MethodHandles.constant(type.returnType(), value)); + } + + static class Loader extends ClassLoader { + Class define(byte[] bytes) { return defineClass(null, bytes, 0, bytes.length); } + } + + public static void main(String[] args) throws Exception { + ClassDesc owner = ClassDesc.of("LinkageCaching"); + DirectMethodHandleDesc bootstrap = MethodHandleDesc.ofMethod(DirectMethodHandleDesc.Kind.STATIC, owner, "bootstrap", + MethodTypeDesc.of(CD_CallSite, CD_MethodHandles_Lookup, CD_String, CD_MethodType)); + DirectMethodHandleDesc constantBootstrap = MethodHandleDesc.ofMethod(DirectMethodHandleDesc.Kind.STATIC, owner, "constant", + MethodTypeDesc.of(CD_Object, CD_MethodHandles_Lookup, CD_String, CD_Class)); + DirectMethodHandleDesc argumentBootstrap = MethodHandleDesc.ofMethod(DirectMethodHandleDesc.Kind.STATIC, owner, "withArgument", + MethodTypeDesc.of(CD_CallSite, CD_MethodHandles_Lookup, CD_String, CD_MethodType, CD_Object)); + DynamicConstantDesc constant = DynamicConstantDesc.ofNamed(constantBootstrap, "value", CD_String); + DynamicConstantDesc number = DynamicConstantDesc.ofNamed(constantBootstrap, "number", CD_long); + DynamicConstantDesc reverse = DynamicConstantDesc.ofNamed(constantBootstrap, "reverse", CD_String); + DirectMethodHandleDesc error = MethodHandleDesc.ofMethod(DirectMethodHandleDesc.Kind.STATIC, owner, "error", + MethodTypeDesc.of(CD_CallSite, CD_MethodHandles_Lookup, CD_String, CD_MethodType)); + MethodTypeDesc type = MethodTypeDesc.of(CD_String); + MethodTypeDesc longType = MethodTypeDesc.of(CD_long); + byte[] bytes = ClassFile.of().build(ClassDesc.of("GeneratedLinkage"), writer -> { + writer.withMethodBody("invoke", type, ACC_PUBLIC | ACC_STATIC, + code -> code.invokedynamic(DynamicCallSiteDesc.of(bootstrap, "call", type)).areturn()); + writer.withMethodBody("load", type, ACC_PUBLIC | ACC_STATIC, code -> code.ldc(constant).areturn()); + writer.withMethodBody("argument", type, ACC_PUBLIC | ACC_STATIC, + code -> code.invokedynamic(DynamicCallSiteDesc.of(argumentBootstrap, "argument", type, constant)).areturn()); + writer.withMethodBody("number", longType, ACC_PUBLIC | ACC_STATIC, code -> code.ldc(number).lreturn()); + writer.withMethodBody("numberArgument", longType, ACC_PUBLIC | ACC_STATIC, + code -> code.invokedynamic(DynamicCallSiteDesc.of(argumentBootstrap, "number", longType, number)).lreturn()); + writer.withMethodBody("reverse", type, ACC_PUBLIC | ACC_STATIC, code -> code.ldc(reverse).areturn()); + writer.withMethodBody("reverseArgument", type, ACC_PUBLIC | ACC_STATIC, + code -> code.invokedynamic(DynamicCallSiteDesc.of(argumentBootstrap, "reverse", type, reverse)).areturn()); + writer.withMethodBody("error", type, ACC_PUBLIC | ACC_STATIC, + code -> code.invokedynamic(DynamicCallSiteDesc.of(error, "error", type)).areturn()); + }); + Class generated = new Loader().define(bytes); + Method invoke = generated.getMethod("invoke"); + int failures = 0; + Throwable first = null; + for (int i = 0; i < 1000; i++) { + try { + invoke.invoke(null); + } catch (InvocationTargetException e) { + failures++; + if (i == 0) first = e.getCause(); + if (i < 2) { + Throwable failure = e.getCause(); + System.out.println("failure " + i + ": " + failure.getClass().getName() + " same " + (failure == first) + + " cause " + (failure.getCause() == null ? "none" : failure.getCause().getClass().getName())); + } + } + } + System.out.println("bootstrap calls " + bootstrapCalls + " failures " + failures); + System.out.println("load " + generated.getMethod("load").invoke(null)); + System.out.println("argument " + generated.getMethod("argument").invoke(null)); + System.out.println("load again " + generated.getMethod("load").invoke(null)); + System.out.println("constant calls " + constantCalls); + System.out.println("long load " + generated.getMethod("number").invoke(null)); + System.out.println("long argument " + generated.getMethod("numberArgument").invoke(null)); + System.out.println("reverse argument " + generated.getMethod("reverseArgument").invoke(null)); + System.out.println("reverse load " + generated.getMethod("reverse").invoke(null)); + System.out.println("all constant calls " + constantCalls); + for (int i = 0; i < 2; i++) { + try { + generated.getMethod("error").invoke(null); + } catch (InvocationTargetException e) { + System.out.println("error " + i + ": " + e.getCause().getClass().getName()); + } + } + System.out.println("error calls " + errorCalls); + } +} From 310dcdaa23c32cc5c15ceded58855673576cee7f Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:01:16 -0600 Subject: [PATCH 03/12] Implement NIO POSIX permission changes --- src/fs.ts | 2 ++ src/natives/nio.ts | 14 +++++++++++--- src/node.ts | 2 +- test/java/PosixPermissions.java | 25 +++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 test/java/PosixPermissions.java diff --git a/src/fs.ts b/src/fs.ts index 287840a..b974d8e 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -4,6 +4,8 @@ export interface Stat { dir: boolean; size: number; mtime: number; + // permission and special bits, when the backend can report them + mode?: number; // a character device; it streams and is never read whole device?: boolean; } diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 783181d..d61de7b 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -51,7 +51,7 @@ function fillAttrs(t: JThread, attrs: JObject, p: string): number { if (!st) return ENOENT; const vm = t.vm, A = "sun/nio/fs/UnixFileAttributes"; const writable = vm.fs.writable(p); - const mode = st.device ? S_IFCHR | 0o666 : st.dir ? S_IFDIR | (writable ? 0o755 : 0o555) : S_IFREG | (writable ? 0o644 : 0o444); + const mode = st.device ? S_IFCHR | 0o666 : (st.dir ? S_IFDIR : S_IFREG) | (st.mode ?? (st.dir ? (writable ? 0o755 : 0o555) : (writable ? 0o644 : 0o444))); const sec = BigInt(Math.floor(st.mtime / 1000)), nsec = BigInt(Math.floor((st.mtime % 1000) * 1e6)); vm.setField(attrs, A, "st_mode", mode); vm.setField(attrs, A, "st_ino", BigInt(inode(t, p))); @@ -164,8 +164,16 @@ export const natives: Natives = { [`${N}readlink0(J)[B`]: () => unixError(EINVAL), [`${N}symlink0(JJ)V`]: () => unixError(ENOSYS), [`${N}link0(JJ)V`]: () => unixError(ENOSYS), - [`${N}chmod0(JI)V`]: () => undefined, - [`${N}fchmod0(II)V`]: () => undefined, + [`${N}chmod0(JI)V`]: (t, [p, mode]) => { + const abs = path(t, p); + if (!t.vm.fs.stat(abs)) unixError(ENOENT); + if (!t.vm.fs.chmod(abs, (old) => (old & ~0o7777) | (mode & 0o7777))) unixError(EROFS); + }, + [`${N}fchmod0(II)V`]: (t, [fd, mode]) => { + const f = fileOf(t, fd); + if (!t.vm.fs.stat(f.path)) unixError(ENOENT); + if (!t.vm.fs.chmod(f.path, (old) => (old & ~0o7777) | (mode & 0o7777))) unixError(EROFS); + }, [`${N}chown0(JII)V`]: () => undefined, [`${N}lchown0(JII)V`]: () => undefined, [`${N}fchown0(III)V`]: () => undefined, diff --git a/src/node.ts b/src/node.ts index 5608b75..8b68cd0 100644 --- a/src/node.ts +++ b/src/node.ts @@ -40,7 +40,7 @@ export class NodeBackend implements Backend { let st: Stat | null = null; try { const s = fs.statSync(this.host(p)); - st = { dir: s.isDirectory(), size: s.size, mtime: s.mtimeMs }; + st = { dir: s.isDirectory(), size: s.size, mtime: s.mtimeMs, mode: s.mode & 0o7777 }; } catch { st = null; } diff --git a/test/java/PosixPermissions.java b/test/java/PosixPermissions.java new file mode 100644 index 0000000..e1f182e --- /dev/null +++ b/test/java/PosixPermissions.java @@ -0,0 +1,25 @@ +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Set; + +public class PosixPermissions { + public static void main(String[] args) throws Exception { + Path file = Files.createTempFile("java-web-mode", ".tmp"); + try { + Set permissions = PosixFilePermissions.fromString("rwxr-----"); + Files.setPosixFilePermissions(file, permissions); + System.out.println(PosixFilePermissions.toString(Files.getPosixFilePermissions(file))); + try { + Files.setPosixFilePermissions(file.resolveSibling("missing"), permissions); + System.out.println("missing path accepted"); + } catch (NoSuchFileException e) { + System.out.println("missing path rejected"); + } + } finally { + Files.deleteIfExists(file); + } + } +} From 0d60b1ef7a203b729a483958eaff21a1f08dfff1 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:17:55 -0600 Subject: [PATCH 04/12] Restrict library method selection to public methods --- docs/browser.md | 6 ++++++ src/library.ts | 2 +- test/library.test.ts | 5 +++++ test/library/Lib.java | 3 +++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/browser.md b/docs/browser.md index 0baf24c..dd7b60b 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -69,9 +69,15 @@ try { rejection; call a setter to wait for the write. - Overloads are chosen by how well each argument fits the parameter type, then varargs as a last resort. Pass an array or the elements for a varargs parameter. + Library calls expose public methods, for both static and instance calls. - Java strings are JS strings. Boxed `Integer`, `Double`, `Boolean` and the other numbers are JS primitives, and a JS number passed as an `Object` becomes an `Integer` when it is whole and a `Double` otherwise. Any other object is a proxy. +- Java `long` and boxed `Long` results are JS numbers, following + [CheerpJ's LiveConnect conversion rules](https://cheerpj.com/docs/reference/CJ3Library). + Values outside the JS safe-integer range can lose precision. Return a string + or `long[]` (a `BigInt64Array`) when every bit must be preserved. JS `bigint` + arguments can be passed to Java `long` parameters without losing precision. - Arrays are copies. A primitive array is a typed array (`Int32Array` for `int[]`, `Int8Array` for `byte[]`, `Uint8Array` for `boolean[]`) and a reference array is a JS array. A typed array you pass to a call is updated with what Java wrote diff --git a/src/library.ts b/src/library.ts index 57cc82d..3f49bde 100644 --- a/src/library.ts +++ b/src/library.ts @@ -500,7 +500,7 @@ export class Library { for (const c of classes) { for (const m of c.methods) { if (m.name !== name || m.isStatic !== isStatic || m.access & ACC_SYNTHETIC || seen.has(m.key)) continue; - if (!isStatic && !(m.access & ACC_PUBLIC)) continue; + if (!(m.access & ACC_PUBLIC)) continue; seen.add(m.key); out.push(m); } diff --git a/test/library.test.ts b/test/library.test.ts index 4a2aee6..4524141 100644 --- a/test/library.test.ts +++ b/test/library.test.ts @@ -52,6 +52,11 @@ test("calls run in order", async () => { assert.equal(await stat("Lib", "calls"), before + 3); }); +test("static calls select only public methods", async () => { + assert.equal(await stat("Lib", "visible", "text"), "public"); + await assert.rejects(stat("Lib", "hidden"), /no static method hidden/); +}); + test("objects are handles that name their class once", async () => { const a = await make(Counter, "a"); assert.equal(a.cls, "Lib$Counter"); diff --git a/test/library/Lib.java b/test/library/Lib.java index bdcf8e8..3cba204 100644 --- a/test/library/Lib.java +++ b/test/library/Lib.java @@ -1,5 +1,8 @@ // Static methods called from JS in library mode. public class Lib { + public static String visible(Object value) { return "public"; } + private static String visible(String value) { return "private"; } + private static String hidden() { return "private"; } static int calls; public static String greet(String name) { From a082b46ba4899edb37a86bf63c0c607e605bbc4b Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:20:52 -0600 Subject: [PATCH 05/12] Document compression and browser redirect limits --- docs/support.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/support.md b/docs/support.md index f36682a..1c49461 100644 --- a/docs/support.md +++ b/docs/support.md @@ -10,6 +10,9 @@ java-web targets headless Java: console apps, libraries and javac. drawing on a `BufferedImage`, and JavaBeans. So does `java.xml`, including XML Schema validation and DTDs from the JDK's built-in catalog. - `jrt:/` URLs read the JDK's resources but not its class files. +- `Deflater` writes valid DEFLATE streams using stored blocks. Compression + levels and strategies currently do not reduce output size; compressed bytes + and compression ratios differ from HotSpot's zlib output. - Not supported: GUI apps (AWT and Swing windows), `ConvolveOp`, child processes, and native libraries other than JS natives. @@ -66,7 +69,9 @@ Locales other than English need `cheerpjInit({ localeData: true })`, or `javaProperties` sets its own), and that proxy turns each request into a `fetch`. The browser's rules apply: CORS, and no `http:` requests from an `https:` page. A failed fetch is a 502 response. Redirects are followed by - `fetch`, and a response body is read whole before Java sees it. Raw sockets + `fetch`, even when Java disables redirect following: a browser's manual + redirect response hides the status and `Location` header. A response body is + read whole before Java sees it. Raw sockets to other hosts still fail. - The browser does the TLS for `https:`. The proxy answers `CONNECT host:port` and then reads plain HTTP in the tunnel, and `fetch` does the TLS and checks From 0fec8b6ed9cf63b10ade90de07db0e0a75e5638b Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:24:49 -0600 Subject: [PATCH 06/12] Validate descriptor permission errors --- src/natives/nio.ts | 5 +++-- test/java/PosixPermissions.java | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/natives/nio.ts b/src/natives/nio.ts index d61de7b..4980999 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -170,8 +170,9 @@ export const natives: Natives = { if (!t.vm.fs.chmod(abs, (old) => (old & ~0o7777) | (mode & 0o7777))) unixError(EROFS); }, [`${N}fchmod0(II)V`]: (t, [fd, mode]) => { - const f = fileOf(t, fd); - if (!t.vm.fs.stat(f.path)) unixError(ENOENT); + const f = t.vm.state.get("fds")?.files.get(fd); + if (!f) unixError(EBADF); + if (!f.path || !t.vm.fs.stat(f.path)) unixError(ENOENT); if (!t.vm.fs.chmod(f.path, (old) => (old & ~0o7777) | (mode & 0o7777))) unixError(EROFS); }, [`${N}chown0(JII)V`]: () => undefined, diff --git a/test/java/PosixPermissions.java b/test/java/PosixPermissions.java index e1f182e..001f384 100644 --- a/test/java/PosixPermissions.java +++ b/test/java/PosixPermissions.java @@ -1,3 +1,7 @@ +// java: --add-opens java.base/sun.nio.fs=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED +import java.io.FileDescriptor; +import java.io.RandomAccessFile; +import java.lang.reflect.InvocationTargetException; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; @@ -12,6 +16,19 @@ public static void main(String[] args) throws Exception { Set permissions = PosixFilePermissions.fromString("rwxr-----"); Files.setPosixFilePermissions(file, permissions); System.out.println(PosixFilePermissions.toString(Files.getPosixFilePermissions(file))); + var fchmod = Class.forName("sun.nio.fs.UnixNativeDispatcher").getDeclaredMethod("fchmod", int.class, int.class); + fchmod.setAccessible(true); + var descriptor = FileDescriptor.class.getDeclaredField("fd"); + descriptor.setAccessible(true); + try (var open = new RandomAccessFile(file.toFile(), "rw")) { + fchmod.invoke(null, descriptor.getInt(open.getFD()), 0600); + System.out.println("descriptor " + PosixFilePermissions.toString(Files.getPosixFilePermissions(file))); + } + try { + fchmod.invoke(null, -1, 0600); + } catch (InvocationTargetException e) { + System.out.println("invalid descriptor " + e.getCause().getClass().getName()); + } try { Files.setPosixFilePermissions(file.resolveSibling("missing"), permissions); System.out.println("missing path accepted"); From 0b9878ebf30cbbfe0f7bedd1be9d70057110ff63 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:22:08 -0600 Subject: [PATCH 07/12] Preserve fstat attrs after unlink --- src/natives/io.ts | 48 ++++++++++++++++++++++++--- src/natives/nio.ts | 34 +++++++++---------- test/java/FstatAfterUnlink.java | 58 +++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 22 deletions(-) create mode 100644 test/java/FstatAfterUnlink.java diff --git a/src/natives/io.ts b/src/natives/io.ts index ebd1dcc..23c9b9f 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -5,7 +5,7 @@ import { JArray, JObject } from "../runtime.ts"; import { JThread, PendingThrow } from "../thread.ts"; import type { NativeGen } from "../thread.ts"; import { normalize } from "../fs.ts"; -import type { VFS } from "../fs.ts"; +import type { Stat, VFS } from "../fs.ts"; import { closeSockFd } from "../sockets.ts"; import { nonNull, u8 } from "./util.ts"; import type { Natives } from "./util.ts"; @@ -23,6 +23,8 @@ export class OpenFile { pos = 0; writable: boolean; contents: Contents; + ino?: number; + detached?: { path: string; stat: Stat; writable: boolean }; constructor(path: string, data: Uint8Array, writable: boolean, contents?: Contents) { this.path = path; this.contents = contents ?? { data, size: data.length, dirty: false }; @@ -124,6 +126,26 @@ function table(t: JThread): FdTable { return tab; } +export function fileInode(t: JThread, path: string): number { + let inodes = t.vm.state.get("inodes") as Map | undefined; + if (!inodes) t.vm.state.set("inodes", (inodes = new Map())); + let next = t.vm.state.get("inodeNext") as number | undefined; + if (next === undefined) { + next = inodes.size + 2; + t.vm.state.set("inodeNext", next); + } + const files = table(t).files.values(); + for (const f of files) { + if (f.detached?.path === path && f.ino === inodes.get(path)) inodes.set(path, next++); + } + t.vm.state.set("inodeNext", next); + let ino = inodes.get(path); + if (ino === undefined) inodes.set(path, (ino = next++)); + t.vm.state.set("inodeNext", next); + for (const f of table(t).files.values()) if (f.path === path) f.ino = ino; + return ino; +} + // rmdir fails on a path ending in "." or "..": "." stays the working directory export function endsInDot(p: string): boolean { const last = p.replace(/\/+$/, "").split("/").pop(); @@ -245,20 +267,36 @@ export function renamePath(t: JThread, from: string, to: string): boolean { const files = [...table(t).files.values()]; const under = (f: OpenFile, p: string) => f.path === p || f.path.startsWith(p + "/"); const open = files.filter((f) => under(f, from)); + const replaced = files.filter((f) => !open.includes(f) && under(f, to)); + for (const f of replaced) detachFile(t, f); for (const f of open) flush(t, f); - if (!t.vm.fs.rename(from, to)) return false; + if (!t.vm.fs.rename(from, to)) { + for (const f of replaced) f.detached = undefined; + return false; + } // a file the rename replaced stays open, unlinked - for (const f of files) if (!open.includes(f) && under(f, to)) f.path = ""; + for (const f of replaced) f.path = ""; for (const f of open) f.path = to + f.path.slice(from.length); return true; } export function removePath(t: JThread, abs: string): boolean { - if (!t.vm.fs.remove(abs)) return false; - for (const f of table(t).files.values()) if (f.path === abs) f.path = ""; + const open = [...table(t).files.values()].filter((f) => f.path === abs); + for (const f of open) detachFile(t, f); + if (!t.vm.fs.remove(abs)) { + for (const f of open) f.detached = undefined; + return false; + } + for (const f of open) f.path = ""; return true; } +function detachFile(t: JThread, f: OpenFile) { + f.ino ??= fileInode(t, f.path); + const stat = t.vm.fs.stat(f.path); + if (stat) f.detached = { path: f.path, stat, writable: t.vm.fs.writable(f.path) }; +} + function fdOf(t: JThread, stream: JObject, owner: string): number { const fdObj = t.vm.getField(stream, owner, "fd") as JObject; return t.vm.getField(fdObj, "java/io/FileDescriptor", "fd"); diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 4980999..c5b5eff 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -2,11 +2,11 @@ // native memory; failures throw UnixException(errno) like the real natives. import type { JObject } from "../runtime.ts"; +import type { Stat } from "../fs.ts"; import { preCloseSock } from "../sockets.ts"; import { sockRead, sockReadv, sockWrite, sockWritev } from "./sockets.ts"; import { JThread, PendingThrow } from "../thread.ts"; -import { closeFd, endsInDot, fileOf, flush, flushOrThrow, openFile, removePath, renamePath, resolvePath, setMtime, syncPath, writeFailed } from "./io.ts"; -import type { OpenFile } from "./io.ts"; +import { closeFd, endsInDot, fileInode, fileOf, flush, flushOrThrow, openFile, removePath, renamePath, resolvePath, setMtime, syncPath, writeFailed } from "./io.ts"; import type { Natives } from "./util.ts"; export const ENOENT = 2, EBADF = 9, EACCES = 13, EEXIST = 17, EXDEV = 18, ENOTDIR = 20, EISDIR = 21, @@ -49,22 +49,25 @@ function fillAttrs(t: JThread, attrs: JObject, p: string): number { syncPath(t, p); const st = t.vm.fs.stat(p); if (!st) return ENOENT; + fillStat(t, attrs, st, t.vm.fs.writable(p), fileInode(t, p)); + return 0; +} + +function fillStat(t: JThread, attrs: JObject, st: Stat, writable: boolean, ino: number, size = st.size, nlink = 1) { const vm = t.vm, A = "sun/nio/fs/UnixFileAttributes"; - const writable = vm.fs.writable(p); const mode = st.device ? S_IFCHR | 0o666 : (st.dir ? S_IFDIR : S_IFREG) | (st.mode ?? (st.dir ? (writable ? 0o755 : 0o555) : (writable ? 0o644 : 0o444))); const sec = BigInt(Math.floor(st.mtime / 1000)), nsec = BigInt(Math.floor((st.mtime % 1000) * 1e6)); vm.setField(attrs, A, "st_mode", mode); - vm.setField(attrs, A, "st_ino", BigInt(inode(t, p))); + vm.setField(attrs, A, "st_ino", BigInt(ino)); vm.setField(attrs, A, "st_dev", 1n); - vm.setField(attrs, A, "st_nlink", 1); + vm.setField(attrs, A, "st_nlink", nlink); vm.setField(attrs, A, "st_uid", 1000); vm.setField(attrs, A, "st_gid", 1000); - vm.setField(attrs, A, "st_size", BigInt(st.size)); + vm.setField(attrs, A, "st_size", BigInt(size)); for (const k of ["atime", "mtime", "ctime"]) { vm.setField(attrs, A, `st_${k}_sec`, sec); vm.setField(attrs, A, `st_${k}_nsec`, nsec); } - return 0; } // open mount table handles: whether the one entry has been read @@ -74,15 +77,6 @@ function mountTables(t: JThread): Map { return m; } -// stable inode numbers so file keys compare equal for the same path -function inode(t: JThread, p: string): number { - let m = t.vm.state.get("inodes") as Map | undefined; - if (!m) t.vm.state.set("inodes", (m = new Map())); - let n = m.get(p); - if (n === undefined) m.set(p, (n = m.size + 2)); - return n; -} - interface Dir { names: string[]; pos: number } function dirs(t: JThread): Map { @@ -108,6 +102,10 @@ export const natives: Natives = { const files = t.vm.state.get("fds")?.files; const f = files?.get(fd); if (!f) unixError(EBADF); + if (f.detached) { + fillStat(t, attrs, f.detached.stat, f.detached.writable, f.ino ?? fileInode(t, f.detached.path), f.size, 0); + return; + } fillAttrs(t, attrs, f.path); }, [`${N}access0(JI)I`]: (t, [p, amode]) => { @@ -124,7 +122,9 @@ export const natives: Natives = { if (st?.dir && write) unixError(EISDIR); if ((write || !st) && t.vm.fs.denied(abs)) unixError(EACCES); try { - return openFile(t, abs, write, (flags & O_TRUNC) !== 0, (flags & O_APPEND) !== 0); + const fd = openFile(t, abs, write, (flags & O_TRUNC) !== 0, (flags & O_APPEND) !== 0); + fileOf(t, fd).ino = fileInode(t, abs); + return fd; } catch (e) { if (e instanceof PendingThrow) unixError(t.vm.fs.writable(abs) ? ENOENT : EROFS); throw e; diff --git a/test/java/FstatAfterUnlink.java b/test/java/FstatAfterUnlink.java new file mode 100644 index 0000000..fd2fa1b --- /dev/null +++ b/test/java/FstatAfterUnlink.java @@ -0,0 +1,58 @@ +// java: --add-opens java.base/sun.nio.fs=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED +import java.io.FileDescriptor; +import java.io.RandomAccessFile; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; + +public class FstatAfterUnlink { + private static Object stat(int fd) throws Exception { + Class attrs = Class.forName("sun.nio.fs.UnixFileAttributes"); + Method get = attrs.getDeclaredMethod("get", int.class); + get.setAccessible(true); + return get.invoke(null, fd); + } + + private static Object value(Object attrs, String name) throws Exception { + Method method = attrs.getClass().getDeclaredMethod(name); + method.setAccessible(true); + return method.invoke(attrs); + } + + public static void main(String[] args) throws Exception { + Path path = Files.createTempFile("fstat-unlink", ".tmp"); + try (RandomAccessFile file = new RandomAccessFile(path.toFile(), "rw")) { + file.write(new byte[10]); + Field descriptor = FileDescriptor.class.getDeclaredField("fd"); + descriptor.setAccessible(true); + int fd = descriptor.getInt(file.getFD()); + Files.delete(path); + file.write(new byte[5]); + Files.writeString(path, "fresh"); + Object attrs = stat(fd); + boolean regular = (boolean) value(attrs, "isRegularFile"); + long size = (long) value(attrs, "size"); + long ino = (long) value(attrs, "ino"); + int links = (int) value(attrs, "nlink"); + try (RandomAccessFile fresh = new RandomAccessFile(path.toFile(), "r")) { + if (ino == (long) value(stat(descriptor.getInt(fresh.getFD())), "ino")) + throw new AssertionError("detached descriptor has replacement inode"); + } + if (!regular || size != 15 || links != 0) + throw new AssertionError("detached attrs: regular=" + regular + " size=" + size + " links=" + links); + System.out.println("detached " + regular + " " + size); + } + try { + stat(-1); + } catch (InvocationTargetException e) { + Method errno = e.getCause().getClass().getDeclaredMethod("errno"); + errno.setAccessible(true); + int code = (int) errno.invoke(e.getCause()); + if (code != 9) throw new AssertionError("invalid fd errno=" + code); + System.out.println("invalid " + code); + } + Files.delete(path); + } +} From a6106068ee342b31a8568d71a5207c3717a6b93c Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:13:24 -0600 Subject: [PATCH 08/12] Resolve catch types by loader and preserve descriptor identity --- src/interp.ts | 47 +++++++++++++++++++++-- src/jit.ts | 3 +- src/jvm.ts | 5 +-- src/natives/io.ts | 27 +++++++------- src/natives/nio.ts | 10 ++++- test/java/FstatAfterUnlink.java | 10 +++++ test/java/LinkageCaching.java | 21 +++++++++++ test/java/LoaderCatch.java | 66 +++++++++++++++++++++++++++++++++ 8 files changed, 168 insertions(+), 21 deletions(-) create mode 100644 test/java/LoaderCatch.java diff --git a/src/interp.ts b/src/interp.ts index 07971a7..27ae34c 100644 --- a/src/interp.ts +++ b/src/interp.ts @@ -621,6 +621,10 @@ export function raise(t: JThread, exc: JObject) { return; } const h = findHandler(t.vm, f, exc); + if (h < -1) { + t.pushNative(resolveHandler(t, f, exc, -h - 2), null, "V", true); + return; + } if (h >= 0) { if (t.frames.length < MAX_FRAMES - 500) t.stackLimit = MAX_FRAMES; f.sp = 0; @@ -632,18 +636,55 @@ export function raise(t: JThread, exc: JObject) { } } +// Negative values below -1 identify an unresolved handler's table index. function findHandler(vm: JVM, f: Frame, exc: JObject): number { const handlers = f.m.code!.handlers; const pc = f.pc; - for (const h of handlers) { + for (let i = 0; i < handlers.length; i++) { + const h = handlers[i]; if (pc < h.start || pc >= h.end) continue; if (h.catchType === 0) return h.handler; - const name = vm.cpClassName(f.m.cls, h.catchType); - for (let c: JClass | null = exc.cls; c; c = c.superclass) if (c.name === name) return h.handler; + const cls = f.m.cls; + const name = vm.cpClassName(cls, h.catchType); + const target = cls.rcp[h.catchType] ?? vm.findLoaded(cls.loader, name) ?? (cls.loader === null ? vm.loadBoot(name) : null); + if (!target) return -i - 2; + cls.rcp[h.catchType] = target; + if (exc.cls.isSubclassOf(target)) return h.handler; } return -1; } +function* resolveHandler(t: JThread, f: Frame, exc: JObject, start: number): NativeGen { + const cls = f.m.cls; + const handlers = f.m.code!.handlers; + for (let i = start; i < handlers.length; i++) { + const h = handlers[i]; + if (f.pc < h.start || f.pc >= h.end) continue; + if (h.catchType !== 0) { + try { + const target = cls.rcp[h.catchType] ?? (yield* t.vm.loadClassGen(t, cls.loader, t.vm.cpClassName(cls, h.catchType))); + cls.rcp[h.catchType] = target; + if (!exc.cls.isSubclassOf(target)) continue; + } catch (e) { + if (e instanceof JavaThrow) exc = e.obj; + else if (e instanceof PendingThrow) exc = yield* t.vm.newThrowable(t, e.cls, e.msg, null, e.ctor); + else throw e; + continue; + } + } + if (t.frames.length < MAX_FRAMES - 500) t.stackLimit = MAX_FRAMES; + f.sp = 1; + f.stack[0] = exc; + f.pc = h.handler; + return; + } + // Unwind the throwing frame while keeping this native frame on top. + const resolver = t.frames.pop()!; + popFrame(t, f); + t.frames.push(resolver); + throw new JavaThrow(exc); +} + export function handleError(t: JThread, e: unknown) { if (e instanceof JavaThrow) raise(t, e.obj); else if (e instanceof PendingThrow) t.vm.throwNew(t, e.cls, e.msg, e.ctor, e.native); diff --git a/src/jit.ts b/src/jit.ts index 71f350a..455e154 100644 --- a/src/jit.ts +++ b/src/jit.ts @@ -605,6 +605,7 @@ function generate(t: JThread, m: JMethod): { src: string; consts: any[] } { out.push(`default:return rt.viaInterpreter(t,f);}}catch(e){`); if (g.regions.length) out.push(deoptCatch(g)); out.push(`if(e instanceof rt.JavaThrow){const h=H.findHandler(t,f,pc,e.obj);if(h>=0){${S(0)}=e.obj;pc=h;continue;}`); + out.push(`if(h< -1){x=pc*${exitCode(1, 0, 0)}+${EXIT_ERROR};xe=e;if(f.direct)f.direct=false;break X;}`); out.push(`H.popFrame(t,f);if(f.direct)throw e;H.raise(t,e.obj);return 1;}`); out.push(`x=(e instanceof rt.PendingThrow&&e.pc!==undefined?e.pc:pc)*${exitCode(1, 0, 0)}+${EXIT_ERROR};xe=e;if(f.direct)f.direct=false;break X;}}}`); // the shared exit: spill into the frame's arrays, then leave as x says @@ -884,7 +885,7 @@ function insn(g: Gen, b: Body, pc: number) { } case 0xba: { const site = m.cache?.[pc]; - if (!site || site instanceof PendingThrow || site instanceof JavaThrow || region) { emit(slow(pc, d)); break; } + if (!site || site instanceof PendingThrow || region) { emit(slow(pc, d)); break; } let dd = d; if (site.appendix !== undefined) { emit(`${S(dd)}=${K(site.appendix)};`); dd++; } emit(genInvoke(g, 0xb8, pc, next, dd, site.m, cls, d)); diff --git a/src/jvm.ts b/src/jvm.ts index d297de2..8fbfd30 100644 --- a/src/jvm.ts +++ b/src/jvm.ts @@ -454,7 +454,7 @@ export class JVM { const cache = f.m.cache ??= []; const pc = f.pc; const site = cache[pc]; - if (site instanceof PendingThrow || site instanceof JavaThrow) throw site; + if (site instanceof PendingThrow) throw site; if (site !== undefined) return site; t.pushNative((function* (vm: JVM) { try { @@ -464,8 +464,7 @@ export class JVM { const cls = e.obj.cls; const detail = vm.getField(e.obj, "java/lang/Throwable", "detailMessage"); // HotSpot recreates cached bootstrap errors without their original cause. - cache[pc] = vm.loadBoot(cls.name) === cls - ? new PendingThrow(cls.name, detail === null ? null : vm.jsString(detail)) : e; + cache[pc] = new PendingThrow(cls.name, detail === null ? null : vm.jsString(detail)); } else if (e instanceof PendingThrow && vm.loadBoot(e.cls)?.isAssignableTo(vm.loadBootOrFail("java/lang/LinkageError"))) { cache[pc] = e; } diff --git a/src/natives/io.ts b/src/natives/io.ts index 23c9b9f..6a2b939 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -129,20 +129,13 @@ function table(t: JThread): FdTable { export function fileInode(t: JThread, path: string): number { let inodes = t.vm.state.get("inodes") as Map | undefined; if (!inodes) t.vm.state.set("inodes", (inodes = new Map())); - let next = t.vm.state.get("inodeNext") as number | undefined; - if (next === undefined) { - next = inodes.size + 2; - t.vm.state.set("inodeNext", next); + const ino = inodes.get(path); + if (ino === undefined) { + const next = (t.vm.state.get("inodeNext") as number | undefined) ?? 2; + inodes.set(path, next); + t.vm.state.set("inodeNext", next + 1); + return next; } - const files = table(t).files.values(); - for (const f of files) { - if (f.detached?.path === path && f.ino === inodes.get(path)) inodes.set(path, next++); - } - t.vm.state.set("inodeNext", next); - let ino = inodes.get(path); - if (ino === undefined) inodes.set(path, (ino = next++)); - t.vm.state.set("inodeNext", next); - for (const f of table(t).files.values()) if (f.path === path) f.ino = ino; return ino; } @@ -277,6 +270,13 @@ export function renamePath(t: JThread, from: string, to: string): boolean { // a file the rename replaced stays open, unlinked for (const f of replaced) f.path = ""; for (const f of open) f.path = to + f.path.slice(from.length); + const inodes = t.vm.state.get("inodes") as Map | undefined; + if (inodes) { + const moved = [...inodes].filter(([p]) => p === from || p.startsWith(from + "/")); + for (const p of inodes.keys()) if (p === to || p.startsWith(to + "/")) inodes.delete(p); + for (const [p] of moved) inodes.delete(p); + for (const [p, ino] of moved) inodes.set(to + p.slice(from.length), ino); + } return true; } @@ -288,6 +288,7 @@ export function removePath(t: JThread, abs: string): boolean { return false; } for (const f of open) f.path = ""; + t.vm.state.get("inodes")?.delete(abs); return true; } diff --git a/src/natives/nio.ts b/src/natives/nio.ts index c5b5eff..e6968b6 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -7,6 +7,7 @@ import { preCloseSock } from "../sockets.ts"; import { sockRead, sockReadv, sockWrite, sockWritev } from "./sockets.ts"; import { JThread, PendingThrow } from "../thread.ts"; import { closeFd, endsInDot, fileInode, fileOf, flush, flushOrThrow, openFile, removePath, renamePath, resolvePath, setMtime, syncPath, writeFailed } from "./io.ts"; +import type { OpenFile } from "./io.ts"; import type { Natives } from "./util.ts"; export const ENOENT = 2, EBADF = 9, EACCES = 13, EEXIST = 17, EXDEV = 18, ENOTDIR = 20, EISDIR = 21, @@ -172,6 +173,13 @@ export const natives: Natives = { [`${N}fchmod0(II)V`]: (t, [fd, mode]) => { const f = t.vm.state.get("fds")?.files.get(fd); if (!f) unixError(EBADF); + if (f.detached) { + const files = t.vm.state.get("fds").files as Map; + for (const open of files.values()) { + if (open.contents === f.contents && open.detached) open.detached.stat.mode = mode & 0o7777; + } + return; + } if (!f.path || !t.vm.fs.stat(f.path)) unixError(ENOENT); if (!t.vm.fs.chmod(f.path, (old) => (old & ~0o7777) | (mode & 0o7777))) unixError(EROFS); }, @@ -411,7 +419,7 @@ Object.assign(natives, { "sun/nio/ch/FileKey.init(Ljava/io/FileDescriptor;[J)V": (t: JThread, [fd, info]: any[]) => { const f = chanFile(t, fd); info.a[0] = 1n; - info.a[1] = BigInt(inode(t, f.path)); + info.a[1] = BigInt(f.ino ?? fileInode(t, f.path)); }, "sun/nio/ch/UnixDispatcher.init()V": () => undefined, "sun/nio/ch/UnixDispatcher.preClose0(Ljava/io/FileDescriptor;)V": (t: JThread, [fd]: any[]) => preCloseSock(t.vm.state, fdNum(t, fd)), diff --git a/test/java/FstatAfterUnlink.java b/test/java/FstatAfterUnlink.java index fd2fa1b..3219968 100644 --- a/test/java/FstatAfterUnlink.java +++ b/test/java/FstatAfterUnlink.java @@ -28,14 +28,24 @@ public static void main(String[] args) throws Exception { Field descriptor = FileDescriptor.class.getDeclaredField("fd"); descriptor.setAccessible(true); int fd = descriptor.getInt(file.getFD()); + long originalInode = (long) value(stat(fd), "ino"); + Path renamed = path.resolveSibling(path.getFileName() + ".renamed"); + Files.move(path, renamed); + path = renamed; + if ((long) value(stat(fd), "ino") != originalInode) throw new AssertionError("renamed inode changed"); Files.delete(path); file.write(new byte[5]); + Method chmod = Class.forName("sun.nio.fs.UnixNativeDispatcher").getDeclaredMethod("fchmod", int.class, int.class); + chmod.setAccessible(true); + chmod.invoke(null, fd, 0640); Files.writeString(path, "fresh"); Object attrs = stat(fd); boolean regular = (boolean) value(attrs, "isRegularFile"); long size = (long) value(attrs, "size"); long ino = (long) value(attrs, "ino"); + if (ino != originalInode) throw new AssertionError("detached inode changed"); int links = (int) value(attrs, "nlink"); + if (((int) value(attrs, "mode") & 0777) != 0640) throw new AssertionError("detached permissions"); try (RandomAccessFile fresh = new RandomAccessFile(path.toFile(), "r")) { if (ino == (long) value(stat(descriptor.getInt(fresh.getFD())), "ino")) throw new AssertionError("detached descriptor has replacement inode"); diff --git a/test/java/LinkageCaching.java b/test/java/LinkageCaching.java index be1a574..dbd2159 100644 --- a/test/java/LinkageCaching.java +++ b/test/java/LinkageCaching.java @@ -9,6 +9,10 @@ public class LinkageCaching { static int bootstrapCalls; static int constantCalls; static int errorCalls; + static int customCalls; + public static class CustomError extends LinkageError { + public CustomError() { super("custom bootstrap error"); } + } public static CallSite bootstrap(MethodHandles.Lookup lookup, String name, MethodType type) { if (++bootstrapCalls == 1) throw new IllegalStateException("first attempt"); @@ -28,6 +32,11 @@ public static CallSite error(MethodHandles.Lookup lookup, String name, MethodTyp throw new AssertionError("bootstrap assertion"); } + public static CallSite custom(MethodHandles.Lookup lookup, String name, MethodType type) { + customCalls++; + throw new CustomError(); + } + public static CallSite withArgument(MethodHandles.Lookup lookup, String name, MethodType type, Object value) { return new ConstantCallSite(MethodHandles.constant(type.returnType(), value)); } @@ -49,6 +58,8 @@ public static void main(String[] args) throws Exception { DynamicConstantDesc reverse = DynamicConstantDesc.ofNamed(constantBootstrap, "reverse", CD_String); DirectMethodHandleDesc error = MethodHandleDesc.ofMethod(DirectMethodHandleDesc.Kind.STATIC, owner, "error", MethodTypeDesc.of(CD_CallSite, CD_MethodHandles_Lookup, CD_String, CD_MethodType)); + DirectMethodHandleDesc custom = MethodHandleDesc.ofMethod(DirectMethodHandleDesc.Kind.STATIC, owner, "custom", + MethodTypeDesc.of(CD_CallSite, CD_MethodHandles_Lookup, CD_String, CD_MethodType)); MethodTypeDesc type = MethodTypeDesc.of(CD_String); MethodTypeDesc longType = MethodTypeDesc.of(CD_long); byte[] bytes = ClassFile.of().build(ClassDesc.of("GeneratedLinkage"), writer -> { @@ -65,6 +76,8 @@ public static void main(String[] args) throws Exception { code -> code.invokedynamic(DynamicCallSiteDesc.of(argumentBootstrap, "reverse", type, reverse)).areturn()); writer.withMethodBody("error", type, ACC_PUBLIC | ACC_STATIC, code -> code.invokedynamic(DynamicCallSiteDesc.of(error, "error", type)).areturn()); + writer.withMethodBody("custom", type, ACC_PUBLIC | ACC_STATIC, + code -> code.invokedynamic(DynamicCallSiteDesc.of(custom, "custom", type)).areturn()); }); Class generated = new Loader().define(bytes); Method invoke = generated.getMethod("invoke"); @@ -101,5 +114,13 @@ public static void main(String[] args) throws Exception { } } System.out.println("error calls " + errorCalls); + for (int i = 0; i < 2; i++) { + try { + generated.getMethod("custom").invoke(null); + } catch (InvocationTargetException e) { + System.out.println("custom " + i + ": " + e.getCause().getClass().getName()); + } + } + System.out.println("custom calls " + customCalls); } } diff --git a/test/java/LoaderCatch.java b/test/java/LoaderCatch.java new file mode 100644 index 0000000..6a64dc6 --- /dev/null +++ b/test/java/LoaderCatch.java @@ -0,0 +1,66 @@ +import java.lang.reflect.InvocationTargetException; + +public class LoaderCatch { + public static class E extends RuntimeException {} + public static class Caller { + public static int calls; + public static String run(Runnable task) { + calls++; + try { + try { + task.run(); + return "ok"; + } catch (E e) { + return "caught"; + } + } catch (LinkageError e) { + return "linkage " + e.getClass().getName(); + } + } + } + public static class Thrower implements Runnable { + public void run() { throw new E(); } + } + static class Loader extends ClassLoader { + final String own; + final boolean split; + Loader(String own) { this(own, true); } + Loader(String own, boolean split) { this.own = own; this.split = split; } + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!split && name.equals("LoaderCatch$E")) return super.loadClass(name, resolve); + if (!name.equals("LoaderCatch$E") && !name.equals("LoaderCatch$" + own)) return super.loadClass(name, resolve); + Class cls = findLoadedClass(name); + if (cls == null) { + try (var in = LoaderCatch.class.getResourceAsStream("/" + name + ".class")) { + byte[] bytes = in.readAllBytes(); + cls = defineClass(name, bytes, 0, bytes.length); + } catch (java.io.IOException e) { + throw new ClassNotFoundException(name, e); + } + } + if (resolve) resolveClass(cls); + return cls; + } + } + + static void check(String label, int warmup) throws Exception { + Class caller = new Loader("Caller").loadClass("LoaderCatch$Caller"); + var method = caller.getMethod("run", Runnable.class); + for (int i = 0; i < warmup; i++) method.invoke(null, (Runnable) () -> {}); + Runnable task = (Runnable) new Loader("Thrower").loadClass("LoaderCatch$Thrower").getConstructor().newInstance(); + try { + System.out.println(label + " " + method.invoke(null, task)); + } catch (InvocationTargetException e) { + System.out.println(label + " escaped " + (e.getCause().getClass().getClassLoader() == task.getClass().getClassLoader())); + } + System.out.println(label + " calls " + caller.getField("calls").get(null)); + } + + public static void main(String[] args) throws Exception { + check("cold", 0); + check("hot", 1000); + Class caller = new Loader("Caller", false).loadClass("LoaderCatch$Caller"); + System.out.println("parent " + caller.getMethod("run", Runnable.class).invoke(null, (Runnable) () -> { throw new E(); })); + System.out.println("parent calls " + caller.getField("calls").get(null)); + } +} From 4a70750d9dda85d8a60962e5d746af91b3995c32 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:16:52 -0600 Subject: [PATCH 09/12] Follow host symlinks before parent path components --- src/fs.ts | 13 +++++++++++++ src/natives/io.ts | 3 +-- src/node.ts | 12 ++++++++++++ test/fs.test.ts | 19 ++++++++++++++++++- test/paths/SymlinkDotDot.java | 19 +++++++++++++++++++ test/programs.test.ts | 25 ++++++++++++++++++++++++- 6 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 test/paths/SymlinkDotDot.java diff --git a/src/fs.ts b/src/fs.ts index b974d8e..58cfa5c 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -16,6 +16,8 @@ export const DEVICES = new Set(["/dev/null", "/dev/zero", "/dev/random", "/dev/u export interface Backend { readonly writable: boolean; + // A host backend can follow symlinks before resolving parent components. + resolvePath?(path: string): string | null; stat(path: string): Stat | null; read(path: string): Uint8Array | null; // bytes [off, off + len) of a file, for backends that can read part of one @@ -53,6 +55,17 @@ export class VFS { private mounts: { prefix: string; backend: Backend }[] = []; private hidden: string[] = []; + resolvePath(path: string): string { + const normalized = normalize(path); + if (!path.includes("/..")) return normalized; + const r = this.resolve(normalized); + if (!r?.[0].resolvePath) return normalized; + const prefix = normalized.slice(0, normalized.length - r[1].length); + const raw = prefix === "" ? path : path.startsWith(prefix + "/") ? path.slice(prefix.length) : r[1]; + const resolved = r[0].resolvePath(raw); + return resolved === null ? normalized : normalize(prefix + resolved); + } + mount(prefix: string, backend: Backend) { prefix = normalize(prefix); this.mounts.push({ prefix, backend }); diff --git a/src/natives/io.ts b/src/natives/io.ts index 6a2b939..96add50 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -4,7 +4,6 @@ import { JArray, JObject } from "../runtime.ts"; import { JThread, PendingThrow } from "../thread.ts"; import type { NativeGen } from "../thread.ts"; -import { normalize } from "../fs.ts"; import type { Stat, VFS } from "../fs.ts"; import { closeSockFd } from "../sockets.ts"; import { nonNull, u8 } from "./util.ts"; @@ -146,7 +145,7 @@ export function endsInDot(p: string): boolean { } export function resolvePath(t: JThread, p: string): string { - return normalize(p.startsWith("/") ? p : `${t.vm.opts.cwd ?? "/"}/${p}`); + return t.vm.fs.resolvePath(p.startsWith("/") ? p : `${t.vm.opts.cwd ?? "/"}/${p}`); } function notFound(path: string, why = "No such file or directory"): never { diff --git a/src/node.ts b/src/node.ts index 8b68cd0..bb5e001 100644 --- a/src/node.ts +++ b/src/node.ts @@ -35,6 +35,18 @@ export class NodeBackend implements Backend { private host(p: string) { return this.root === "/" ? p : this.root + p; } + resolvePath(p: string): string | null { + if (this.root !== "/") return null; + try { + return fs.realpathSync.native(p); + } catch { + try { + return fs.realpathSync.native(path.dirname(p)) + "/" + path.basename(p); + } catch { + return null; + } + } + } stat(p: string): Stat | null { if (!this.writable && this.stats.has(p)) return this.stats.get(p)!; let st: Stat | null = null; diff --git a/test/fs.test.ts b/test/fs.test.ts index f1ad084..f8373e5 100644 --- a/test/fs.test.ts +++ b/test/fs.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { MemoryBackend } from "../src/fs.ts"; +import { MemoryBackend, VFS } from "../src/fs.ts"; test("renaming a file onto a directory leaves both intact", () => { const fs = new MemoryBackend(); @@ -17,3 +17,20 @@ test("renaming a file onto a directory leaves both intact", () => { assert.equal(fs.rename("/file", "/other"), true); assert.deepEqual(fs.read("/other"), new Uint8Array([1])); }); + +test("host path resolution leaves other mounts and memory paths lexical", () => { + const fs = new VFS(); + const host = new MemoryBackend(); + let hostCalls = 0; + Object.assign(host, { resolvePath: () => { hostCalls++; return "/target/file"; } }); + fs.mount("/", host); + fs.mount("/jdk", new MemoryBackend()); + assert.equal(fs.resolvePath("/jdk/dir/../file"), "/jdk/file"); + assert.equal(hostCalls, 0); + assert.equal(fs.resolvePath("/work/link/../file"), "/target/file"); + assert.equal(hostCalls, 1); + + const memory = new VFS(); + memory.mount("/files", new MemoryBackend()); + assert.equal(memory.resolvePath("/files/dir/../file"), "/files/file"); +}); diff --git a/test/paths/SymlinkDotDot.java b/test/paths/SymlinkDotDot.java new file mode 100644 index 0000000..0e6b099 --- /dev/null +++ b/test/paths/SymlinkDotDot.java @@ -0,0 +1,19 @@ +import java.nio.file.Files; +import java.nio.file.Path; + +public class SymlinkDotDot { + public static void main(String[] args) throws Exception { + Path work = Path.of(args[0]); + Path target = Path.of(args[1]); + Path throughLink = work.resolve("link/../file"); + System.out.println(Files.readString(throughLink)); + Path created = work.resolve("link/../created"); + Files.writeString(created, "new"); + System.out.println("created " + Files.exists(target.resolve("created")) + " lexical " + Files.exists(work.resolve("created"))); + Path moved = work.resolve("link/../moved"); + Files.move(created, moved); + System.out.println("moved " + Files.readString(target.resolve("moved"))); + Files.delete(moved); + System.out.println("deleted " + !Files.exists(target.resolve("moved"))); + } +} diff --git a/test/programs.test.ts b/test/programs.test.ts index 7430300..b67f647 100644 --- a/test/programs.test.ts +++ b/test/programs.test.ts @@ -2,7 +2,7 @@ // what HotSpot prints, with the same exit code. import { execFileSync, spawn, spawnSync } from "node:child_process"; -import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -111,6 +111,29 @@ function flags(file: string, tool: string): string[] { return m ? m[1].split(" ") : []; } +test("host symlinks resolve before parent components", { skip: process.platform === "win32" }, () => { + const classes = join(root, "build", "test-paths"); + mkdirSync(classes, { recursive: true }); + execFileSync(hotspot("javac"), ["-d", classes, join(root, "test", "paths", "SymlinkDotDot.java")]); + const dir = mkdtempSync(join(tmpdir(), "java-web-links-")); + const setup = (name: string) => { + const work = join(dir, name, "work"), target = join(dir, name, "target"); + mkdirSync(work, { recursive: true }); + mkdirSync(join(target, "dir"), { recursive: true }); + symlinkSync(join(target, "dir"), join(work, "link"), "dir"); + writeFileSync(join(work, "file"), "lexical"); + writeFileSync(join(target, "file"), "target"); + return ["-cp", classes, "SymlinkDotDot", work, target]; + }; + try { + const ref = spawnSync(hotspot("java"), setup("hotspot"), { encoding: "utf8" }); + const got = spawnSync(process.execPath, [join(root, "src", "node.ts"), ...setup("vm")], { encoding: "utf8" }); + assert.deepEqual({ stdout: got.stdout, code: got.status }, { stdout: ref.stdout, code: ref.status }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + function javacFlags(file: string): string[] { return flags(file, "javac"); } From acc624b7fa7a5f723509b3078ba2eb28269d38d3 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:25:33 -0600 Subject: [PATCH 10/12] Preserve terminal symlinks and hidden catch types --- src/fs.ts | 2 +- src/interp.ts | 7 +++++-- src/node.ts | 10 ++++------ test/fs.test.ts | 4 ++++ test/java/LoaderCatch.java | 19 ++++++++++++++++++- test/paths/SymlinkDotDot.java | 2 ++ test/programs.test.ts | 3 +++ 7 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/fs.ts b/src/fs.ts index 58cfa5c..7094935 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -60,7 +60,7 @@ export class VFS { if (!path.includes("/..")) return normalized; const r = this.resolve(normalized); if (!r?.[0].resolvePath) return normalized; - const prefix = normalized.slice(0, normalized.length - r[1].length); + const prefix = r[1] === "/" && normalized !== "/" ? normalized : normalized.slice(0, normalized.length - r[1].length); const raw = prefix === "" ? path : path.startsWith(prefix + "/") ? path.slice(prefix.length) : r[1]; const resolved = r[0].resolvePath(raw); return resolved === null ? normalized : normalize(prefix + resolved); diff --git a/src/interp.ts b/src/interp.ts index 27ae34c..2e4da0b 100644 --- a/src/interp.ts +++ b/src/interp.ts @@ -646,7 +646,8 @@ function findHandler(vm: JVM, f: Frame, exc: JObject): number { if (h.catchType === 0) return h.handler; const cls = f.m.cls; const name = vm.cpClassName(cls, h.catchType); - const target = cls.rcp[h.catchType] ?? vm.findLoaded(cls.loader, name) ?? (cls.loader === null ? vm.loadBoot(name) : null); + const self = name === cls.cf?.name || name === cls.cf?.renamedFrom; + const target = cls.rcp[h.catchType] ?? (self ? cls : vm.findLoaded(cls.loader, name) ?? (cls.loader === null ? vm.loadBoot(name) : null)); if (!target) return -i - 2; cls.rcp[h.catchType] = target; if (exc.cls.isSubclassOf(target)) return h.handler; @@ -662,7 +663,9 @@ function* resolveHandler(t: JThread, f: Frame, exc: JObject, start: number): Nat if (f.pc < h.start || f.pc >= h.end) continue; if (h.catchType !== 0) { try { - const target = cls.rcp[h.catchType] ?? (yield* t.vm.loadClassGen(t, cls.loader, t.vm.cpClassName(cls, h.catchType))); + const name = t.vm.cpClassName(cls, h.catchType); + const self = name === cls.cf?.name || name === cls.cf?.renamedFrom; + const target = cls.rcp[h.catchType] ?? (self ? cls : yield* t.vm.loadClassGen(t, cls.loader, name)); cls.rcp[h.catchType] = target; if (!exc.cls.isSubclassOf(target)) continue; } catch (e) { diff --git a/src/node.ts b/src/node.ts index bb5e001..8686261 100644 --- a/src/node.ts +++ b/src/node.ts @@ -38,13 +38,11 @@ export class NodeBackend implements Backend { resolvePath(p: string): string | null { if (this.root !== "/") return null; try { - return fs.realpathSync.native(p); + const name = path.basename(p); + if (name === "." || name === "..") return fs.realpathSync.native(p); + return fs.realpathSync.native(path.dirname(p)) + "/" + name; } catch { - try { - return fs.realpathSync.native(path.dirname(p)) + "/" + path.basename(p); - } catch { - return null; - } + return null; } } stat(p: string): Stat | null { diff --git a/test/fs.test.ts b/test/fs.test.ts index f8373e5..5798824 100644 --- a/test/fs.test.ts +++ b/test/fs.test.ts @@ -29,6 +29,10 @@ test("host path resolution leaves other mounts and memory paths lexical", () => assert.equal(hostCalls, 0); assert.equal(fs.resolvePath("/work/link/../file"), "/target/file"); assert.equal(hostCalls, 1); + const mountRoot = new MemoryBackend(); + Object.assign(mountRoot, { resolvePath: () => "/" }); + fs.mount("/host", mountRoot); + assert.equal(fs.resolvePath("/host/dir/.."), "/host"); const memory = new VFS(); memory.mount("/files", new MemoryBackend()); diff --git a/test/java/LoaderCatch.java b/test/java/LoaderCatch.java index 6a64dc6..4b7f323 100644 --- a/test/java/LoaderCatch.java +++ b/test/java/LoaderCatch.java @@ -1,7 +1,14 @@ import java.lang.reflect.InvocationTargetException; public class LoaderCatch { - public static class E extends RuntimeException {} + public static class E extends RuntimeException { + public static boolean selfCatch() { + try { throw new E(); } catch (E e) { return true; } + } + public static boolean catchTask(Runnable task) { + try { task.run(); return false; } catch (E e) { return true; } + } + } public static class Caller { public static int calls; public static String run(Runnable task) { @@ -62,5 +69,15 @@ public static void main(String[] args) throws Exception { Class caller = new Loader("Caller", false).loadClass("LoaderCatch$Caller"); System.out.println("parent " + caller.getMethod("run", Runnable.class).invoke(null, (Runnable) () -> { throw new E(); })); System.out.println("parent calls " + caller.getField("calls").get(null)); + try (var in = LoaderCatch.class.getResourceAsStream("/LoaderCatch$E.class")) { + Class hidden = java.lang.invoke.MethodHandles.lookup().defineHiddenClass(in.readAllBytes(), true).lookupClass(); + RuntimeException exception = (RuntimeException) hidden.getConstructor().newInstance(); + try { + System.out.println("hidden provided " + hidden.getMethod("catchTask", Runnable.class).invoke(null, (Runnable) () -> { throw exception; })); + } catch (InvocationTargetException e) { + System.out.println("hidden provided false"); + } + System.out.println("hidden " + hidden.getMethod("selfCatch").invoke(null)); + } } } diff --git a/test/paths/SymlinkDotDot.java b/test/paths/SymlinkDotDot.java index 0e6b099..38dead2 100644 --- a/test/paths/SymlinkDotDot.java +++ b/test/paths/SymlinkDotDot.java @@ -15,5 +15,7 @@ public static void main(String[] args) throws Exception { System.out.println("moved " + Files.readString(target.resolve("moved"))); Files.delete(moved); System.out.println("deleted " + !Files.exists(target.resolve("moved"))); + Files.delete(work.resolve("dir/../last")); + System.out.println("link target kept " + Files.exists(target.resolve("victim"))); } } diff --git a/test/programs.test.ts b/test/programs.test.ts index b67f647..9f1527f 100644 --- a/test/programs.test.ts +++ b/test/programs.test.ts @@ -119,10 +119,13 @@ test("host symlinks resolve before parent components", { skip: process.platform const setup = (name: string) => { const work = join(dir, name, "work"), target = join(dir, name, "target"); mkdirSync(work, { recursive: true }); + mkdirSync(join(work, "dir")); mkdirSync(join(target, "dir"), { recursive: true }); symlinkSync(join(target, "dir"), join(work, "link"), "dir"); writeFileSync(join(work, "file"), "lexical"); writeFileSync(join(target, "file"), "target"); + writeFileSync(join(target, "victim"), "keep"); + symlinkSync(join(target, "victim"), join(work, "last")); return ["-cp", classes, "SymlinkDotDot", work, target]; }; try { From 9020855a70bbe8af6da753b5acc29957c6da3b68 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:34:00 -0600 Subject: [PATCH 11/12] Preserve non-private static native callbacks --- docs/browser.md | 3 ++- src/library.ts | 2 +- test/library.test.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/browser.md b/docs/browser.md index dd7b60b..4eede28 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -69,7 +69,8 @@ try { rejection; call a setter to wait for the write. - Overloads are chosen by how well each argument fits the parameter type, then varargs as a last resort. Pass an array or the elements for a varargs parameter. - Library calls expose public methods, for both static and instance calls. + Instance calls expose public methods. Static calls also expose package-private + and protected helpers used by native callbacks; private methods are excluded. - Java strings are JS strings. Boxed `Integer`, `Double`, `Boolean` and the other numbers are JS primitives, and a JS number passed as an `Object` becomes an `Integer` when it is whole and a `Double` otherwise. Any other object is a proxy. diff --git a/src/library.ts b/src/library.ts index 3f49bde..0474a94 100644 --- a/src/library.ts +++ b/src/library.ts @@ -500,7 +500,7 @@ export class Library { for (const c of classes) { for (const m of c.methods) { if (m.name !== name || m.isStatic !== isStatic || m.access & ACC_SYNTHETIC || seen.has(m.key)) continue; - if (!(m.access & ACC_PUBLIC)) continue; + if (m.isPrivate || (!isStatic && !(m.access & ACC_PUBLIC))) continue; seen.add(m.key); out.push(m); } diff --git a/test/library.test.ts b/test/library.test.ts index 4524141..512d72c 100644 --- a/test/library.test.ts +++ b/test/library.test.ts @@ -52,7 +52,7 @@ test("calls run in order", async () => { assert.equal(await stat("Lib", "calls"), before + 3); }); -test("static calls select only public methods", async () => { +test("static calls exclude private methods", async () => { assert.equal(await stat("Lib", "visible", "text"), "public"); await assert.rejects(stat("Lib", "hidden"), /no static method hidden/); }); From a28620f920a054f431897074d308e0a56fb4dd45 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:50:29 -0600 Subject: [PATCH 12/12] Preserve permission errors and catch-resolution dispatch --- src/fs.ts | 6 +++ src/interp.ts | 3 ++ src/natives/nio.ts | 35 ++++++++++++---- src/node.ts | 8 +++- test/java/CatchTypeFailure.java | 50 ++++++++++++++++++++++ test/nio.test.ts | 73 +++++++++++++++++++++++++++++++++ test/programs.test.ts | 1 + 7 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 test/java/CatchTypeFailure.java create mode 100644 test/nio.test.ts diff --git a/src/fs.ts b/src/fs.ts index 7094935..3b8d26f 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -35,6 +35,7 @@ export interface Backend { denied?(path: string): boolean; // sets the permission bits to f(current bits) chmod?(path: string, f: (mode: number) => number): boolean; + chmodError?(path: string): string | undefined; // whether the host allows reading (4) and executing (1), as access(2) access?(path: string, mode: number): boolean; } @@ -200,6 +201,11 @@ export class VFS { return r[0].writable && r[0].chmod(r[1], f); } + chmodError(path: string): string | undefined { + const r = this.resolve(path); + return r?.[0].chmodError?.(r[1]); + } + // access(2) on an existing path: mode is 4 read, 2 write, 1 execute access(path: string, mode: number): boolean { if (DEVICES.has(normalize(path))) return true; diff --git a/src/interp.ts b/src/interp.ts index 2e4da0b..d285417 100644 --- a/src/interp.ts +++ b/src/interp.ts @@ -672,6 +672,9 @@ function* resolveHandler(t: JThread, f: Frame, exc: JObject, start: number): Nat if (e instanceof JavaThrow) exc = e.obj; else if (e instanceof PendingThrow) exc = yield* t.vm.newThrowable(t, e.cls, e.msg, null, e.ctor); else throw e; + // HotSpot restarts lookup at the failing handler's bytecode position. + f.pc = h.handler; + i = -1; continue; } } diff --git a/src/natives/nio.ts b/src/natives/nio.ts index e6968b6..5771d09 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -10,16 +10,25 @@ import { closeFd, endsInDot, fileInode, fileOf, flush, flushOrThrow, openFile, r import type { OpenFile } from "./io.ts"; import type { Natives } from "./util.ts"; -export const ENOENT = 2, EBADF = 9, EACCES = 13, EEXIST = 17, EXDEV = 18, ENOTDIR = 20, EISDIR = 21, - EINVAL = 22, EROFS = 30, ENOTEMPTY = 39, ENOSYS = 38; +export const EPERM = 1, ENOENT = 2, EINTR = 4, EIO = 5, EBADF = 9, ENOMEM = 12, EACCES = 13, + EEXIST = 17, EXDEV = 18, ENOTDIR = 20, EISDIR = 21, EINVAL = 22, ENOSPC = 28, EROFS = 30, + ENOTEMPTY = 39, ENOSYS = 38, ELOOP = 40, ENOTSUP = 95, ESTALE = 116; const MESSAGES: Record = { + [EPERM]: "Operation not permitted", [EINTR]: "Interrupted system call", [EIO]: "Input/output error", + [ENOMEM]: "Cannot allocate memory", [ENOSPC]: "No space left on device", + [ELOOP]: "Too many levels of symbolic links", [ENOTSUP]: "Operation not supported", [ESTALE]: "Stale file handle", [ENOENT]: "No such file or directory", [EBADF]: "Bad file descriptor", [EACCES]: "Permission denied", [EEXIST]: "File exists", [EXDEV]: "Invalid cross-device link", [ENOTDIR]: "Not a directory", [EISDIR]: "Is a directory", [EINVAL]: "Invalid argument", [EROFS]: "Read-only file system", [ENOTEMPTY]: "Directory not empty", [ENOSYS]: "Function not implemented", }; +const CHMOD_ERRNO: Record = { + EPERM, ENOENT, EINTR, EIO, EBADF, ENOMEM, EACCES, ENOTDIR, EINVAL, ENOSPC, EROFS, ELOOP, + ENOTSUP, EOPNOTSUPP: ENOTSUP, ESTALE, +}; + // Linux open(2) flags const O_ACCMODE = 3, O_CREAT = 0o100, O_EXCL = 0o200, O_TRUNC = 0o1000, O_APPEND = 0o2000; const S_IFDIR = 0o040000, S_IFREG = 0o100000, S_IFCHR = 0o020000; @@ -46,6 +55,20 @@ function path(t: JThread, addr: bigint) { return resolvePath(t, cstring(t, addr)); } +function chmod(t: JThread, p: string, mode: number) { + const fs = t.vm.fs; + const result = fs.chmod(p, (old) => (old & ~0o7777) | (mode & 0o7777)); + if (result !== false) { + if (result === undefined && !fs.stat(p)) unixError(ENOENT); + return; + } + if (!fs.writable(p)) { + if (!fs.stat(p)) unixError(ENOENT); + unixError(EROFS); + } + unixError(CHMOD_ERRNO[fs.chmodError(p) ?? "EIO"] ?? EIO); +} + function fillAttrs(t: JThread, attrs: JObject, p: string): number { syncPath(t, p); const st = t.vm.fs.stat(p); @@ -166,9 +189,7 @@ export const natives: Natives = { [`${N}symlink0(JJ)V`]: () => unixError(ENOSYS), [`${N}link0(JJ)V`]: () => unixError(ENOSYS), [`${N}chmod0(JI)V`]: (t, [p, mode]) => { - const abs = path(t, p); - if (!t.vm.fs.stat(abs)) unixError(ENOENT); - if (!t.vm.fs.chmod(abs, (old) => (old & ~0o7777) | (mode & 0o7777))) unixError(EROFS); + chmod(t, path(t, p), mode); }, [`${N}fchmod0(II)V`]: (t, [fd, mode]) => { const f = t.vm.state.get("fds")?.files.get(fd); @@ -180,8 +201,8 @@ export const natives: Natives = { } return; } - if (!f.path || !t.vm.fs.stat(f.path)) unixError(ENOENT); - if (!t.vm.fs.chmod(f.path, (old) => (old & ~0o7777) | (mode & 0o7777))) unixError(EROFS); + if (!f.path) unixError(ENOENT); + chmod(t, f.path, mode); }, [`${N}chown0(JII)V`]: () => undefined, [`${N}lchown0(JII)V`]: () => undefined, diff --git a/src/node.ts b/src/node.ts index 8686261..9f1d281 100644 --- a/src/node.ts +++ b/src/node.ts @@ -101,14 +101,20 @@ export class NodeBackend implements Backend { return (e as NodeJS.ErrnoException).code === "EACCES" || (e as NodeJS.ErrnoException).code === "EPERM"; } } + private chmodErrors = new Map(); chmod(p: string, f: (mode: number) => number) { try { fs.chmodSync(this.host(p), f(fs.statSync(this.host(p)).mode & 0o7777)); + this.chmodErrors.delete(p); return true; - } catch { + } catch (e) { + this.chmodErrors.set(p, (e as NodeJS.ErrnoException).code ?? "EIO"); return false; } } + chmodError(p: string) { + return this.chmodErrors.get(p); + } access(p: string, mode: number) { try { fs.accessSync(this.host(p), mode); diff --git a/test/java/CatchTypeFailure.java b/test/java/CatchTypeFailure.java new file mode 100644 index 0000000..9e1feac --- /dev/null +++ b/test/java/CatchTypeFailure.java @@ -0,0 +1,50 @@ +// java: -XX:+UnlockDiagnosticVMOptions -XX:-BytecodeVerificationRemote -XX:-BytecodeVerificationLocal +// Disable eager verifier resolution to exercise failure during handler lookup. +import java.lang.reflect.InvocationTargetException; + +public class CatchTypeFailure { + public static class Missing extends RuntimeException {} + public static class Caller { + public static String run(Runnable task) { + try { + try { + task.run(); + return "ok"; + } catch (Missing e) { + return "missing"; + } catch (LinkageError e) { + return "sibling"; + } + } catch (LinkageError e) { + return "outer " + e.getClass().getName(); + } + } + } + static class Loader extends ClassLoader { + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.equals("CatchTypeFailure$Missing")) throw new ClassNotFoundException(name); + if (!name.equals("CatchTypeFailure$Caller")) return super.loadClass(name, resolve); + Class cls = findLoadedClass(name); + if (cls == null) { + try (var in = CatchTypeFailure.class.getResourceAsStream("/" + name + ".class")) { + byte[] bytes = in.readAllBytes(); + cls = defineClass(name, bytes, 0, bytes.length); + } catch (java.io.IOException e) { + throw new ClassNotFoundException(name, e); + } + } + if (resolve) resolveClass(cls); + return cls; + } + } + public static void main(String[] args) throws Exception { + Class caller = new Loader().loadClass("CatchTypeFailure$Caller"); + try { + System.out.println(caller.getMethod("run", Runnable.class).invoke(null, (Runnable) () -> { + throw new IllegalStateException("original"); + })); + } catch (InvocationTargetException e) { + System.out.println("escaped " + e.getCause().getClass().getName()); + } + } +} diff --git a/test/nio.test.ts b/test/nio.test.ts new file mode 100644 index 0000000..1ad8312 --- /dev/null +++ b/test/nio.test.ts @@ -0,0 +1,73 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MemoryBackend, VFS } from "../src/fs.ts"; +import type { Backend } from "../src/fs.ts"; +import { natives } from "../src/natives/nio.ts"; +import { PendingThrow } from "../src/thread.ts"; +import type { JThread } from "../src/thread.ts"; +import { NodeBackend } from "../src/node.ts"; + +function thread(backend: Backend): JThread { + const fs = new VFS(); + fs.mount("/", backend); + return { vm: { + fs, opts: {}, mem: { u8: new TextEncoder().encode("/file\0") }, + state: new Map([["fds", { files: new Map([[3, { path: "/file" }]]) }]]), + } } as unknown as JThread; +} + +function chmod(t: JThread, descriptor: boolean) { + return descriptor + ? natives["sun/nio/fs/UnixNativeDispatcher.fchmod0(II)V"](t, [3, 0o600]) + : natives["sun/nio/fs/UnixNativeDispatcher.chmod0(JI)V"](t, [0n, 0o600]); +} + +function memory(): MemoryBackend { + const backend = new MemoryBackend(); + backend.put("/file", new Uint8Array([1])); + return backend; +} + +test("NIO chmod preserves the no-op for virtual backends without permission bits", () => { + const t = thread(memory()); + assert.doesNotThrow(() => chmod(t, false)); + assert.doesNotThrow(() => chmod(t, true)); +}); + +test("NIO chmod preserves host errno on writable backends", () => { + for (const [code, errno] of [["EPERM", 1], ["EACCES", 13], ["ELOOP", 40]] as const) { + const backend = Object.assign(memory(), { chmod: () => false, chmodError: () => code }); + for (const descriptor of [false, true]) { + assert.throws(() => chmod(thread(backend), descriptor), (e) => { + assert.ok(e instanceof PendingThrow); + assert.equal(e.cls, "sun/nio/fs/UnixException"); + assert.deepEqual(e.ctor?.args, [errno]); + return true; + }); + } + } +}); + +test("NIO chmod reports a read-only backend as EROFS", () => { + const backend = Object.assign(memory(), { writable: false, chmod: () => true }); + assert.throws(() => chmod(thread(backend), false), (e) => { + assert.ok(e instanceof PendingThrow); + assert.deepEqual(e.ctor?.args, [30]); + return true; + }); +}); + +test("NodeBackend retains the errno from a failed chmod", () => { + const dir = mkdtempSync(join(tmpdir(), "java-web-chmod-")); + try { + writeFileSync(join(dir, "file"), "data"); + const backend = new NodeBackend(dir, true); + assert.equal(backend.chmod("/file/child", (mode) => mode), false); + assert.equal(backend.chmodError("/file/child"), "ENOTDIR"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/programs.test.ts b/test/programs.test.ts index 9f1527f..a4866d5 100644 --- a/test/programs.test.ts +++ b/test/programs.test.ts @@ -130,6 +130,7 @@ test("host symlinks resolve before parent components", { skip: process.platform }; try { const ref = spawnSync(hotspot("java"), setup("hotspot"), { encoding: "utf8" }); + assert.equal(ref.status, 0, ref.stderr); const got = spawnSync(process.execPath, [join(root, "src", "node.ts"), ...setup("vm")], { encoding: "utf8" }); assert.deepEqual({ stdout: got.stdout, code: got.status }, { stdout: ref.stdout, code: ref.status }); } finally {