summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 10:58:24 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commita96c7e873d26686f0e49cf2882c1336046396d37 (patch)
treea1771b70cdf30c449a0f025ea45ccf141ce46ced
parent048e81e3471c73b9ac690901819976f689f6a4ce (diff)
downloadpardes-a96c7e873d26686f0e49cf2882c1336046396d37.tar.gz
pardes-a96c7e873d26686f0e49cf2882c1336046396d37.zip
Pane, column and workspace tags take one set of write checks, and a refused `>` write leaves the tag as it was
A column's or the workspace's tag took any length, so a write past 4096 bytes went in and a later Dump failed on it (bad dump tag), and a truncating open wiped any tag before the write after it could be refused. The column and workspace tags now refuse what a pane tag refuses: the 4096-byte limit (ENOSPC, tag: no space: over 4096 bytes) as well as the control characters they already did. A truncation of any of the three is held until the write after it is known to fit, and done with it; a refused write drops it; a close or read with no write after it does it then (so `: > tag` still clears). A test runs a truncating write too long, a control character and a bare truncation at each kind, and Dumps after. docs/fs.md says so. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--docs/fs.md5
-rw-r--r--src/fs.zig3
-rw-r--r--src/ninep/cols.zig60
-rw-r--r--src/ninep/pane.zig72
-rw-r--r--src/ninep/tree.zig1
5 files changed, 117 insertions, 24 deletions
diff --git a/docs/fs.md b/docs/fs.md
index 06fa1406..b30ae3ef 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -320,7 +320,10 @@ blank matters; `echo` would start a second line). A pane tag may hold
several lines; a column or workspace tag is one, a newline written into it
becoming a space. Control characters other than tab, DEL, C1 controls and
non-UTF-8 bytes are refused (`invalid tag text`). The editable text is at
-most 4096 bytes (`tag: no space: over 4096 bytes`, ENOSPC). A clear is an ordinary edit
+most 4096 bytes (`tag: no space: over 4096 bytes`, ENOSPC). All three kinds
+take the same checks, whole or not at all, and a `>` whose write is refused
+changes nothing: its truncation is done with the write that fits, or at the
+close (or a read) when none came. A clear is an ordinary edit
and `u` in the tag undoes it. [tags.md](tags.md) covers tags on screen.
## Panes
diff --git a/src/fs.zig b/src/fs.zig
index 1d0bbc22..e66a6a49 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1335,6 +1335,9 @@ pub const Namespace = struct {
/// 0 the workspace's, a column's its index + 1; and whether the newline
/// that ended its last write is held back.
header_rewrite: ?u32 = null,
+ /// A column's or the workspace's tag truncated, not yet done (as a
+ /// pane tag's `tag_trunc_pending`): its key, the workspace's 0.
+ header_trunc_pending: ?u32 = null,
/// A look or exec a 9P write made found every pane slot taken; the
/// write fails with what was said, kept at the head of `ename`.
no_pane_slot: bool = false,
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig
index cc583c93..c049e0f3 100644
--- a/src/ninep/cols.zig
+++ b/src/ninep/cols.zig
@@ -7,6 +7,7 @@ const std = @import("std");
const pardes = @import("../pardes.zig");
const Pardes = pardes.Pardes;
const tree = @import("tree.zig");
+const limits = @import("../memory.zig").limits;
const tagline = pardes.tagline;
const layout = pardes.layout;
const Text = pardes.panes.Text;
@@ -65,6 +66,7 @@ pub fn headerText(p: *Pardes, serial: ?u32) ?[]const u8 {
}
pub fn read(p: *Pardes, req: Req, serial: ?u32) Reply {
+ applyTruncation(p, serial);
const text = headerText(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
const out = p.fs.stage(p.gpa);
out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM);
@@ -78,7 +80,19 @@ pub fn read(p: *Pardes, req: Req, serial: ?u32) Reply {
pub fn write(p: *Pardes, req: Req, serial: ?u32) Reply {
const t = (header(p, serial) orelse return Reply.fail(req.tag, E.NOENT)).text;
if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
- if (pardes.ctlfs.pane.tagFault(req.data)) |why| return tree.failText(req.tag, E.INVAL, why);
+ const pending = p.fs.header_trunc_pending == rewriteKey(serial);
+ // The same checks as a pane tag's, whole or not at all; a refused
+ // write drops the truncation it would have come with.
+ if (pardes.ctlfs.pane.tagFault(req.data)) |why| {
+ if (pending) p.fs.header_trunc_pending = null;
+ return tree.failText(req.tag, E.INVAL, why);
+ }
+ const had = if (pending) 0 else headerText(p, serial).?.len;
+ if (req.data.len > limits.max_tag_tail -| had) {
+ if (pending) p.fs.header_trunc_pending = null;
+ return tree.failText(req.tag, E.NOSPC, pardes.ctlfs.pane.e_tag_over);
+ }
+ applyTruncation(p, serial);
const was = headerText(p, serial).?;
var data = req.data;
const rewriting = p.fs.header_rewrite == rewriteKey(serial);
@@ -99,6 +113,20 @@ pub fn write(p: *Pardes, req: Req, serial: ?u32) Reply {
/// Truncating clears it, as a pane tag's `cleartag`: its default words go
/// too, and `u` in it brings them back.
pub fn truncate(p: *Pardes, serial: ?u32) tree.Status {
+ if (header(p, serial) == null) return .err;
+ // Done with the write after it, which may yet be refused.
+ p.fs.header_trunc_pending = rewriteKey(serial);
+ return .ok;
+}
+
+/// A pending truncation of this header, done.
+pub fn applyTruncation(p: *Pardes, serial: ?u32) void {
+ if (p.fs.header_trunc_pending != rewriteKey(serial)) return;
+ p.fs.header_trunc_pending = null;
+ _ = clear(p, serial);
+}
+
+fn clear(p: *Pardes, serial: ?u32) tree.Status {
const t = (header(p, serial) orelse return .err).text;
const empty = p.gpa.alloc(u8, 0) catch return .err;
t.remember(p.gpa, if (t.own) |own| .{ .text = own } else null);
@@ -118,6 +146,7 @@ fn rewriteKey(serial: ?u32) u32 {
}
pub fn released(p: *Pardes, serial: ?u32) void {
+ applyTruncation(p, serial);
if (p.fs.header_rewrite != rewriteKey(serial)) return;
p.fs.header_rewrite = null;
p.fs.header_held = false;
@@ -356,6 +385,35 @@ test "a focus write makes its pane's column the active one: layout says so and p
}
}
+test "a refused write after a truncation leaves every kind of tag as it was, and Dump still works" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = th.serialOf(p);
+ const col = layout.columnSerial(p, 0);
+ const big = "w" ** (limits.max_tag_tail + 1);
+ // Each tag: a truncating open, then a write too long: nothing changes.
+ for ([_]u64{ Node.of(serial, .tag), Node.ofCol(col, .tag), @intFromEnum(tree.TopFile.tag) }) |node| {
+ _ = th.wr(p, node, " Mine");
+ const before = try testing.allocator.dupe(u8, th.rd(p, node, 0, 1 << 16).bytes);
+ defer testing.allocator.free(before);
+ _ = th.call(p, .{ .tag = 1, .op = .setattr, .node = node, .truncate = true });
+ const h = th.call(p, .{ .tag = 2, .op = .open, .node = node, .omode = 1 }).reply.handle;
+ const r = th.call(p, .{ .tag = 3, .op = .write, .node = node, .handle = h, .data = big });
+ try testing.expectEqual(E.NOSPC, r.errno());
+ try testing.expectEqualStrings("tag: no space: over 4096 bytes", r.reply.ename);
+ _ = th.call(p, .{ .tag = 4, .op = .release, .node = node, .handle = h, .opened = true });
+ try testing.expectEqualStrings(before, th.rd(p, node, 0, 1 << 16).bytes);
+ // A control character is refused the same way on every kind.
+ try testing.expectEqual(E.INVAL, th.wr(p, node, "a\x01b").errno());
+ // A truncation with no write after it clears at the close.
+ _ = th.call(p, .{ .tag = 5, .op = .setattr, .node = node, .truncate = true });
+ const e = th.call(p, .{ .tag = 6, .op = .open, .node = node, .omode = 1 }).reply.handle;
+ _ = th.call(p, .{ .tag = 7, .op = .release, .node = node, .handle = e, .opened = true });
+ try testing.expect(std.mem.indexOf(u8, th.rd(p, node, 0, 1 << 16).bytes, "Mine") == null);
+ }
+ try pardes.dump.dumpState(p);
+}
+
test "a column's ctl and exec act on it as its tag would, and rmdir closes it only empty" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index af8bb52c..eb317655 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -77,6 +77,10 @@ pub const State = struct {
/// whole of it is dropped and draws no empty row.
tag_rewrite: bool = false,
tag_held_newline: bool = false,
+ /// A truncation of `tag` not yet done: the write after it does it, once
+ /// the write is known to fit, so a refused `>` leaves the tag as it was;
+ /// a read or a close with no write does it then.
+ tag_trunc_pending: bool = false,
/// The pty's size as pty/ctl's winsize last set it, until the pane's own
/// grid resizes and gives the pty its size again.
winsize: ?[2]u16 = null,
@@ -396,6 +400,7 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
},
.body => readBody(p, req, id, pane),
.tag => tag: {
+ applyTagTruncation(p, pane);
const out = p.fs.stage(p.gpa);
out.appendSlice(p.gpa, tagOf(p, pane)) catch {};
break :tag tree.stagedReply(p, req);
@@ -516,7 +521,7 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
return switch (file) {
.name => writeName(p, req, id, pane),
.body => writeBody(p, req, id, pane),
- .tag => writeTag(req, pane),
+ .tag => writeTag(p, req, pane),
.ctl => ctl.writePane(p, req, pane),
.addr, .dot, .limit => writeRange(req, pane, file),
.data, .xdata => writeData(p, req, pane),
@@ -630,16 +635,28 @@ pub fn tagFault(data: []const u8) ?[]const u8 {
return null;
}
-fn writeTag(req: Req, pane: *Pane) Reply {
+/// A tag write's limit, the same for a pane's, a column's and the
+/// workspace's: whole or not at all.
+pub const e_tag_over = std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail});
+
+fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply {
if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
- if (tagFault(req.data)) |why| return tree.failText(req.tag, E.INVAL, why);
+ const pf = &pane.fs;
+ // A refused write after a truncation changes nothing: the truncation
+ // it would have come with is dropped too.
+ if (tagFault(req.data)) |why| {
+ pf.tag_trunc_pending = false;
+ return tree.failText(req.tag, E.INVAL, why);
+ }
// The tag's own text grows by what is written, newlines and all: a tag
// is a text like any other (acme's tag file appends the same way).
- const pf = &pane.fs;
- const had = tagline.curTail(pane).len;
- const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline);
- // Whole or not at all: a write that would pass the limit changes nothing.
- if (req.data.len > room) return tree.failText(req.tag, E.NOSPC, std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail}));
+ const had = if (pf.tag_trunc_pending) 0 else tagline.curTail(pane).len;
+ const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline and !pf.tag_trunc_pending);
+ if (req.data.len > room) {
+ pf.tag_trunc_pending = false;
+ return tree.failText(req.tag, E.NOSPC, e_tag_over);
+ }
+ applyTagTruncation(p, pane);
const take = wholeUtf8(req.data);
// A truncating write drops ONE trailing newline, its whole text's: a
// newline held from a write before goes in once more text follows it.
@@ -656,6 +673,26 @@ fn writeTag(req: Req, pane: *Pane) Reply {
return .{ .tag = req.tag, .written = @intCast(take) };
}
+/// A pending truncation of the tag, done: its text cleared as `cleartag`
+/// clears it, the prefix left.
+pub fn applyTagTruncation(p: *Pardes, pane: *Pane) void {
+ const pf = &pane.fs;
+ if (!pf.tag_trunc_pending) return;
+ pf.tag_trunc_pending = false;
+ const empty = p.gpa.alloc(u8, 0) catch return;
+ // An edit like a keyboard one: `u` in the tag brings it back.
+ pane.tag.remember(p.gpa, if (pane.tag.own) |own| .{ .text = own } else null);
+ if (pane.tag.own) |own| p.gpa.free(own);
+ pane.tag.own = empty;
+ // what is left is the prefix; the cursor goes after it
+ pane.tag.cur_row = 0;
+ pane.tag.cur_col = if (tagline.pathPrefix(p.scratch.allocator(), pane)) |prefix| @intCast(prefix.len) else |_| 0;
+ pane.tag.vsel.active = false;
+ pane.tag.nsel = 0;
+ pf.tag_rewrite = true;
+ pf.tag_held_newline = false;
+}
+
/// A write only text takes, to a pane with none: said, as every EINVAL is.
pub const e_no_text = "invalid write: this pane has no text (a terminal, an image or a PDF)";
@@ -923,20 +960,8 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
setDot(pane, .{ .q0 = shiftOne(before.q0, q0, q1 - q0, 0), .q1 = shiftOne(before.q1, q0, q1 - q0, 0) });
pf.addr = .{ .q0 = q0, .q1 = q0 };
},
- .tag => {
- const empty = p.gpa.alloc(u8, 0) catch return .err;
- // An edit like a keyboard one: `u` in the tag brings it back.
- pane.tag.remember(p.gpa, if (pane.tag.own) |own| .{ .text = own } else null);
- if (pane.tag.own) |own| p.gpa.free(own);
- pane.tag.own = empty;
- // what is left is the prefix; the cursor goes after it
- pane.tag.cur_row = 0;
- pane.tag.cur_col = if (tagline.pathPrefix(p.scratch.allocator(), pane)) |prefix| @intCast(prefix.len) else |_| 0;
- pane.tag.vsel.active = false;
- pane.tag.nsel = 0;
- pf.tag_rewrite = true;
- pf.tag_held_newline = false;
- },
+ // Done with the write after it, which may yet be refused.
+ .tag => pf.tag_trunc_pending = true,
// A shell's `>` truncates before it writes: the address written
// is evaluated from where the last one left off, as with `>>`.
// `0` (or `,`) is how to start over. See State.addr.
@@ -1397,12 +1422,15 @@ test "truncating the tag clears its editable tail" {
try testing.expect(tagline.curTail(pane).len > 0);
const cleared = call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true });
try testing.expectEqual(Status.ok, cleared.reply.status);
+ // Done at the first read or write after it (or the close).
+ _ = rd(p, tag, 0, 4096);
try testing.expectEqualStrings("", pane.tag.own.?);
try testing.expect(std.mem.indexOf(u8, rd(p, tag, 0, 4096).bytes, " Mine") == null);
// The tag keeps its undo: undoing in it brings back what was cleared.
pardes.edit.doUndo(p, &pane.tag);
try testing.expect(std.mem.endsWith(u8, tagline.curTail(pane), " Mine"));
_ = call(p, .{ .tag = 2, .op = .setattr, .node = tag, .truncate = true });
+ _ = rd(p, tag, 0, 4096);
try testing.expectEqualStrings("", pane.tag.own.?);
// Written back without a leading space, the text still stands apart
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 5f3ffc9c..d39854e1 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -935,6 +935,7 @@ fn releaseHandle(p: *Pardes, req: Req) void {
// A truncating write to `tag` is over when its open goes (pane.zig
// writeTag): a newline it held back is dropped.
if (t == .pane and t.pane.file == .tag) if (p.paneBySerial(t.pane.serial)) |id| {
+ pane.applyTagTruncation(p, p.panes[id].?);
p.panes[id].?.fs.tag_rewrite = false;
p.panes[id].?.fs.tag_held_newline = false;
};