From 03f99eea54c816f30852f799b291e2e47a77783a Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:46:33 -0600 Subject: [PATCH 01/25] Evaluate @requires in the OpenJDK runner instead of skipping every test that has one --- scripts/jtreg/run.ts | 92 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/scripts/jtreg/run.ts b/scripts/jtreg/run.ts index 7d8dee1..570c29c 100644 --- a/scripts/jtreg/run.ts +++ b/scripts/jtreg/run.ts @@ -14,6 +14,7 @@ import { createHash } from "node:crypto"; import { spawn, spawnSync } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { totalmem } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { hotspot } from "../hotspot.ts"; @@ -55,6 +56,89 @@ if (!existsSync(testRoot)) { } const javaHome = dirname(dirname(realpathSync(hotspot("java")))); +// ---- @requires -------------------------------------------------------------- + +// What jtreg reports for a release Linux HotSpot with default options. A +// property not listed makes the test skip. +const required: Record = { + "os.family": "linux", + "os.arch": process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "amd64" : process.arch, + "os.maxMemory": String(totalmem()), + "sun.arch.data.model": "64", + "vm.bits": "64", + "vm.continuations": "true", + "vm.flagless": "true", + "vm.debug": "false", + "vm.musl": "false", + "vm.compMode": "Xmixed", + "vm.gc": "null", + "vm.gc.G1": "true", + "vm.gc.Serial": "true", + "vm.gc.Parallel": "true", + "vm.gc.Z": "false", + "vm.hasJFR": "true", + "vm.graal.enabled": "false", + "vm.cpu.features": "", +}; + +function size(s: string): number { + const m = s.match(/^(\d+)([kmgt]?)$/i); + return m ? Number(m[1]) * 1024 ** (m[2] ? "kmgt".indexOf(m[2].toLowerCase()) + 1 : 0) : NaN; +} + +// Evaluates a jtreg @requires expression: & | ! ( ) and == != >= <= ~= over properties. +function requiresHolds(expr: string): boolean { + const toks = expr.match(/"[^"]*"|==|!=|>=|<=|~=|[()&|!<>]|[^\s()&|!=<>~"]+/g) ?? []; + let i = 0; + const value = (): string | null => { + const t = toks[i++]; + if (t === undefined) throw new Error("incomplete"); + if (t.startsWith('"')) return t.slice(1, -1); + if (/^[\d.]+[kmgt]?$/i.test(t) || t === "true" || t === "false") return t; + if (t.startsWith("vm.opt.")) return null; + if (t.startsWith("vm.gc.") && !(t in required)) return "false"; + if (t in required) return required[t]; + throw new Error(`unknown property ${t}`); + }; + const atom = (): boolean => { + if (toks[i] === "(") { + i++; + const v = or(); + if (toks[i++] !== ")") throw new Error("missing )"); + return v; + } + if (toks[i] === "!") { + i++; + return !atom(); + } + const a = value(); + const op = toks[i]; + if (!op || !/^(==|!=|>=|<=|~=|<|>)$/.test(op)) return a === "true"; + i++; + const b = value(); + switch (op) { + case "==": return a === b; + case "!=": return a !== b; + case "~=": return a !== null && new RegExp(b ?? "").test(a); + } + const x = size(a ?? ""), y = size(b ?? ""); + return op === ">=" ? x >= y : op === "<=" ? x <= y : op === ">" ? x > y : x < y; + }; + const and = (): boolean => { + let v = atom(); + while (toks[i] === "&") { i++; const r = atom(); v = v && r; } + return v; + }; + const or = (): boolean => { + let v = and(); + while (toks[i] === "|") { i++; const r = and(); v = v || r; } + return v; + }; + const v = or(); + if (i < toks.length) throw new Error("trailing input"); + return v; +} + type Kind = "main" | "testng" | "junit" | "compile"; // a compile action keeps its javac options in opts and its files in args interface Action { kind: Kind; opts: string[]; main: string; args: string[]; timeout: number } @@ -140,7 +224,13 @@ function parseFile(file: string): TestCase[] { case "build": tc.build.push(...value.split(/\s+/).filter(Boolean)); break; case "modules": tc.modules.push(...value.split(/\s+/).filter(Boolean)); break; case "enablePreview": tc.preview = true; break; - case "requires": tc.skip ??= "requires"; break; + case "requires": + try { + if (!requiresHolds(value)) tc.skip ??= "requires"; + } catch (e) { + tc.skip ??= `requires: ${(e as Error).message}`; + } + break; case "ignore": tc.skip ??= "ignore"; break; case "clean": tc.skip ??= "@clean"; break; case "compile": { From 9bf8d4b7935881bfb3d0d20fb5b19e56b1bc3a3e Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:06:51 -0600 Subject: [PATCH 02/25] Apply the requested mode to new files and directories, and add jdk.management with UnixOperatingSystemMXBean natives --- scripts/build-jdk.sh | 2 +- src/natives/io.ts | 9 ++++++- src/natives/management.ts | 45 +++++++++++++++++++++++++++++++++ src/natives/nio.ts | 14 ++++++++-- test/java/CreateModes.java | 52 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 test/java/CreateModes.java diff --git a/scripts/build-jdk.sh b/scripts/build-jdk.sh index 2196547..0ca65a9 100644 --- a/scripts/build-jdk.sh +++ b/scripts/build-jdk.sh @@ -4,7 +4,7 @@ set -euo pipefail cd "$(dirname "$0")/.." out=build/jdk -modules=${JDK_MODULES:-java.se,jdk.compiler,jdk.httpserver,jdk.jartool,jdk.security.jgss,jdk.unsupported,jdk.zipfs,jdk.localedata,jdk.internal.md,javaweb.tls} +modules=${JDK_MODULES:-java.se,jdk.compiler,jdk.httpserver,jdk.jartool,jdk.security.jgss,jdk.unsupported,jdk.zipfs,jdk.localedata,jdk.management,jdk.internal.md,javaweb.tls} # Builds from one pinned JDK release so every machine makes the same image. # Renovate bumps the release. diff --git a/src/natives/io.ts b/src/natives/io.ts index 238e40a..491b76e 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -6,7 +6,7 @@ import { JThread, PendingThrow } from "../thread.ts"; import type { NativeGen } from "../thread.ts"; import type { JVM } from "../jvm.ts"; import type { FileHandle, Stat, VFS } from "../fs.ts"; -import { closeSockFd } from "../sockets.ts"; +import { closeSockFd, socksOf } from "../sockets.ts"; import { nonNull, u8 } from "./util.ts"; import type { Natives } from "./util.ts"; @@ -439,6 +439,13 @@ export function writeFailed(t: JThread, path: string): PendingThrow { return new PendingThrow("java/io/IOException", ERRNO[t.vm.fs.writeError(path) ?? ""] ?? "Input/output error"); } +// Descriptors the program holds, with the three standard streams. +export function openFdCount(vm: JVM): number { + const files = (vm.state.get("fds") as FdTable | undefined)?.files.size ?? 0; + const pipeCount = (vm.state.get("processPipes") as Map | undefined)?.size ?? 0; + return 3 + files + pipeCount + socksOf(vm.state).size; +} + export function newFd(t: JThread): number { return table(t).next++; } diff --git a/src/natives/management.ts b/src/natives/management.ts index 89af721..d4aae01 100644 --- a/src/natives/management.ts +++ b/src/natives/management.ts @@ -10,6 +10,7 @@ import type { JMethod, JObject } from "../runtime.ts"; import { TERMINATED } from "../thread.ts"; import type { JThread, NativeGen } from "../thread.ts"; import type { JVM } from "../jvm.ts"; +import { openFdCount } from "./io.ts"; import type { Natives } from "./util.ts"; const MANAGERS = [ @@ -112,7 +113,26 @@ function liveThreads(vm: JVM): JThread[] { return [...vm.threads].filter((x) => x.state !== TERMINATED && x.obj); } +// A host module under Node; none in a browser. +function host(name: string): any { + return (globalThis as any).process?.getBuiltinModule?.(name); +} + +// A number from a /proc file's line that starts with the label, in its unit +function procValue(file: string, label: string, field = 1): number { + try { + const line = host("node:fs").readFileSync(file, "utf8").split("\n").find((l: string) => l.startsWith(label)); + return Number(line?.trim().split(/\s+/)[field]); + } catch { + return NaN; + } +} + +const orElse = (x: number, fallback: number) => (Number.isFinite(x) ? x : fallback); +const KB = 1024; + const V = "sun/management/VMManagementImpl."; +const OS = "com/sun/management/internal/OperatingSystemImpl."; const P = "sun/management/MemoryPoolImpl."; const T = "sun/management/ThreadImpl."; @@ -209,4 +229,29 @@ export const natives: Natives = { "sun/management/HotspotThread.getInternalThreadCount()I": () => 0, "sun/management/HotspotThread.getInternalThreadTimes0([Ljava/lang/String;[J)I": () => 0, "sun/management/ClassLoadingImpl.setVerboseClass(Z)V": () => undefined, + + // no container limits: Container.metrics() is null + "jdk/internal/platform/CgroupMetrics.isUseContainerSupport()Z": () => 0, + "jdk/internal/platform/CgroupMetrics.isContainerized0()Z": () => 0, + "jdk/internal/platform/CgroupMetrics.getTotalMemorySize0()J": () => BigInt(host("node:os")?.totalmem() ?? MAX_HEAP), + "jdk/internal/platform/CgroupMetrics.getTotalSwapSize0()J": () => BigInt(orElse(procValue("/proc/meminfo", "SwapTotal:") * KB, 0)), + + [`${OS}initialize0()V`]: () => undefined, + [`${OS}getOpenFileDescriptorCount0()J`]: (t) => BigInt(openFdCount(t.vm)), + [`${OS}getMaxFileDescriptorCount0()J`]: () => BigInt(orElse(procValue("/proc/self/limits", "Max open files", 3), 1024)), + [`${OS}getCommittedVirtualMemorySize0()J`]: () => BigInt(orElse(procValue("/proc/self/statm", "", 0) * 4096, (globalThis as any).process?.memoryUsage?.().rss ?? -1)), + [`${OS}getTotalMemorySize0()J`]: () => BigInt(host("node:os")?.totalmem() ?? MAX_HEAP), + [`${OS}getFreeMemorySize0()J`]: () => BigInt(host("node:os")?.freemem() ?? 0), + [`${OS}getTotalSwapSpaceSize0()J`]: () => BigInt(orElse(procValue("/proc/meminfo", "SwapTotal:") * KB, 0)), + [`${OS}getFreeSwapSpaceSize0()J`]: () => BigInt(orElse(procValue("/proc/meminfo", "SwapFree:") * KB, 0)), + [`${OS}getProcessCpuTime0()J`]: () => { + const u = (globalThis as any).process?.cpuUsage?.(); + return u ? BigInt(u.user + u.system) * 1000n : -1n; + }, + [`${OS}getProcessCpuLoad0()D`]: () => -1, + [`${OS}getCpuLoad0()D`]: () => -1, + [`${OS}getSingleCpuLoad0(I)D`]: () => -1, + [`${OS}getHostConfiguredCpuCount0()I`]: () => host("node:os")?.availableParallelism() ?? 1, + [`${OS}getHostOnlineCpuCount0()I`]: () => host("node:os")?.availableParallelism() ?? 1, + [`${OS}getHostTotalCpuTicks0()J`]: () => -1n, }; diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 9b0c7e1..559ed27 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -71,6 +71,14 @@ function chmod(t: JThread, p: string, mode: number) { unixError(HOST_ERRNO[fs.chmodError(p) ?? "EIO"] ?? EIO); } +let processUmask: number | undefined; + +// open(2) and mkdir(2) give a new file the requested mode less the umask +function applyCreateMode(t: JThread, abs: string, mode: number) { + processUmask ??= (globalThis as any).process?.umask?.() ?? 0o022; + t.vm.fs.chmod(abs, (old) => (old & ~0o7777) | (mode & ~processUmask! & 0o7777)); +} + function fillAttrs(t: JThread, attrs: JObject, p: string): number { syncPath(t, p); const st = t.vm.fs.stat(p); @@ -143,7 +151,7 @@ export const natives: Natives = { if (!t.vm.fs.stat(abs)) return ENOENT; return t.vm.fs.access(abs, amode) ? 0 : EACCES; }, - [`${N}open0(JII)I`]: (t, [p, flags]) => { + [`${N}open0(JII)I`]: (t, [p, flags, mode]) => { const abs = path(t, p); const st = t.vm.fs.stat(abs); if ((flags & O_CREAT) && (flags & O_EXCL) && st) unixError(EEXIST); @@ -154,6 +162,7 @@ export const natives: Natives = { try { const fd = openFile(t, abs, write, (flags & O_TRUNC) !== 0, (flags & O_APPEND) !== 0, (flags & O_ACCMODE) !== 1, true); fileOf(t, fd).ino = fileInode(t, abs); + if (!st) applyCreateMode(t, abs, mode); return fd; } catch (e) { if (e instanceof PendingThrow) { @@ -169,10 +178,11 @@ export const natives: Natives = { closeFd(t, fd); }, [`${N}dup(I)I`]: () => unixError(ENOSYS), - [`${N}mkdir0(JI)V`]: (t, [p]) => { + [`${N}mkdir0(JI)V`]: (t, [p, mode]) => { const abs = path(t, p); if (t.vm.fs.stat(abs)) unixError(EEXIST); if (!t.vm.fs.mkdir(abs)) unixError(t.vm.fs.writable(abs) ? ENOENT : EROFS); + applyCreateMode(t, abs, mode); }, [`${N}rmdir0(J)V`]: (t, [p]) => { if (endsInDot(cstring(t, p))) unixError(EINVAL); diff --git a/test/java/CreateModes.java b/test/java/CreateModes.java new file mode 100644 index 0000000..266c062 --- /dev/null +++ b/test/java/CreateModes.java @@ -0,0 +1,52 @@ +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Set; + +import static java.nio.file.StandardOpenOption.*; + +public class CreateModes { + static void show(String what, Path p) throws IOException { + System.out.println(what + " " + PosixFilePermissions.toString(Files.getPosixFilePermissions(p))); + } + + static FileAttribute> perms(String s) { + return PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString(s)); + } + + public static void main(String[] args) throws IOException { + Path dir = Files.createTempDirectory("modes"); + try { + Path a = Files.createFile(dir.resolve("a"), perms("rw-------")); + show("createFile 600", a); + Path b = Files.createFile(dir.resolve("b"), perms("rwxr-x---")); + show("createFile 750", b); + Path c = Files.createFile(dir.resolve("c")); + show("createFile default", c); + Path d = Files.createDirectory(dir.resolve("d"), perms("rwx------")); + show("createDirectory 700", d); + Path t = Files.createTempFile(dir, "t", ".tmp"); + show("createTempFile", t); + Path td = Files.createTempDirectory(dir, "td"); + show("createTempDirectory", td); + // a read-only file can still be written through the descriptor that created it + Path f = dir.resolve("f"); + try (FileChannel ch = FileChannel.open(f, Set.of(CREATE_NEW, WRITE), perms("r--r--r--"))) { + ch.write(ByteBuffer.wrap("data".getBytes())); + } + show("channel 444", f); + System.out.println(Files.readString(f)); + Files.setPosixFilePermissions(f, PosixFilePermissions.fromString("rw-------")); + Files.delete(f); + } finally { + try (var s = Files.walk(dir)) { + s.sorted(java.util.Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); + } + } + } +} From f02a61fbe9fe4a37087bc6bafd586e346fef1cc0 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:41:26 -0600 Subject: [PATCH 03/25] Reject unknown user names, make File.createNewFile fail on an empty path, and test descriptor counts against HotSpot --- docs/support.md | 3 ++- src/natives/io.ts | 1 + src/natives/nio.ts | 14 ++++++++++---- test/java/OpenFileDescriptors.java | 26 ++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 test/java/OpenFileDescriptors.java diff --git a/docs/support.md b/docs/support.md index e20fce4..ad9879a 100644 --- a/docs/support.md +++ b/docs/support.md @@ -4,7 +4,8 @@ java-web targets headless Java: console apps, libraries and javac. - The image has every Java SE module (`java.se`) plus `jdk.compiler`, `jdk.httpserver`, `jdk.jartool` (the `jar` tool), `jdk.security.jgss`, - `jdk.unsupported`, `jdk.zipfs`, `jdk.internal.md` (Markdown in doc comments, + `jdk.management` (`UnixOperatingSystemMXBean`), `jdk.unsupported`, + `jdk.zipfs`, `jdk.internal.md` (Markdown in doc comments, for javac's doc comment API) and `jdk.localedata` (see Locales). - `java.desktop`'s headless parts work: 2D geometry, image pixels, `Graphics2D` drawing on a `BufferedImage`, and JavaBeans. So does `java.xml`, including diff --git a/src/natives/io.ts b/src/natives/io.ts index 491b76e..69f598c 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -759,6 +759,7 @@ export const natives: Natives = { return chmod(t, f, (m) => (enable ? m | bits : m & ~bits)); }, [`${UFS}.createFileExclusively0(Ljava/lang/String;)Z`]: (t, [, p]) => { + if (t.vm.jsString(p) === "") throw new PendingThrow("java/io/IOException", "No such file or directory"); const abs = resolvePath(t, t.vm.jsString(p)); if (t.vm.fs.stat(abs)) return 0; if (t.vm.fs.denied(abs)) throw new PendingThrow("java/io/IOException", "Permission denied"); diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 559ed27..40c4566 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -71,6 +71,11 @@ function chmod(t: JThread, p: string, mode: number) { unixError(HOST_ERRNO[fs.chmodError(p) ?? "EIO"] ?? EIO); } +function accountId(t: JThread, name: string): number { + if (name === "root") return 0; + return name === (t.vm.props.get("user.name") ?? "user") ? 1000 : -1; +} + let processUmask: number | undefined; // open(2) and mkdir(2) give a new file the requested mode less the umask @@ -255,10 +260,11 @@ export const natives: Natives = { return d.pos < d.names.length ? bytes(t, d.names[d.pos++]) : null; }, [`${N}closedir(J)V`]: (t, [id]) => void dirs(t).delete(id), - [`${N}getpwuid(I)[B`]: (t) => bytes(t, t.vm.props.get("user.name") ?? "user"), - [`${N}getgrgid(I)[B`]: (t) => bytes(t, t.vm.props.get("user.name") ?? "user"), - [`${N}getpwnam0(J)I`]: () => 1000, - [`${N}getgrnam0(J)I`]: () => 1000, + [`${N}getpwuid(I)[B`]: (t, [id]) => bytes(t, id === 0 ? "root" : t.vm.props.get("user.name") ?? "user"), + [`${N}getgrgid(I)[B`]: (t, [id]) => bytes(t, id === 0 ? "root" : t.vm.props.get("user.name") ?? "user"), + // the user and root are the only accounts; -1 for any other name + [`${N}getpwnam0(J)I`]: (t, [p]) => accountId(t, cstring(t, p)), + [`${N}getgrnam0(J)I`]: (t, [p]) => accountId(t, cstring(t, p)), [`${N}fchmodatNoFollowSupported0()Z`]: () => 0, [`${N}statvfs0(JLsun/nio/fs/UnixFileStoreAttributes;)V`]: (t, [p, attrs]) => { const abs = path(t, p); diff --git a/test/java/OpenFileDescriptors.java b/test/java/OpenFileDescriptors.java new file mode 100644 index 0000000..638d24f --- /dev/null +++ b/test/java/OpenFileDescriptors.java @@ -0,0 +1,26 @@ +import com.sun.management.UnixOperatingSystemMXBean; +import java.io.File; +import java.io.FileInputStream; +import java.io.RandomAccessFile; +import java.lang.management.ManagementFactory; + +public class OpenFileDescriptors { + public static void main(String[] args) throws Exception { + var os = (UnixOperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean(); + System.out.println("max > 0: " + (os.getMaxFileDescriptorCount() > 0)); + System.out.println("open >= 3: " + (os.getOpenFileDescriptorCount() >= 3)); + File f = File.createTempFile("fds", null); + try { + long before = os.getOpenFileDescriptorCount(); + var in = new FileInputStream(f); + var raf = new RandomAccessFile(f, "rw"); + System.out.println("opened 2: " + (os.getOpenFileDescriptorCount() - before)); + in.close(); + System.out.println("closed 1: " + (os.getOpenFileDescriptorCount() - before)); + raf.close(); + System.out.println("closed 2: " + (os.getOpenFileDescriptorCount() - before)); + } finally { + f.delete(); + } + } +} From 3aa0324e73ed23a20ec88082210010470a9891c0 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:42:33 -0600 Subject: [PATCH 04/25] Answer HotSpotDiagnosticMXBean.getVMOption for the VM options java-web can state --- src/natives/management.ts | 34 ++++++++++++++++++++++++++++++++++ src/natives/reflect.ts | 2 +- test/java/VmOptions.java | 17 +++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 test/java/VmOptions.java diff --git a/src/natives/management.ts b/src/natives/management.ts index d4aae01..1d0bdff 100644 --- a/src/natives/management.ts +++ b/src/natives/management.ts @@ -11,6 +11,7 @@ import { TERMINATED } from "../thread.ts"; import type { JThread, NativeGen } from "../thread.ts"; import type { JVM } from "../jvm.ts"; import { openFdCount } from "./io.ts"; +import { box, initialized } from "./reflect.ts"; import type { Natives } from "./util.ts"; const MANAGERS = [ @@ -131,6 +132,17 @@ function procValue(file: string, label: string, field = 1): number { const orElse = (x: number, fallback: number) => (Number.isFinite(x) ? x : fallback); const KB = 1024; +// The VM options java-web can state, as HotSpot reports them with the Serial collector +const FLAGS: { name: string; value: boolean | number; writeable: boolean }[] = [ + { name: "HeapDumpOnOutOfMemoryError", value: false, writeable: true }, + { name: "LockingMode", value: 2, writeable: false }, + { name: "MaxHeapSize", value: MAX_HEAP, writeable: false }, + { name: "PrintConcurrentLocks", value: false, writeable: true }, + { name: "UseCompressedOops", value: true, writeable: false }, + { name: "UseParallelGC", value: false, writeable: false }, + { name: "UseSerialGC", value: true, writeable: false }, +]; + const V = "sun/management/VMManagementImpl."; const OS = "com/sun/management/internal/OperatingSystemImpl."; const P = "sun/management/MemoryPoolImpl."; @@ -236,6 +248,28 @@ export const natives: Natives = { "jdk/internal/platform/CgroupMetrics.getTotalMemorySize0()J": () => BigInt(host("node:os")?.totalmem() ?? MAX_HEAP), "jdk/internal/platform/CgroupMetrics.getTotalSwapSize0()J": () => BigInt(orElse(procValue("/proc/meminfo", "SwapTotal:") * KB, 0)), + "com/sun/management/internal/Flag.initialize()V": () => undefined, + "com/sun/management/internal/Flag.getInternalFlagCount()I": () => FLAGS.length, + "com/sun/management/internal/Flag.getAllFlagNames()[Ljava/lang/String;": (t) => t.vm.newObjectArray("java/lang/String", FLAGS.map((f) => t.vm.str(f.name))), + "com/sun/management/internal/Flag.getFlags([Ljava/lang/String;[Lcom/sun/management/internal/Flag;I)I": function* (t, [names, out, count]): NativeGen { + const vm = t.vm; + const cls = vm.loadBoot("com/sun/management/internal/Flag")!; + const origin = yield* initialized(t, "com/sun/management/VMOption$Origin"); + const defaultOrigin = yield t.call(origin.methodMap.get("valueOf(Ljava/lang/String;)Lcom/sun/management/VMOption$Origin;")!, vm.str("DEFAULT")); + const wanted = names ? (names.a as (JObject | null)[]).map((n) => (n ? vm.jsString(n) : null)) : FLAGS.map((f) => f.name); + let filled = 0; + for (let i = 0; i < Math.min(count, wanted.length); i++) { + const f = FLAGS.find((x) => x.name === wanted[i]); + if (!f) continue; + const value = typeof f.value === "boolean" ? yield* box(t, "Z", f.value ? 1 : 0) : yield* box(t, "J", BigInt(f.value)); + const flag = vm.newObject(cls); + yield t.call(cls.methodMap.get("(Ljava/lang/String;Ljava/lang/Object;ZZLcom/sun/management/VMOption$Origin;)V")!, flag, vm.str(f.name), value, f.writeable ? 1 : 0, 1, defaultOrigin); + (out.a as (JObject | null)[])[i] = flag; + filled++; + } + return filled; + }, + [`${OS}initialize0()V`]: () => undefined, [`${OS}getOpenFileDescriptorCount0()J`]: (t) => BigInt(openFdCount(t.vm)), [`${OS}getMaxFileDescriptorCount0()J`]: () => BigInt(orElse(procValue("/proc/self/limits", "Max open files", 3), 1024)), diff --git a/src/natives/reflect.ts b/src/natives/reflect.ts index 71f067a..c0b1eed 100644 --- a/src/natives/reflect.ts +++ b/src/natives/reflect.ts @@ -58,7 +58,7 @@ function* exceptionTypes(t: JThread, m: JMethod): NativeGen { return t.vm.newObjectArray("java/lang/Class", out); } -function* initialized(t: JThread, name: string): NativeGen { +export function* initialized(t: JThread, name: string): NativeGen { const c = t.vm.loadBoot(name)!; if (c.state !== INITIALIZED) yield* t.vm.initGen(t, c); return c; diff --git a/test/java/VmOptions.java b/test/java/VmOptions.java new file mode 100644 index 0000000..3669ad0 --- /dev/null +++ b/test/java/VmOptions.java @@ -0,0 +1,17 @@ +import com.sun.management.HotSpotDiagnosticMXBean; +import java.lang.management.ManagementFactory; + +public class VmOptions { + public static void main(String[] args) { + var diag = ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class); + for (String name : new String[] {"LockingMode", "HeapDumpOnOutOfMemoryError"}) { + var o = diag.getVMOption(name); + System.out.println(o.getName() + "=" + o.getValue() + " " + o.getOrigin() + " writeable=" + o.isWriteable()); + } + try { + diag.getVMOption("NoSuchOption"); + } catch (IllegalArgumentException e) { + System.out.println("unknown: " + e.getMessage()); + } + } +} From a3ae7fdd71e40da1e071d2dddb137a03cca5f03c Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:48:29 -0600 Subject: [PATCH 05/25] Search the parent's PATH for child programs as the JDK launcher does, with EACCES for a match that cannot run --- src/process-node.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/process-node.ts b/src/process-node.ts index 1cac603..76a4c3e 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -16,6 +16,27 @@ const drained = (output: Writable) => output.writableNeedDrain ? new Promise((resolve) => { output.once("drain", resolve); output.once("close", resolve); }) : undefined; +// execvp as the JDK's launcher does it: the parent's PATH, ":/bin:/usr/bin" when it has none, +// and EACCES in place of ENOENT when a match was not executable. +function findProgram(command: string, cwd: string, parentPath: string | undefined): string | NodeJS.ErrnoException { + if (command === "" || command.includes("/")) return command; + let denied = false; + for (const dir of (parentPath ?? ":/bin:/usr/bin").split(":")) { + const candidate = dir ? `${dir}/${command}` : `./${command}`; + const full = path.resolve(cwd, candidate); + try { + if (fs.statSync(full).isDirectory()) denied = true; + else { + fs.accessSync(full, fs.constants.X_OK); + return candidate; + } + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "EACCES") denied = true; + } + } + return Object.assign(new Error(denied ? "EACCES" : "ENOENT"), { code: denied ? "EACCES" : "ENOENT", errno: denied ? -13 : -2 }); +} + class Pipe implements ProcessPipe { private chunks: Uint8Array[] = []; private length = 0; @@ -207,7 +228,12 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho merged = result; stdio[1] = stdio[2] = merged.writer; } else if (redirectError) stdio[2] = stdio[1]; - const child = spawn(command, args, { cwd: dir ? decode(dir) : t.vm.opts.cwd, env, stdio: stdio as [any, any, any] }); + const cwd = dir ? decode(dir) : t.vm.opts.cwd; + const program = java ? command : findProgram(command, cwd ?? process.cwd(), (t.vm.opts.env ?? process.env).PATH); + if (typeof program !== "string") { + throw new PendingThrow("java/io/IOException", program.code === "EACCES" ? "error=13, Permission denied" : "error=2, No such file or directory"); + } + const child = spawn(program, args, { cwd, env, stdio: stdio as [any, any, any] }); merged?.writer.destroy(); const status = (code: number | null, signal: NodeJS.Signals | null) => code ?? 128 + ((os.constants.signals as Record)[signal ?? ""] ?? 0); From 1772d5781ad3b86b4e5151d07057029dfc6cfcd4 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:48:29 -0600 Subject: [PATCH 06/25] Add openat, unlinkat, renameat, fstatat, fchmodat, fdopendir and dup so SecureDirectoryStream works --- src/natives/nio.ts | 132 ++++++++++++++++++++++++++++++--------------- 1 file changed, 90 insertions(+), 42 deletions(-) diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 40c4566..843e26b 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -6,7 +6,7 @@ import type { Stat } from "../fs.ts"; import { preCloseSock } from "../sockets.ts"; import { closeInotify, 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 { closeFd, endsInDot, newFd, 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"; @@ -57,6 +57,59 @@ function path(t: JThread, addr: bigint) { return resolvePath(t, cstring(t, addr)); } +const SUPPORTS_OPENAT = 2; +const AT_FDCWD = -100, AT_REMOVEDIR = 0x200; + +// path(2) relative to the directory behind dfd, as the *at system calls resolve it +function pathAt(t: JThread, dfd: number, p: bigint): string { + const name = cstring(t, p); + if (dfd === AT_FDCWD || name.startsWith("/")) return resolvePath(t, name); + const dir = t.vm.state.get("fds")?.files.get(dfd); + if (!dir) unixError(EBADF); + return resolvePath(t, `${dir.path}/${name}`); +} + +function removeDir(t: JThread, abs: string) { + const st = t.vm.fs.stat(abs); + if (!st) unixError(ENOENT); + if (!st.dir) unixError(ENOTDIR); + if (!removePath(t, abs)) unixError(ENOTEMPTY); +} + +function unlinkAbs(t: JThread, abs: string) { + const st = t.vm.fs.stat(abs); + if (!st) unixError(ENOENT); + if (st.dir) unixError(EISDIR); + if (!removePath(t, abs)) unixError(EROFS); +} + +function renameAbs(t: JThread, from: string, to: string) { + if (!t.vm.fs.stat(from)) unixError(ENOENT); + if (!renamePath(t, from, to)) unixError(EXDEV); +} + +function openAbs(t: JThread, abs: string, flags: number, mode: number): number { + const st = t.vm.fs.stat(abs); + if ((flags & O_CREAT) && (flags & O_EXCL) && st) unixError(EEXIST); + if (!st && !(flags & O_CREAT)) unixError(ENOENT); + const write = (flags & O_ACCMODE) !== 0; + if (st?.dir && write) unixError(EISDIR); + if ((write || !st) && t.vm.fs.denied(abs)) unixError(EACCES); + try { + const fd = openFile(t, abs, write, (flags & O_TRUNC) !== 0, (flags & O_APPEND) !== 0, (flags & O_ACCMODE) !== 1, true); + fileOf(t, fd).ino = fileInode(t, abs); + if (!st) applyCreateMode(t, abs, mode); + return fd; + } catch (e) { + if (e instanceof PendingThrow) { + // a new file the host would not create: its reason + const host = st ? undefined : HOST_ERRNO[t.vm.fs.writeError(abs) ?? ""]; + unixError(host ?? (t.vm.fs.writable(abs) ? ENOENT : EROFS)); + } + throw e; + } +} + function chmod(t: JThread, p: string, mode: number) { const fs = t.vm.fs; const result = fs.chmod(p, (old) => (old & ~0o7777) | (mode & 0o7777)); @@ -120,7 +173,7 @@ function mountTables(t: JThread): Map { return m; } -interface Dir { names: string[]; pos: number } +interface Dir { names: string[]; pos: number; fd?: number } function dirs(t: JThread): Map { let m = t.vm.state.get("dirs"); @@ -133,7 +186,7 @@ let nextDir = 1n; const N = "sun/nio/fs/UnixNativeDispatcher."; export const natives: Natives = { - [`${N}init()I`]: () => 0, + [`${N}init()I`]: () => SUPPORTS_OPENAT, [`${N}getcwd()[B`]: (t) => bytes(t, t.vm.opts.cwd ?? "/"), [`${N}strerror(I)[B`]: (t, [errno]) => bytes(t, MESSAGES[errno] ?? `errno ${errno}`), [`${N}stat0(JLsun/nio/fs/UnixFileAttributes;)I`]: (t, [p, attrs]) => fillAttrs(t, attrs, path(t, p)), @@ -156,33 +209,37 @@ export const natives: Natives = { if (!t.vm.fs.stat(abs)) return ENOENT; return t.vm.fs.access(abs, amode) ? 0 : EACCES; }, - [`${N}open0(JII)I`]: (t, [p, flags, mode]) => { - const abs = path(t, p); - const st = t.vm.fs.stat(abs); - if ((flags & O_CREAT) && (flags & O_EXCL) && st) unixError(EEXIST); - if (!st && !(flags & O_CREAT)) unixError(ENOENT); - const write = (flags & O_ACCMODE) !== 0; - if (st?.dir && write) unixError(EISDIR); - if ((write || !st) && t.vm.fs.denied(abs)) unixError(EACCES); - try { - const fd = openFile(t, abs, write, (flags & O_TRUNC) !== 0, (flags & O_APPEND) !== 0, (flags & O_ACCMODE) !== 1, true); - fileOf(t, fd).ino = fileInode(t, abs); - if (!st) applyCreateMode(t, abs, mode); - return fd; - } catch (e) { - if (e instanceof PendingThrow) { - // a new file the host would not create: its reason - const host = st ? undefined : HOST_ERRNO[t.vm.fs.writeError(abs) ?? ""]; - unixError(host ?? (t.vm.fs.writable(abs) ? ENOENT : EROFS)); - } - throw e; - } + [`${N}open0(JII)I`]: (t, [p, flags, mode]) => openAbs(t, path(t, p), flags, mode), + [`${N}openat0(IJII)I`]: (t, [dfd, p, flags, mode]) => openAbs(t, pathAt(t, dfd, p), flags, mode), + [`${N}unlinkat0(IJI)V`]: (t, [dfd, p, flag]) => { + const abs = pathAt(t, dfd, p); + if (flag & AT_REMOVEDIR) removeDir(t, abs); + else unlinkAbs(t, abs); + }, + [`${N}renameat0(IJIJ)V`]: (t, [sfd, a, dfd, b]) => renameAbs(t, pathAt(t, sfd, a), pathAt(t, dfd, b)), + [`${N}fstatat0(IJILsun/nio/fs/UnixFileAttributes;)V`]: (t, [dfd, p, , attrs]) => { + const err = fillAttrs(t, attrs, pathAt(t, dfd, p)); + if (err) unixError(err); + }, + [`${N}fchmodat0(IJII)V`]: (t, [dfd, p, mode]) => chmod(t, pathAt(t, dfd, p), mode), + [`${N}fdopendir(I)J`]: (t, [fd]) => { + const f = fileOf(t, fd); + if (!t.vm.fs.stat(f.path)?.dir) unixError(ENOTDIR); + const id = nextDir++; + dirs(t).set(id, { names: t.vm.fs.list(f.path) ?? [], pos: 0, fd }); + return id; + }, + [`${N}dup(I)I`]: (t, [fd]) => { + const f = fileOf(t, fd); + const copy = newFd(t); + t.vm.state.get("fds").files.set(copy, f); + f.retain(); + return copy; }, [`${N}close0(I)V`]: (t, [fd]) => { closeInotify(t, fd); closeFd(t, fd); }, - [`${N}dup(I)I`]: () => unixError(ENOSYS), [`${N}mkdir0(JI)V`]: (t, [p, mode]) => { const abs = path(t, p); if (t.vm.fs.stat(abs)) unixError(EEXIST); @@ -191,23 +248,10 @@ export const natives: Natives = { }, [`${N}rmdir0(J)V`]: (t, [p]) => { if (endsInDot(cstring(t, p))) unixError(EINVAL); - const abs = path(t, p); - const st = t.vm.fs.stat(abs); - if (!st) unixError(ENOENT); - if (!st.dir) unixError(ENOTDIR); - if (!removePath(t, abs)) unixError(ENOTEMPTY); - }, - [`${N}unlink0(J)V`]: (t, [p]) => { - const abs = path(t, p); - const st = t.vm.fs.stat(abs); - if (!st) unixError(ENOENT); - if (st.dir) unixError(EISDIR); - if (!removePath(t, abs)) unixError(EROFS); - }, - [`${N}rename0(JJ)V`]: (t, [a, b]) => { - if (!t.vm.fs.stat(path(t, a))) unixError(ENOENT); - if (!renamePath(t, path(t, a), path(t, b))) unixError(EXDEV); + removeDir(t, path(t, p)); }, + [`${N}unlink0(J)V`]: (t, [p]) => unlinkAbs(t, path(t, p)), + [`${N}rename0(JJ)V`]: (t, [a, b]) => renameAbs(t, path(t, a), path(t, b)), [`${N}realpath0(J)[B`]: (t, [p]) => { const abs = path(t, p); if (!t.vm.fs.stat(abs)) unixError(ENOENT); @@ -259,7 +303,11 @@ export const natives: Natives = { if (!d) unixError(EBADF); return d.pos < d.names.length ? bytes(t, d.names[d.pos++]) : null; }, - [`${N}closedir(J)V`]: (t, [id]) => void dirs(t).delete(id), + [`${N}closedir(J)V`]: (t, [id]) => { + const fd = dirs(t).get(id)?.fd; + dirs(t).delete(id); + if (fd !== undefined) closeFd(t, fd); + }, [`${N}getpwuid(I)[B`]: (t, [id]) => bytes(t, id === 0 ? "root" : t.vm.props.get("user.name") ?? "user"), [`${N}getgrgid(I)[B`]: (t, [id]) => bytes(t, id === 0 ? "root" : t.vm.props.get("user.name") ?? "user"), // the user and root are the only accounts; -1 for any other name From e70cf17dd5cf6b4c07fb4b1fccc236561bf9eed5 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:51:11 -0600 Subject: [PATCH 07/25] Support symbolic links and hard links on host file systems, and resolve them in canonical paths --- src/fs.ts | 46 +++++++++++++++++++++++++ src/natives/io.ts | 5 ++- src/natives/nio.ts | 43 +++++++++++++++--------- src/node.ts | 46 ++++++++++++++++++++++++- test/java/SymbolicLinks.java | 65 ++++++++++++++++++++++++++++++++++++ 5 files changed, 187 insertions(+), 18 deletions(-) create mode 100644 test/java/SymbolicLinks.java diff --git a/src/fs.ts b/src/fs.ts index 9253812..69fc54f 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -20,6 +20,8 @@ export interface Stat { mode?: number; // a character device; it streams and is never read whole device?: boolean; + // lstat of a symbolic link: the length of its target, which is also its size + link?: number; } // The same in every mount layout, so SecureRandom and the like work in the @@ -63,6 +65,13 @@ export interface Backend { chmodError?(path: string): string | undefined; // whether the host allows reading (4) and executing (1), as access(2) access?(path: string, mode: number): boolean; + // symbolic and hard links, for a host that has them; failures return the errno code + lstat?(path: string): Stat | null; + readlink?(path: string): { target: string } | { code: string }; + symlink?(target: string, path: string): string | undefined; + link?(existing: string, path: string): string | undefined; + // the path with every link resolved, where the missing tail stays as written + realpath?(path: string): string | null; } export interface WarmTrace { @@ -407,6 +416,43 @@ export class VFS { return !!r && r[0].writable; } + // like stat, but a symbolic link reports itself + lstat(path: string): Stat | null { + const r = this.resolve(path); + return (r && r[0].lstat?.(r[1])) || this.stat(path); + } + + readlink(path: string): { target: string } | { code: string } { + const r = this.resolve(path); + return r?.[0].readlink?.(r[1]) ?? { code: this.stat(path) ? "EINVAL" : "ENOENT" }; + } + + // undefined on success, else the errno code; ENOSYS where the backend has no links + symlink(target: string, path: string): string | undefined { + const r = this.resolve(path); + if (!r || !r[0].writable || !r[0].symlink) return "ENOSYS"; + const code = r[0].symlink(target, r[1]); + if (!code) this.changed(path, "create"); + return code; + } + + link(existing: string, path: string): string | undefined { + const a = this.resolve(existing), b = this.resolve(path); + if (!a || !b || a[0] !== b[0] || !b[0].writable || !b[0].link) return "ENOSYS"; + const code = b[0].link(a[1], b[1]); + if (!code) this.changed(path, "create"); + return code; + } + + realpath(path: string): string | null { + const r = this.resolve(path); + if (!r?.[0].realpath) return null; + const real = r[0].realpath(r[1]); + if (real === null) return null; + const n = normalize(path); + return normalize(n.slice(0, n.length - r[1].length) + real); + } + // A file system without its own figures reports 4 KB blocks and no space, // as Linux does for virtual file systems such as /proc. space(path: string): Space { diff --git a/src/natives/io.ts b/src/natives/io.ts index 69f598c..8eb5739 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -736,7 +736,10 @@ export const natives: Natives = { f.setLength(Number(n)); }, - [`${UFS}.canonicalize0(Ljava/lang/String;)Ljava/lang/String;`]: (t, [, p]) => t.vm.str(resolvePath(t, t.vm.jsString(p))), + [`${UFS}.canonicalize0(Ljava/lang/String;)Ljava/lang/String;`]: (t, [, p]) => { + const abs = resolvePath(t, t.vm.jsString(p)); + return t.vm.str(t.vm.fs.realpath(abs) ?? abs); + }, [`${UFS}.getBooleanAttributes0(Ljava/io/File;)I`]: (t, [, f]) => attrs(t, f), [`${UFS}.checkAccess0(Ljava/io/File;I)Z`]: (t, [, f, access]) => { const abs = resolvePath(t, pathOf(t, f)); diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 843e26b..2796c7c 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -27,13 +27,13 @@ const MESSAGES: Record = { // errno numbers of the host errors Node reports by name const HOST_ERRNO: Record = { - EPERM, ENOENT, EINTR, EIO, EBADF, ENOMEM, EACCES, ENOTDIR, EINVAL, ENOSPC, EROFS, ELOOP, + EPERM, ENOENT, EINTR, EIO, EBADF, ENOMEM, EACCES, EEXIST, EXDEV, ENOTDIR, EISDIR, EINVAL, ENOSPC, EROFS, ELOOP, ENOTEMPTY, ENOSYS, ENOTSUP, EOPNOTSUPP: ENOTSUP, ESTALE, ENAMETOOLONG, EDQUOT, }; // 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; +const S_IFDIR = 0o040000, S_IFREG = 0o100000, S_IFCHR = 0o020000, S_IFLNK = 0o120000; export function unixError(errno: number): never { throw new PendingThrow("sun/nio/fs/UnixException", null, { desc: "(I)V", args: [errno] }); @@ -58,7 +58,7 @@ function path(t: JThread, addr: bigint) { } const SUPPORTS_OPENAT = 2; -const AT_FDCWD = -100, AT_REMOVEDIR = 0x200; +const AT_SYMLINK_NOFOLLOW = 0x100, AT_FDCWD = -100, AT_REMOVEDIR = 0x200; // path(2) relative to the directory behind dfd, as the *at system calls resolve it function pathAt(t: JThread, dfd: number, p: bigint): string { @@ -70,16 +70,16 @@ function pathAt(t: JThread, dfd: number, p: bigint): string { } function removeDir(t: JThread, abs: string) { - const st = t.vm.fs.stat(abs); + const st = t.vm.fs.lstat(abs); if (!st) unixError(ENOENT); if (!st.dir) unixError(ENOTDIR); if (!removePath(t, abs)) unixError(ENOTEMPTY); } function unlinkAbs(t: JThread, abs: string) { - const st = t.vm.fs.stat(abs); + const st = t.vm.fs.lstat(abs); if (!st) unixError(ENOENT); - if (st.dir) unixError(EISDIR); + if (st.dir && st.link === undefined) unixError(EISDIR); if (!removePath(t, abs)) unixError(EROFS); } @@ -137,9 +137,9 @@ function applyCreateMode(t: JThread, abs: string, mode: number) { t.vm.fs.chmod(abs, (old) => (old & ~0o7777) | (mode & ~processUmask! & 0o7777)); } -function fillAttrs(t: JThread, attrs: JObject, p: string): number { +function fillAttrs(t: JThread, attrs: JObject, p: string, follow = true): number { syncPath(t, p); - const st = t.vm.fs.stat(p); + const st = follow ? t.vm.fs.stat(p) : t.vm.fs.lstat(p); if (!st) return ENOENT; fillStat(t, attrs, st, t.vm.fs.writable(p), fileInode(t, p)); return 0; @@ -147,7 +147,7 @@ function fillAttrs(t: JThread, attrs: JObject, p: string): number { 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 mode = st.device ? S_IFCHR | 0o666 : (st.dir ? S_IFDIR : S_IFREG) | (st.mode ?? (st.dir ? (writable ? 0o755 : 0o555) : (writable ? 0o644 : 0o444))); + const mode = st.device ? S_IFCHR | 0o666 : (st.link !== undefined ? S_IFLNK : st.dir ? S_IFDIR : S_IFREG) | (st.mode ?? (st.dir ? (writable ? 0o755 : 0o555) : (writable ? 0o644 : 0o444))); let sec = BigInt(Math.floor(st.mtime / 1000)), nsec = BigInt(Math.floor((st.mtime % 1000) * 1e6)); if (st.mtimeNs !== undefined) { sec = st.mtimeNs / 1_000_000_000n; @@ -191,7 +191,7 @@ export const natives: Natives = { [`${N}strerror(I)[B`]: (t, [errno]) => bytes(t, MESSAGES[errno] ?? `errno ${errno}`), [`${N}stat0(JLsun/nio/fs/UnixFileAttributes;)I`]: (t, [p, attrs]) => fillAttrs(t, attrs, path(t, p)), [`${N}lstat0(JLsun/nio/fs/UnixFileAttributes;)V`]: (t, [p, attrs]) => { - const err = fillAttrs(t, attrs, path(t, p)); + const err = fillAttrs(t, attrs, path(t, p), false); if (err) unixError(err); }, [`${N}fstat0(ILsun/nio/fs/UnixFileAttributes;)V`]: (t, [fd, attrs]) => { @@ -217,8 +217,8 @@ export const natives: Natives = { else unlinkAbs(t, abs); }, [`${N}renameat0(IJIJ)V`]: (t, [sfd, a, dfd, b]) => renameAbs(t, pathAt(t, sfd, a), pathAt(t, dfd, b)), - [`${N}fstatat0(IJILsun/nio/fs/UnixFileAttributes;)V`]: (t, [dfd, p, , attrs]) => { - const err = fillAttrs(t, attrs, pathAt(t, dfd, p)); + [`${N}fstatat0(IJILsun/nio/fs/UnixFileAttributes;)V`]: (t, [dfd, p, flag, attrs]) => { + const err = fillAttrs(t, attrs, pathAt(t, dfd, p), !(flag & AT_SYMLINK_NOFOLLOW)); if (err) unixError(err); }, [`${N}fchmodat0(IJII)V`]: (t, [dfd, p, mode]) => chmod(t, pathAt(t, dfd, p), mode), @@ -255,11 +255,22 @@ export const natives: Natives = { [`${N}realpath0(J)[B`]: (t, [p]) => { const abs = path(t, p); if (!t.vm.fs.stat(abs)) unixError(ENOENT); - return bytes(t, abs); + return bytes(t, t.vm.fs.realpath(abs) ?? abs); + }, + [`${N}readlink0(J)[B`]: (t, [p]) => { + const r = t.vm.fs.readlink(path(t, p)); + if ("code" in r) unixError(HOST_ERRNO[r.code] ?? EIO); + return bytes(t, r.target); + }, + [`${N}symlink0(JJ)V`]: (t, [target, p]) => { + const code = t.vm.fs.symlink(cstring(t, target), path(t, p)); + if (code) unixError(HOST_ERRNO[code] ?? EIO); + }, + [`${N}link0(JJ)V`]: (t, [existing, p]) => { + syncPath(t, path(t, existing)); + const code = t.vm.fs.link(path(t, existing), path(t, p)); + if (code) unixError(HOST_ERRNO[code] ?? EIO); }, - [`${N}readlink0(J)[B`]: () => unixError(EINVAL), - [`${N}symlink0(JJ)V`]: () => unixError(ENOSYS), - [`${N}link0(JJ)V`]: () => unixError(ENOSYS), [`${N}chmod0(JI)V`]: (t, [p, mode]) => { chmod(t, path(t, p), mode); }, diff --git a/src/node.ts b/src/node.ts index 69a1efd..cfffa8e 100644 --- a/src/node.ts +++ b/src/node.ts @@ -88,6 +88,50 @@ export class NodeBackend implements Backend { if (!this.writable) this.stats.set(p, st); return st; } + lstat(p: string): Stat | null { + try { + const s = fs.lstatSync(this.host(p)); + if (!s.isSymbolicLink()) return null; + return { dir: false, size: s.size, mtime: s.mtimeMs, mode: s.mode & 0o7777, link: s.size }; + } catch { + return null; + } + } + readlink(p: string) { + try { + return { target: fs.readlinkSync(this.host(p)) }; + } catch (e) { + return { code: (e as NodeJS.ErrnoException).code ?? "EIO" }; + } + } + symlink(target: string, p: string) { + try { + fs.symlinkSync(target, this.host(p)); + } catch (e) { + return (e as NodeJS.ErrnoException).code ?? "EIO"; + } + } + link(existing: string, p: string) { + try { + fs.linkSync(this.host(existing), this.host(p)); + } catch (e) { + return (e as NodeJS.ErrnoException).code ?? "EIO"; + } + } + realpath(p: string) { + const host = this.host(p); + const rest: string[] = []; + for (let dir = host; ; dir = path.dirname(dir)) { + try { + const real = fs.realpathSync.native(dir); + const full = path.join(real, ...rest); + return this.root === "/" ? full : full.startsWith(this.root) ? full.slice(this.root.length) || "/" : null; + } catch (e) { + if (dir === path.dirname(dir) || (e as NodeJS.ErrnoException).code === "EACCES") return null; + rest.unshift(path.basename(dir)); + } + } + } // Only regular files: readFileSync on a device or pipe can grow without end. read(p: string) { try { @@ -205,7 +249,7 @@ export class NodeBackend implements Backend { // like unlink or rmdir: a directory must be empty remove(p: string) { try { - if (fs.statSync(this.host(p)).isDirectory()) fs.rmdirSync(this.host(p)); + if (fs.lstatSync(this.host(p)).isDirectory()) fs.rmdirSync(this.host(p)); else fs.rmSync(this.host(p)); return true; } catch { diff --git a/test/java/SymbolicLinks.java b/test/java/SymbolicLinks.java new file mode 100644 index 0000000..cd76b1f --- /dev/null +++ b/test/java/SymbolicLinks.java @@ -0,0 +1,65 @@ +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.stream.Collectors; + +public class SymbolicLinks { + public static void main(String[] args) throws IOException { + Path dir = Files.createTempDirectory("links").toRealPath(); + try { + Path file = Files.writeString(dir.resolve("file"), "data"); + Path sub = Files.createDirectory(dir.resolve("sub")); + Path toFile = Files.createSymbolicLink(dir.resolve("toFile"), Path.of("file")); + Path toDir = Files.createSymbolicLink(dir.resolve("toDir"), sub); + Path dangling = Files.createSymbolicLink(dir.resolve("dangling"), Path.of("missing")); + + System.out.println("readlink " + Files.readSymbolicLink(toFile) + " " + (Files.readSymbolicLink(toDir).equals(sub))); + System.out.println("isSymbolicLink " + Files.isSymbolicLink(toFile) + " " + Files.isSymbolicLink(file)); + System.out.println("read through link " + Files.readString(toFile)); + System.out.println("exists " + Files.exists(toFile) + " " + Files.exists(dangling) + " " + Files.exists(dangling, LinkOption.NOFOLLOW_LINKS)); + System.out.println("isDirectory " + Files.isDirectory(toDir) + " " + Files.isDirectory(toDir, LinkOption.NOFOLLOW_LINKS)); + System.out.println("size nofollow " + Files.readAttributes(toFile, java.nio.file.attribute.BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS).size()); + System.out.println("toRealPath " + dir.relativize(toFile.toRealPath()) + " " + dir.relativize(toDir.toRealPath())); + System.out.println("canonical " + new File(toFile.toString()).getCanonicalPath().equals(file.toString())); + System.out.println("canonical missing " + new File(dir + "/toDir/new").getCanonicalPath().equals(sub + "/new")); + + Files.writeString(toDir.resolve("inner"), "x"); + System.out.println("list through link " + Files.list(toDir).map(p -> p.getFileName().toString()).collect(Collectors.toList())); + + try { + Files.createSymbolicLink(toFile, file); + } catch (java.nio.file.FileAlreadyExistsException e) { + System.out.println("exists error"); + } + try { + Files.readSymbolicLink(file); + } catch (java.nio.file.NotLinkException e) { + System.out.println("not a link"); + } + + Path copy = Files.copy(toFile, dir.resolve("copy"), LinkOption.NOFOLLOW_LINKS); + System.out.println("copy of link " + Files.isSymbolicLink(copy)); + Path moved = Files.move(toDir, dir.resolve("moved"), StandardCopyOption.ATOMIC_MOVE); + System.out.println("moved link " + Files.isSymbolicLink(moved)); + + Files.delete(moved); + Files.delete(toFile); + System.out.println("target kept " + Files.exists(sub) + " " + Files.exists(file)); + System.out.println("deleting dangling " + Files.deleteIfExists(dangling)); + try { + Files.delete(dangling); + } catch (NoSuchFileException e) { + System.out.println("already gone"); + } + } finally { + try (var s = Files.walk(dir)) { + s.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); + } + } + } +} From 08fb05d8007046272aece63d89953143d7173b40 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:21:07 -0600 Subject: [PATCH 08/25] Report EACCES for an unreadable file, ELOOP for O_NOFOLLOW on a link, and write to a write-only file through a host handle --- src/natives/io.ts | 3 ++- src/natives/nio.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/natives/io.ts b/src/natives/io.ts index 8eb5739..1758ad3 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -323,7 +323,8 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo return tab.next++; } if (write && fs.denied(abs)) notFound(path, "Permission denied"); - if (st && !shared && !truncate && st.size >= STREAM_MIN && fs.canOpenFile(abs, write)) { + // a file too big to hold, or one that may be written but not read, goes through a host handle + if (st && !shared && !truncate && (st.size >= STREAM_MIN || (write && !fs.access(abs, 4))) && fs.canOpenFile(abs, write)) { const host = openHost(fs, abs, write, readable); if (host) { closeHostsOnExit(t.vm); diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 2796c7c..646bac3 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -32,7 +32,7 @@ const HOST_ERRNO: Record = { }; // Linux open(2) flags -const O_ACCMODE = 3, O_CREAT = 0o100, O_EXCL = 0o200, O_TRUNC = 0o1000, O_APPEND = 0o2000; +const O_ACCMODE = 3, O_DIRECTORY = 0o200000, O_NOFOLLOW = 0o400000, O_CREAT = 0o100, O_EXCL = 0o200, O_TRUNC = 0o1000, O_APPEND = 0o2000; const S_IFDIR = 0o040000, S_IFREG = 0o100000, S_IFCHR = 0o020000, S_IFLNK = 0o120000; export function unixError(errno: number): never { @@ -92,9 +92,12 @@ function openAbs(t: JThread, abs: string, flags: number, mode: number): number { const st = t.vm.fs.stat(abs); if ((flags & O_CREAT) && (flags & O_EXCL) && st) unixError(EEXIST); if (!st && !(flags & O_CREAT)) unixError(ENOENT); + if (flags & O_NOFOLLOW && t.vm.fs.lstat(abs)?.link !== undefined) unixError(ELOOP); + if (flags & O_DIRECTORY && st && !st.dir) unixError(ENOTDIR); const write = (flags & O_ACCMODE) !== 0; if (st?.dir && write) unixError(EISDIR); if ((write || !st) && t.vm.fs.denied(abs)) unixError(EACCES); + if (st && !st.dir && (flags & O_ACCMODE) !== 1 && !t.vm.fs.access(abs, 4)) unixError(EACCES); try { const fd = openFile(t, abs, write, (flags & O_TRUNC) !== 0, (flags & O_APPEND) !== 0, (flags & O_ACCMODE) !== 1, true); fileOf(t, fd).ino = fileInode(t, abs); From edec36b61d8e16148eb61587cbac8c0cee4d9867 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:27:11 -0600 Subject: [PATCH 09/25] Share an inode between hard links and let renameat move a dangling link --- src/natives/nio.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 646bac3..8c7573a 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -84,7 +84,7 @@ function unlinkAbs(t: JThread, abs: string) { } function renameAbs(t: JThread, from: string, to: string) { - if (!t.vm.fs.stat(from)) unixError(ENOENT); + if (!t.vm.fs.lstat(from)) unixError(ENOENT); if (!renamePath(t, from, to)) unixError(EXDEV); } @@ -273,6 +273,9 @@ export const natives: Natives = { syncPath(t, path(t, existing)); const code = t.vm.fs.link(path(t, existing), path(t, p)); if (code) unixError(HOST_ERRNO[code] ?? EIO); + // both names are one file + const ino = fileInode(t, path(t, existing)); + t.vm.state.get("inodes").set(path(t, p), ino); }, [`${N}chmod0(JI)V`]: (t, [p, mode]) => { chmod(t, path(t, p), mode); From 68bfa01097c4750c22e71cf930f9bc25d1a5b9b2 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:28:39 -0600 Subject: [PATCH 10/25] Let File.createNewFile see a dangling link as an existing file --- src/natives/io.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/natives/io.ts b/src/natives/io.ts index 1758ad3..612f9ba 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -765,7 +765,7 @@ export const natives: Natives = { [`${UFS}.createFileExclusively0(Ljava/lang/String;)Z`]: (t, [, p]) => { if (t.vm.jsString(p) === "") throw new PendingThrow("java/io/IOException", "No such file or directory"); const abs = resolvePath(t, t.vm.jsString(p)); - if (t.vm.fs.stat(abs)) return 0; + if (t.vm.fs.lstat(abs)) return 0; if (t.vm.fs.denied(abs)) throw new PendingThrow("java/io/IOException", "Permission denied"); if (!t.vm.fs.write(abs, new Uint8Array(0))) throw new PendingThrow("java/io/IOException", "No such file or directory"); return 1; From f40826282668ae7a5523818e687fa54e8d263d95 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:33:29 -0600 Subject: [PATCH 11/25] Fail an empty program name with ENOENT, split environment entries at the first = after the first character, and keep 64 KB of test output --- scripts/jtreg/run.ts | 2 +- src/process-node.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/jtreg/run.ts b/scripts/jtreg/run.ts index 570c29c..791889a 100644 --- a/scripts/jtreg/run.ts +++ b/scripts/jtreg/run.ts @@ -431,7 +431,7 @@ function exec(cmd: string, args: string[], cwd: string, limit: number): Promise< const start = Date.now(); const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], detached: true }); let text = ""; - const keep = (d: Buffer) => { text = (text + d.toString()).slice(-4000); }; + const keep = (d: Buffer) => { text = (text + d.toString()).slice(-65536); }; child.stdout.on("data", keep); child.stderr.on("data", keep); let timedOut = false; diff --git a/src/process-node.ts b/src/process-node.ts index 76a4c3e..2d11fa8 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -19,7 +19,8 @@ const drained = (output: Writable) => output.writableNeedDrain // execvp as the JDK's launcher does it: the parent's PATH, ":/bin:/usr/bin" when it has none, // and EACCES in place of ENOENT when a match was not executable. function findProgram(command: string, cwd: string, parentPath: string | undefined): string | NodeJS.ErrnoException { - if (command === "" || command.includes("/")) return command; + if (command === "") return Object.assign(new Error("ENOENT"), { code: "ENOENT", errno: -2 }); + if (command.includes("/")) return command; let denied = false; for (const dir of (parentPath ?? ":/bin:/usr/bin").split(":")) { const candidate = dir ? `${dir}/${command}` : `./${command}`; @@ -192,8 +193,8 @@ export function processNatives(entry: string, jdk: string, packaged: boolean, ho args = [entry, "--java-child", ...(packaged ? [] : ["--jdk", jdk]), ...args]; } const env = envBlock === null ? t.vm.opts.env : Object.fromEntries(block(envBlock).map((s) => { - const i = s.indexOf("="); - return [s.slice(0, i), s.slice(i + 1)]; + const i = s.indexOf("=", 1); + return i < 0 ? [s, ""] : [s.slice(0, i), s.slice(i + 1)]; })); const descriptors = (fds as JArray).a as Int32Array; const opened: number[] = []; From 1113ab03aa623beec0dbfb0c9b208830d7e36c29 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:39:04 -0600 Subject: [PATCH 12/25] Compare directory-relative file operations and program lookup with HotSpot --- test/java/ProcessLookup.java | 37 +++++++++++++++++++ test/java/SecureDirectories.java | 63 ++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 test/java/ProcessLookup.java create mode 100644 test/java/SecureDirectories.java diff --git a/test/java/ProcessLookup.java b/test/java/ProcessLookup.java new file mode 100644 index 0000000..6705102 --- /dev/null +++ b/test/java/ProcessLookup.java @@ -0,0 +1,37 @@ +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +public class ProcessLookup { + static void start(String what, String... command) { + try { + Process p = new ProcessBuilder(command).start(); + System.out.println(what + " exit " + p.waitFor()); + } catch (IOException e) { + // HotSpot's launch helper words the reason differently from a direct fork + var m = java.util.regex.Pattern.compile("error[=:] ?(\\d+)").matcher(e.getCause().getMessage()); + System.out.println(what + " errno " + (m.find() ? m.group(1) : e.getCause().getMessage())); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + public static void main(String[] args) throws Exception { + File dir = Files.createTempDirectory("lookup").toFile(); + try { + start("empty", ""); + start("missing", "no-such-program-xyz"); + start("missing path", dir + "/no-such-program"); + start("directory", dir.getPath()); + File script = new File(dir, "plain"); + Files.writeString(script.toPath(), "#!/bin/true\n"); + start("not executable", script.getPath()); + script.setExecutable(true); + start("script", script.getPath()); + start("on PATH", "true"); + } finally { + for (File f : dir.listFiles()) f.delete(); + dir.delete(); + } + } +} diff --git a/test/java/SecureDirectories.java b/test/java/SecureDirectories.java new file mode 100644 index 0000000..d515016 --- /dev/null +++ b/test/java/SecureDirectories.java @@ -0,0 +1,63 @@ +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.NotDirectoryException; +import java.nio.file.Path; +import java.nio.file.SecureDirectoryStream; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributeView; +import java.util.Comparator; +import java.util.Set; +import java.util.TreeSet; + +public class SecureDirectories { + public static void main(String[] args) throws IOException { + Path dir = Files.createTempDirectory("secure"); + Path other = Files.createTempDirectory("secure"); + try { + try (DirectoryStream stream = Files.newDirectoryStream(dir)) { + var secure = (SecureDirectoryStream) stream; + try (SeekableByteChannel ch = secure.newByteChannel(Path.of("a"), + Set.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE))) { + ch.write(ByteBuffer.wrap("hello".getBytes())); + } + System.out.println("created " + Files.readString(dir.resolve("a"))); + System.out.println("is file " + secure.getFileAttributeView(Path.of("a"), BasicFileAttributeView.class) + .readAttributes().isRegularFile()); + try (SecureDirectoryStream sub = secure.newDirectoryStream(Path.of("."))) { + var names = new TreeSet(); + for (Path p : sub) names.add(p.getFileName().toString()); + System.out.println("listed " + names); + } + try { + secure.newDirectoryStream(Path.of("a")); + } catch (NotDirectoryException e) { + System.out.println("not a directory"); + } + secure.move(Path.of("a"), (SecureDirectoryStream) Files.newDirectoryStream(other), Path.of("b")); + System.out.println("moved " + Files.exists(dir.resolve("a")) + " " + Files.exists(other.resolve("b"))); + secure.deleteDirectory(Path.of("missing-dir")); + } catch (java.nio.file.NoSuchFileException e) { + System.out.println("no such file"); + } + try (DirectoryStream stream = Files.newDirectoryStream(other)) { + var secure = (SecureDirectoryStream) stream; + secure.deleteFile(Path.of("b")); + System.out.println("deleted " + Files.exists(other.resolve("b"))); + } + try { + Files.newDirectoryStream(other.resolve("nothing")); + } catch (java.nio.file.NoSuchFileException e) { + System.out.println("missing directory"); + } + } finally { + for (Path d : new Path[] {dir, other}) { + try (var s = Files.walk(d)) { + s.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); + } + } + } + } +} From 615eeb50fb69deafd3c84b3b1a47f6862075ce60 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:43:24 -0600 Subject: [PATCH 13/25] Use the host's inode number and link count for file keys, so links to one file share a key --- src/fs.ts | 3 +++ src/natives/io.ts | 5 +++-- src/natives/nio.ts | 5 +---- src/node.ts | 4 ++-- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/fs.ts b/src/fs.ts index 69fc54f..e982467 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -22,6 +22,9 @@ export interface Stat { device?: boolean; // lstat of a symbolic link: the length of its target, which is also its size link?: number; + // the host's inode number and link count, so hard links and links to a file share a key + ino?: number; + nlink?: number; } // The same in every mount layout, so SecureRandom and the like work in the diff --git a/src/natives/io.ts b/src/natives/io.ts index 612f9ba..49945b6 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -277,7 +277,8 @@ function table(t: JThread): FdTable { return tab; } -export function fileInode(t: JThread, path: string): number { +export function fileInode(t: JThread, path: string, st: Stat | null = t.vm.fs.stat(path)): number { + if (st?.ino !== undefined) return st.ino; let inodes = t.vm.state.get("inodes") as Map | undefined; if (!inodes) t.vm.state.set("inodes", (inodes = new Map())); const ino = inodes.get(path); @@ -509,8 +510,8 @@ export function removePath(t: JThread, abs: string): boolean { } function detachFile(t: JThread, f: OpenFile) { - f.ino ??= fileInode(t, f.path); const stat = t.vm.fs.stat(f.path); + f.ino ??= fileInode(t, f.path, stat); if (stat) f.detached = { path: f.path, stat, writable: t.vm.fs.writable(f.path) }; } diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 8c7573a..9f01fdd 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -144,7 +144,7 @@ function fillAttrs(t: JThread, attrs: JObject, p: string, follow = true): number syncPath(t, p); const st = follow ? t.vm.fs.stat(p) : t.vm.fs.lstat(p); if (!st) return ENOENT; - fillStat(t, attrs, st, t.vm.fs.writable(p), fileInode(t, p)); + fillStat(t, attrs, st, t.vm.fs.writable(p), fileInode(t, p, st), st.size, st.nlink ?? 1); return 0; } @@ -273,9 +273,6 @@ export const natives: Natives = { syncPath(t, path(t, existing)); const code = t.vm.fs.link(path(t, existing), path(t, p)); if (code) unixError(HOST_ERRNO[code] ?? EIO); - // both names are one file - const ino = fileInode(t, path(t, existing)); - t.vm.state.get("inodes").set(path(t, p), ino); }, [`${N}chmod0(JI)V`]: (t, [p, mode]) => { chmod(t, path(t, p), mode); diff --git a/src/node.ts b/src/node.ts index cfffa8e..7f4f6ad 100644 --- a/src/node.ts +++ b/src/node.ts @@ -81,7 +81,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, mode: s.mode & 0o7777 }; + st = { dir: s.isDirectory(), size: s.size, mtime: s.mtimeMs, mode: s.mode & 0o7777, ino: s.ino, nlink: s.nlink }; } catch { st = null; } @@ -92,7 +92,7 @@ export class NodeBackend implements Backend { try { const s = fs.lstatSync(this.host(p)); if (!s.isSymbolicLink()) return null; - return { dir: false, size: s.size, mtime: s.mtimeMs, mode: s.mode & 0o7777, link: s.size }; + return { dir: false, size: s.size, mtime: s.mtimeMs, mode: s.mode & 0o7777, link: s.size, ino: s.ino, nlink: s.nlink }; } catch { return null; } From 09bebd221abda2f6353d212bde5143f47bd6ea92 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:43:37 -0600 Subject: [PATCH 14/25] Treat O_CREAT with O_EXCL on a dangling link as an existing file --- src/natives/nio.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 9f01fdd..19a9284 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -90,7 +90,7 @@ function renameAbs(t: JThread, from: string, to: string) { function openAbs(t: JThread, abs: string, flags: number, mode: number): number { const st = t.vm.fs.stat(abs); - if ((flags & O_CREAT) && (flags & O_EXCL) && st) unixError(EEXIST); + if ((flags & O_CREAT) && (flags & O_EXCL) && (st || t.vm.fs.lstat(abs))) unixError(EEXIST); if (!st && !(flags & O_CREAT)) unixError(ENOENT); if (flags & O_NOFOLLOW && t.vm.fs.lstat(abs)?.link !== undefined) unixError(ELOOP); if (flags & O_DIRECTORY && st && !st.dir) unixError(ENOTDIR); From de6501db9a0791f040938f9bb6b5075f59ec9ad3 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:44:02 -0600 Subject: [PATCH 15/25] Set the modification time of a link itself when asked not to follow it --- src/fs.ts | 7 ++++--- src/natives/io.ts | 4 ++-- src/natives/nio.ts | 8 ++++---- src/node.ts | 5 +++-- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/fs.ts b/src/fs.ts index e982467..6ad6c37 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -60,7 +60,7 @@ export interface Backend { // the file system holding path: block size and block counts, as statvfs gives them space?(path: string): Space | null; // sets the modification time, in milliseconds since the epoch - setMtime?(path: string, ms: number): boolean; + setMtime?(path: string, ms: number, noFollow?: boolean): boolean; // whether the host refuses to write the file, or to create it when it is missing denied?(path: string): boolean; // sets the permission bits to f(current bits) @@ -466,10 +466,11 @@ export class VFS { // ns, when given, is the exact time asked for. Hosts keep times as floating // point seconds or milliseconds, which lose nanoseconds, so stat reports ns // for as long as the file keeps the time this set. - setMtime(path: string, ms: number, ns?: bigint): boolean { + setMtime(path: string, ms: number, ns?: bigint, noFollow = false): boolean { const r = this.resolve(path); - if (!r || !r[0].writable || !r[0].setMtime?.(r[1], ms)) return false; + if (!r || !r[0].writable || !r[0].setMtime?.(r[1], ms, noFollow)) return false; this.changed(path, "modify"); + if (noFollow) return true; const n = normalize(path); const st = ns === undefined ? null : r[0].stat(r[1]); // only a time the host kept, give or take its rounding; a host can clamp it diff --git a/src/natives/io.ts b/src/natives/io.ts index 49945b6..e1dc845 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -389,9 +389,9 @@ export function syncPath(t: JThread, abs: string) { } // Pending writes go out first, so closing the file later does not change the time again. -export function setMtime(t: JThread, abs: string, ms: number, ns?: bigint): boolean { +export function setMtime(t: JThread, abs: string, ms: number, ns?: bigint, noFollow = false): boolean { syncPath(t, abs); - return t.vm.fs.setMtime(abs, ms, ns); + return t.vm.fs.setMtime(abs, ms, ns, noFollow); } // write(2) on a descriptor opened read-only fails with EBADF. diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 19a9284..d999601 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -294,10 +294,10 @@ export const natives: Natives = { [`${N}lchown0(JII)V`]: () => undefined, [`${N}fchown0(III)V`]: () => undefined, // times in nanoseconds; only the modification time is kept - [`${N}utimensat0(IJJJI)V`]: (t, [, p, , mtime]) => { - const abs = path(t, p); - if (!t.vm.fs.stat(abs)) unixError(ENOENT); - if (!setMtime(t, abs, Number(mtime) / 1e6, BigInt(mtime))) unixError(EROFS); + [`${N}utimensat0(IJJJI)V`]: (t, [dfd, p, , mtime, flags]) => { + const abs = pathAt(t, dfd, p); + if (!t.vm.fs.lstat(abs)) unixError(ENOENT); + if (!setMtime(t, abs, Number(mtime) / 1e6, BigInt(mtime), (flags & AT_SYMLINK_NOFOLLOW) !== 0)) unixError(EROFS); }, [`${N}futimens0(IJJ)V`]: (t, [fd, , mtime]) => { const f = fileOf(t, fd); diff --git a/src/node.ts b/src/node.ts index 7f4f6ad..95611fa 100644 --- a/src/node.ts +++ b/src/node.ts @@ -230,9 +230,10 @@ export class NodeBackend implements Backend { return null; } } - setMtime(p: string, ms: number) { + setMtime(p: string, ms: number, noFollow = false) { try { - fs.utimesSync(this.host(p), fs.statSync(this.host(p)).atime, ms / 1000); + if (noFollow) fs.lutimesSync(this.host(p), fs.lstatSync(this.host(p)).atime, ms / 1000); + else fs.utimesSync(this.host(p), fs.statSync(this.host(p)).atime, ms / 1000); return true; } catch { return false; From 35165cf5fbeb3494bd323e9951ef830b7379e18c Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:44:25 -0600 Subject: [PATCH 16/25] Document links, inode numbers, the runner's @requires and the VM features that are still missing --- docs/development.md | 4 ++++ docs/support.md | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/docs/development.md b/docs/development.md index 38f8176..597a608 100644 --- a/docs/development.md +++ b/docs/development.md @@ -44,6 +44,10 @@ node scripts/jtreg/run.ts java/lang without the `+build` part. A sparse checkout of `test/lib` and the test directories is enough. +The runner evaluates a test's `@requires` as jtreg does on a release Linux +HotSpot with default options (for example `vm.continuations` is true and +`vm.debug` is false); a test that needs more is skipped. + `test/jtreg/passing.txt` lists the tests CI runs, with `--only-passing`. `--shard k/n` splits them into n shards of about equal run time, using each test's time on java-web from `test/jtreg/times.tsv` (the median for a test diff --git a/docs/support.md b/docs/support.md index ad9879a..309bf5f 100644 --- a/docs/support.md +++ b/docs/support.md @@ -18,6 +18,15 @@ java-web targets headless Java: console apps, libraries and javac. java-web VM on the same image. A child cannot use a directory that exists only in the VM, such as `/jdk`, as its working directory. `ProcessHandle` reads other processes from Linux's `/proc`; elsewhere it sees only its own children. +- Symbolic and hard links work on file systems that come from the host, as + under Node. Elsewhere, such as the browser's in-memory files, creating a link + fails with "Function not implemented". Files in the VM have the host's inode + numbers and link counts, and the modes `open` and `mkdir` ask for, less the + process's umask. +- `-XX:OnOutOfMemoryError` runs no command, and `StackWalker` + has no live locals or operands. `ThreadMXBean.getThreadInfo` and + `dumpThreads` are not implemented, and a virtual thread that yields while it + holds a monitor stays pinned to its carrier. - `WatchService` reports files the program itself, or another thread in the same VM, creates, changes or deletes. It does not see changes made outside the VM, including by child processes the program starts. From 591679d820a157040b0eb736958ac6c2794edfd7 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:55:51 -0600 Subject: [PATCH 17/25] Run 47 more OpenJDK tests that now pass --- test/jtreg/passing.txt | 47 ++++++++++++++++++++++++++++++++++++++++++ test/jtreg/times.tsv | 47 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/test/jtreg/passing.txt b/test/jtreg/passing.txt index 17e8cde..236aeb8 100644 --- a/test/jtreg/passing.txt +++ b/test/jtreg/passing.txt @@ -408,8 +408,10 @@ java/io/File/CreateNewFile.java java/io/File/DeleteOnExit.java java/io/File/DeleteOnExitLong.java java/io/File/DeleteOnExitNPE.java +java/io/File/EmptyPath.java java/io/File/FileMethods.java java/io/File/GetAbsolutePath.java +java/io/File/GetCanonicalPath.java java/io/File/GetParent.java java/io/File/HashCodeEquals.java java/io/File/IsAbsolute.java @@ -445,8 +447,11 @@ java/io/FileInputStream/OpenDir.java java/io/FileInputStream/OpsAfterClose.java java/io/FileInputStream/ReadXBytes.java java/io/FileInputStream/TransferTo.java +java/io/FileInputStream/UnreferencedFISClosesFd.java +java/io/FileOutputStream/AtomicAppend.java java/io/FileOutputStream/ManyFiles.java java/io/FileOutputStream/OpsAfterClose.java +java/io/FileOutputStream/UnreferencedFOSClosesFd.java java/io/FilePermission/Correctness.java java/io/FilePermission/FilePermissionCollection.java java/io/FilePermission/FilePermissionCollectionMerge.java @@ -542,6 +547,7 @@ java/io/RandomAccessFile/ReadLine.java java/io/RandomAccessFile/ReadWritePrimitives.java java/io/RandomAccessFile/Seek.java java/io/RandomAccessFile/SetLength.java +java/io/RandomAccessFile/UnreferencedRAFClosesFd.java java/io/RandomAccessFile/WriteBytesChars.java java/io/RandomAccessFile/WriteUTF.java java/io/RandomAccessFile/skipBytes/SkipBytes.java @@ -854,23 +860,39 @@ java/lang/Package/PackageVersionTest.java java/lang/PrimitiveSumMinMaxTest.java java/lang/Process/WaitFor.java java/lang/Process/WaitForDuration.java +java/lang/ProcessBuilder/CloseRace.java java/lang/ProcessBuilder/DestroyTest.java java/lang/ProcessBuilder/InheritIOEHandle.java +java/lang/ProcessBuilder/InheritIOTest.java +java/lang/ProcessBuilder/NonPipelineLeaksFD.java +java/lang/ProcessBuilder/NonPipelineLeaksFD.java#2 java/lang/ProcessBuilder/PipelineTest.java java/lang/ProcessBuilder/ProcessReaperCCL.java +java/lang/ProcessBuilder/ReaderWriterTest.java +java/lang/ProcessBuilder/UnblockSignals.java +java/lang/ProcessBuilder/UnblockSignals.java#2 java/lang/ProcessBuilder/Zombies.java java/lang/ProcessHandle/Basic.java +java/lang/ProcessHandle/InfoTest.java +java/lang/ProcessHandle/OnExitTest.java +java/lang/ProcessHandle/TreeTest.java java/lang/RuntimePermission/ExitVM.java java/lang/RuntimeTests/Resources.java java/lang/RuntimeTests/Version/Basic.java java/lang/RuntimeTests/Version/VersionProps.java +java/lang/RuntimeTests/exec/ArgWithSpaceAndFinalBackslash.java java/lang/RuntimeTests/exec/BadEnvp.java java/lang/RuntimeTests/exec/ConcurrentRead.java +java/lang/RuntimeTests/exec/Duped.java java/lang/RuntimeTests/exec/ExecEmptyString.java java/lang/RuntimeTests/exec/ExecWithInput.java +java/lang/RuntimeTests/exec/ExecWithLotsOfArgs.java +java/lang/RuntimeTests/exec/ExitValue.java java/lang/RuntimeTests/exec/LotsOfDestroys.java java/lang/RuntimeTests/exec/LotsOfOutput.java +java/lang/RuntimeTests/exec/SetCwd.java java/lang/RuntimeTests/exec/SleepyCat.java +java/lang/RuntimeTests/exec/Status.java java/lang/RuntimeTests/exec/WinCommand.java java/lang/RuntimeTests/loadLibrary/LoadLibraryTest.java java/lang/RuntimeTests/shutdown/Basic.java @@ -1060,19 +1082,31 @@ java/lang/Thread/SleepWithDuration.java java/lang/Thread/ThreadStateTest.java java/lang/Thread/ThreadStopTest.java java/lang/Thread/UncaughtExceptionsTest.java +java/lang/Thread/virtual/BigStackChunk.java java/lang/Thread/virtual/Collectable.java +java/lang/Thread/virtual/CustomScheduler.java java/lang/Thread/virtual/Locking.java +java/lang/Thread/virtual/MonitorEnterWaitOOME.java#2 +java/lang/Thread/virtual/ParkWithFixedThreadPool.java java/lang/Thread/virtual/Reflection.java java/lang/Thread/virtual/ShutdownHook.java java/lang/Thread/virtual/ThreadLocals.java +java/lang/Thread/virtual/TraceVirtualThreadLocals.java +java/lang/Thread/virtual/YieldQueuing.java +java/lang/Thread/virtual/stress/CompletableFutureTimedGet.java +java/lang/Thread/virtual/stress/GetStackTraceALotWhenParking.java java/lang/Thread/virtual/stress/LotsOfUncontendedMonitorEnter.java java/lang/Thread/virtual/stress/LotsOfUncontendedMonitorEnter.java#2 java/lang/Thread/virtual/stress/LotsOfUncontendedMonitorEnter.java#3 java/lang/Thread/virtual/stress/NotifiedThenTimedOutWait.java#2 +java/lang/Thread/virtual/stress/PingPong.java +java/lang/Thread/virtual/stress/PingPong.java#2 +java/lang/Thread/virtual/stress/SleepALot.java java/lang/Thread/virtual/stress/TimedWaitALot.java java/lang/Thread/virtual/stress/TimedWaitALot.java#2 java/lang/Thread/virtual/stress/TimedWaitALot.java#3 java/lang/Thread/virtual/stress/TimedWaitALot.java#4 +java/lang/Thread/virtual/stress/YieldALot.java java/lang/ThreadGroup/BasicTests.java java/lang/ThreadLocal/Basic.java java/lang/ThreadLocal/ImmutableLocal.java @@ -1967,6 +2001,8 @@ java/nio/channels/DatagramChannel/ThereCanBeOnlyOne.java java/nio/channels/DatagramChannel/Truncate.java java/nio/channels/DatagramChannel/UseDGWithIPv6.java java/nio/channels/FileChannel/Args.java +java/nio/channels/FileChannel/AtomicAppend.java +java/nio/channels/FileChannel/CleanerTest.java java/nio/channels/FileChannel/CloseDuringTransfer.java java/nio/channels/FileChannel/ClosedByInterrupt.java java/nio/channels/FileChannel/ClosedChannelTransfer.java @@ -2131,11 +2167,15 @@ java/nio/charset/coders/IsLegalReplacement.java java/nio/charset/coders/StreamTimeout.java java/nio/charset/coders/Surrogates.java java/nio/file/DirectoryStream/Basic.java +java/nio/file/DirectoryStream/SecureDS.java java/nio/file/FileStore/Basic.java java/nio/file/FileSystem/Basic.java java/nio/file/Files/BytesAndLines.java java/nio/file/Files/CallWithInterruptSet.java +java/nio/file/Files/CopyAndMove.java java/nio/file/Files/CopyInterference.java +java/nio/file/Files/CopyMoveVariations.java +java/nio/file/Files/CopyProcFile.java java/nio/file/Files/CopyToNonDefaultFS.java java/nio/file/Files/CreateDirectories.java java/nio/file/Files/CustomOptions.java @@ -2146,9 +2186,14 @@ java/nio/file/Files/Links.java java/nio/file/Files/Misc.java java/nio/file/Files/Mismatch.java java/nio/file/Files/NameLimits.java +java/nio/file/Files/NotADirectory.java java/nio/file/Files/ReadWriteString.java +java/nio/file/Files/SetLastModifiedTime.java java/nio/file/Files/StreamTest.java +java/nio/file/Files/TemporaryFiles.java java/nio/file/Files/probeContentType/ForceLoad.java +java/nio/file/Files/probeContentType/ParallelProbes.java +java/nio/file/Files/walkFileTree/FindTest.java java/nio/file/Files/walkFileTree/MaxDepth.java java/nio/file/Files/walkFileTree/Nulls.java java/nio/file/Files/walkFileTree/SkipSiblings.java @@ -2171,6 +2216,7 @@ java/nio/file/attribute/AclFileAttributeView/Basic.java java/nio/file/attribute/BasicFileAttributeView/Basic.java java/nio/file/attribute/DosFileAttributeView/Basic.java java/nio/file/attribute/FileTime/Basic.java +java/nio/file/attribute/PosixFileAttributeView/Basic.java java/nio/file/attribute/UserDefinedFileAttributeView/Basic.java java/nio/file/etc/Exceptions.java java/security/AccessControlContext/CheckCtor.java @@ -3505,6 +3551,7 @@ jdk/nio/zipfs/PathOps.java jdk/nio/zipfs/PropertyPermissionTests.java jdk/nio/zipfs/ReleaseDeflater.java jdk/nio/zipfs/TestLocOffsetFromZip64EF.java +jdk/nio/zipfs/TestPosix.java jdk/nio/zipfs/UpdateEntryTest.java jdk/nio/zipfs/ZFSTests.java jdk/nio/zipfs/ZeroDate.java diff --git a/test/jtreg/times.tsv b/test/jtreg/times.tsv index 0ae8ee6..42d8136 100644 --- a/test/jtreg/times.tsv +++ b/test/jtreg/times.tsv @@ -40,6 +40,7 @@ 63593 java/security/SecureRandom/ThreadSafe.java 63402 java/beans/Performance/Test4058433.java 63310 java/net/Socket/Timeouts.java +63072 java/nio/channels/FileChannel/CleanerTest.java 62820 java/lang/invoke/VarHandles/VarHandleTestMethodTypeFloat.java 59689 java/security/cert/CertPathValidator/OCSP/OCSPTimeout.java 59400 java/time/test/java/time/chrono/TestThaiBuddhistChronoImpl.java @@ -147,6 +148,7 @@ 26649 java/lang/Thread/virtual/stress/NotifiedThenTimedOutWait.java#2 26478 java/nio/channels/Selector/ChangingInterests.java 26329 java/text/Format/DateFormat/SimpleDateFormatPatternTest.java +26157 java/lang/ProcessHandle/OnExitTest.java 25808 java/lang/invoke/ClassValueTest.java 25743 java/util/zip/DeflateIn_InflateOut.java 25299 java/net/httpclient/websocket/PendingBinaryPongClose.java @@ -186,6 +188,7 @@ 20178 java/security/KeyAgreement/KeyAgreementTest.java 20120 java/util/concurrent/StructuredTaskScope/StructuredTaskScopeTest.java 19836 java/net/httpclient/ssltest/TlsVersionTest.java +19801 java/lang/ProcessHandle/TreeTest.java 19748 java/lang/String/HashCode.java 19622 java/lang/invoke/LFCaching/LFSingleThreadCachingTest.java 19399 java/util/Arrays/SortingNearlySortedPrimitive.java @@ -242,7 +245,9 @@ 16708 java/net/Authenticator/B4769350.java 16651 java/text/Format/common/FormatIteratorTest.java 16608 java/util/concurrent/ThreadPerTaskExecutor/ThreadPerTaskExecutorTest.java#2 +16579 java/io/FileOutputStream/UnreferencedFOSClosesFd.java 16505 java/lang/String/CompactString/EqualsIgnoreCase.java +16499 java/io/FileInputStream/UnreferencedFISClosesFd.java 16471 java/text/Format/DateFormat/IntlTestDateFormat.java 16468 java/net/PortUnreachableException/OneExceptionOnly.java 16420 java/nio/channels/Selector/SelectWithConsumer.java @@ -264,6 +269,7 @@ 15637 java/lang/module/ConfigurationTest.java 15622 java/lang/String/CompactString/Contains.java 15589 java/net/httpclient/websocket/BlowupOutputQueue.java +15528 java/lang/Thread/virtual/stress/PingPong.java 15519 java/net/DatagramSocketImpl/CheckDiscard.java 15510 java/time/test/java/time/TestOffsetDateTime_instants.java 15430 java/net/httpclient/DebugLoggerTest.java @@ -274,6 +280,7 @@ 15296 java/util/Calendar/CalendarRegression.java 15288 java/lang/reflect/callerCache/CustomLoaderTest.java 15259 java/util/LinkedHashSet/Basic.java +15112 java/lang/Thread/virtual/stress/GetStackTraceALotWhenParking.java 15079 java/security/KeyAgreement/NegativeTest.java 15076 java/io/Serializable/serialFilter/MixedFiltersTest.java 15072 java/util/concurrent/atomic/DoubleAdderDemo.java @@ -282,6 +289,7 @@ 14999 java/util/concurrent/FutureTask/DoneMeansDone.java 14964 java/util/HexFormat/HexFormatTest.java 14914 java/net/Authenticator/Deadlock.java +14876 java/lang/Thread/virtual/stress/PingPong.java#2 14806 java/nio/channels/Selector/LotsOfInterrupts.java 14792 java/lang/String/CompactString/CodePointBefore.java 14787 java/lang/reflect/Proxy/ProxyLayerTest.java @@ -692,6 +700,7 @@ 7920 java/util/Calendar/CalendarDataTest.java 7912 java/lang/Thread/ThreadStopTest.java 7907 java/math/BigDecimal/StrippingZerosTest.java +7901 java/lang/ProcessBuilder/ReaderWriterTest.java 7897 java/lang/RuntimeTests/shutdown/ShutdownHooks.java 7885 java/lang/String/CompactString/CodePointAt.java 7881 java/util/Calendar/bug4028518.java @@ -722,6 +731,7 @@ 7724 java/net/ServerSocket/ClosedServerSocketTest.java 7719 java/text/Format/DateFormat/Bug8193444.java 7718 java/time/tck/java/time/chrono/serial/TCKChronoZonedDateTimeSerialization.java +7713 jdk/nio/zipfs/TestPosix.java 7706 java/text/Format/DecimalFormat/SettersShouldThrowNPETest.java 7703 java/security/Provider/NewInstance.java 7699 java/util/zip/ZipFile/Assortment.java @@ -1184,6 +1194,7 @@ 5424 java/beans/XMLEncoder/Test5023559.java 5420 java/security/cert/CertPathValidator/indirectCRL/CircularCRLOneLevelRevoked.java 5417 java/util/Locale/CaseCheckVariant.java +5410 java/lang/ProcessBuilder/InheritIOTest.java 5409 java/lang/management/ManagementFactory/ProxyExceptions.java 5391 java/util/zip/ZipFile/InvalidCompressedSizeTest.java 5389 java/lang/String/ToLowerCase.java @@ -1217,6 +1228,7 @@ 5300 java/net/httpclient/LineStreamsAndSurrogatesTest.java 5298 java/math/BigDecimal/IntegralDivisionTests.java 5296 java/text/Format/MessageFormat/ListSubFormats.java +5291 java/nio/file/Files/CopyMoveVariations.java 5289 jdk/nio/zipfs/Basic.java 5288 java/time/test/java/time/format/ZoneName.java 5281 java/nio/file/Files/CallWithInterruptSet.java @@ -1265,6 +1277,7 @@ 5110 java/net/Socket/TestClose.java 5110 java/beans/XMLEncoder/javax_swing_border_BevelBorder.java 5089 java/lang/reflect/ChainedReflection.java +5088 java/lang/Thread/virtual/MonitorEnterWaitOOME.java#2 5085 java/lang/StackWalker/GetCallerClassTest.java 5078 javax/xml/jaxp/testng/parse/jdk7156085/UTF8ReaderBug.java 5077 java/io/File/LastModifiedTest.java @@ -1290,6 +1303,7 @@ 4979 java/util/Locale/ProviderPoolMultiThreadAccess.java 4979 java/lang/Thread/virtual/ThreadLocals.java 4977 java/time/test/java/time/TestDuration.java +4969 java/lang/ProcessHandle/InfoTest.java 4967 java/io/Reader/Skip.java 4966 java/io/Reader/NullReader.java 4962 java/text/Collator/SurrogatesTest.java @@ -1335,6 +1349,7 @@ 4813 java/io/PrintStream/OversynchronizedTest.java 4810 java/security/cert/CertPathBuilder/zeroLengthPath/ZeroLengthPath.java 4803 java/time/tck/java/time/zone/TCKZoneOffsetTransition.java +4803 java/lang/Thread/virtual/stress/YieldALot.java 4794 java/util/logging/LogManager/Configuration/updateConfiguration/HandlersOnComplexUpdate.java 4794 java/nio/channels/SelectionKey/AtomicUpdates.java 4794 java/net/DatagramSocket/Send12k.java @@ -1343,6 +1358,7 @@ 4787 java/security/Security/Nulls.java 4787 java/nio/channels/SocketChannel/Write.java 4781 java/util/Locale/ISO3166.java +4781 java/lang/Thread/virtual/TraceVirtualThreadLocals.java 4776 java/beans/Introspector/8130937/TestBooleanBeanProperties.java 4774 java/math/BigDecimal/AddTests.java 4772 java/util/logging/LogRecordThreadIdTest.java @@ -1705,6 +1721,7 @@ 3814 java/lang/invoke/lambda/MetafactoryArityTest.java 3813 java/time/tck/java/time/serial/TCKYearSerialization.java 3811 java/util/concurrent/CountDownLatch/Basic.java +3810 java/io/File/EmptyPath.java 3808 java/net/InetAddress/CachedUnknownHostName.java 3801 java/util/jar/JarFile/TurkCert.java 3797 java/security/Security/SynchronizedAccess.java @@ -1964,6 +1981,7 @@ 3439 java/beans/PropertyChangeSupport/TestSerialization.java 3437 java/math/BigDecimal/DivideTests.java 3436 javax/xml/jaxp/datatype/8033980/CalendarDuration1097Test.java +3436 java/lang/ProcessBuilder/CloseRace.java 3433 java/awt/geom/AffineTransform/EqualsAndHashCode.java 3431 java/net/InetAddress/B6246242.java 3431 java/awt/geom/Area/TightBBox.java @@ -1975,6 +1993,7 @@ 3422 java/io/DataOutputStream/CounterOverflow.java 3421 java/net/httpclient/LineSubscribersAndSurrogatesTest.java 3421 java/lang/reflect/Proxy/nonJavaNames/Test.java +3420 java/lang/Thread/virtual/CustomScheduler.java 3417 java/beans/Introspector/Test6447751.java 3415 java/util/Collections/CheckedQueue.java 3415 java/net/URLConnection/UnknownContentType.java @@ -2022,6 +2041,7 @@ 3368 java/net/NetworkInterface/NetParamsTest.java 3368 java/lang/ref/EarlyTimeout.java 3367 java/security/cert/CertPath/Serialize.java +3367 java/lang/RuntimeTests/exec/SetCwd.java 3365 java/util/logging/GetGlobalTest.java 3363 java/math/BigDecimal/CompareToTests.java 3361 java/util/AbstractMap/AbstractMapClone.java @@ -2052,6 +2072,7 @@ 3337 java/net/InterfaceAddress/Equals.java 3337 java/beans/XMLEncoder/6777487/TestCheckedCollection.java 3336 java/lang/String/IsEmpty.java +3335 java/io/File/GetCanonicalPath.java 3334 java/beans/Introspector/Test4168833.java 3333 java/util/concurrent/BlockingQueue/Interrupt.java 3333 java/io/RandomAccessFile/FileLengthTest.java @@ -2086,6 +2107,7 @@ 3298 java/awt/geom/Arc2D/SerializationTest.java 3297 java/util/Collections/CheckedIdentityMap.java 3297 java/beans/beancontext/Test4132698.java +3294 java/nio/file/Files/NotADirectory.java 3291 java/lang/RuntimeTests/Resources.java 3288 java/nio/channels/Pipe/PipeChannel.java 3288 java/io/File/ListNull.java @@ -2123,6 +2145,7 @@ 3243 java/text/Format/DecimalFormat/TieRoundingTest.java 3242 java/util/logging/LoggerSupplierAPIsTest.java 3240 java/nio/file/FileSystem/Basic.java +3240 java/lang/ProcessBuilder/NonPipelineLeaksFD.java 3235 java/lang/Math/RoundTests.java 3234 java/util/LinkedHashMap/EmptyMapIterator.java 3233 java/net/URLClassLoader/B7050028.java @@ -2189,6 +2212,7 @@ 3166 java/beans/VetoableChangeSupport/TestListeners.java 3161 java/lang/ClassLoader/BadRegisterAsParallelCapableCaller.java 3160 java/nio/channels/SelectionKey/RacyRegister.java +3158 java/lang/Thread/virtual/stress/CompletableFutureTimedGet.java 3156 java/io/Serializable/proxy/Basic.java 3156 java/beans/PropertyEditor/TestLongTypeValue.java 3155 java/lang/management/CompositeData/MemoryUsageCompositeData.java @@ -2208,6 +2232,7 @@ 3127 java/beans/XMLDecoder/spec/TestNull.java 3124 java/security/KeyStore/PKCS12/CheckDefaults.java 3123 java/nio/charset/coders/Errors.java +3123 java/lang/ProcessBuilder/NonPipelineLeaksFD.java#2 3122 java/util/logging/IsLoggableHandlerTest.java 3121 javax/crypto/JceSecurity/FinalRestricted.java 3121 java/util/Properties/LoadAndStoreNPE.java @@ -2299,6 +2324,7 @@ 2977 java/security/KeyFactory/Failover.java 2976 java/security/cert/X509Certificate/X509BadCertificate.java 2975 java/text/AttributedCharacterIterator/Attribute/ReadResolve.java +2975 java/lang/RuntimeTests/exec/ExecWithLotsOfArgs.java 2974 java/util/Date/TZ.java 2972 java/text/Collator/CurrencyCollate.java 2971 java/security/cert/CertPathBuilder/targetConstraints/BuildEEBasicConstraints.java @@ -2443,6 +2469,7 @@ 2817 java/net/HttpURLConnection/getResponseCode.java 2816 java/util/HashMap/PutNullKey.java 2816 java/lang/Double/BitwiseConversion.java +2816 java/lang/ProcessBuilder/UnblockSignals.java#2 2815 java/nio/channels/ServerSocketChannel/Basic.java 2815 java/io/Serializable/enum/unshared/Test.java 2815 java/io/charStreams/UTF8.java @@ -2559,6 +2586,7 @@ 2677 java/net/NetworkInterface/NetworkInterfaceRetrievalTests.java 2676 java/io/Serializable/auditStreamSubclass/AuditStreamSubclass.java 2675 java/lang/reflect/TypeVariable/TestAnnotatedElement.java +2675 java/lang/RuntimeTests/exec/Status.java 2674 java/lang/ref/ReferenceEnqueuePending.java 2674 java/lang/reflect/Parameter/GetAnnotatedTypeTest.java 2670 java/io/Serializable/partialClassDesc/PartialClassDesc.java @@ -2614,6 +2642,7 @@ 2623 java/lang/constant/DynamicConstantDescTest.java 2622 java/lang/Thread/MainThreadTest.java 2621 java/util/jar/JarFile/Constructor.java +2621 java/lang/ProcessBuilder/UnblockSignals.java 2620 java/io/pathNames/win32/BadDriveLetter.java 2615 java/io/Serializable/enum/ignoreSerializationFields/Test.java 2612 java/util/ArrayList/IteratorMicroBenchmark.java @@ -2669,6 +2698,7 @@ 2554 java/text/Format/DateFormat/Bug4396385.java 2554 java/lang/management/ThreadMXBean/ThreadCounts.java 2552 java/net/InetSocketAddress/CreateUnresolved.java +2551 java/nio/file/Files/CopyAndMove.java 2550 java/util/logging/RootLogger/RootLevelInConfigFile.java 2550 java/nio/channels/FileChannel/TransferOverlappedFileChannel.java 2550 java/net/URL/B6529759.java @@ -2682,6 +2712,7 @@ 2541 javax/xml/jaxp/transform/8004476/XSLTExFuncTest.java 2541 java/lang/reflect/classInitialization/ExceptionInClassInitialization.java 2538 java/util/PropertyPermission/PropertyPermissionCollection.java +2538 java/lang/Thread/virtual/YieldQueuing.java 2534 java/util/TreeMap/EmptyMapAndNulls.java 2528 java/io/charStreams/BufferedReaderMark.java 2527 java/io/Serializable/oldTests/AnnotateClass.java @@ -2696,6 +2727,7 @@ 2521 java/text/Format/DateFormat/Bug6609750.java 2521 java/io/BufferedReader/BigMark.java 2519 java/util/AbstractSequentialList/AddAll.java +2518 java/lang/RuntimeTests/exec/ExitValue.java 2517 java/beans/Introspector/Test6868189.java 2513 java/security/Provider/GetServiceRace.java 2512 java/nio/channels/DatagramChannel/Disconnect.java @@ -2754,6 +2786,7 @@ 2448 java/text/Bidi/BidiEmbeddingTest.java 2448 java/math/BigInteger/DoubleValueOverflow.java 2447 java/lang/Character/TestUndefinedIgnorable.java +2446 java/lang/RuntimeTests/exec/ArgWithSpaceAndFinalBackslash.java 2445 java/lang/Class/getDeclaredField/ClassDeclaredFieldsTest.java 2441 java/util/logging/HigherResolutionTimeStamps/SerializeLogRecord.java 2439 java/nio/charset/CharsetEncoder/CanEncode.java @@ -2836,6 +2869,7 @@ 2365 java/lang/reflect/Proxy/CharType.java 2365 java/lang/Boolean/GetBoolean.java 2365 java/io/Serializable/verifyDynamicObjHandleTable/VerifyDynamicObjHandleTable.java +2364 java/lang/Thread/virtual/BigStackChunk.java 2363 java/util/zip/GZIP/Accordion.java 2362 java/util/ResourceBundle/Control/BadControlsTest.java 2362 java/lang/reflect/Generics/getAnnotationTest.java @@ -2977,6 +3011,7 @@ 2208 java/lang/StrictMath/ExactArithTests.java 2207 java/util/Collections/RotateEmpty.java 2206 java/lang/reflect/Generics/TestMissingTypeVariable.java +2206 java/io/RandomAccessFile/UnreferencedRAFClosesFd.java 2204 java/lang/Class/getClasses/Sanity.java 2203 java/util/Formatter/Constructors.java 2203 java/io/RandomAccessFile/EOF.java @@ -3033,6 +3068,7 @@ 2125 java/lang/String/ICCBasher.java 2124 java/lang/ThreadLocal/Basic.java 2123 java/lang/StringBuffer/Capacity.java +2123 java/nio/file/Files/CopyProcFile.java 2120 java/io/LineNumberReader/ReadReadLine.java 2118 java/io/Serializable/finalFields/FinalFields.java 2118 java/beans/XMLDecoder/spec/TestNew.java @@ -3149,6 +3185,7 @@ 1994 java/lang/Integer/Decode.java 1993 java/math/BigInteger/ProbablePrime.java 1992 java/lang/Class/getField/ArrayLength.java +1992 java/nio/file/Files/SetLastModifiedTime.java 1988 java/util/Calendar/Bug6645263.java 1987 java/io/Serializable/NPEProvoker/NPEProvoker.java 1985 java/util/EnumSet/BogusEnumSet.java @@ -3316,6 +3353,7 @@ 1755 java/nio/file/Path/PathOps.java 1754 java/net/URLEncoder/Decoder.java 1751 java/net/URLClassLoader/AddURLTest.java +1745 java/nio/file/Files/walkFileTree/FindTest.java 1744 java/lang/management/RuntimeMXBean/InputArgument.java 1735 java/util/Properties/PropertiesSerialization.java 1726 java/io/charStreams/UTF16.java @@ -3326,7 +3364,9 @@ 1709 java/net/ipv6tests/ScopeTests.java 1705 java/net/URL/OpenConnection.java 1704 java/beans/VetoableChangeSupport/Test4994635.java +1704 java/nio/file/Files/probeContentType/ParallelProbes.java 1702 java/util/Collections/MinMax.java +1702 java/lang/RuntimeTests/exec/Duped.java 1701 java/lang/RuntimePermission/ExitVM.java 1699 java/lang/annotation/EqualityTest.java 1692 java/net/URLClassLoader/B6827999.java @@ -3379,6 +3419,7 @@ 1564 java/lang/ThreadLocal/InitialValue.java 1562 java/net/URLEncoder/URLEncodeDecode.java 1553 java/io/Reader/NullLock.java +1551 java/lang/Thread/virtual/stress/SleepALot.java 1547 java/lang/invoke/DropArgumentsTest.java 1542 java/net/URLDecoder/URLDecoderArgs.java 1540 java/lang/ThreadLocal/ThreadLocalSupplierTest.java @@ -3465,9 +3506,12 @@ 1077 java/io/FileInputStream/LargeFileAvailable.java 1074 java/nio/channels/Selector/SelectAfterRead.java 1070 java/nio/file/Files/FileAttributes.java +1055 java/nio/channels/FileChannel/AtomicAppend.java 1038 java/nio/channels/FileChannel/LoopingTruncate.java 1036 java/net/URL/Constructor.java +1021 java/nio/file/Files/TemporaryFiles.java 1019 java/nio/channels/DatagramChannel/StressNativeSignal.java +1015 java/nio/file/DirectoryStream/SecureDS.java 1011 java/nio/channels/SocketChannel/CloseAfterConnect.java 1009 java/nio/Buffer/StringCharBufferSliceTest.java 986 java/nio/channels/FileChannel/ReadFull.java @@ -3485,7 +3529,10 @@ 856 java/lang/invoke/7087570/Test7087570.java 845 java/nio/channels/SocketChannel/Close.java 829 java/nio/channels/Channels/ReadOffset.java +812 java/lang/Thread/virtual/ParkWithFixedThreadPool.java +785 java/io/FileOutputStream/AtomicAppend.java 763 java/nio/channels/ServerSocketChannel/NonBlockingAccept.java +758 java/nio/file/attribute/PosixFileAttributeView/Basic.java 755 java/nio/Buffer/CharAt.java 679 java/util/concurrent/forkjoin/ThrowingRunnable.java 668 java/net/URL/LocaleDependentURLTest.java From ab79fb4acc0a405bca4cf0289ad584f5271cbe82 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:27:50 -0600 Subject: [PATCH 18/25] Hold a host descriptor on each open file so its inode number is not reused after the path is deleted --- src/natives/io.ts | 26 ++++++++++++++++++++++---- test/nio.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/natives/io.ts b/src/natives/io.ts index e1dc845..2134f77 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -45,6 +45,8 @@ interface Contents { host?: FileHandle; hostWritable?: boolean; hostReadable?: boolean; + // a host descriptor that keeps the file's inode alive, as an open descriptor does on Linux + pin?: FileHandle; } // Opens a file's host handle when it outgrows memory. Kept apart from the @@ -66,6 +68,8 @@ function closeHostsOnExit(vm: JVM) { for (const f of tab?.files.values() ?? []) { f.contents.host?.close(); f.contents.host = undefined; + f.contents.pin?.close(); + f.contents.pin = undefined; } }); } @@ -181,6 +185,8 @@ export class OpenFile { return false; } c.host = host; + c.pin?.close(); + c.pin = undefined; c.hostWritable = true; c.data = new Uint8Array(0); c.dirty = false; @@ -190,10 +196,12 @@ export class OpenFile { // a mapping keeps the file open after its descriptor closes retain() { this.contents.refs++; } release() { - if (--this.contents.refs === 0 && this.contents.host) { - this.contents.host.close(); - this.contents.host = undefined; - } + const c = this.contents; + if (--c.refs > 0) return; + c.host?.close(); + c.host = undefined; + c.pin?.close(); + c.pin = undefined; } close() { this.release(); } } @@ -357,6 +365,7 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo Object.assign(shared, { host, hostWritable: w, hostReadable: r }); } const f = new OpenFile(abs, data, write, shared); + if (!f.contents.host && !f.contents.pin) pinInode(t, f.contents, abs, write, readable); // only a writer can grow a file, so read-only files never hold an opener if (write && !hostOpeners.has(f.contents) && fs.canOpenFile(abs, true)) { const vm = t.vm; @@ -377,6 +386,15 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo return fd; } +// Only a writable host tree can lose the file while it is open, and only one +// that reports inode numbers can reuse them. +function pinInode(t: JThread, c: Contents, abs: string, write: boolean, readable: boolean) { + const fs = t.vm.fs; + if (!fs.canOpenFile(abs, true) || fs.stat(abs)?.ino === undefined) return; + c.pin = openHost(fs, abs, false, true) ?? (write ? openHost(fs, abs, true, readable) : undefined); + if (c.pin) closeHostsOnExit(t.vm); +} + function openHost(fs: VFS, abs: string, write: boolean, readable: boolean): FileHandle | undefined { try { return fs.openFile(abs, write, false, readable); } catch { return undefined; } diff --git a/test/nio.test.ts b/test/nio.test.ts index d41e866..bdb3199 100644 --- a/test/nio.test.ts +++ b/test/nio.test.ts @@ -125,6 +125,30 @@ test("a large file on a read-only host mount uses a handle", async () => { } }); +test("an open host file keeps its inode when its path is deleted and recreated", async () => { + const { openFile, fileOf, closeFd, removePath } = await import("../src/natives/io.ts"); + const dir = mkdtempSync(join(tmpdir(), "java-web-pin-")); + const fs = new VFS(); + fs.mount("/", new NodeBackend(dir, true)); + const t = { vm: { fs, opts: {}, state: new Map(), exited: new Promise(() => {}) } } as unknown as JThread; + try { + writeFileSync(join(dir, "file"), "old"); + const fd = openFile(t, "/file", true, false, false); + const f = fileOf(t, fd); + assert.ok(f.contents.pin); + const original = fs.stat("/file")!.ino; + assert.equal(removePath(t, "/file"), true); + const replacement = openFile(t, "/file", true, false, false); + assert.notEqual(fs.stat("/file")!.ino, original); + assert.equal(f.ino, original); + closeFd(t, replacement); + closeFd(t, fd); + assert.equal(f.contents.pin, undefined); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("map0 of a region that does not fit throws OutOfMemoryError for FileChannelImpl to wrap", () => { const backend = memory(); const t = thread(backend); From a03ae47336264bc1dbfd851c3171dbc55e1d0415 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:56:56 -0600 Subject: [PATCH 19/25] Close directory streams in the new test programs, release pinned descriptors on failure, and drop a duplicate test duration. --- test/java/SecureDirectories.java | 16 ++++++++++------ test/java/SymbolicLinks.java | 4 +++- test/jtreg/times.tsv | 1 - test/nio.test.ts | 20 ++++++++++++-------- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/test/java/SecureDirectories.java b/test/java/SecureDirectories.java index d515016..75a45bf 100644 --- a/test/java/SecureDirectories.java +++ b/test/java/SecureDirectories.java @@ -31,16 +31,20 @@ public static void main(String[] args) throws IOException { for (Path p : sub) names.add(p.getFileName().toString()); System.out.println("listed " + names); } - try { - secure.newDirectoryStream(Path.of("a")); + try (var notDir = secure.newDirectoryStream(Path.of("a"))) { + System.out.println("opened a file as a directory"); } catch (NotDirectoryException e) { System.out.println("not a directory"); } - secure.move(Path.of("a"), (SecureDirectoryStream) Files.newDirectoryStream(other), Path.of("b")); + try (DirectoryStream target = Files.newDirectoryStream(other)) { + secure.move(Path.of("a"), (SecureDirectoryStream) target, Path.of("b")); + } System.out.println("moved " + Files.exists(dir.resolve("a")) + " " + Files.exists(other.resolve("b"))); - secure.deleteDirectory(Path.of("missing-dir")); - } catch (java.nio.file.NoSuchFileException e) { - System.out.println("no such file"); + try { + secure.deleteDirectory(Path.of("missing-dir")); + } catch (java.nio.file.NoSuchFileException e) { + System.out.println("no such file"); + } } try (DirectoryStream stream = Files.newDirectoryStream(other)) { var secure = (SecureDirectoryStream) stream; diff --git a/test/java/SymbolicLinks.java b/test/java/SymbolicLinks.java index cd76b1f..37e4925 100644 --- a/test/java/SymbolicLinks.java +++ b/test/java/SymbolicLinks.java @@ -29,7 +29,9 @@ public static void main(String[] args) throws IOException { System.out.println("canonical missing " + new File(dir + "/toDir/new").getCanonicalPath().equals(sub + "/new")); Files.writeString(toDir.resolve("inner"), "x"); - System.out.println("list through link " + Files.list(toDir).map(p -> p.getFileName().toString()).collect(Collectors.toList())); + try (var list = Files.list(toDir)) { + System.out.println("list through link " + list.map(p -> p.getFileName().toString()).collect(Collectors.toList())); + } try { Files.createSymbolicLink(toFile, file); diff --git a/test/jtreg/times.tsv b/test/jtreg/times.tsv index dae7f7c..982a3b8 100644 --- a/test/jtreg/times.tsv +++ b/test/jtreg/times.tsv @@ -3507,7 +3507,6 @@ 1077 java/io/FileInputStream/LargeFileAvailable.java 1074 java/nio/channels/Selector/SelectAfterRead.java 1070 java/nio/file/Files/FileAttributes.java -1055 java/nio/channels/FileChannel/AtomicAppend.java 1038 java/nio/channels/FileChannel/LoopingTruncate.java 1036 java/net/URL/Constructor.java 1021 java/nio/file/Files/TemporaryFiles.java diff --git a/test/nio.test.ts b/test/nio.test.ts index bdb3199..91cc4f4 100644 --- a/test/nio.test.ts +++ b/test/nio.test.ts @@ -135,14 +135,18 @@ test("an open host file keeps its inode when its path is deleted and recreated", writeFileSync(join(dir, "file"), "old"); const fd = openFile(t, "/file", true, false, false); const f = fileOf(t, fd); - assert.ok(f.contents.pin); - const original = fs.stat("/file")!.ino; - assert.equal(removePath(t, "/file"), true); - const replacement = openFile(t, "/file", true, false, false); - assert.notEqual(fs.stat("/file")!.ino, original); - assert.equal(f.ino, original); - closeFd(t, replacement); - closeFd(t, fd); + let replacement = -1; + try { + assert.ok(f.contents.pin); + const original = fs.stat("/file")!.ino; + assert.equal(removePath(t, "/file"), true); + replacement = openFile(t, "/file", true, false, false); + assert.notEqual(fs.stat("/file")!.ino, original); + assert.equal(f.ino, original); + } finally { + if (replacement >= 0) closeFd(t, replacement); + closeFd(t, fd); + } assert.equal(f.contents.pin, undefined); } finally { rmSync(dir, { recursive: true, force: true }); From 090ff9e8c30b0fc832377ae4cfa5fe024ceb1b2a Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:09:49 -0600 Subject: [PATCH 20/25] Keep the mount prefix when canonicalizing a mount point. --- src/fs.ts | 4 +++- test/fs.test.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/fs.ts b/src/fs.ts index 6ad6c37..c2a4213 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -453,7 +453,9 @@ export class VFS { const real = r[0].realpath(r[1]); if (real === null) return null; const n = normalize(path); - return normalize(n.slice(0, n.length - r[1].length) + real); + // a mount point itself maps to "/" in its backend + const base = n.slice(0, n.length - (r[1] === "/" ? 0 : r[1].length)); + return normalize(base + real); } // A file system without its own figures reports 4 KB blocks and no space, diff --git a/test/fs.test.ts b/test/fs.test.ts index 5798824..238071d 100644 --- a/test/fs.test.ts +++ b/test/fs.test.ts @@ -38,3 +38,14 @@ test("host path resolution leaves other mounts and memory paths lexical", () => memory.mount("/files", new MemoryBackend()); assert.equal(memory.resolvePath("/files/dir/../file"), "/files/file"); }); + +test("realpath keeps the mount prefix of a mount point", () => { + const fs = new VFS(); + const withRealpath = () => Object.assign(new MemoryBackend(), { realpath: (p: string) => p }); + fs.mount("/", withRealpath()); + fs.mount("/jdk", withRealpath()); + assert.equal(fs.realpath("/jdk"), "/jdk"); + assert.equal(fs.realpath("/jdk/lib"), "/jdk/lib"); + assert.equal(fs.realpath("/"), "/"); + assert.equal(fs.realpath("/dir/file"), "/dir/file"); +}); From ebc7bbc01df56de564e460b32f504a08ef701b8b Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:09:49 -0600 Subject: [PATCH 21/25] Evaluate the runner's @requires ~= as a full match, as jtreg does. --- scripts/jtreg/run.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/jtreg/run.ts b/scripts/jtreg/run.ts index 791889a..958b9e4 100644 --- a/scripts/jtreg/run.ts +++ b/scripts/jtreg/run.ts @@ -119,7 +119,7 @@ function requiresHolds(expr: string): boolean { switch (op) { case "==": return a === b; case "!=": return a !== b; - case "~=": return a !== null && new RegExp(b ?? "").test(a); + case "~=": return a !== null && new RegExp(`^(?:${b ?? ""})$`).test(a); } const x = size(a ?? ""), y = size(b ?? ""); return op === ">=" ? x >= y : op === "<=" ? x <= y : op === ">" ? x > y : x < y; From 54c5c437bc3ac1123ec40de190d882fe1a490694 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:09:50 -0600 Subject: [PATCH 22/25] Limit the inode and mode claims in the support notes to host file systems. --- docs/support.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/support.md b/docs/support.md index 2f7ce14..504cb00 100644 --- a/docs/support.md +++ b/docs/support.md @@ -24,9 +24,10 @@ java-web targets headless Java: console apps, libraries and javac. other processes from Linux's `/proc`; elsewhere it sees only its own children. - Symbolic and hard links work on file systems that come from the host, as under Node. Elsewhere, such as the browser's in-memory files, creating a link - fails with "Function not implemented". Files in the VM have the host's inode - numbers and link counts, and the modes `open` and `mkdir` ask for, less the - process's umask. + fails with "Function not implemented". Files on host file systems have the + host's inode numbers and link counts, and the modes `open` and `mkdir` ask + for, less the process's umask. In-memory files get synthetic inode numbers, a + link count of 1 and default modes. - `-XX:OnOutOfMemoryError` runs no command, and `StackWalker` has no live locals or operands. `ThreadMXBean.getThreadInfo` and `dumpThreads` are not implemented, and a virtual thread that yields while it From be9cee6a15e67b6f994ff3df148aeddd053b66cd Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:09:50 -0600 Subject: [PATCH 23/25] Treat any non-regular file on PATH as EACCES when looking up a program. --- src/process-node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/process-node.ts b/src/process-node.ts index 2d11fa8..3a17bcd 100644 --- a/src/process-node.ts +++ b/src/process-node.ts @@ -26,7 +26,7 @@ function findProgram(command: string, cwd: string, parentPath: string | undefine const candidate = dir ? `${dir}/${command}` : `./${command}`; const full = path.resolve(cwd, candidate); try { - if (fs.statSync(full).isDirectory()) denied = true; + if (!fs.statSync(full).isFile()) denied = true; else { fs.accessSync(full, fs.constants.X_OK); return candidate; From 961938a0b00f4c68ebca1ee708c80a2af170202a Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:09:50 -0600 Subject: [PATCH 24/25] Pin only the inode a file was read from without creating it, and share open contents only between paths that reach one inode. --- src/fs.ts | 2 ++ src/natives/io.ts | 15 ++++++++++----- src/node.ts | 3 ++- test/nio.test.ts | 36 +++++++++++++++++++++++++++++++++++- 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/fs.ts b/src/fs.ts index c2a4213..72eed71 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -34,6 +34,7 @@ export const DEVICES = new Set(["/dev/null", "/dev/zero", "/dev/random", "/dev/u // Errors carry an errno name in `code`, as Node's do. export interface FileHandle { size(): number; + ino(): number; read(dst: Uint8Array, off: number, len: number, pos: number): number; write(src: Uint8Array, pos: number): void; truncate(size: number): void; @@ -321,6 +322,7 @@ export class VFS { // writes through the handle are changes watchers see, as writes through VFS.write are return { size: () => h.size(), + ino: () => h.ino(), read: (dst, off, len, pos) => h.read(dst, off, len, pos), close: () => h.close(), write: (src, pos) => { h.write(src, pos); this.changed(path, "modify"); }, diff --git a/src/natives/io.ts b/src/natives/io.ts index 2134f77..66f9db6 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -47,6 +47,8 @@ interface Contents { hostReadable?: boolean; // a host descriptor that keeps the file's inode alive, as an open descriptor does on Linux pin?: FileHandle; + // the host inode these bytes came from, where the host has them + ino?: number; } // Opens a file's host handle when it outgrows memory. Kept apart from the @@ -207,7 +209,7 @@ export class OpenFile { } // A read-only directory descriptor can be opened, but cannot be read as bytes. -class DirectoryFile extends OpenFile { +export class DirectoryFile extends OpenFile { constructor(path: string, size: number) { super(path, new Uint8Array(0), false); this.size = size; @@ -325,7 +327,7 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo tab.files.set(tab.next, new DirectoryFile(abs, st.size)); return tab.next++; } - const shared = st ? [...table(t).files.values()].find((o) => o.path === abs && o.constructor === OpenFile)?.contents : undefined; + const shared = st ? [...table(t).files.values()].find((o) => o.path === abs && o.constructor === OpenFile && (o.contents.ino === undefined || o.contents.ino === st.ino))?.contents : undefined; if (st?.device || (st && !shared && !write && st.size >= LAZY_MIN && fs.canReadRange(abs))) { const tab = table(t); tab.files.set(tab.next, st.device ? new DeviceFile(abs) : new LazyFile(abs, st.size, fs)); @@ -365,6 +367,7 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo Object.assign(shared, { host, hostWritable: w, hostReadable: r }); } const f = new OpenFile(abs, data, write, shared); + if (!shared) f.contents.ino = (st ?? fs.stat(abs))?.ino; if (!f.contents.host && !f.contents.pin) pinInode(t, f.contents, abs, write, readable); // only a writer can grow a file, so read-only files never hold an opener if (write && !hostOpeners.has(f.contents) && fs.canOpenFile(abs, true)) { @@ -390,9 +393,11 @@ export function openFile(t: JThread, path: string, write: boolean, truncate: boo // that reports inode numbers can reuse them. function pinInode(t: JThread, c: Contents, abs: string, write: boolean, readable: boolean) { const fs = t.vm.fs; - if (!fs.canOpenFile(abs, true) || fs.stat(abs)?.ino === undefined) return; - c.pin = openHost(fs, abs, false, true) ?? (write ? openHost(fs, abs, true, readable) : undefined); - if (c.pin) closeHostsOnExit(t.vm); + if (c.ino === undefined || !fs.canOpenFile(abs, true)) return; + const pin = openHost(fs, abs, false, true) ?? (write ? openHost(fs, abs, true, readable) : undefined); + if (pin?.ino() !== c.ino) return pin?.close(); + c.pin = pin; + closeHostsOnExit(t.vm); } function openHost(fs: VFS, abs: string, write: boolean, readable: boolean): FileHandle | undefined { diff --git a/src/node.ts b/src/node.ts index 38ce160..1e9d9a6 100644 --- a/src/node.ts +++ b/src/node.ts @@ -40,7 +40,7 @@ export class NodeBackend implements Backend { return this.root === "/" ? p : this.root + p; } openFile(p: string, write: boolean, truncate: boolean, readable: boolean) { - const flags = write ? (readable ? fs.constants.O_RDWR : fs.constants.O_WRONLY) | fs.constants.O_CREAT | (truncate ? fs.constants.O_TRUNC : 0) : fs.constants.O_RDONLY; + const flags = write ? (readable ? fs.constants.O_RDWR : fs.constants.O_WRONLY) | (truncate ? fs.constants.O_TRUNC : 0) : fs.constants.O_RDONLY; let fd: number; try { const host = this.host(p); @@ -58,6 +58,7 @@ export class NodeBackend implements Backend { } return { size: () => fs.fstatSync(fd).size, + ino: () => fs.fstatSync(fd).ino, read: (dst: Uint8Array, off: number, len: number, pos: number) => fs.readSync(fd, dst, off, len, pos), write: (src: Uint8Array, pos: number) => { let n = 0; diff --git a/test/nio.test.ts b/test/nio.test.ts index 91cc4f4..8fcf51e 100644 --- a/test/nio.test.ts +++ b/test/nio.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, truncateSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { MemoryBackend, VFS } from "../src/fs.ts"; @@ -153,6 +153,40 @@ test("an open host file keeps its inode when its path is deleted and recreated", } }); +test("opening a host file for writing does not create a missing one", () => { + const dir = mkdtempSync(join(tmpdir(), "java-web-nocreate-")); + try { + assert.throws(() => new NodeBackend(dir, true).openFile("/missing", true, false, false), { code: "ENOENT" }); + assert.equal(existsSync(join(dir, "missing")), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("a path that reaches a different inode does not share the open file's contents", async () => { + const { openFile, fileOf, closeFd } = await import("../src/natives/io.ts"); + const dir = mkdtempSync(join(tmpdir(), "java-web-share-")); + const fs = new VFS(); + fs.mount("/", new NodeBackend(dir, true)); + const t = { vm: { fs, opts: {}, state: new Map(), exited: new Promise(() => {}) } } as unknown as JThread; + try { + writeFileSync(join(dir, "target"), "old"); + symlinkSync("target", join(dir, "link")); + const first = openFile(t, "/link", false, false, false); + renameSync(join(dir, "target"), join(dir, "moved")); + writeFileSync(join(dir, "target"), "new"); + const second = openFile(t, "/link", false, false, false); + const bytes = new Uint8Array(3); + assert.equal(fileOf(t, second).read(bytes, 0, 3), 3); + assert.equal(new TextDecoder().decode(bytes), "new"); + assert.equal(readFileSync(join(dir, "moved"), "utf8"), "old"); + closeFd(t, second); + closeFd(t, first); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("map0 of a region that does not fit throws OutOfMemoryError for FileChannelImpl to wrap", () => { const backend = memory(); const t = thread(backend); From 2dbbc0cf2eb35fe86afb02e0cae31a0ecee97bba Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:09:50 -0600 Subject: [PATCH 25/25] Fail the directory-relative natives with the errors Linux gives for detached and non-directory descriptors, dot names, bad descriptors and dangling links. --- src/natives/nio.ts | 27 ++++++++++++++++++++++----- test/java/SecureDirectories.java | 31 +++++++++++++++++++++++++++++++ test/java/SymbolicLinks.java | 14 ++++++++++++++ 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 2840ff8..e5f7ea3 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -6,7 +6,7 @@ import type { Stat } from "../fs.ts"; import { preCloseSock } from "../sockets.ts"; import { closeInotify, sockRead, sockReadv, sockWrite, sockWritev } from "./sockets.ts"; import { JThread, PendingThrow } from "../thread.ts"; -import { closeFd, endsInDot, newFd, fileInode, fileOf, flush, flushOrThrow, openFile, removePath, renamePath, resolvePath, setMtime, syncPath, writeFailed } from "./io.ts"; +import { closeFd, DirectoryFile, newFd, 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"; @@ -66,9 +66,23 @@ function pathAt(t: JThread, dfd: number, p: bigint): string { if (dfd === AT_FDCWD || name.startsWith("/")) return resolvePath(t, name); const dir = t.vm.state.get("fds")?.files.get(dfd); if (!dir) unixError(EBADF); + if (!(dir instanceof DirectoryFile)) unixError(ENOTDIR); + if (!dir.path) unixError(ENOENT); return resolvePath(t, `${dir.path}/${name}`); } +// the last path component, ignoring trailing slashes +function lastComponent(p: string): string | undefined { + return p.replace(/\/+$/, "").split("/").pop(); +} + +// the descriptor's file, or EBADF as the system calls give for one that is not open +function openFileOf(t: JThread, fd: number) { + const f = t.vm.state.get("fds")?.files.get(fd); + if (!f) unixError(EBADF); + return f; +} + function removeDir(t: JThread, abs: string) { const st = t.vm.fs.lstat(abs); if (!st) unixError(ENOENT); @@ -215,7 +229,9 @@ export const natives: Natives = { [`${N}open0(JII)I`]: (t, [p, flags, mode]) => openAbs(t, path(t, p), flags, mode), [`${N}openat0(IJII)I`]: (t, [dfd, p, flags, mode]) => openAbs(t, pathAt(t, dfd, p), flags, mode), [`${N}unlinkat0(IJI)V`]: (t, [dfd, p, flag]) => { + const last = lastComponent(cstring(t, p)); const abs = pathAt(t, dfd, p); + if (last === "." || last === "..") unixError(flag & AT_REMOVEDIR ? (last === "." ? EINVAL : ENOTEMPTY) : EISDIR); if (flag & AT_REMOVEDIR) removeDir(t, abs); else unlinkAbs(t, abs); }, @@ -226,14 +242,14 @@ export const natives: Natives = { }, [`${N}fchmodat0(IJII)V`]: (t, [dfd, p, mode]) => chmod(t, pathAt(t, dfd, p), mode), [`${N}fdopendir(I)J`]: (t, [fd]) => { - const f = fileOf(t, fd); + const f = openFileOf(t, fd); if (!t.vm.fs.stat(f.path)?.dir) unixError(ENOTDIR); const id = nextDir++; dirs(t).set(id, { names: t.vm.fs.list(f.path) ?? [], pos: 0, fd }); return id; }, [`${N}dup(I)I`]: (t, [fd]) => { - const f = fileOf(t, fd); + const f = openFileOf(t, fd); const copy = newFd(t); t.vm.state.get("fds").files.set(copy, f); f.retain(); @@ -245,12 +261,13 @@ export const natives: Natives = { }, [`${N}mkdir0(JI)V`]: (t, [p, mode]) => { const abs = path(t, p); - if (t.vm.fs.stat(abs)) unixError(EEXIST); + if (t.vm.fs.lstat(abs)) unixError(EEXIST); if (!t.vm.fs.mkdir(abs)) unixError(t.vm.fs.writable(abs) ? ENOENT : EROFS); applyCreateMode(t, abs, mode); }, [`${N}rmdir0(J)V`]: (t, [p]) => { - if (endsInDot(cstring(t, p))) unixError(EINVAL); + const last = lastComponent(cstring(t, p)); + if (last === "." || last === "..") unixError(last === "." ? EINVAL : ENOTEMPTY); removeDir(t, path(t, p)); }, [`${N}unlink0(J)V`]: (t, [p]) => unlinkAbs(t, path(t, p)), diff --git a/test/java/SecureDirectories.java b/test/java/SecureDirectories.java index 75a45bf..13df5df 100644 --- a/test/java/SecureDirectories.java +++ b/test/java/SecureDirectories.java @@ -45,12 +45,43 @@ public static void main(String[] args) throws IOException { } catch (java.nio.file.NoSuchFileException e) { System.out.println("no such file"); } + for (String name : new String[] {".", ".."}) { + try { + secure.deleteDirectory(Path.of(name)); + System.out.println("deleted directory " + name); + } catch (IOException e) { + System.out.println("delete directory " + name + " " + e.getClass().getSimpleName()); + } + try { + secure.deleteFile(Path.of(name)); + System.out.println("deleted file " + name); + } catch (IOException e) { + System.out.println("delete file " + name + " " + e.getClass().getSimpleName()); + } + } + System.out.println("directory kept " + Files.isDirectory(dir)); } try (DirectoryStream stream = Files.newDirectoryStream(other)) { var secure = (SecureDirectoryStream) stream; secure.deleteFile(Path.of("b")); System.out.println("deleted " + Files.exists(other.resolve("b"))); } + Path gone = Files.createTempDirectory("secure"); + try (DirectoryStream stream = Files.newDirectoryStream(gone)) { + var secure = (SecureDirectoryStream) stream; + Files.delete(gone); + try (var ch = secure.newByteChannel(Path.of("late"), + Set.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE))) { + System.out.println("created in a deleted directory"); + } catch (IOException e) { + System.out.println("deleted directory " + e.getClass().getSimpleName()); + } + try { + secure.deleteFile(Path.of("late")); + } catch (IOException e) { + System.out.println("delete in deleted directory " + e.getClass().getSimpleName()); + } + } try { Files.newDirectoryStream(other.resolve("nothing")); } catch (java.nio.file.NoSuchFileException e) { diff --git a/test/java/SymbolicLinks.java b/test/java/SymbolicLinks.java index 37e4925..4c77797 100644 --- a/test/java/SymbolicLinks.java +++ b/test/java/SymbolicLinks.java @@ -43,6 +43,20 @@ public static void main(String[] args) throws IOException { } catch (java.nio.file.NotLinkException e) { System.out.println("not a link"); } + try { + Files.createDirectory(dangling); + } catch (java.nio.file.FileAlreadyExistsException e) { + System.out.println("directory over dangling link"); + } + for (String name : new String[] {"sub/.", "sub/.."}) { + try { + Files.delete(dir.resolve(name)); + System.out.println("deleted " + name); + } catch (IOException e) { + System.out.println("delete " + name + " " + e.getClass().getSimpleName()); + } + } + System.out.println("sub kept " + Files.isDirectory(sub)); Path copy = Files.copy(toFile, dir.resolve("copy"), LinkOption.NOFOLLOW_LINKS); System.out.println("copy of link " + Files.isSymbolicLink(copy));