diff options
| author | Gabriel Schneider <[email protected]> | 2026-10-01 05:01:31 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 05:18:09 -0300 |
| commit | 250db41743f77ce3d8421f729bdea0065aa6bc79 (patch) | |
| tree | 906bbefca6d448ba04c81c93cf552e23b5e6c3a4 | |
| parent | 36fe45e3a4a7ce9b61ab7bf2df07f0132a9170c1 (diff) | |
| download | pardes-250db41743f77ce3d8421f729bdea0065aa6bc79.tar.gz pardes-250db41743f77ce3d8421f729bdea0065aa6bc79.zip | |
A name under a directory that may not be searched or written is refused, permission denied, and a Save there says so, not no such directory
`pardes noperm/a/b/c` and `pardes /proc/1/root/x` took the missing
directory for one Save would make, opened a pane and exited 0; its Save
then said "no such directory". fs.deniedAbove finds the nearest
directory there and asks whether it may be searched and written:
forwarding refuses such a name, exit 1, "permission denied", and a
failed Save says "permission denied", which the writer's 9P error
carries as EPERM (9ns: EACCES).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | src/9p_io.zig | 2 | ||||
| -rw-r--r-- | src/fs.zig | 20 | ||||
| -rw-r--r-- | src/main.zig | 8 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 9 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 6 | ||||
| -rw-r--r-- | src/pardes.zig | 6 | ||||
| -rw-r--r-- | test/fs.py | 11 |
7 files changed, 56 insertions, 6 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig index 9fc19df9..ea3f799c 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -328,7 +328,7 @@ pub const Listener = struct { // builtin's failure saying so is (ctl.failureErrno). const errno = if (std.mem.indexOf(u8, late, "no such") != null or std.mem.indexOf(u8, late, "not found") != null) pardes.ctlfs.E.NOENT - else if (std.mem.indexOf(u8, late, "no space") != null) pardes.ctlfs.E.NOSPC else pardes.ctlfs.E.IO; + else if (std.mem.indexOf(u8, late, "no space") != null) pardes.ctlfs.E.NOSPC else if (std.mem.indexOf(u8, late, "permission denied") != null) pardes.ctlfs.E.PERM else pardes.ctlfs.E.IO; const failed = pardes.ctlfs.failText(req.tag, errno, late); // Its err record says it (the path in it), the one record: it // was said with no msg while this waited (fs.write_waits). @@ -685,6 +685,26 @@ pub fn makeDirs(dir: []const u8) void { _ = libc.mkdir(z[0..dir.len :0], 0o755); } +/// Whether `dir` is not there to make because one above it refuses: its +/// nearest ancestor that is there may not be searched or written (EACCES: +/// `noperm/a/b`, `/proc/1/root/x`). +pub fn deniedAbove(dir: []const u8) bool { + if (comptime !platform_has_fs) return false; + var at: ?[]const u8 = dir; + while (at) |d| : (at = std.fs.path.dirname(d)) { + var z: [4096]u8 = undefined; + const dz = std.fmt.bufPrintSentinel(&z, "{s}", .{d}, 0) catch return false; + if (libc.access(dz.ptr, 0) != 0) continue; // F_OK + return libc.access(dz.ptr, 2 | 1) != 0; // W_OK | X_OK + } + return false; +} + +test "a directory under one that may not be searched or written is denied, one merely missing is not" { + try std.testing.expect(deniedAbove("/proc/1/root/x/y")); + try std.testing.expect(!deniedAbove("/tmp/pardes-surely-missing-dir/a/b")); +} + pub fn localPath(path: []const u8) ?[]const u8 { if (std.mem.eql(u8, path, "/n/os")) return "/"; if (std.mem.startsWith(u8, path, "/n/os/")) return path[5..]; diff --git a/src/main.zig b/src/main.zig index a7d354b5..86558bae 100644 --- a/src/main.zig +++ b/src/main.zig @@ -256,7 +256,7 @@ fn forwardWords(err: anyerror, buf: []u8) []const u8 { error.NotOneLine => "a file name is one line, and this one holds a newline", error.NotAFileName => "names a directory, not a file", error.NoWorkingDirectory => "this shell's working directory is gone", - error.DirectoryNotWritable => "its directory may not be read or written, so it could never be saved", + error.DirectoryNotWritable => "permission denied: its directory may not be read or written, so it could never be saved", error.NotAPaneAddress => "not a pane address: @p and a pane's number", error.NoSuchPane => "this session has no such pane", else => if (pardes.Messages.dialReason(err)) |why| why else words: { @@ -653,7 +653,11 @@ fn nativeMain(init: std.process.Init) !void { var cwd_buf: [4096]u8 = undefined; if (std.c.getcwd(&cwd_buf, cwd_buf.len) == null) Refuse.with(init.io, word, error.NoWorkingDirectory); const cwd = std.mem.sliceTo(&cwd_buf, 0); - break :named std.fs.path.resolvePosix(arena, &.{ cwd, target.path }) catch |err| Refuse.with(init.io, word, err); + const absolute = std.fs.path.resolvePosix(arena, &.{ cwd, target.path }) catch |err| Refuse.with(init.io, word, err); + // Missing because one above may not be searched or written: + // its Save could never make it, so refused now. + if (pardes.filesystem.deniedAbove(std.fs.path.dirname(absolute) orelse "/")) Refuse.with(init.io, word, error.DirectoryNotWritable); + break :named absolute; }; // A directory that is there but may not be read or written: a // pane for the name could only fail at its Save, so refused now. diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index c88eb328..d6d7a6ee 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -4056,3 +4056,12 @@ test "a command pane an exec open was answered is leased to it while it stays op try testing.expectEqual(Status.ok, wr(p, exec, "echo c\n").reply.status); try testing.expectEqual(a_pane, p.paneBySerial(p.fs.results[0]).?); } + +test "a Save whose directory is missing because one above refuses says permission denied, not no such directory" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + p.saveFailed(0, "/proc/1/root/x/f.txt", error.FileNotFound); + try testing.expect(th.logHas(p, "Save /proc/1/root/x/f.txt: permission denied")); + p.saveFailed(0, "/tmp/pardes-surely-missing-dir/f.txt", error.FileNotFound); + try testing.expect(th.logHas(p, "no such directory")); +} diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index b9fa2857..3963c605 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -1535,13 +1535,15 @@ test "a name with a component over 255 bytes is refused, and a long path's faile try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/" ++ "c" ** 255 ++ "\n").reply.status); // A Save of a 3000-byte path the host refuses: the waiting write's // reason is at the end, the path giving up its middle. - const long = "/nonexistent-pardes-root/" ++ ("d" ** 200 ++ "/") ** 15 ++ "f.txt"; + // (Under /tmp: under / a user's Save is refused permission, not + // missing.) + const long = "/tmp/nonexistent-pardes-root/" ++ ("d" ** 200 ++ "/") ** 15 ++ "f.txt"; const id = p.paneBySerial(serial).?; try nameBuffer(p, id, long, false); p.fs.late_failure_len = 0; p.saveFailed(@intCast(id), long, error.AccessDenied); const late = p.fs.late_failure[0..p.fs.late_failure_len]; - try testing.expect(std.mem.startsWith(u8, late, "Save /nonexistent-pardes-root/")); + try testing.expect(std.mem.startsWith(u8, late, "Save /tmp/nonexistent-pardes-root/")); try testing.expect(std.mem.endsWith(u8, late, "/f.txt: no such directory")); } diff --git a/src/pardes.zig b/src/pardes.zig index 945fcafa..f5001797 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -5541,7 +5541,11 @@ pub const Pardes = struct { const parent = std.fs.path.dirname(path) orelse "/"; const no_dir = err == error.FileNotFound or err == error.NotDir or (if (comptime hosted) (if (filesystem.localPath(parent)) |local| !exec.isDirectory(local) else false) else false); - if (no_dir) + // Not there because one above refuses: that is the reason, EACCES. + const denied = no_dir and (if (comptime hosted) (if (filesystem.localPath(parent)) |local| filesystem.deniedAbove(local) else false) else false); + if (denied) + p.reportFailure(id, std.fmt.bufPrint(&what, "Save {s}: permission denied", .{path}) catch "Save: permission denied") + else if (no_dir) p.reportFailure(id, std.fmt.bufPrint(&what, "Save {s}: no such directory", .{path}) catch "Save: no such directory") else p.reportError(id, std.fmt.bufPrint(&what, "Save {s}", .{path}) catch "Save", err); @@ -1405,6 +1405,17 @@ def test(binary, quic=False): dashed, = serials() - before assert client.read(f'/pane/{dashed}/name').rstrip(b'\n') == str(dash).encode() client.remove(f'/pane/{dashed}') + # A name under a directory that may not be searched is refused, + # permission denied, exit 1, no pane: its Save could never be. + (root / 'noperm').mkdir() + (root / 'noperm').chmod(0) + before = serials() + try: + denied = subprocess.run([binary, 'noperm/a/b/c'], cwd=root, env=env, capture_output=True, timeout=10) + finally: + (root / 'noperm').chmod(0o755) + assert denied.returncode == 1 and b'permission denied' in denied.stderr, denied + assert serials() == before # --wait waits on the pane its look answered, on the look's own # open: a name /index shows escaped (a backslash is `\\` there) # is still the pane waited on, not one already gone. |
