From dd6a3c2c367cee12a8e2da81e7da6a2c8f3103d5 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 1 Oct 2026 23:20:19 -0300 Subject: Round 39's small ones: a name write past its one line is refused by that write; Get and Edit's e load over a scratch under 100 bytes unasked, as Del closes it; Putall saves a Zerox pair once; bare Tab on the root ctl says the width as from a pane, and a ctl check of Tab no longer sets it; a directory read is "is a directory" (EISDIR), an unreadable file "permission denied" (EACCES), Incl of a file "not a directory" (ENOTDIR), and a device "not a regular file", never read to the stream limit Co-Authored-By: Claude Opus 5.5 --- src/ninep/ctl.zig | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 2 deletions(-) (limited to 'src/ninep/ctl.zig') diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index c6b76d51..dc5e8386 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -322,6 +322,10 @@ pub fn failureErrno(failure: []const u8) u16 { // word 9ns reads as it. if (std.mem.indexOf(u8, failure, "busy: ") != null) return E.BUSY; if (std.mem.indexOf(u8, failure, "no such") != null or std.mem.indexOf(u8, failure, "not found") != null) return E.NOENT; + // As 9ns reads the same words (cloud9.fs.enameErrno). + if (std.mem.indexOf(u8, failure, "not a dir") != null) return E.NOTDIR; + if (std.mem.indexOf(u8, failure, "is a dir") != null) return E.ISDIR; + if (std.mem.indexOf(u8, failure, "permission denied") != null) return 13; // EACCES // A pattern refused (Edit's too) is malformed input, as 9ns reads it; // so is a word this build has not (`invalid: acme's Put ...`). if (std.mem.indexOf(u8, failure, "bad regular expression") != null or std.mem.indexOf(u8, failure, "invalid") != null or @@ -548,6 +552,8 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ? return refuse(p, req, std.fmt.bufPrint(&why, head ++ "{s}" ++ tail, .{pardes.colors.themesNear(near[0..@min(room, near.len)], arg)}) catch "bad value in control message", arg); }, .font => config.Runtime.FontSpec.parse(arg) != null, + // Bare, it says the width, as from a pane's exec. + .tab => arg.len == 0 or (std.fmt.parseInt(u16, arg, 10) catch 0) -% 1 < 16, else => probe: { var probe = p.settings; break :probe probe.apply(setting, if (arg.len > 0) arg else null); @@ -1158,6 +1164,9 @@ const e_locked = tree.e_in_use ++ ": another open of this ctl holds the lock"; pub fn getRefused(p: *Pardes, pane: *Pane, word: []const u8) ?[]const u8 { const f = pane_files.fileOf(pane) orelse return null; if (!panes.Output.fileTraits(f.output).saves or f.revision == f.saved_revision) return null; + // A scratch under 100 bytes is not asked about, as Del and Exit do not + // ask (builtins.warnModifiedIn): nothing worth a question to lose. + if (f.output != null and f.content.len < 100) return null; // Dirty by a rename alone is no text to lose: only edits are asked about. const saved = f.saved_hash orelse return null; if (std.hash.Wyhash.hash(0, f.content) == saved) return null; @@ -2106,6 +2115,64 @@ test "Undo to unsaved text under its old name says nothing changed on disk when } } +test "round 39's small refusals: one name an open, small scratches unasked, twins saved once, bare Tab on ctl, and errnos in words a mount reads" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "f\n" }); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "noread.txt", .data = "secret\n" }); + try tmp.dir.createDirPath(testing.io, "sub"); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + const pane = p.panes[0].?; + var line: [4300]u8 = undefined; + // One name an open: a second line, in the write or after, is refused. + try testing.expectEqual(E.INVAL, wr(p, Node.of(serial, .name), "/tmp/pardes-a\nb").errno()); + const before = try testing.allocator.dupe(u8, pane.file.?.path); + defer testing.allocator.free(before); + const h = call(p, .{ .tag = 1, .op = .open, .node = Node.of(serial, .name), .omode = 1 }).reply.handle; + try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = Node.of(serial, .name), .handle = h, .data = "/tmp/pardes-one.txt\n" }).reply.status); + try testing.expectEqual(E.INVAL, call(p, .{ .tag = 3, .op = .write, .node = Node.of(serial, .name), .handle = h, .data = "b" }).errno()); + _ = call(p, .{ .tag = 4, .op = .release, .node = Node.of(serial, .name), .handle = h, .opened = true }); + tree.runClosedLines(p); + try testing.expectEqualStrings("/tmp/pardes-one.txt", pane.file.?.path); + // Bare Tab on the root ctl says the width, as from a pane. + try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Tab\n").reply.status); + // Errnos a mount reads from the words. + _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{dir})); + try testing.expect(p.executeBuiltinLine(0, "Get")); + try testing.expectEqual(E.ISDIR, wr(p, Node.of(serial, .exec), "Get sub\n").errno()); + if (comptime pardes.hosted) { + var noread_buf: [4300]u8 = undefined; + _ = std.c.chmod(try std.fmt.bufPrintSentinel(&noread_buf, "{s}/noread.txt", .{dir}, 0), 0); + try testing.expectEqual(@as(u16, 13), wr(p, Node.of(serial, .exec), "Get noread.txt\n").errno()); + try testing.expectEqual(E.NOTDIR, wr(p, Node.of(serial, .exec), try std.fmt.bufPrint(&line, "Incl {s}/f.txt\n", .{dir})).errno()); + const zero = wr(p, Node.of(serial, .look), "/dev/zero\n"); + _ = zero; + try testing.expect(th.logHas(p, "not a regular file")); + } + // Twins saved once by Putall. + _ = wr(p, Node.of(serial, .body), "edited\n"); + try testing.expect(p.executeBuiltinLine(0, "Zerox")); + while (p.nextEffect()) |_| {} + try testing.expect(p.executeBuiltinLine(0, "Putall")); + var saves: usize = 0; + while (p.nextEffect()) |e| if (e == .save_file) { + saves += 1; + }; + try testing.expectEqual(@as(usize, 1), saves); + // A scratch of 2 bytes is loaded over unasked. + try testing.expect(p.executeBuiltinLine(0, "New")); + const scratch = p.panes[p.active].?; + _ = pane_files.spliceBody(p, scratch, 0, 0, "hi") orelse return error.NoScratch; + const failures = p.fs.failures; + try testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&line, "Get {s}/f.txt", .{dir}))); + try testing.expectEqual(failures, p.fs.failures); + try testing.expectEqualStrings("f\n", scratch.file.?.content); +} + test "a body write to a terminal is typed input, not a paste, even to a program that asked for bracketed paste" { const p = try Pardes.init(testing.allocator, .{ .cols = 90, .rows = 30 }); defer p.deinit(); @@ -2761,7 +2828,8 @@ test "Shell refuses a path that is no executable, and bare it goes back to the d try testing.expectEqual(Status.err, refused.reply.status); try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "Shell: shell \"/nonexistent/zzsh\" not found") != null); try testing.expect(th.logHas(p, "shell \"/nonexistent/zzsh\" not found")); - try testing.expectEqual(E.IO, wr(p, root_ctl, "Shell /etc\n").errno()); + // Its words say a directory, which a mount reads as EISDIR too. + try testing.expectEqual(E.ISDIR, wr(p, root_ctl, "Shell /etc\n").errno()); try testing.expect(th.logHas(p, "Shell: not a shell: /etc is a directory")); try testing.expectEqualStrings("", p.settings.shell.requested.get()); try testing.expectEqual(Status.ok, wr(p, root_ctl, "Shell /bin/sh\n").reply.status); @@ -3073,7 +3141,7 @@ test "pty/ctl exec in a directory that is gone fails ENOENT; a shell that cannot // The host's own failure is the waiting write's (late_failure). p.fs.late_failure_len = 0; p.shellFailed(@intCast(p.paneBySerial(serial).?), error.AccessDenied); - try testing.expectEqualStrings("shell: access denied", p.fs.late_failure[0..p.fs.late_failure_len]); + try testing.expectEqualStrings("shell: permission denied", p.fs.late_failure[0..p.fs.late_failure_len]); } test "a script whose interpreter is not there: Tty refuses it up front, only an err logged, and pty/ctl exec keeps the running shell" { -- cgit v1.3