From 67be3b6594a60d36723000a1a33a09016b29ff97 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 11:03:10 -0300 Subject: A control character in a write to any ctl file refuses the whole write, and a refused line is quoted with its control bytes shown as blanks fs.md already promised that the whole of a write to /ctl, a pane's ctl and a column's ctl is checked first, as it is for exec and look. But a ctl write ran its lines one by one, so a line holding a control byte gave that line's own, misleading reason ("unknown command"). Worse, the reason quoted the raw byte back into the err record. The check now happens in tree.write before anything runs, with the same EINVAL and reason exec gives. A refusal's quoted line shows control bytes as blanks, so an err record never carries a raw escape. Co-Authored-By: Claude Opus 5.5 --- src/ninep/ctl.zig | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) (limited to 'src/ninep/ctl.zig') diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 05f93ac5..d6230308 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -372,17 +372,27 @@ const Builtin = builtins.registry.Builtin(); /// (kernel/misc/parse.c:82): `unknown control message "Bogus 3"`. fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply { const room = p.fs.ename.len -| (why.len + 3); - const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\"", .{ why, line[0..@min(line.len, room)] }) catch why; + var shown: [320]u8 = undefined; + const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\"", .{ why, blanked(line[0..@min(line.len, room)], &shown) }) catch why; return tree.failText(req.tag, E.INVAL, text); } /// `refuse`, saying which ctl takes the message instead. pub fn refuseTo(p: *Pardes, req: Req, why: []const u8, line: []const u8, ctl: []const u8) Reply { const room = p.fs.ename.len -| (why.len + ctl.len + 18); - const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, line[0..@min(line.len, room)], ctl }) catch why; + var shown: [320]u8 = undefined; + const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, blanked(line[0..@min(line.len, room)], &shown), ctl }) catch why; return tree.failText(req.tag, E.INVAL, text); } +/// A quoted line as a refusal shows it: a control byte (a tab, the one a +/// line may hold) is a blank, so the reason reads as one line of words. +fn blanked(line: []const u8, buf: *[320]u8) []const u8 { + const n = @min(line.len, buf.len); + for (line[0..n], buf[0..n]) |c, *o| o.* = if (c < ' ' or c == 0x7f) ' ' else c; + return buf[0..n]; +} + /// Checks one line written to a ctl as a builtin of `scope` before any line /// of the write runs: a word the registry knows, of this ctl's scope, given /// an argument if and only if it takes or requires one, and for a setting a @@ -1008,6 +1018,21 @@ const root_status = @intFromEnum(tree.TopFile.status); const root_look = @intFromEnum(tree.TopFile.look); const root_exec = @intFromEnum(tree.TopFile.exec); +test "a control character in a write to any ctl refuses the whole write, and a quoted line shows a tab as a blank" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const col = layout.columnSerial(p, 0); + for ([_]u64{ @intFromEnum(tree.TopFile.ctl), Node.of(serialOf(p), .ctl), Node.ofCol(col, .ctl) }) |node| { + const r = wr(p, node, "Wrap off\nbo\x01gus\n"); + try testing.expectEqual(E.INVAL, r.errno()); + try testing.expectEqualStrings(e_control, r.reply.ename); + } + const quoted = wr(p, Node.of(serialOf(p), .ctl), "bogus\tword\n"); + try testing.expectEqual(E.INVAL, quoted.errno()); + try testing.expect(std.mem.indexOf(u8, quoted.reply.ename, "bogus word") != null); + try testing.expect(std.mem.indexOfScalar(u8, quoted.reply.ename, '\t') == null); +} + test "pane ctl read is acme's fields -- index's five, width in cells, font, tab width, undo, redo -- then whether it is current" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); -- cgit v1.3