From 642bcfd9e37c1a3437c2757434c1d6d9c941d9c4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 1 Oct 2026 08:35:03 -0300 Subject: A ctl, exec or look line over 1 MiB is refused once, EINVAL in words a mount maps, and the rest of it is dropped through its newline, not run as a second line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Past the 1 MiB a line may hold, the write that crossed it was refused and the pending text dropped, but what followed of the same line was taken as a new line and ran when its newline came: a second refusal, `unknown control message "zzz…"`, and a second err. The open now drops the rest through the newline, and the refusal says `invalid write: a line or Edit block over 1 MiB`. Co-Authored-By: Claude Opus 5.5 --- src/ninep/ctl.zig | 24 ++++++++++++++++++++++++ src/ninep/tree.zig | 17 +++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 2b366de0..89c2e5dd 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -4333,3 +4333,27 @@ test "a look of ./x that is not there is a miss, as x and /abs/x are: answered, } try testing.expect(th.logHas(p, "look: ./zzq-nosuch.txt: no such file")); } + +test "a ctl line over 1 MiB is refused once, EINVAL, and its tail is not run as a line of its own" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const node = Node.of(serialOf(p), .ctl); + const h = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; + const chunk: [4096]u8 = @splat('z'); + var refused: usize = 0; + var off: u64 = 0; + for (0..270) |_| { + const r = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .off = off, .data = &chunk }); + if (r.reply.status == .err) { + refused += 1; + try testing.expectEqual(E.INVAL, r.errno()); + } + off += chunk.len; + } + try testing.expectEqual(Status.ok, call(p, .{ .tag = 3, .op = .write, .node = node, .handle = h, .off = off, .data = "zzz\nEdit ,d\n" }).reply.status); + try testing.expectEqual(@as(usize, 1), refused); + try testing.expect(!th.logHas(p, "unknown control message")); + // What came after the long line's newline ran. + try testing.expectEqualStrings("", pane_files.fileOf(p.panes[0].?).?.content); + _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = h }); +} diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index e566482c..95e46ab3 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -165,6 +165,9 @@ pub const Open = struct { /// A `name` open has named its pane: one name an open, so a second line /// on it (bash writes `printf 'a\nb\n'` a line at a time) is refused. named: bool = false, + /// A line over 1 MiB was refused: what follows of it, through its + /// newline, is dropped rather than run as a line of its own. + discarding: bool = false, pub const Replay = struct { off: u64, bytes: []u8 }; @@ -1176,7 +1179,16 @@ fn write(p: *Pardes, req: Req, target: Target) Reply { if (resultsFile(target) or ctlFile(target)) for (req.data) |c| if ((c < ' ' and c != '\t' and c != '\n' and c != '\r') or c == 0x7f) return failText(req.tag, E.INVAL, ctl.e_control); const o = (if (linesFile(target)) openOf(p, req) else null) orelse return writeNow(p, req, target); if (o.what != .lines and o.what != .ctl) return writeNow(p, req, target); - o.pending.appendSlice(p.gpa, req.data) catch return Reply.fail(req.tag, E.NOMEM); + // The rest of a line refused for its length goes through its newline + // unread: its one refusal said it, and its tail is no command of its own. + var data = req.data; + if (o.discarding) { + const nl = std.mem.indexOfScalar(u8, data, '\n') orelse return .{ .tag = req.tag, .written = @intCast(req.data.len) }; + o.discarding = false; + data = data[nl + 1 ..]; + if (data.len == 0) return .{ .tag = req.tag, .written = @intCast(req.data.len) }; + } + o.pending.appendSlice(p.gpa, data) catch return Reply.fail(req.tag, E.NOMEM); var end = ctl.completeEnd(p, o.pending.items); // A tail with no newline is a whole line when the write is the whole of // what its client wrote (shorter than a Twrite can carry): it runs now, @@ -1197,7 +1209,8 @@ fn write(p: *Pardes, req: Req, target: Target) Reply { if (end == 0) { if (o.pending.items.len <= pending_cap) return .{ .tag = req.tag, .written = @intCast(req.data.len) }; o.pending.clearRetainingCapacity(); - return failText(req.tag, E.INVAL, "a line or Edit block over 1 MiB"); + o.discarding = true; + return failText(req.tag, E.INVAL, "invalid write: a line or Edit block over 1 MiB"); } if (target == .pane and target.pane.file == .name) { if (o.named or std.mem.count(u8, o.pending.items[0..end], "\n") > 1) { -- cgit v1.3