From ded64dbbc5ec58e0d6872707d9d0aa4f6377adb8 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:54:31 -0600 Subject: [PATCH 1/3] Make File.setReadOnly and setPermission change the host file's mode, and give the release's OpenJDK tests more time. --- .github/workflows/release.yml | 2 +- package.json | 3 +++ src/fs.ts | 9 +++++++++ src/natives/io.ts | 12 ++++++++++-- src/node.ts | 8 ++++++++ test/permissions/Permissions.java | 6 +++++- test/programs.test.ts | 8 +++++--- 7 files changed, 41 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dee22c4..2d75d8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ jobs: git clone -q --filter=blob:none --sparse --depth 1 --branch "jdk-${release%+*}-ga" https://github.com/openjdk/jdk25u.git build/ref/jdk25u dirs=$(grep -v '^#' test/jtreg/passing.txt | cut -d/ -f1-2 | sort -u | sed 's|^|test/jdk/|') git -C build/ref/jdk25u sparse-checkout set test/lib test/jdk/lib test/jdk/tools/lib $dirs - node scripts/jtreg/run.ts build/ref/jdk25u --only-passing --jobs 4 + node scripts/jtreg/run.ts build/ref/jdk25u --only-passing --jobs 4 --timeout 600 - run: pnpm build - name: Node entry on the built dist/ run: node --expose-gc --test test/image.test.ts diff --git a/package.json b/package.json index 51d5692..0a04fd3 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,9 @@ "dist/jdk/jdk.localedata.txt", "dist/jdk/legal/" ], + "publishConfig": { + "access": "public" + }, "engines": { "node": ">=23.6" }, diff --git a/src/fs.ts b/src/fs.ts index 8d14aae..09e6622 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -27,6 +27,8 @@ export interface Backend { setMtime?(path: string, ms: number): 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) + chmod?(path: string, f: (mode: number) => number): boolean; } export function normalize(path: string): string { @@ -165,6 +167,13 @@ export class VFS { const r = this.resolve(path); return !!r && r[0].writable && !!r[0].denied?.(r[1]); } + + // undefined when the backend has no permission bits + chmod(path: string, f: (mode: number) => number): boolean | undefined { + const r = this.resolve(path); + if (!r || !r[0].chmod) return undefined; + return r[0].writable && r[0].chmod(r[1], f); + } } // Writable in-memory tree, for /tmp, /files and strings added by the page. diff --git a/src/natives/io.ts b/src/natives/io.ts index 660c5cd..8c4629b 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -278,6 +278,11 @@ function attrs(t: JThread, file: JObject): number { return 1 | (st.dir ? 4 : st.device ? 0 : 2); } +// a backend without permission bits takes any change to an existing file +function chmod(t: JThread, file: JObject, f: (mode: number) => number): boolean { + return t.vm.fs.chmod(resolvePath(t, pathOf(t, file)), f) ?? attrs(t, file) !== 0; +} + function pathOf(t: JThread, file: JObject): string { return t.vm.jsString(t.vm.getField(file, "java/io/File", "path")); } @@ -384,7 +389,10 @@ export const natives: Natives = { syncPath(t, abs); return BigInt(t.vm.fs.stat(abs)?.size ?? 0); }, - [`${UFS}.setPermission0(Ljava/io/File;IZZ)Z`]: (t, [, f]) => attrs(t, f) !== 0, + [`${UFS}.setPermission0(Ljava/io/File;IZZ)Z`]: (t, [, f, access, enable, ownerOnly]) => { + const bits = ownerOnly ? access << 6 : access * 0o111; + return chmod(t, f, (m) => (enable ? m | bits : m & ~bits)); + }, [`${UFS}.createFileExclusively0(Ljava/lang/String;)Z`]: (t, [, p]) => { const abs = resolvePath(t, t.vm.jsString(p)); if (t.vm.fs.stat(abs)) return 0; @@ -401,7 +409,7 @@ export const natives: Natives = { [`${UFS}.rename0(Ljava/io/File;Ljava/io/File;)Z`]: (t, [, a, b]) => renamePath(t, resolvePath(t, pathOf(t, a)), resolvePath(t, pathOf(t, b))), [`${UFS}.setLastModifiedTime0(Ljava/io/File;J)Z`]: (t, [, f, ms]) => setMtime(t, resolvePath(t, pathOf(t, f)), Number(ms)), - [`${UFS}.setReadOnly0(Ljava/io/File;)Z`]: (t, [, f]) => attrs(t, f) !== 0, + [`${UFS}.setReadOnly0(Ljava/io/File;)Z`]: (t, [, f]) => chmod(t, f, (m) => m & ~0o222), [`${UFS}.getSpace0(Ljava/io/File;I)J`]: () => 0n, [`${UFS}.getNameMax0(Ljava/lang/String;)J`]: () => 255n, }; diff --git a/src/node.ts b/src/node.ts index 8105894..a6b948b 100644 --- a/src/node.ts +++ b/src/node.ts @@ -81,6 +81,14 @@ export class NodeBackend implements Backend { return (e as NodeJS.ErrnoException).code === "EACCES" || (e as NodeJS.ErrnoException).code === "EPERM"; } } + chmod(p: string, f: (mode: number) => number) { + try { + fs.chmodSync(this.host(p), f(fs.statSync(this.host(p)).mode & 0o7777)); + return true; + } catch { + return false; + } + } setMtime(p: string, ms: number) { try { fs.utimesSync(this.host(p), fs.statSync(this.host(p)).atime, ms / 1000); diff --git a/test/permissions/Permissions.java b/test/permissions/Permissions.java index 196de0e..c271c55 100644 --- a/test/permissions/Permissions.java +++ b/test/permissions/Permissions.java @@ -4,7 +4,8 @@ import java.nio.file.Files; import java.nio.file.Path; -// Writes the host refuses: args are a read-only file and a read-only directory. +// Writes the host refuses: args are a read-only file, a read-only directory and a +// writable file. public class Permissions { public static void main(String[] args) throws Exception { Path file = Path.of(args[0]), dir = Path.of(args[1]); @@ -30,5 +31,8 @@ public static void main(String[] args) throws Exception { System.out.println(e.getClass().getName()); } System.out.println(Files.isWritable(dir) + " " + Files.exists(dir.resolve("new.txt"))); + File w = new File(args[2]); + System.out.println(w.setReadOnly() + " " + w.canWrite()); + System.out.println(w.setWritable(true) + " " + w.canWrite()); } } diff --git a/test/programs.test.ts b/test/programs.test.ts index 3a71054..ff79a74 100644 --- a/test/programs.test.ts +++ b/test/programs.test.ts @@ -122,14 +122,16 @@ test("permission denied", { skip: process.getuid?.() === 0 }, async () => { const classes = join(root, "build", "test-permissions"); execFileSync(hotspot("javac"), ["-d", classes, join(root, "test", "permissions", "Permissions.java")]); const dir = mkdtempSync(join(tmpdir(), "java-web-perm-")); - const file = join(dir, "read-only.txt"), locked = join(dir, "locked"); + const file = join(dir, "read-only.txt"), locked = join(dir, "locked"), open = join(dir, "open.txt"); writeFileSync(file, "x"); chmodSync(file, 0o444); mkdirSync(locked, { mode: 0o555 }); try { - const ref = spawnSync(hotspot("java"), ["-cp", classes, "Permissions", file, locked], { encoding: "utf8" }); + writeFileSync(open, "x"); + const ref = spawnSync(hotspot("java"), ["-cp", classes, "Permissions", file, locked, open], { encoding: "utf8" }); + chmodSync(open, 0o644); const stdout: Uint8Array[] = []; - const code = await runNode({ classpath: [classes], mainClass: "Permissions", args: [file, locked], stdout: (b) => stdout.push(b) }); + const code = await runNode({ classpath: [classes], mainClass: "Permissions", args: [file, locked, open], stdout: (b) => stdout.push(b) }); assert.equal(Buffer.concat(stdout).toString("utf8"), ref.stdout); assert.equal(code, ref.status); } finally { From 49c2c1bf281a43bdd9a4a82869ff390c80c56710 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:07:52 -0600 Subject: [PATCH 2/3] Keep open files writable after a mode change, check read and execute access on the host, and leave device modes alone. --- src/fs.ts | 12 ++++++++++++ src/natives/io.ts | 11 ++++++++--- src/natives/nio.ts | 2 +- src/node.ts | 8 ++++++++ test/permissions/Permissions.java | 9 +++++++++ test/programs.test.ts | 1 + 6 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/fs.ts b/src/fs.ts index 09e6622..5d1fc19 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -29,6 +29,8 @@ export interface Backend { denied?(path: string): boolean; // sets the permission bits to f(current bits) chmod?(path: string, f: (mode: number) => number): boolean; + // whether the host allows reading (4) and executing (1), as access(2) + access?(path: string, mode: number): boolean; } export function normalize(path: string): string { @@ -170,10 +172,20 @@ export class VFS { // undefined when the backend has no permission bits chmod(path: string, f: (mode: number) => number): boolean | undefined { + if (DEVICES.has(normalize(path))) return false; const r = this.resolve(path); if (!r || !r[0].chmod) return undefined; return r[0].writable && r[0].chmod(r[1], f); } + + // access(2) on an existing path: mode is 4 read, 2 write, 1 execute + access(path: string, mode: number): boolean { + if (DEVICES.has(normalize(path))) return true; + const r = this.resolve(path); + if (!r) return false; + if (mode & 2 && (!r[0].writable || r[0].denied?.(r[1]))) return false; + return !(mode & 5) || (r[0].access?.(r[1], mode & 5) ?? true); + } } // Writable in-memory tree, for /tmp, /files and strings added by the page. diff --git a/src/natives/io.ts b/src/natives/io.ts index 8c4629b..c81d3a9 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -192,7 +192,13 @@ export function fileOf(t: JThread, fd: number): OpenFile { export function flush(t: JThread, f: OpenFile) { if (f.dirty && f.writable && f.path) { - t.vm.fs.write(f.path, f.data.slice(0, f.size)); + const data = f.data.slice(0, f.size); + // an open file keeps its write access when the file's mode changes, as an fd does + let mode = 0; + if (!t.vm.fs.write(f.path, data) && t.vm.fs.chmod(f.path, (m) => (mode = m) | 0o200)) { + t.vm.fs.write(f.path, data); + t.vm.fs.chmod(f.path, () => mode); + } f.dirty = false; } } @@ -380,8 +386,7 @@ export const natives: Natives = { [`${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)); - if (!t.vm.fs.stat(abs)) return 0; - return (access & 2) === 0 || (t.vm.fs.writable(abs) && !t.vm.fs.denied(abs)); + return !!t.vm.fs.stat(abs) && t.vm.fs.access(abs, access); }, [`${UFS}.getLastModifiedTime0(Ljava/io/File;)J`]: (t, [, f]) => BigInt(Math.floor(t.vm.fs.stat(resolvePath(t, pathOf(t, f)))?.mtime ?? 0)), [`${UFS}.getLength0(Ljava/io/File;)J`]: (t, [, f]) => { diff --git a/src/natives/nio.ts b/src/natives/nio.ts index 8b6eb78..091a0b4 100644 --- a/src/natives/nio.ts +++ b/src/natives/nio.ts @@ -113,7 +113,7 @@ export const natives: Natives = { [`${N}access0(JI)I`]: (t, [p, amode]) => { const abs = path(t, p); if (!t.vm.fs.stat(abs)) return ENOENT; - return amode & 2 && (!t.vm.fs.writable(abs) || t.vm.fs.denied(abs)) ? EACCES : 0; + return t.vm.fs.access(abs, amode) ? 0 : EACCES; }, [`${N}open0(JII)I`]: (t, [p, flags]) => { const abs = path(t, p); diff --git a/src/node.ts b/src/node.ts index a6b948b..71f9c31 100644 --- a/src/node.ts +++ b/src/node.ts @@ -89,6 +89,14 @@ export class NodeBackend implements Backend { return false; } } + access(p: string, mode: number) { + try { + fs.accessSync(this.host(p), mode); + return true; + } catch { + return false; + } + } setMtime(p: string, ms: number) { try { fs.utimesSync(this.host(p), fs.statSync(this.host(p)).atime, ms / 1000); diff --git a/test/permissions/Permissions.java b/test/permissions/Permissions.java index c271c55..a0ddd0c 100644 --- a/test/permissions/Permissions.java +++ b/test/permissions/Permissions.java @@ -34,5 +34,14 @@ public static void main(String[] args) throws Exception { File w = new File(args[2]); System.out.println(w.setReadOnly() + " " + w.canWrite()); System.out.println(w.setWritable(true) + " " + w.canWrite()); + System.out.println(w.canExecute() + " " + w.setExecutable(true) + " " + w.canExecute()); + try (var out = new FileOutputStream(w)) { + out.write('a'); + w.setReadOnly(); + out.write('b'); + } + w.setWritable(true); + System.out.println(Files.readString(w.toPath())); + System.out.println(w.setReadable(false) + " " + w.canRead() + " " + Files.isReadable(w.toPath())); } } diff --git a/test/programs.test.ts b/test/programs.test.ts index ff79a74..448e6cc 100644 --- a/test/programs.test.ts +++ b/test/programs.test.ts @@ -129,6 +129,7 @@ test("permission denied", { skip: process.getuid?.() === 0 }, async () => { try { writeFileSync(open, "x"); const ref = spawnSync(hotspot("java"), ["-cp", classes, "Permissions", file, locked, open], { encoding: "utf8" }); + writeFileSync(open, "x"); chmodSync(open, 0o644); const stdout: Uint8Array[] = []; const code = await runNode({ classpath: [classes], mainClass: "Permissions", args: [file, locked, open], stdout: (b) => stdout.push(b) }); From 85b3eb89980e685f59d288f48bab8d8be7173b34 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:19:06 -0600 Subject: [PATCH 3/3] Keep an open file dirty until its flush succeeds. --- src/natives/io.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/natives/io.ts b/src/natives/io.ts index c81d3a9..bb978c8 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -193,13 +193,14 @@ export function fileOf(t: JThread, fd: number): OpenFile { export function flush(t: JThread, f: OpenFile) { if (f.dirty && f.writable && f.path) { const data = f.data.slice(0, f.size); + let ok = t.vm.fs.write(f.path, data); // an open file keeps its write access when the file's mode changes, as an fd does let mode = 0; - if (!t.vm.fs.write(f.path, data) && t.vm.fs.chmod(f.path, (m) => (mode = m) | 0o200)) { - t.vm.fs.write(f.path, data); + if (!ok && t.vm.fs.chmod(f.path, (m) => (mode = m) | 0o200)) { + ok = t.vm.fs.write(f.path, data); t.vm.fs.chmod(f.path, () => mode); } - f.dirty = false; + if (ok) f.dirty = false; } }