summaryrefslogtreecommitdiff
path: root/src/ninep/tree.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep/tree.zig')
-rw-r--r--src/ninep/tree.zig208
1 files changed, 203 insertions, 5 deletions
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index d6f4211b..5f3ffc9c 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -454,6 +454,86 @@ pub fn stagedReply(p: *Pardes, req: Req) Reply {
// ---- dispatch ----
pub fn handle(p: *Pardes, req: Req) Reply {
+ var reply = serve(p, req);
+ if (reply.status == .err and reply.ename.len == 0) reply.ename = errWords(reply.errno);
+ if (reply.status == .err and reply.ename.len > cloud9.fs.errmax)
+ if (p.scratch.allocator().alloc(u8, cloud9.fs.errmax)) |room| {
+ reply.ename = fitErr(reply.ename, room);
+ } else |_| {};
+ return reply;
+}
+
+/// A reason longer than an Rerror carries (Plan 9's ERRMAX, 128 bytes)
+/// keeps its end, which says why: the longest path in it -- else the whole
+/// text -- gives up its middle to `…`.
+pub fn fitErr(text: []const u8, out: []u8) []const u8 {
+ const ell = "…";
+ if (text.len <= out.len) return text;
+ const over = text.len - out.len + ell.len;
+ var from: usize = 0;
+ var to: usize = text.len;
+ var words = std.mem.tokenizeAny(u8, text, " \t");
+ var longest: usize = 0;
+ while (words.next()) |w| if (std.mem.indexOfScalar(u8, w, '/') != null and w.len > longest and w.len > over + 2) {
+ longest = w.len;
+ from = @intFromPtr(w.ptr) - @intFromPtr(text.ptr);
+ to = from + w.len;
+ };
+ var a = from + (to - from - over) / 2;
+ var b = a + over;
+ while (a > from and text[a] & 0xC0 == 0x80) a -= 1;
+ while (b < to and text[b] & 0xC0 == 0x80) b += 1;
+ return std.fmt.bufPrint(out, "{s}" ++ ell ++ "{s}", .{ text[0..a], text[b..] }) catch text[0..out.len];
+}
+
+test "a reason past 128 bytes keeps its end: the path in it gives up its middle" {
+ var out: [128]u8 = undefined;
+ const long = "Save /home/someone/projects/" ++ "deep/" ** 30 ++ "file.txt: no such directory";
+ const fit = fitErr(long, &out);
+ try testing.expect(fit.len <= 128);
+ try testing.expect(std.mem.startsWith(u8, fit, "Save /home/someone/"));
+ try testing.expect(std.mem.endsWith(u8, fit, "file.txt: no such directory"));
+ try testing.expect(std.mem.indexOf(u8, fit, "…") != null);
+ try testing.expect(std.unicode.utf8ValidateSlice(fit));
+ // No path: the whole text's middle goes, the end stays.
+ const words = "why " ** 40 ++ "the reason";
+ try testing.expect(std.mem.endsWith(u8, fitErr(words, &out), "the reason"));
+ try testing.expectEqualStrings("short", fitErr("short", &out));
+ // Through the tree: a look at a long ./ name not there says why at the end.
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var line: [512]u8 = undefined;
+ const r = th.wr(p, Node.of(serialOf(p), .look), try std.fmt.bufPrint(&line, "./{s}x.txt\n", .{"no-such-dir/" ** 20}));
+ try testing.expectEqual(Status.err, r.reply.status);
+ try testing.expect(r.reply.ename.len <= 128);
+ try testing.expect(std.mem.endsWith(u8, r.reply.ename, "no such file") or std.mem.endsWith(u8, r.reply.ename, "no such directory"));
+}
+
+/// A refusal with no reason of its own said in Plan 9's words, not the C
+/// library's (`Operation not permitted`); each maps back to its errno in
+/// 9ns (enameToErrno), EPERM's to EACCES as Plan 9's does.
+pub fn errWords(errno: u16) []const u8 {
+ return switch (errno) {
+ E.PERM => "permission denied",
+ E.NOENT => "file does not exist",
+ E.NOMEM => "out of memory",
+ E.BUSY => "file in use",
+ E.EXIST => "file already exists",
+ E.NOTDIR => "not a directory",
+ E.ISDIR => "is a directory",
+ E.INVAL => "bad argument",
+ E.NFILE => "too many open files",
+ E.NOSPC => "no space left",
+ E.NOSYS => "not supported",
+ E.NOTEMPTY => "directory not empty",
+ else => "i/o error",
+ };
+}
+
+fn serve(p: *Pardes, req: Req) Reply {
+ // Held writes go in before anything but the next write of their open.
+ if (p.fs.batch.bytes.items.len > 0 and !(req.op == .write and req.node == p.fs.batch.node and req.handle == p.fs.batch.handle))
+ pane.flushBatch(p);
if (req.op == .open and req.create) {
if (Node.target(req.node)) |t| if (t == .pane and p.paneBySerial(t.pane.serial) == null)
return Reply.fail(req.tag, E.NOENT);
@@ -1036,7 +1116,7 @@ fn linesFile(target: Target) bool {
.top => |f| f == .look or f == .exec or f == .ctl or f == .tagexec,
.col => |c| c.file == .ctl or c.file == .exec,
// `name` too: one name, however its writes cut it, applied once
- // its newline or its close comes.
+ // its newline comes, or with its write when that is whole.
.pane => |t| t.file == .look or t.file == .exec or t.file == .ctl or t.file == .tagexec or t.file == .name,
};
}
@@ -1073,7 +1153,18 @@ fn write(p: *Pardes, req: Req, target: Target) Reply {
// 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);
- const end = ctl.completeEnd(p, o.pending.items);
+ 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,
+ // and its failure is this write's, as acme takes each write whole. Held
+ // only when it is not whole yet -- an open `{` block, an `a`/`c`/`i`
+ // text awaiting its `.` -- or the write filled its Twrite and may go on.
+ const cut = p.fs.write_room != 0 and req.data.len >= p.fs.write_room;
+ if (end < o.pending.items.len and !cut) {
+ o.pending.append(p.gpa, '\n') catch return Reply.fail(req.tag, E.NOMEM);
+ const whole = ctl.completeEnd(p, o.pending.items);
+ if (whole == o.pending.items.len) end = whole else o.pending.shrinkRetainingCapacity(o.pending.items.len - 1);
+ }
if (end == 0) {
if (o.pending.items.len <= pending_cap) return .{ .tag = req.tag, .written = @intCast(req.data.len) };
o.pending.clearRetainingCapacity();
@@ -1537,6 +1628,55 @@ test "editor paths resolve to the same nodes the wire serves" {
try testing.expectEqual(before, p.next_serial);
}
+test "random placements, refused or not, leave no pane under its tag and two rows" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "one\ntwo\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var look_line: [4200]u8 = undefined;
+ const looked = try std.fmt.bufPrint(&look_line, "{s}/f.txt\n", .{dir});
+ var seed: u64 = 0;
+ while (seed < 300) : (seed += 1) {
+ var prng = std.Random.DefaultPrng.init(seed);
+ const r = prng.random();
+ const rows = r.intRangeAtMost(u16, 6, 40);
+ const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = r.intRangeAtMost(u16, 60, 240), .rows = rows });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ _ = try p.setTestFile("x\n");
+ while (p.nextEffect()) |_| {}
+ for (0..r.uintLessThan(usize, 3)) |_| _ = th.wr(p, Node.of(serialOf(p), .exec), "Newcol\n");
+ if (r.boolean()) p.settings.placement = .pardes;
+ for (0..40) |step| {
+ var serials: [pardes.MAX_PANES]u32 = undefined;
+ var n: usize = 0;
+ for (p.panes) |slot| if (slot) |pn| {
+ serials[n] = pn.serial;
+ n += 1;
+ };
+ if (n == 0) break;
+ const at = serials[r.uintLessThan(usize, n)];
+ const action = r.uintLessThan(u8, 8);
+ switch (action) {
+ 0 => _ = th.wr(p, Node.of(at, .exec), "New\n"),
+ 1 => _ = call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(TopFile.new) }),
+ 2 => _ = th.wr(p, Node.of(at, .exec), "Newcol\n"),
+ 3 => _ = th.wr(p, Node.of(at, .exec), "Edit =\n"),
+ 4 => _ = th.wr(p, Node.of(at, .exec), "Tty\n"),
+ 5 => _ = th.wr(p, Node.of(at, .look), looked),
+ 6 => _ = th.wr(p, Node.of(at, .exec), "Delcol\n"),
+ else => _ = th.wr(p, Node.of(at, .ctl), "delete\n"),
+ }
+ for (0..p.ncol) |c| if (!layout.columnAtMinimums(p, c)) {
+ std.debug.print("seed {d} rows {d} step {d} action {d}: column {d} under its minimums\n", .{ seed, rows, step, action, c });
+ for (p.col_panes[c][0..p.col_n[c]]) |id| std.debug.print(" pane {d} h {d} min {d}\n", .{ id, p.rects[id].h, layout.minRows(p, id) });
+ return error.TestUnexpectedResult;
+ };
+ }
+ }
+}
+
test "at the pane cap, pane/new, look and New each say so, and look reads back empty" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
@@ -1550,8 +1690,8 @@ test "at the pane cap, pane/new, look and New each say so, and look reads back e
const refused = call(p, .{ .tag = 2, .op = .open, .node = new });
try testing.expectEqual(E.NOSPC, refused.errno());
try testing.expect(std.mem.endsWith(u8, refused.reply.ename, said));
- try testing.expect(th.logHas(p, said));
- // Its err alone says it: no msg besides (the one rule).
+ // A refused open is its error alone: no err, and no msg besides.
+ try testing.expect(!th.logHas(p, said));
try testing.expect(!th.logHas(p, "New: no space"));
// A look that would open a pane fails its write, and says the same.
var tmp = testing.tmpDir(.{});
@@ -1580,8 +1720,12 @@ test "a command line cut across writes runs once whole, and the last runs at rel
const exec = @intFromEnum(TopFile.exec);
const h = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle;
try testing.expect(h != 0);
- for ([_][]const u8{ "Msg he", "llo\nMsg wor", "ld\nMsg tail" }) |piece|
+ // Each piece fills its Twrite (a client's one write cut at msize).
+ for ([_][]const u8{ "Msg he", "llo\nMsg wor", "ld\nMsg tail" }) |piece| {
+ p.fs.write_room = @intCast(piece.len);
try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = h, .data = piece }).reply.status);
+ }
+ p.fs.write_room = 0;
try testing.expect(th.logHas(p, " hello\n") and th.logHas(p, " world\n"));
try testing.expect(!th.logHas(p, " he\n") and !th.logHas(p, " tail\n"));
_ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = h, .opened = true });
@@ -1596,6 +1740,60 @@ test "a command line cut across writes runs once whole, and the last runs at rel
try testing.expectEqualStrings("x\none\ntwo\n", p.panes[0].?.file.?.content);
}
+test "a refused open, create or remove says why in words and logs no err; a refused write logs one" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ // pane/new is read, never written: a write is refused, and is no
+ // failure the log tells of.
+ const new = th.wr(p, @intFromEnum(TopFile.new), "x\n");
+ try testing.expectEqual(Status.err, new.reply.status);
+ try testing.expectEqualStrings("permission denied", new.reply.ename);
+ const made = call(p, .{ .tag = 2, .op = .open, .node = Node.of(serial, .body), .create = true });
+ try testing.expectEqualStrings("permission denied", made.reply.ename);
+ const gone = th.rmdir(p, @intFromEnum(TopFile.index));
+ try testing.expectEqual(Status.err, gone.reply.status);
+ try testing.expect(gone.reply.ename.len > 0 and !std.ascii.isUpper(gone.reply.ename[0]));
+ // An OTRUNC open's truncation of data after a failed addr: refused, no err.
+ _ = th.wr(p, Node.of(serial, .addr), "/nothere/");
+ const errs = th.logCount(p, "\nerr ");
+ try testing.expectEqual(Status.err, call(p, .{ .tag = 3, .op = .setattr, .node = Node.of(serial, .data), .truncate = true }).reply.status);
+ try testing.expectEqual(errs, th.logCount(p, "\nerr "));
+ try testing.expectEqual(@as(usize, 1), errs); // the addr write's own
+ // A write is the rule's: refused, it logs its one err, in words too.
+ try testing.expectEqual(Status.err, th.wr(p, Node.of(serial, .ctl), "bogus\n").reply.status);
+ try testing.expectEqual(@as(usize, 2), th.logCount(p, "\nerr "));
+}
+
+test "a write with no newline, whole in its Twrite, runs then and fails the write; one needing more waits" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const ctl_node = Node.of(serial, .ctl);
+ const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
+ p.fs.write_room = 8192;
+ defer p.fs.write_room = 0;
+ try testing.expectEqual(Status.err, call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "bogus" }).reply.status);
+ try testing.expectEqual(Status.err, call(p, .{ .tag = 3, .op = .write, .node = ctl_node, .handle = c, .data = "Edit ,s/zzz/y/" }).reply.status);
+ // An `a` needs its text and `.`: held, not run, not failed.
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 4, .op = .write, .node = ctl_node, .handle = c, .data = "Edit $a" }).reply.status);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 5, .op = .write, .node = ctl_node, .handle = c, .data = "\nmore\n.\n" }).reply.status);
+ try testing.expectEqualStrings("abc\nmore\n", p.panes[0].?.file.?.content);
+ _ = call(p, .{ .tag = 6, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ // The root's ctl too.
+ const root_ctl = @intFromEnum(TopFile.ctl);
+ const r = call(p, .{ .tag = 7, .op = .open, .node = root_ctl, .omode = 1 }).reply.handle;
+ try testing.expectEqual(Status.err, call(p, .{ .tag = 8, .op = .write, .node = root_ctl, .handle = r, .data = "bogus" }).reply.status);
+ _ = call(p, .{ .tag = 9, .op = .release, .node = root_ctl, .handle = r, .opened = true });
+ // And a name: a bad one fails its write, not the close after it.
+ const name = Node.of(serial, .name);
+ const n = call(p, .{ .tag = 10, .op = .open, .node = name, .omode = 1 }).reply.handle;
+ const bad = call(p, .{ .tag = 11, .op = .write, .node = name, .handle = n, .data = "delete " });
+ try testing.expectEqual(Status.err, bad.reply.status);
+ try testing.expect(std.mem.indexOf(u8, bad.reply.ename, "a blank at its end") != null);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 12, .op = .release, .node = name, .handle = n, .opened = true }).reply.status);
+}
+
test "look and exec read back what their own open's write touched; a fresh open reads the session's last" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();