summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 11:03:10 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commit67be3b6594a60d36723000a1a33a09016b29ff97 (patch)
tree3260b872643bf98f001e59038e5c55e06268b229 /src
parenta96c7e873d26686f0e49cf2882c1336046396d37 (diff)
downloadpardes-67be3b6594a60d36723000a1a33a09016b29ff97.tar.gz
pardes-67be3b6594a60d36723000a1a33a09016b29ff97.zip
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 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/ninep/ctl.zig29
-rw-r--r--src/ninep/tree.zig15
2 files changed, 39 insertions, 5 deletions
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");
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index d39854e1..61ac7e87 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -1147,12 +1147,21 @@ fn holdsLines(p: *Pardes, req: Req) bool {
/// A line held back longer than this is refused rather than kept growing.
const pending_cap = 1 << 20;
+/// The ctl files, whose lines are commands as look's and exec's are.
+fn ctlFile(target: Target) bool {
+ return switch (target) {
+ .top => |f| f == .ctl,
+ .col => |c| c.file == .ctl,
+ .pane => |t| t.file == .ctl,
+ };
+}
+
fn write(p: *Pardes, req: Req, target: Target) Reply {
+ // A command line holds no control character but a tab: the whole write
+ // is refused at once, not held to fail unseen at the close.
+ 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);
- // A clicked line holds no control character: refused at once, not held
- // to fail unseen at the close.
- if (resultsFile(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);
o.pending.appendSlice(p.gpa, req.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