From 36241daca2aaf90cc8933dffd95fc3cd6216e2b7 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 09:13:55 -0300 Subject: /index shows names as the log does, one line of UTF-8, and a newline in a name as \n /index printed a name as it was, so a directory with a newline in it split its row in two, and bytes not UTF-8 went through; the log turned the newline into a space, silently another name. Both now show a name one way: a newline `\n`, other controls, DEL and C1 a space, bytes not UTF-8 `\xNN`. Co-Authored-By: Claude Opus 5.5 --- docs/fs.md | 5 ++++- src/ninep/ctl.zig | 11 +++++++++++ src/ninep/events.zig | 48 ++++++++++++++++++++++++++++++++++++------------ src/ninep/pane.zig | 3 ++- src/ninep/tree.zig | 6 +++++- 5 files changed, 58 insertions(+), 15 deletions(-) diff --git a/docs/fs.md b/docs/fs.md index ffce848e..8ca23c85 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -100,6 +100,7 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC. ``` /README this guide, also src/fs-help.txt /index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name, column serial + (the name as the log shows it: one line of UTF-8, a newline in it `\n`) /status pid, version and pane count /look write a line: a right click on it at the active pane; read: the serials the last look, exec or ctl write touched (made, else acted at) @@ -824,7 +825,9 @@ takes \`follow\` or \`follow new\``, `invalid write: this pane has no text`), never a bare `Invalid argument`. Control characters, DEL and C1 controls (U+0080-U+009F) in a record become spaces, and a byte that is not UTF-8 is written `\xNN`, so a record is one -line of UTF-8. +line of UTF-8; a pane's name, in a record and in `/index` alike, shows a +newline in it as `\n` rather than a space, so a directory named with one +reads back as what it is. (An `event` record is not: acme's ` \n`, whose text may hold newlines. The origin is `E` (a 9P write to body or tag), `F` (other files, the editor's own lines), `K` (the keyboard) or `M` (the mouse); the diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index ccba342e..db37fa49 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -1990,6 +1990,17 @@ test "every EINVAL a write gets says why, in its err record too; DEL is a contro try testing.expect(th.logHas(p, "an empty name")); } +test "/index shows a name as the log does: a newline in it is \\n, controls spaces, bytes not UTF-8 \\xNN" { + const p = try th.withTerm(testing.allocator); + defer p.deinit(); + const term = p.paneBySerial(serialOf(p)).?; + p.setCwd(term, "/tmp/two\nlines\x7f\xff"); + const index = rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes; + try testing.expect(std.mem.indexOf(u8, index, "/tmp/two\\nlines \\xff ") != null); + try testing.expectEqual(@as(usize, 1), std.mem.count(u8, index, "\n")); + try testing.expectEqual(@as(u64, index.len), call(p, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(tree.TopFile.index) }).reply.attr.size); +} + test "size is monotonic: growing is never refused, and a size once taken is taken again" { const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 60 }); defer p.deinit(); diff --git a/src/ninep/events.zig b/src/ninep/events.zig index f58d970e..45569b22 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -145,9 +145,11 @@ pub fn announce(p: *Pardes) void { /// Records ` `. pub fn noteLog(p: *Pardes, kind: LogKind, pane: *Pane) void { - var buf: [4096 + 64]u8 = undefined; - const name = pane_files.nameOf(pane); - pushLog(p, std.fmt.bufPrint(&buf, "{s} {d} {s}\n", .{ @tagName(kind), pane.serial, name[0..@min(name.len, 4096)] }) catch return); + var buf: [4 * 4096 + 64]u8 = undefined; + var name_buf: [4 * 4096]u8 = undefined; + // As /index shows it: a newline in the name is `\n`. + const name = shown(pane_files.nameOf(pane), &name_buf); + pushLog(p, std.fmt.bufPrint(&buf, "{s} {d} {s}\n", .{ @tagName(kind), pane.serial, name }) catch return); } /// Records `msg ` for what the editor said, `-` for no pane. @@ -342,16 +344,37 @@ fn followerRead(p: *Pardes, seq: u64) bool { /// The log is one ring that records whether or not anyone reads it. A record /// is one line: a newline in a message or a name would read as two records. -/// A record as one line of text: a control character, DEL or a C1 control -/// (U+0080-U+009F) is a space each, and a byte that is not UTF-8 is -/// written `\xNN`, so a reader splitting on newlines and decoding UTF-8 -/// never trips. The record's own newline, last, is kept. +/// A record as one line of text (shown): the record's own newline, last, +/// is kept. fn sanitize(record: []const u8, out: []u8) []u8 { + // A newline in a message is a space (its words go on); a name's own + // newline is escaped before it gets here (noteLog). + const w = shownAs(record[0 .. record.len - 1], out[0 .. out.len - 1], false).len; + out[w] = '\n'; + return out[0 .. w + 1]; +} + +/// `text` as one line of UTF-8 a reader can split and decode, as /log and +/// /index show names: a newline is `\n` (a name with one stays readable as +/// what it is, not run into the next), any other control character, DEL +/// or C1 control (U+0080-U+009F) a space, and a byte that is not UTF-8 +/// `\xNN`. `out` of 4 bytes a byte of `text` holds it all. +pub fn shown(text: []const u8, out: []u8) []u8 { + return shownAs(text, out, true); +} + +fn shownAs(text: []const u8, out: []u8, escape_newline: bool) []u8 { var w: usize = 0; var i: usize = 0; - const body = record[0 .. record.len - 1]; - while (i < body.len and w + 4 < out.len) { + const body = text; + while (i < body.len and w + 4 <= out.len) { const c = body[i]; + if (c == '\n' and escape_newline) { + @memcpy(out[w..][0..2], "\\n"); + w += 2; + i += 1; + continue; + } if (c < ' ' or c == 0x7f) { out[w] = ' '; w += 1; @@ -371,13 +394,12 @@ fn sanitize(record: []const u8, out: []u8) []u8 { i += 2; continue; } - if (w + n + 1 > out.len) break; + if (w + n > out.len) break; @memcpy(out[w..][0..n], body[i..][0..n]); w += n; i += n; } - out[w] = '\n'; - return out[0 .. w + 1]; + return out[0..w]; } fn pushLog(p: *Pardes, raw: []u8) void { @@ -1285,6 +1307,8 @@ test "a long msg record is cut between words at its cap, with an ellipsis" { test "a record is one line of UTF-8: DEL and C1 are spaces, bytes not UTF-8 are escaped" { var out: [64]u8 = undefined; + var name: [64]u8 = undefined; + try testing.expectEqualStrings("/tmp/two\\nlines", shown("/tmp/two\nlines", &name)); try testing.expectEqualStrings("msg - a b c \\xff d\n", sanitize("msg - a\x7fb\xc2\x85c \xff d\n", &out)); try testing.expectEqualStrings("msg - caf\xc3\xa9\n", sanitize("msg - caf\xc3\xa9\n", &out)); } diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 4c967fc5..d29c8b49 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -298,7 +298,8 @@ pub fn indexLen(p: *Pardes) u64 { const pane = p.panes[p.paneBySerial(serial).?].?; var digits: [16]u8 = undefined; n += (std.fmt.bufPrint(&digits, "{d}", .{serial}) catch unreachable).len; - n += 1 + kindOf(pane).len + 3 + nameOf(pane).len + 1; + var name_buf: [4 * 4096]u8 = undefined; + n += 1 + kindOf(pane).len + 3 + events.shown(nameOf(pane), &name_buf).len + 1; n += 1 + (std.fmt.bufPrint(&digits, "{d}", .{columnOf(p, p.paneBySerial(serial).?)}) catch unreachable).len; } return n; diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index 391847f5..c089c31d 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -917,7 +917,11 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply { last = serial; const id = p.paneBySerial(serial).?; const pn = p.panes[id].?; - out.print(p.gpa, "{d} {s} {d} {s} {d}\n", .{ serial, pane.kindOf(pn), @intFromBool(pane.dirtyOf(pn)), pane.nameOf(pn), pane.columnOf(p, id) }) catch {}; + // A name as /log shows it: one line of UTF-8, a + // newline in it `\n` (events.shown). + var name_buf: [4 * 4096]u8 = undefined; + const name = events.shown(pane.nameOf(pn), &name_buf); + out.print(p.gpa, "{d} {s} {d} {s} {d}\n", .{ serial, pane.kindOf(pn), @intFromBool(pane.dirtyOf(pn)), name, pane.columnOf(p, id) }) catch {}; } break :index stagedReply(p, req); }, -- cgit v1.3