From a96c7e873d26686f0e49cf2882c1336046396d37 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 10:58:24 -0300 Subject: 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 --- src/fs.zig | 3 +++ src/ninep/cols.zig | 60 ++++++++++++++++++++++++++++++++++++++++++++- src/ninep/pane.zig | 72 +++++++++++++++++++++++++++++++++++++----------------- src/ninep/tree.zig | 1 + 4 files changed, 113 insertions(+), 23 deletions(-) (limited to 'src') 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; }; -- cgit v1.3