summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-10-01 23:20:19 -0300
committerGabriel Schneider <[email protected]>2026-10-01 23:22:39 -0300
commitdd6a3c2c367cee12a8e2da81e7da6a2c8f3103d5 (patch)
tree3c2acaf6836af9c52b7cfbf201a2c1b533512d10
parente4562384ac910b78254ec7aaaecad8b74717f2ee (diff)
downloadpardes-dd6a3c2c367cee12a8e2da81e7da6a2c8f3103d5.tar.gz
pardes-dd6a3c2c367cee12a8e2da81e7da6a2c8f3103d5.zip
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 <[email protected]>
-rw-r--r--src/Messages.zig3
-rw-r--r--src/builtins.zig13
-rw-r--r--src/config.zig1
-rw-r--r--src/dump.zig1
-rw-r--r--src/exec.zig2
-rw-r--r--src/fs.zig7
-rw-r--r--src/ninep/ctl.zig72
-rw-r--r--src/ninep/tree.zig10
8 files changed, 104 insertions, 5 deletions
diff --git a/src/Messages.zig b/src/Messages.zig
index 366445c7..2314939f 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -498,6 +498,9 @@ pub fn errorWords(err: anyerror, buf: []u8) []const u8 {
return switch (err) {
error.FileTooLarge => "too large",
error.NotAFile => "not a regular file",
+ // In the words a mount turns into EISDIR and EACCES (cloud9.fs.enameErrno).
+ error.IsDirectory => "is a directory",
+ error.PermissionDenied, error.AccessDenied => "permission denied",
error.ReadFailed => "could not be read",
error.ParseZon => "not valid ZON",
error.ThemeFileTooLarge => "too large: a theme file is at most 1 MiB",
diff --git a/src/builtins.zig b/src/builtins.zig
index 70ca93a1..c42d4978 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -1157,7 +1157,9 @@ pub const Get = struct {
const errno = if (typed.len == 0) ctl.get(c.p, c.pane, &failed) else ctl.getFile(c.p, c.pane, path, &failed);
if (errno != 0) {
var said: [320]u8 = undefined;
- c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Get: {s}: {s}", .{ path[0..@min(path.len, 256)], if (failed == error.FileNotFound) "no such file" else "cannot be read" }) catch "Get: cannot be read");
+ var words: [128]u8 = undefined;
+ const why = if (failed == error.FileNotFound) "no such file" else pardes.Messages.errorWords(failed, &words);
+ c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Get: {s}: {s}", .{ path[0..@min(path.len, 256)], why }) catch "Get: cannot be read");
}
}
};
@@ -1172,6 +1174,10 @@ pub const Putall = struct {
const pane = slot orelse continue;
const f = pane.file orelse continue;
if (f.output != null or f.listing != null or f.revision == f.saved_revision) continue;
+ // A Zerox pair is one buffer: saved once, by its first pane.
+ if (f.twin != 0) if (for (c.p.panes[0..id]) |earlier| {
+ if ((earlier orelse continue).file) |ef| if (ef.twin == f.twin) break true;
+ } else false) continue;
exec.saveFile(c.p, id);
}
}
@@ -1203,7 +1209,10 @@ pub const Incl = struct {
var home_buf: [4096]u8 = undefined;
const expanded = @import("fs.zig").expandHome(dir_word, &home_buf);
if (comptime pardes.hosted) if (@import("fs.zig").localPath(expanded)) |local| if (!exec.isDirectory(local))
- return p.reportFailure(c.id, std.fmt.bufPrint(&said, "Incl: no such directory: {s}", .{shown}) catch "Incl: no such directory");
+ return p.reportFailure(c.id, if (exec.kindOf(local) != null)
+ std.fmt.bufPrint(&said, "Incl: not a directory: {s}", .{shown}) catch "Incl: not a directory"
+ else
+ std.fmt.bufPrint(&said, "Incl: no such directory: {s}", .{shown}) catch "Incl: no such directory");
}
var words = std.mem.tokenizeAny(u8, typed, " \t");
while (words.next()) |dir_word| {
diff --git a/src/config.zig b/src/config.zig
index 26e1de2c..d6ea7358 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -1299,7 +1299,6 @@ pub const Runtime = struct {
const width = std.fmt.parseInt(u16, text, 10) catch return false;
if (width < 1 or width > 16) return false;
state.tab = width;
- tab_now = width;
},
.theme, .font => return false,
}
diff --git a/src/dump.zig b/src/dump.zig
index 1ded9989..201f5bf2 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -864,6 +864,7 @@ fn applySettings(p: *Pardes, lines: []const []const u8) void {
}
const setting = config.Runtime.find(line[0 .. sp orelse line.len]) orelse continue;
_ = p.settings.apply(setting, if (sp) |s| line[s + 1 ..] else null);
+ if (setting.action == .tab) config.tab_now = p.settings.tab;
}
}
diff --git a/src/exec.zig b/src/exec.zig
index d6834797..a5b6e506 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -1124,6 +1124,8 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]
return p.setMessage(p.active, std.fmt.bufPrint(&said, "Tab {d}", .{p.settings.tab}) catch "Tab");
} else if (!p.settings.apply(setting, arg)) {
p.reportFailure(p.active, "Tab: invalid tab width: takes " ++ comptime config.Runtime.takes(.tab).?);
+ } else {
+ config.tab_now = p.settings.tab;
},
// Said with what it takes: its words, its range.
else => if (!p.settings.apply(setting, arg)) {
diff --git a/src/fs.zig b/src/fs.zig
index db07672e..fb76cee3 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1521,6 +1521,13 @@ fn readFileLimit(gpa: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 {
};
defer _ = libc.close(fd);
+ // A device (/dev/zero, a tty) is no file to read whole: said so, not
+ // read to the stream limit and called too large.
+ if (ninep_io.statNoFollow(path_z)) |facts| {
+ const kind = facts.mode & 0o170000;
+ if (kind == 0o040000) return error.IsDirectory;
+ if (kind == 0o020000 or kind == 0o060000) return error.NotAFile;
+ }
const end = libc.lseek(fd, 0, libc.SEEK.END);
// Not seekable: a pipe or a FIFO is no file; a regular file that is a
// stream (a 9ns mount's log or screen, opened nonseekable) is read to
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" {
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 1990d09e..c78a1aa2 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -1270,6 +1270,16 @@ fn writeLines(p: *Pardes, req: Req, target: Target) Reply {
data = data[nl + 1 ..];
if (data.len == 0) return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
+ // One name an open, on one line: anything after its newline, in this
+ // write or a later one, is refused by the write that brings it, never
+ // left to rename the pane again at the close (`printf 'a\nb' > name`).
+ if (target == .pane and target.pane.file == .name) {
+ const past_newline = if (std.mem.indexOfScalar(u8, data, '\n')) |nl| nl + 1 < data.len else false;
+ if (o.named or past_newline) {
+ o.pending.clearRetainingCapacity();
+ return failText(req.tag, E.INVAL, "invalid file name: one name a write, on one line");
+ }
+ }
o.pending.appendSlice(p.gpa, data) catch return Reply.fail(req.tag, E.NOMEM);
// A line runs when its newline comes, or, the last one, when its open
// is let go (release). Nothing is inferred from a write's size: a mount