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..5d1fc19 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -27,6 +27,10 @@ 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; + // whether the host allows reading (4) and executing (1), as access(2) + access?(path: string, mode: number): boolean; } export function normalize(path: string): string { @@ -165,6 +169,23 @@ 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 { + 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 660c5cd..bb978c8 100644 --- a/src/natives/io.ts +++ b/src/natives/io.ts @@ -192,8 +192,15 @@ 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)); - f.dirty = false; + 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 (!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); + } + if (ok) f.dirty = false; } } @@ -278,6 +285,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")); } @@ -375,8 +387,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]) => { @@ -384,7 +395,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 +415,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/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 8105894..71f9c31 100644 --- a/src/node.ts +++ b/src/node.ts @@ -81,6 +81,22 @@ 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; + } + } + 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 196de0e..a0ddd0c 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,17 @@ 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()); + 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 3a71054..448e6cc 100644 --- a/test/programs.test.ts +++ b/test/programs.test.ts @@ -122,14 +122,17 @@ 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" }); + writeFileSync(open, "x"); + 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 {