From f8710ab27b54bf0241ad2ed629ccc98aaa619709 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 21:40:57 -0300 Subject: Edit's w and the other left-out sam commands fail EIO in words, never EOPNOTSUPP, and /os refuses a rename or a chmod as not permitted, EACCES "w is not supported in pardes" read through a mount as EOPNOTSUPP, as if the file system lacked an operation, and a chmod under /os was answered "invalid truncate", EINVAL. An Edit command pardes leaves out now says so in words 9ns reads as EIO, and a rename or mode change of an /os file is "permission denied", EACCES. fs.md's Failure section and /os entry say both. Co-Authored-By: Claude Opus 5.5 --- src/fs.zig | 29 +++++++++++++++++++++++++++++ src/ninep/ctl.zig | 1 + src/sam_edit.zig | 6 +++--- 3 files changed, 33 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/fs.zig b/src/fs.zig index bc35e1cb..d13e36bd 100644 --- a/src/fs.zig +++ b/src/fs.zig @@ -213,6 +213,11 @@ pub fn osHandle(p: *pardes.Pardes, req: Req) Reply { return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; }, .read, .write, .setattr => { + // A rename or a mode change (`mv`, `chmod`) is refused as not + // permitted, EACCES through a mount: /os reads and writes the + // files it shows and changes nothing about them. + if (req.op == .setattr and (req.set.name or req.set.mode)) + return tree.failText(req.tag, E.PERM, "permission denied: /os renames nothing and changes no mode"); if (stat.kind != .file) return Reply.fail(req.tag, E.PERM); var z: [4096]u8 = undefined; const path_z = std.fmt.bufPrintSentinel(&z, "{s}", .{path}, 0) catch return Reply.fail(req.tag, E.NOENT); @@ -255,6 +260,30 @@ pub fn validMountName(name: []const u8) bool { return true; } +test "/os refuses a rename or a mode change as not permitted, in words, and still truncates to zero" { + if (comptime !pardes.hosted) return error.SkipZigTest; + const p = try th.withFile(std.testing.allocator, "x\n"); + defer p.deinit(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "kept.txt", .data = "kept\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)]; + var node: u64 = os_root; + var parts = std.mem.tokenizeScalar(u8, dir, '/'); + while (parts.next()) |part| node = th.look_up(p, node, part).reply.attr.node; + node = th.look_up(p, node, "kept.txt").reply.attr.node; + for ([_]Req{ + .{ .tag = 1, .op = .setattr, .node = node, .set = .{ .mode = true } }, + .{ .tag = 2, .op = .setattr, .node = node, .set = .{ .name = true }, .data = "moved.txt" }, + }) |req| { + const r = th.call(p, req); + try std.testing.expectEqual(E.PERM, r.errno()); + try std.testing.expectStringStartsWith(r.reply.ename, "permission denied"); + } + try std.testing.expectEqual(tree.Status.ok, th.call(p, .{ .tag = 3, .op = .setattr, .node = node, .truncate = true }).reply.status); +} + test "mounts own their names and dials and reject duplicate or reserved names" { const gpa = std.testing.allocator; var ns: Namespace = .{}; diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 5a199f55..6e09084b 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -1854,6 +1854,7 @@ test "an error's words give the errno a mount reads: EINVAL for what is malforme .{ .text = addressing.e_order, .malformed = false }, .{ .text = addressing.e_slow, .malformed = false }, .{ .text = "/tmp/x.txt: Modified (Exit again to discard)", .malformed = false }, + .{ .text = "Edit: w is a sam command pardes's Edit leaves out (it has no file or shell commands)", .malformed = false }, }) |c| { const says_einval = for (einval) |w| { if (std.ascii.findIgnoreCase(c.text, w) != null) break true; diff --git a/src/sam_edit.zig b/src/sam_edit.zig index 94cf6fe0..9b055ef9 100644 --- a/src/sam_edit.zig +++ b/src/sam_edit.zig @@ -242,7 +242,7 @@ const Parser = struct { } }, '\'' => return fail(ps.why, "can't handle '", .{}), - '"' => return fail(ps.why, "file addresses are not supported", .{}), + '"' => return fail(ps.why, "a file address is no address pardes's Edit takes", .{}), else => break, } } @@ -410,7 +410,7 @@ const Parser = struct { if (nest == 0) return fail(ps.why, "right brace with no left brace", .{}); return null; }, - 'b', 'B', 'D', 'e', 'r', 'w', 'f', 'X', 'Y', '<', '|', '>' => return fail(ps.why, "{c} is not supported in pardes", .{c}), + 'b', 'B', 'D', 'e', 'r', 'w', 'f', 'X', 'Y', '<', '|', '>' => return fail(ps.why, "{c} is a sam command pardes's Edit leaves out (it has no file or shell commands)", .{c}), else => return fail(ps.why, "unknown command {c}", .{c}), } return cmd; @@ -768,7 +768,7 @@ test "an Edit that fails halfway changes nothing, and says why in acme's words" .{ "}", "right brace with no left brace" }, .{ ",x/foo/{", "unmatched `{'" }, .{ ",x/foo/{\nd", "unmatched `{'" }, - .{ "w /tmp/x", "w is not supported in pardes" }, + .{ "w /tmp/x", "w is a sam command pardes's Edit leaves out (it has no file or shell commands)" }, .{ ",s/(a)/\\1/", "no \\1: mvzr keeps no submatches" }, .{ "1 m 1,2", "move overlaps itself" }, .{ ",x/(^|\\n)foo/d", "bad regular expression: in a pattern with \\n, ^ can only come first and $ only just before a \\n" }, -- cgit v1.3