const std = @import("std"); const limits = @import("memory.zig").limits; const libc = std.c; const pardes = @import("pardes.zig"); const config = @import("config.zig"); const lsp = @import("lsp/lsp.zig"); const ninep_io = @import("9p_io.zig"); const panes = @import("panes.zig"); const modal = @import("modal.zig"); const look = @import("look.zig"); pub const tree = @import("ninep/tree.zig"); const sources_fs = @import("ninep/sources.zig"); const th = @import("ninep/testing.zig"); const Pardes = pardes.Pardes; const MAX_PANES = pardes.MAX_PANES; const Req = tree.Req; const Reply = tree.Reply; const E = tree.E; /// The served README; tree.zig answers it. pub const help = @embedFile("fs-help.txt"); /// Host filesystem nodes: the root, and one hashed node per path a client walked to. pub const os_root: u64 = (1 << 63) + 2; pub const os_node: u64 = 1 << 62; pub const name_capacity: usize = 255; pub const Source = struct { path: []const u8, contents: []const u8 }; pub const sources: []const Source = if (limits.embedded_sources) &(source_files ++ @import("effect_sources.zig").files) else &.{}; const source_files = [_]Source{ .{ .path = "src/pardes.zig", .contents = @embedFile("pardes.zig") }, .{ .path = "src/panes.zig", .contents = @embedFile("panes.zig") }, .{ .path = "src/Text.zig", .contents = @embedFile("Text.zig") }, .{ .path = "src/surface.zig", .contents = @embedFile("surface.zig") }, .{ .path = "src/colors.zig", .contents = @embedFile("colors.zig") }, .{ .path = "src/Messages.zig", .contents = @embedFile("Messages.zig") }, .{ .path = "src/body_layer.zig", .contents = @embedFile("body_layer.zig") }, .{ .path = "src/tagline.zig", .contents = @embedFile("tagline.zig") }, .{ .path = "src/edit.zig", .contents = @embedFile("edit.zig") }, .{ .path = "src/normal.zig", .contents = @embedFile("normal.zig") }, .{ .path = "src/mouse.zig", .contents = @embedFile("mouse.zig") }, .{ .path = "src/exec.zig", .contents = @embedFile("exec.zig") }, .{ .path = "src/Pipe.zig", .contents = @embedFile("Pipe.zig") }, .{ .path = "src/File.zig", .contents = @embedFile("File.zig") }, .{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") }, .{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") }, .{ .path = "src/image.zig", .contents = @embedFile("image.zig") }, .{ .path = "src/Terminal.zig", .contents = @embedFile("Terminal.zig") }, .{ .path = "src/pdf_view.zig", .contents = @embedFile("pdf_view.zig") }, .{ .path = "src/layout.zig", .contents = @embedFile("layout.zig") }, .{ .path = "src/fs.zig", .contents = @embedFile("fs.zig") }, .{ .path = "src/look.zig", .contents = @embedFile("look.zig") }, .{ .path = "src/9p.zig", .contents = @embedFile("9p.zig") }, .{ .path = "src/9p_io.zig", .contents = @embedFile("9p_io.zig") }, .{ .path = "src/host_io.zig", .contents = @embedFile("host_io.zig") }, .{ .path = "src/config.zig", .contents = @embedFile("config.zig") }, .{ .path = "src/main.zig", .contents = @embedFile("main.zig") }, .{ .path = "src/builtins.zig", .contents = @embedFile("builtins.zig") }, .{ .path = "src/grammar_manifest.zig", .contents = @embedFile("grammar_manifest.zig") }, .{ .path = "src/CHANGELOG.md", .contents = @embedFile("CHANGELOG.md") }, }; pub fn sourceBytes(path: []const u8) ?[]const u8 { for (sources) |entry| if (std.mem.eql(u8, entry.path, path)) return entry.contents; return null; } pub const WriteError = error{ PathTooLong, PermissionDenied, IsDirectory, ReadOnlyFilesystem, NoSpaceLeft, OpenFailed, WriteFailed, }; /// The host takes the bytes. The caller has given the turn up (`write`), /// because the path may be a mount this editor serves. pub fn writeFile(path: []const u8, bytes: []const u8) WriteError!void { var pathbuf: [4096:0]u8 = undefined; if (path.len >= pathbuf.len) return error.PathTooLong; if (std.mem.indexOfScalar(u8, path, 0) != null) return error.OpenFailed; @memcpy(pathbuf[0..path.len], path); pathbuf[path.len] = 0; const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) return switch (libc.errno(fd)) { .ACCES, .PERM => error.PermissionDenied, .ISDIR => error.IsDirectory, .ROFS => error.ReadOnlyFilesystem, .NOSPC, .DQUOT => error.NoSpaceLeft, .NAMETOOLONG => error.PathTooLong, else => error.OpenFailed, }; var off: usize = 0; var wrote = true; while (off < bytes.len) { const n = libc.write(fd, bytes[off..].ptr, bytes.len - off); if (n < 0 and libc.errno(n) == .INTR) continue; if (n <= 0) { wrote = false; break; } off += @intCast(n); } const closed = libc.close(fd) == 0; if (!wrote or !closed) return error.WriteFailed; } extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; pub const Mount = struct { name: []const u8, dial: []const u8 }; pub const max_mounts = 8; pub const Resolved = struct { path: []const u8, dir: bool = false }; pub const OsPath = struct { node: u64, path: []const u8 }; fn osPath(p: *pardes.Pardes, node: u64) ?[]const u8 { if (node == os_root) return "/"; for (p.fs.os_paths.items) |entry| if (entry.node == node) return entry.path; return null; } fn osNode(p: *pardes.Pardes, path: []const u8) !u64 { if (std.mem.eql(u8, path, "/")) return os_root; const node = os_node | (std.hash.Wyhash.hash(0, path) & (os_node - 1)); for (p.fs.os_paths.items) |entry| { if (std.mem.eql(u8, entry.path, path)) return entry.node; if (entry.node == node) return error.NodeCollision; } if (p.fs.os_paths.items.len == 4096) return error.TooManyFiles; const saved = try p.gpa.dupe(u8, path); errdefer p.gpa.free(saved); try p.fs.os_paths.append(p.gpa, .{ .node = node, .path = saved }); return node; } /// The host filesystem under /os. Every syscall here may go out through a /// mount this editor serves, so the turn is given up around each and taken /// back before the core is touched; `path` is the table entry's own copy and /// outlives the yield, and nothing is staged into the shared reply buffer /// until the turn is back. pub fn osHandle(p: *pardes.Pardes, req: Req) Reply { if (comptime !platform_has_fs) return Reply.fail(req.tag, E.NOENT); const path = osPath(p, req.node) orelse return Reply.fail(req.tag, E.NOENT); if (req.op == .release) return .{ .tag = req.tag }; const io = std.Io.Threaded.global_single_threaded.io(); const stat = stat: { pardes.turn.yield(); defer pardes.turn.back(); break :stat std.Io.Dir.cwd().statFile(io, path, .{}) catch return Reply.fail(req.tag, E.NOENT); }; const attr: Reply.Attr = .{ .name = if (req.node == os_root) "os" else std.fs.path.basename(path), .node = req.node, .dir = stat.kind == .directory, .size = stat.size, .mode = if (stat.kind == .directory) 0o755 else 0o644, .mtime = std.math.cast(u32, stat.mtime.toSeconds()) orelse 0 }; switch (req.op) { .getattr => return .{ .tag = req.tag, .attr = attr }, .open => { if (stat.kind != .file and stat.kind != .directory) return Reply.fail(req.tag, E.PERM); return .{ .tag = req.tag, .handle = 1 }; }, .lookup => { if (stat.kind != .directory) return Reply.fail(req.tag, E.NOTDIR); if (req.node == os_root and std.mem.eql(u8, req.data, "..")) return tree.handle(p, .{ .tag = req.tag, .op = .getattr, .node = tree.root }); if (req.data.len == 0 or std.mem.indexOfAny(u8, req.data, "/\x00") != null) return Reply.fail(req.tag, E.NOENT); var buf: [4096]u8 = undefined; const joined = std.fmt.bufPrint(&buf, "{s}/{s}", .{ std.mem.trimEnd(u8, path, "/"), req.data }) catch return Reply.fail(req.tag, E.NOENT); var normalized_buf: [4096]u8 = undefined; const normalized = resolveOs(joined, &normalized_buf) orelse return Reply.fail(req.tag, E.NOENT); const node = osNode(p, normalized.path) catch return Reply.fail(req.tag, E.NFILE); return osHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node }); }, .readdir => { // Listed into a buffer of this request's own first; the shared // reply buffer is another request's to use while the turn is out. var listing: [16 * 1024]u8 = undefined; var fixed: std.heap.FixedBufferAllocator = .init(&listing); var listed: std.ArrayList(u8) = .empty; { pardes.turn.yield(); defer pardes.turn.back(); var dir = std.Io.Dir.cwd().openDir(io, path, .{ .iterate = true }) catch return Reply.fail(req.tag, E.NOTDIR); defer dir.close(io); var it = dir.iterate(); var skip = req.off; while (it.next(io) catch return Reply.fail(req.tag, E.IO)) |entry| { var buf: [4096]u8 = undefined; const joined = std.fmt.bufPrint(&buf, "{s}/{s}", .{ std.mem.trimEnd(u8, path, "/"), entry.name }) catch continue; var normalized_buf: [4096]u8 = undefined; const normalized = resolveOs(joined, &normalized_buf) orelse continue; const child = dir.statFile(io, entry.name, .{}) catch continue; if (skip > 0) { skip -= 1; continue; } const node = if (std.mem.eql(u8, normalized.path, "/")) os_root else os_node | (std.hash.Wyhash.hash(0, normalized.path) & (os_node - 1)); tree.stageDirent(&listed, fixed.allocator(), node, child.kind == .directory, entry.name); if (listed.items.len >= @max(req.size, 512)) break; } } const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, listed.items) catch return Reply.fail(req.tag, E.NOMEM); return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; }, .read, .write, .setattr => { if (stat.kind != .file) return Reply.fail(req.tag, E.PERM); var z: [4096]u8 = undefined; const path_z = std.fmt.bufPrintSentinel(&z, "{s}", .{path}, 0) catch return Reply.fail(req.tag, E.NOENT); var buf: [ninep_io.msize]u8 = undefined; // a read never asks for more than a frame const got = io: { pardes.turn.yield(); defer pardes.turn.back(); const fd = libc.open(path_z, .{ .ACCMODE = if (req.op == .read) .RDONLY else .WRONLY, .NONBLOCK = true, .CLOEXEC = true }); if (fd < 0) return Reply.fail(req.tag, E.PERM); defer _ = libc.close(fd); if (req.op == .setattr) { if (!req.truncate or req.off != 0 or libc.ftruncate(fd, 0) != 0) return Reply.fail(req.tag, E.INVAL); var truncated = attr; truncated.size = 0; return .{ .tag = req.tag, .attr = truncated }; } if (req.off > std.math.maxInt(i64)) return Reply.fail(req.tag, E.INVAL); if (libc.lseek(fd, @intCast(req.off), libc.SEEK.SET) < 0) return Reply.fail(req.tag, E.IO); if (req.op == .write) { const written = libc.write(fd, req.data.ptr, req.data.len); if (written < 0) return Reply.fail(req.tag, E.IO); return .{ .tag = req.tag, .written = @intCast(written) }; } const got = libc.read(fd, &buf, @min(req.size, buf.len)); if (got < 0) return Reply.fail(req.tag, E.IO); break :io @as(usize, @intCast(got)); }; const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, buf[0..got]) catch return Reply.fail(req.tag, E.NOMEM); return .{ .tag = req.tag, .payload = .{ .staged = @intCast(got) } }; }, else => return Reply.fail(req.tag, E.PERM), } } pub fn validMountName(name: []const u8) bool { if (name.len == 0 or name.len > name_capacity or std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return false; if (std.mem.eql(u8, name, "os") or std.mem.eql(u8, name, "self")) return false; for (name) |c| if (!std.ascii.isAlphanumeric(c) and c != '_' and c != '-' and c != '.') return false; return true; } test "mounts own their names and dials and reject duplicate or reserved names" { const gpa = std.testing.allocator; var ns: Namespace = .{}; defer ns.deinit(gpa); var name = "peer".*; var dial = "/tmp/peer.sock".*; try ns.mount(gpa, &name, &dial); @memset(&name, 'x'); @memset(&dial, 'x'); try std.testing.expectEqualStrings("peer", ns.mounts.items[0].name); try std.testing.expectEqualStrings("/tmp/peer.sock", ns.mounts.items[0].dial); try std.testing.expectError(error.AlreadyMounted, ns.mount(gpa, "peer", "/tmp/other.sock")); for ([_][]const u8{ "", ".", "..", "os", "self", "a/b", "with space" }) |bad| try std.testing.expectError(error.BadMountName, ns.mount(gpa, bad, "/tmp/peer.sock")); for ([_][]const u8{ "", "a\x00b", "tcp!127.0.0.1!0", "tcp!localhost!564" }) |bad| try std.testing.expectError(error.BadDial, ns.mount(gpa, "valid", bad)); for (1..max_mounts) |i| { var buf: [16]u8 = undefined; try ns.mount(gpa, try std.fmt.bufPrint(&buf, "peer{d}", .{i}), "/tmp/peer.sock"); } try std.testing.expectEqual(max_mounts, ns.mounts.items.len); try std.testing.expectError(error.TooManyMounts, ns.mount(gpa, "full", "/tmp/peer.sock")); } test "mount allocation failures preserve prior mounts and release partial copies" { const Check = struct { fn run(gpa: std.mem.Allocator) !void { var ns: Namespace = .{}; defer ns.deinit(gpa); try ns.mount(gpa, "first", "/tmp/first.sock"); ns.mount(gpa, "second", "/tmp/second.sock") catch |err| { try std.testing.expectEqual(@as(usize, 1), ns.mounts.items.len); try std.testing.expectEqualStrings("first", ns.mounts.items[0].name); try std.testing.expectEqualStrings("/tmp/first.sock", ns.mounts.items[0].dial); return err; }; } }; try std.testing.checkAllAllocationFailures(std.testing.allocator, Check.run, .{}); } test "unmount refuses pane paths inherited directories and queued save targets" { const gpa = std.testing.allocator; const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); try p.fs.mount(gpa, "peer", "/tmp/peer.sock"); const pane = try p.setTestFile("unsaved\n"); for ([_][]const u8{ "/n/peer", "/n/peer/file", "/n/peer/dir/file" }) |path| { gpa.free(pane.file.?.path); pane.file.?.path = try gpa.dupe(u8, path); try std.testing.expectError(error.MountInUse, unmount(p, "peer")); } gpa.free(pane.file.?.path); pane.file.?.path = try gpa.dupe(u8, "/n/peer2/file"); const slot = p.freeSlot().?; const shell = try p.newShell(slot, "/n/peer/dir"); p.setCwd(slot, "/n/peer/dir"); try std.testing.expectError(error.MountInUse, unmount(p, "peer")); pane.cwd = .{ .inherited = shell }; try std.testing.expectError(error.MountInUse, unmount(p, "peer")); p.setCwd(slot, "/"); while (p.nextEffect()) |_| {} p.emit(.{ .save_text = .{ .pane = 0, .serial = pane.serial, .path = .from("/n/peer/pending") } }); try std.testing.expectError(error.MountInUse, unmount(p, "peer")); while (p.nextEffect()) |_| {} try unmount(p, "peer"); try std.testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len); try std.testing.expectError(error.NotMounted, unmount(p, "peer")); try p.fs.mount(gpa, "peer", "/tmp/other.sock"); try std.testing.expectEqualStrings("/tmp/other.sock", p.fs.mounts.items[0].dial); } test "virtual writes enforce permissions even when contents are empty" { const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); for ([_][]const u8{ "", "bytes" }) |bytes| { for ([_][]const u8{ "/virtual/index", "/virtual/screen", "/virtual/README" }) |path| try std.testing.expectError(error.ReadOnlyFilesystem, write(p, path, bytes)); // /log takes one word, `follow`, and only on the open it changes. if (bytes.len > 0) if (write(p, "/n/self/log", bytes)) |_| return error.TestUnexpectedResult else |_| {}; try std.testing.expectError(error.IsDirectory, write(p, "/virtual/pane", bytes)); try std.testing.expectError(error.FileNotFound, write(p, "/virtual/missing", bytes)); try std.testing.expectError(error.FileNotFound, write(p, "/virtual/cons", bytes)); if (limits.embedded_sources) { for ([_][]const u8{ "/virtual/src/fs.zig", "/n/self/src/fs.zig" }) |path| try std.testing.expectError(error.ReadOnlyFilesystem, write(p, path, bytes)); try std.testing.expectError(error.IsDirectory, write(p, "/virtual/src", bytes)); } else try std.testing.expectError(error.FileNotFound, write(p, "/virtual/src/fs.zig", bytes)); } for (p.fs.opens) |o| try std.testing.expect(o.node == 0); } test "direct self reads and writes use the same dot paths as Look" { const gpa = std.testing.allocator; const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("old contents\n"); var path_buf: [256]u8 = undefined; for ([_][]const u8{ "/virtual", "/n/self" }) |root| { const path = try std.fmt.bufPrint(&path_buf, "{s}/./pane/{d}/../{d}/body/./", .{ root, pane.serial, pane.serial }); try write(p, path, "replacement\n"); try std.testing.expectEqualStrings("replacement\n", pane.file.?.content); const bytes = try read(p, path); defer gpa.free(bytes); try std.testing.expectEqualStrings(pane.file.?.content, bytes); var resolved: [4096]u8 = undefined; const canonical = resolve(p, path, "/", &resolved).?; try std.testing.expectEqual(tree.resolveSelf(p, canonical.path[9..]), tree.resolveSelf(p, path[root.len..])); try std.testing.expectEqual(@as(?u64, tree.root), tree.resolveSelf(p, "./pane/../../")); const index_path = try std.fmt.bufPrint(&path_buf, "{s}/./pane/../index", .{root}); try std.testing.expectError(error.ReadOnlyFilesystem, write(p, index_path, "")); const index = try read(p, index_path); defer gpa.free(index); try std.testing.expect(std.mem.indexOf(u8, index, "/test.txt") != null); } var overlong: [4110]u8 = @splat('x'); @memcpy(overlong[0..9], "/virtual/"); var resolved: [4096]u8 = undefined; try std.testing.expect(resolve(p, &overlong, "/", &resolved) == null); try std.testing.expect(tree.resolveSelf(p, overlong[9..]) == null); try std.testing.expectError(error.FileNotFound, write(p, &overlong, "")); } test "self files share the regular file limit without preallocating it" { const gpa = std.testing.allocator; const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); const contents = try gpa.alloc(u8, limits.max_stream_bytes + 17); defer gpa.free(contents); @memset(contents, 'x'); const pane = try p.setTestFile(contents); var path_buf: [128]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{pane.serial}); const bytes = try read(p, path); defer gpa.free(bytes); try std.testing.expectEqualSlices(u8, contents, bytes); } test "bounded reads accept exact OS file lengths and empty files" { if (!platform_has_fs) return error.SkipZigTest; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var contents: [8209]u8 = @splat('x'); contents[contents.len - 1] = '\n'; for ([_][]const u8{ &contents, "" }) |expected| { try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "limited.txt", .data = expected }); var path_buf: [4096]u8 = undefined; const native = path_buf[0..try tmp.dir.realPathFile(std.testing.io, "limited.txt", &path_buf)]; var explicit_buf: [4096]u8 = undefined; const explicit = try std.fmt.bufPrint(&explicit_buf, "/n/os{s}", .{native}); for ([_][]const u8{ native, explicit }) |path| { const bytes = try readLimit(p, path, expected.len); defer gpa.free(bytes); try std.testing.expectEqualSlices(u8, expected, bytes); if (expected.len > 0) { try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); try std.testing.expectError(error.FileTooLarge, readLimit(p, path, 0)); } } } try tmp.dir.createDir(std.testing.io, "empty", .default_dir); var path_buf: [4096]u8 = undefined; const native = path_buf[0..try tmp.dir.realPathFile(std.testing.io, "empty", &path_buf)]; var explicit_buf: [4096]u8 = undefined; const explicit = try std.fmt.bufPrint(&explicit_buf, "/n/os{s}", .{native}); const empty = try readLimit(p, explicit, 0); defer gpa.free(empty); try std.testing.expectEqual(@as(usize, 0), empty.len); // The directory's node stays in the table for the listener to collect // once no connection names it (src/9p_io.zig, collectOs): a client may // have walked to the same directory meanwhile and hold it by that node. try std.testing.expectEqual(@as(usize, 1), p.fs.os_paths.items.len); try std.testing.expectEqualStrings(native, p.fs.os_paths.items[0].path); } test "bounded reads apply the same limits to self bodies and embedded sources" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); var contents: [8209]u8 = @splat('x'); contents[contents.len - 1] = '\n'; for ([_][]const u8{ &contents, "" }) |expected| { const pane = try p.setTestFile(expected); for ([_][]const u8{ "/virtual", "/n/self" }) |prefix| { var path_buf: [128]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, "{s}/pane/{d}/body", .{ prefix, pane.serial }); const bytes = try readLimit(p, path, expected.len); defer gpa.free(bytes); try std.testing.expectEqualSlices(u8, expected, bytes); if (expected.len > 0) { try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); try std.testing.expectError(error.FileTooLarge, readLimit(p, path, 0)); } try std.testing.expectEqualSlices(u8, expected, pane.file.?.content); } } if (limits.embedded_sources) { const source = findEmbeddedSource("src/look.zig", false).?; for ([_][]const u8{ "/virtual/src/look.zig", "/n/self/src/look.zig" }) |path| { const bytes = try readLimit(p, path, source.contents.len); defer gpa.free(bytes); try std.testing.expectEqualStrings(source.contents, bytes); try std.testing.expectError(error.FileTooLarge, readLimit(p, path, source.contents.len - 1)); } const bytes = try readFileLimit(gpa, "/virtual/src/look.zig", source.contents.len); defer gpa.free(bytes); try std.testing.expectEqualStrings(source.contents, bytes); try std.testing.expectError(error.FileTooLarge, readFileLimit(gpa, "/virtual/src/look.zig", source.contents.len - 1)); } } test "bounded reads count rendered directory paths and unknown self lengths" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); p.fs.socket_path = "/tmp/pardes-limited-in-process.sock"; // so listeners has a line for ([_][]const u8{ "/n", "/virtual", "/n/self", "/virtual/pane", "/virtual/listeners", "/virtual/README" }) |path| { const expected = try read(p, path); defer gpa.free(expected); try std.testing.expect(expected.len > 0); const bytes = try readLimit(p, path, expected.len); defer gpa.free(bytes); try std.testing.expectEqualStrings(expected, bytes); try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); try std.testing.expectError(error.FileTooLarge, readLimit(p, path, 0)); } try std.testing.expectEqual(@as(usize, 0), p.fs.os_paths.items.len); } test "bounded reads release terminal snapshots after success and size refusal" { if (!pardes.terminal_panes) return error.SkipZigTest; const gpa = std.testing.allocator; const p = try th.withTerm(gpa); defer p.deinit(); var path_buf: [128]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{th.serialOf(p)}); const empty = try readLimit(p, path, 0); defer gpa.free(empty); try std.testing.expectEqual(@as(usize, 0), empty.len); p.update(.{ .output = .{ .pane = 0, .bytes = "limited terminal output" } }); const expected = "limited terminal output\n"; // a line ends with its newline const bytes = try readLimit(p, path, expected.len); defer gpa.free(bytes); try std.testing.expectEqualStrings(expected, bytes); try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); for (p.fs.opens) |o| try std.testing.expectEqual(@as(u64, 0), o.node); } test "bounded reads probe size-unknown proc files at the exact limit" { if (!platform_has_fs or @import("builtin").os.tag != .linux) return error.SkipZigTest; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); const expected = read(p, "/proc/version") catch |err| return switch (err) { error.FileNotFound, error.PermissionDenied => error.SkipZigTest, else => err, }; defer gpa.free(expected); try std.testing.expect(expected.len > 0); var storage: [256 * 1024]u8 = undefined; var fixed = std.heap.FixedBufferAllocator.init(&storage); const bounded = try readFileLimit(fixed.allocator(), "/proc/version", limits.max_stream_bytes); try std.testing.expectEqualStrings(expected, bounded); fixed.allocator().free(bounded); for ([_][]const u8{ "/proc/version", "/n/os/proc/version" }) |path| { const bytes = try readLimit(p, path, expected.len); defer gpa.free(bytes); try std.testing.expectEqualStrings(expected, bytes); try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); try std.testing.expectError(error.FileTooLarge, readLimit(p, path, 0)); } } test "self file reads release partial allocations when memory runs out" { const gpa = std.testing.allocator; const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); const contents = try gpa.alloc(u8, 65537); defer gpa.free(contents); @memset(contents, 'x'); const pane = try p.setTestFile(contents); var path_buf: [128]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{pane.serial}); const Read = struct { fn run(allocator: std.mem.Allocator, core: *Pardes, name: []const u8) !void { const original = core.gpa; core.gpa = allocator; defer core.gpa = original; const bytes = read(core, name) catch |err| return switch (err) { error.WriteFailed => error.OutOfMemory, else => err, }; defer allocator.free(bytes); try std.testing.expectEqual(@as(usize, 65537), bytes.len); } }; try std.testing.checkAllAllocationFailures(gpa, Read.run, .{ p, path }); try std.testing.expectEqualSlices(u8, contents, pane.file.?.content); } test "oversized OS files fail before allocating their contents" { if (!platform_has_fs) return error.SkipZigTest; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const file = try tmp.dir.createFile(std.testing.io, "oversized", .{}); defer file.close(std.testing.io); try file.setLength(std.testing.io, limits.max_file_bytes + 1); var path_buf: [4096]u8 = undefined; const path_len = try tmp.dir.realPathFile(std.testing.io, "oversized", &path_buf); var failing: std.testing.FailingAllocator = .init(std.testing.allocator, .{ .fail_index = 0 }); try std.testing.expectError(error.FileTooLarge, readFile(failing.allocator(), path_buf[0..path_len])); try std.testing.expectEqual(@as(usize, 0), failing.allocations); } test "filesystem roots list their namespaces without dialing remote mounts" { const gpa = std.testing.allocator; const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); try p.fs.mount(gpa, "peer", "unavailable-session"); for ([_][]const u8{ "/n", "/n/", "/n///" }) |path| { const contents = try read(p, path); defer gpa.free(contents); try std.testing.expectEqualStrings("/n/os/\n/n/self/\n/n/peer/\n", contents); var buf: [4096]u8 = undefined; try std.testing.expectEqualStrings("/n", resolve(p, path, "/", &buf).?.path); try std.testing.expectError(error.IsDirectory, write(p, path, "")); } const bare = try read(p, "/virtual"); defer gpa.free(bare); const slashed = try read(p, "/virtual/"); defer gpa.free(slashed); try std.testing.expectEqualStrings(bare, slashed); try std.testing.expect(std.mem.indexOf(u8, bare, "/virtual/index\n") != null); try std.testing.expectError(error.IsDirectory, write(p, "/virtual", "")); } test "virtual body writes can read their input from the same pane" { const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("same pane contents\n"); const event_node = tree.Node.of(pane.serial, .event); _ = tree.handle(p, .{ .tag = 0, .op = .open, .node = event_node }); defer _ = tree.handle(p, .{ .tag = 0, .op = .release, .node = event_node }); var path_buffer: [64]u8 = undefined; const path = try std.fmt.bufPrint(&path_buffer, "/virtual/pane/{d}/body", .{pane.serial}); try write(p, path, pane.file.?.content); try std.testing.expectEqualStrings("same pane contents\n", pane.file.?.content); // The truncation and the write are one undo step. try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); const events = &p.panes[p.active].?.fs.events; try std.testing.expect(std.mem.startsWith(u8, events.peek().?, "ED")); events.pop(); try std.testing.expect(std.mem.startsWith(u8, events.peek().?, "EI")); events.pop(); try std.testing.expect(events.empty()); try write(p, path, pane.file.?.content[5..]); try std.testing.expectEqualStrings("pane contents\n", pane.file.?.content); } pub fn isVirtual(path: []const u8) bool { return std.mem.eql(u8, path, "/virtual") or std.mem.startsWith(u8, path, "/virtual/") or std.mem.eql(u8, path, "/n") or std.mem.startsWith(u8, path, "/n/"); } pub fn localPath(path: []const u8) ?[]const u8 { if (std.mem.eql(u8, path, "/n/os")) return "/"; if (std.mem.startsWith(u8, path, "/n/os/")) return path[5..]; if (isVirtual(path)) return null; return path; } test "explicit OS paths retain their namespace until an OS boundary" { for ([_]struct { declared: []const u8, native: ?[]const u8 }{ .{ .declared = "/n/os", .native = "/" }, .{ .declared = "/n/os/project/file", .native = "/project/file" }, .{ .declared = "/n/os/virtual/index", .native = "/virtual/index" }, .{ .declared = "/n/os/n/self/index", .native = "/n/self/index" }, .{ .declared = "/n/self/index", .native = null }, .{ .declared = "/n/peer/os/project/file", .native = null }, .{ .declared = "/virtual/index", .native = null }, .{ .declared = "/project/file", .native = "/project/file" }, }) |case| { const native = localPath(case.declared); if (case.native) |expected| try std.testing.expectEqualStrings(expected, native.?) else try std.testing.expect(native == null); } } pub fn resolve(p: ?*pardes.Pardes, word: []const u8, cwd: []const u8, out: *[4096]u8) ?Resolved { if (word.len == 0 or std.mem.indexOfScalar(u8, word, 0) != null) return null; var joined_buf: [4096]u8 = undefined; const joined = if (word[0] == '/') word else std.fmt.bufPrint(&joined_buf, "{s}/{s}", .{ cwd, word }) catch return null; if (std.mem.eql(u8, std.mem.trimEnd(u8, joined, "/"), "/n")) return .{ .path = "/n" }; if (std.mem.startsWith(u8, joined, "/n/")) { const explicit = joined[3..]; const cut = std.mem.indexOfScalar(u8, explicit, '/') orelse explicit.len; const name = explicit[0..cut]; const path = if (cut < explicit.len) explicit[cut..] else "/"; if (std.mem.eql(u8, name, "os")) { var native_buf: [4096]u8 = undefined; const native = resolveOs(path, &native_buf) orelse return null; return .{ .path = std.fmt.bufPrint(out, "/n/os{s}", .{native.path}) catch return null }; } if (std.mem.eql(u8, name, "self")) return resolveVirtual(p, path, out); const core = p orelse return null; for (core.fs.mounts.items) |mount| { if (!std.mem.eql(u8, mount.name, name)) continue; const normalized = normalizeVirtualPath(path, out) orelse return null; var remote_path: [4096]u8 = undefined; const saved = std.fmt.bufPrint(&remote_path, "/n/{s}/{s}", .{ name, normalized }) catch return null; @memcpy(out[0..saved.len], saved); return .{ .path = out[0..saved.len] }; } return null; } if (std.mem.eql(u8, joined, "/virtual")) return resolveVirtual(p, "/", out); if (std.mem.startsWith(u8, joined, "/virtual/")) return resolveVirtual(p, joined[8..], out); if (resolveOs(joined, out)) |found| return found; if (resolveVirtual(p, joined, out)) |found| return found; if (resolveVirtual(p, word, out)) |found| return found; if (resolveEmbedded(word, cwd, out)) |source| { const path = std.fmt.bufPrint(out, "/virtual/{s}", .{source.path}) catch return null; return .{ .path = path }; } return null; } /// Where a path really is on the host, and whether it is a directory. The /// path may lie in a mount this editor serves, so the turn is given up for /// the syscalls: another thread answers them. pub fn resolveOs(path: []const u8, out: *[4096]u8) ?Resolved { if (comptime !platform_has_fs) return null; var z: [4096]u8 = undefined; const path_z = std.fmt.bufPrintSentinel(&z, "{s}", .{path}, 0) catch return null; pardes.turn.yield(); defer pardes.turn.back(); const resolved = realpath(path_z, out) orelse return null; return .{ .path = std.mem.span(resolved), .dir = isDir(resolved) }; } fn resolveVirtual(p: ?*pardes.Pardes, path: []const u8, out: *[4096]u8) ?Resolved { var normalized_buf: [4096]u8 = undefined; const normalized = normalizeVirtualPath(path, &normalized_buf) orelse return null; if (p) |core| if (tree.resolveSelf(core, normalized) != null) return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{normalized}) catch return null }; if (findEmbeddedSource(normalized, false)) |source| return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{source.path}) catch return null }; if (sources_fs.node(normalized) != null) return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{normalized}) catch return null }; return null; } pub fn read(p: *pardes.Pardes, path: []const u8) ![]u8 { return readLimit(p, path, limits.max_file_bytes); } pub fn readLimit(p: *pardes.Pardes, path: []const u8, max_bytes: usize) ![]u8 { const limit = @min(max_bytes, limits.max_file_bytes); if (std.mem.eql(u8, std.mem.trimEnd(u8, path, "/"), "/n")) { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); if (limit < "/n/os/\n/n/self/\n".len) return error.FileTooLarge; try out.writer.writeAll("/n/os/\n/n/self/\n"); for (p.fs.mounts.items) |mount| { if (mount.name.len + 5 > limit - out.written().len) return error.FileTooLarge; try out.writer.print("/n/{s}/\n", .{mount.name}); } return out.toOwnedSlice(); } if (std.mem.eql(u8, path, "/virtual")) return readNode(p, tree.root, path, limit); if (std.mem.startsWith(u8, path, "/n/")) { const explicit = path[3..]; const cut = std.mem.indexOfScalar(u8, explicit, '/') orelse explicit.len; const name = explicit[0..cut]; const remote_path = if (cut < explicit.len) explicit[cut..] else "/"; if (std.mem.eql(u8, name, "os")) { var native_buf: [4096]u8 = undefined; const native = resolveOs(remote_path, &native_buf) orelse return readFileLimit(p.gpa, path, limit); if (!native.dir) return readFileLimit(p.gpa, path, limit); // The node stays in the table until no connection names it either // (src/9p_io.zig, collectOs): a client may have walked to it too. const node = try osNode(p, native.path); return readNode(p, node, path, limit); } if (std.mem.eql(u8, name, "self")) { const node = tree.resolveSelf(p, remote_path) orelse return error.FileNotFound; return readNode(p, node, path, limit); } for (p.fs.mounts.items) |mount| { if (!std.mem.eql(u8, name, mount.name)) continue; // A peer answers on its own time, and the peer may be this very // editor: the turn goes out with the request. pardes.turn.yield(); defer pardes.turn.back(); return ninep_io.Client.readLimit(p.gpa, mount.dial, remote_path, path, limit); } return error.FileNotFound; } if (std.mem.startsWith(u8, path, "/virtual/")) { const name = path[9..]; if (tree.resolveSelf(p, name)) |node| return readNode(p, node, path, limit); } return readFileLimit(p.gpa, path, limit); } fn readNode(p: *pardes.Pardes, initial_node: u64, path: []const u8, limit: usize) ![]u8 { var node = initial_node; const attr = tree.handle(p, .{ .tag = 0, .op = .getattr, .node = node }); if (attr.status != .ok) return error.FileNotFound; if (attr.attr.dir) { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); var index: u64 = 0; const max_bytes = @min(limit, limits.max_stream_bytes); const prefix = std.mem.trimEnd(u8, path, "/"); while (true) { const reply = tree.handle(p, .{ .tag = 0, .op = .readdir, .node = node, .off = index, .size = 8192 }); if (reply.status != .ok) return error.ReadFailed; const entries = p.fsPayload(reply); if (entries.len == 0) return out.toOwnedSlice(); var off: usize = 0; while (off + 10 <= entries.len) { const len: usize = entries[off + 9]; if (off + 10 + len > entries.len) return error.ReadFailed; const row_len = prefix.len + len + 2 + @as(usize, @intFromBool(entries[off + 8] != 0)); if (row_len > max_bytes - out.written().len) return error.FileTooLarge; try out.writer.print("{s}/{s}", .{ prefix, entries[off + 10 ..][0..len] }); if (entries[off + 8] != 0) try out.writer.writeByte('/'); try out.writer.writeByte('\n'); off += 10 + len; index += 1; } if (off != entries.len) return error.ReadFailed; } } if (attr.attr.size > limit) return error.FileTooLarge; const opened = tree.handle(p, .{ .tag = 0, .op = .open, .node = node }); if (opened.status != .ok) return error.OpenFailed; if (opened.attr.node != 0) node = opened.attr.node; defer _ = tree.handle(p, .{ .tag = 0, .op = .release, .node = node, .handle = opened.handle }); var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); while (true) { const want: u32 = @intCast(@min(8192, limit + 1 - out.written().len)); const reply = tree.handle(p, .{ .tag = 0, .op = .read, .node = node, .handle = opened.handle, .off = out.written().len, .size = want }); if (reply.status == .again) return error.NotAFile; if (reply.status != .ok) return error.ReadFailed; const bytes = p.fsPayload(reply); if (bytes.len == 0) return out.toOwnedSlice(); if (bytes.len > limit - out.written().len) return error.FileTooLarge; try out.writer.writeAll(bytes); } } /// The editor's, between steps: a save or a dump the shell is performing, /// never a step of the core. pub fn write(p: *pardes.Pardes, path: []const u8, bytes: []const u8) !void { if (std.mem.eql(u8, std.mem.trimEnd(u8, path, "/"), "/n")) return error.IsDirectory; if (std.mem.eql(u8, path, "/virtual")) return writeSelf(p, "", bytes); if (std.mem.startsWith(u8, path, "/virtual/")) return writeSelf(p, path[9..], bytes); if (!std.mem.startsWith(u8, path, "/n/")) return writeOut(p, null, path, bytes); const explicit = path[3..]; const cut = std.mem.indexOfScalar(u8, explicit, '/') orelse explicit.len; const name = explicit[0..cut]; const remote_path = if (cut < explicit.len) explicit[cut..] else "/"; if (std.mem.eql(u8, name, "os")) return writeOut(p, null, remote_path, bytes); if (std.mem.eql(u8, name, "self")) return writeSelf(p, remote_path, bytes); for (p.fs.mounts.items) |mount| { if (std.mem.eql(u8, name, mount.name)) return writeOut(p, mount.dial, remote_path, bytes); } return error.FileNotFound; } /// The host, or the peer at `dial`, takes the bytes with the turn given up /// entirely -- the peer may be this very editor, and a write it serves may /// change any pane -- so they are copied first: `bytes` is usually a pane's /// own text. fn writeOut(p: *pardes.Pardes, dial: ?[]const u8, path: []const u8, bytes: []const u8) !void { const copy = try p.gpa.dupe(u8, bytes); defer p.gpa.free(copy); pardes.turn.rest(); defer pardes.turn.wake(); if (dial) |d| return ninep_io.Client.write(p.gpa, d, path, copy); return writeFile(path, copy); } /// This editor's own tree, written in place. fn writeSelf(p: *pardes.Pardes, name: []const u8, bytes: []const u8) !void { var node = tree.resolveSelf(p, name) orelse return error.FileNotFound; const attributes = tree.handle(p, .{ .tag = 0, .op = .getattr, .node = node }); if (attributes.status != .ok) return error.FileNotFound; if (attributes.attr.dir) return error.IsDirectory; if (attributes.attr.mode & 0o200 == 0) return error.ReadOnlyFilesystem; const opened = tree.handle(p, .{ .tag = 0, .op = .open, .node = node }); if (opened.status != .ok) return error.OpenFailed; if (opened.attr.node != 0) node = opened.attr.node; defer _ = tree.handle(p, .{ .tag = 0, .op = .release, .node = node, .handle = opened.handle }); var preserved: ?[]u8 = null; defer if (preserved) |copy| p.gpa.free(copy); const target = tree.Node.target(node); if (target != null and target.? == .pane and target.?.pane.file == .body) { preserved = try p.gpa.dupe(u8, bytes); const trunc = tree.handle(p, .{ .tag = 0, .op = .setattr, .node = node, .truncate = true }); if (trunc.status != .ok) return error.WriteFailed; } const contents = preserved orelse bytes; var off: usize = 0; while (off < contents.len) { const reply = tree.handle(p, .{ .tag = 0, .op = .write, .node = node, .off = off, .data = contents[off..] }); if (reply.status != .ok or reply.written == 0) return error.WriteFailed; off += reply.written; } } fn resolveEmbedded(word: []const u8, cwd: []const u8, scratch: *[4096]u8) ?Source { var wordbuf: [4096]u8 = undefined; const normalized_word = normalizeVirtualPath(word, &wordbuf) orelse return null; if (word.len > 0 and word[0] == '/') return findEmbeddedSource(normalized_word, true); var joined: [4096]u8 = undefined; if (std.fmt.bufPrint(&joined, "{s}/{s}", .{ cwd, word }) catch null) |candidate| if (normalizeVirtualPath(candidate, scratch)) |normalized| if (findEmbeddedSource(normalized, true)) |source| return source; return findEmbeddedSource(normalized_word, false); } pub fn normalizeVirtualPath(path: []const u8, out: *[4096]u8) ?[]const u8 { if (std.mem.indexOfScalar(u8, path, 0) != null) return null; var len: usize = 0; var parts = std.mem.tokenizeAny(u8, path, "/\\"); while (parts.next()) |part| { if (std.mem.eql(u8, part, ".")) continue; if (std.mem.eql(u8, part, "..")) { while (len > 0 and out[len - 1] != '/') len -= 1; if (len > 0) len -= 1; continue; } const extra = part.len + @intFromBool(len != 0); if (len + extra > out.len) return null; if (len != 0) { out[len] = '/'; len += 1; } @memcpy(out[len..][0..part.len], part); len += part.len; } return out[0..len]; } fn findEmbeddedSource(path: []const u8, allow_root_suffix: bool) ?Source { for (sources) |source| if (std.mem.eql(u8, source.path, path)) return source; if (!allow_root_suffix) return null; for (sources) |source| { if (path.len <= source.path.len or path[path.len - source.path.len - 1] != '/') continue; if (std.mem.endsWith(u8, path, source.path)) return source; } return null; } pub const platform_has_fs = !pardes.isolated and switch (pardes.platform) { .tty, .gui, .macos => true, .web, .esp32p4 => false, }; const find_max_hits = 512; const find_max_depth = 16; const find_max_steps = 100_000; pub const search_max_output_bytes = pardes.MAX_PANES * find_max_hits * (4096 + 320); const find_skip = [_][]const u8{ ".git", ".jj", "target", "node_modules", ".venv", "__pycache__", ".zig-cache", "zig-out", }; pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: []u8) !usize { var hits: [find_max_hits][]const u8 = undefined; var hits_len: usize = 0; if (platform_has_fs) { pardes.turn.yield(); defer pardes.turn.back(); const io = std.Io.Threaded.global_single_threaded.io(); var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }); defer root.close(io); var w = try root.walkSelectively(arena); defer w.deinit(); var steps: usize = 0; walk: while (steps < find_max_steps and hits_len < hits.len) { steps += 1; // an unreadable dir burns a step too, so it cannot spin const e = (try w.next(io)) orelse break; if (std.ascii.indexOfIgnoreCase(e.basename, pat) != null) { hits[hits_len] = try arena.dupe(u8, e.path); hits_len += 1; } if (e.kind != .directory or e.depth() >= find_max_depth) continue; for (find_skip) |s| if (std.mem.eql(u8, e.basename, s)) continue :walk; try w.enter(io, e); } } else { for (sources) |s| { if (hits_len >= hits.len) break; if (std.ascii.indexOfIgnoreCase(std.fs.path.basename(s.path), pat) != null) { hits[hits_len] = s.path; hits_len += 1; } } } std.mem.sort([]const u8, hits[0..hits_len], {}, struct { fn lt(_: void, a: []const u8, b: []const u8) bool { return std.mem.lessThan(u8, a, b); } }.lt); var written: usize = 0; for (hits[0..hits_len]) |h| { if (h.len + 1 > out.len - written) break; @memcpy(out[written..][0..h.len], h); written += h.len; out[written] = '\n'; written += 1; } return written; } const grep_max_bytes = 256 * 1024; const grep_max_files = 20_000; const GrepResult = struct { bytes: usize, hits: usize }; fn grepText(path: []const u8, text: []const u8, pat: []const u8, out: []u8, budget: usize) GrepResult { var result: GrepResult = .{ .bytes = 0, .hits = 0 }; var line: usize = 0; var it = std.mem.splitScalar(u8, text, '\n'); while (it.next()) |raw| { line += 1; if (result.hits >= budget) break; const at = std.ascii.indexOfIgnoreCase(raw, pat) orelse continue; const ln = std.mem.trimEnd(u8, raw, " \t\r"); var cut = @min(ln.len, 200); while (cut > 0 and cut < ln.len and ln[cut] & 0xc0 == 0x80) cut -= 1; const row = std.fmt.bufPrint(out[result.bytes..], "{s}:{d}:{d}{c}{d} {s}\n", .{ path, line, at + 1, config.range_sep, at + pat.len, ln[0..cut], }) catch break; result.bytes += row.len; result.hits += 1; } return result; } pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, base: []const u8, pat: []const u8, out: []u8) !usize { var hits: usize = 0; var written: usize = 0; if (!platform_has_fs) { for (sources) |s| { if (hits >= find_max_hits or written == out.len) break; const result = grepText(s.path, s.contents, pat, out[written..], find_max_hits - hits); hits += result.hits; written += result.bytes; } return written; } const root_path = std.mem.trimEnd(u8, dir, "/"); const home = std.mem.trimEnd(u8, base, "/"); const files = try arena.alloc([]const u8, grep_max_files); var files_len: usize = 0; // A walk and thousands of reads: the turn goes out for all of it. pardes.turn.yield(); defer pardes.turn.back(); { const io = std.Io.Threaded.global_single_threaded.io(); var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }); defer root.close(io); var w = try root.walkSelectively(arena); defer w.deinit(); var steps: usize = 0; walk: while (steps < find_max_steps and files_len < files.len) { steps += 1; const e = (try w.next(io)) orelse break; if (e.kind == .directory) { if (e.depth() >= find_max_depth) continue; for (find_skip) |s| if (std.mem.eql(u8, e.basename, s)) continue :walk; try w.enter(io, e); continue; } if (e.kind != .file) continue; files[files_len] = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root_path, e.path }); files_len += 1; } } std.mem.sort([]const u8, files[0..files_len], {}, struct { fn lt(_: void, a: []const u8, b: []const u8) bool { return std.mem.lessThan(u8, a, b); } }.lt); const buf = try gpa.alloc(u8, grep_max_bytes); defer gpa.free(buf); for (files[0..files_len]) |path| { if (hits >= find_max_hits or written == out.len) break; var pathbuf: [4096]u8 = undefined; const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong; const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .CLOEXEC = true, .NONBLOCK = true }); if (fd < 0) continue; var len: usize = 0; var readable = true; while (len < buf.len) { const n = libc.read(fd, buf[len..].ptr, buf.len - len); if (n < 0) { if (libc.errno(n) == .INTR) continue; readable = false; break; } if (n == 0) break; len += @intCast(n); } _ = libc.close(fd); if (!readable) continue; const text = buf[0..len]; if (std.mem.indexOfScalar(u8, text[0..@min(len, 1024)], 0) != null) continue; const shown = lsp.rel(home, path); const result = grepText(shown, text, pat, out[written..], find_max_hits - hits); hits += result.hits; written += result.bytes; } return written; } test "grep skips a file it cannot read instead of abandoning the search" { if (!platform_has_fs) return; const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var base_buf: [std.fs.max_path_bytes]u8 = undefined; const dir = base_buf[0..try tmp.dir.realPath(std.testing.io, &base_buf)]; try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "a-locked.txt", .data = "needle here\n" }); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "b-open.txt", .data = "needle here\n" }); var locked_buf: [std.fs.max_path_bytes]u8 = undefined; const locked = try std.fmt.bufPrintSentinel(&locked_buf, "{s}/a-locked.txt", .{dir}, 0); if (libc.chmod(locked, 0) != 0) return; const probe = libc.open(locked, .{ .ACCMODE = .RDONLY }); if (probe >= 0) { _ = libc.close(probe); _ = libc.chmod(locked, 0o644); return error.SkipZigTest; } var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); const out = try gpa.alloc(u8, 64 * 1024); defer gpa.free(out); const n = try grep(arena.allocator(), gpa, dir, dir, "needle", out); _ = libc.chmod(locked, 0o644); // so `tmp.cleanup` can remove it try std.testing.expect(std.mem.indexOf(u8, out[0..n], "b-open.txt") != null); try std.testing.expect(std.mem.indexOf(u8, out[0..n], "a-locked.txt") == null); } fn isDir(path: [*:0]const u8) bool { const fd = libc.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }); if (fd < 0) return false; _ = libc.close(fd); return true; } pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 { return readFileLimit(gpa, path, limits.max_file_bytes); } /// Restore first tries a relative argument under the default dump directory, /// then preserves the ordinary file lookup. Absolute paths remain verbatim. pub fn readRestore(gpa: std.mem.Allocator, path: []const u8, dir_setting: []const u8) ![]u8 { var dir_buf: [4096]u8 = undefined; const dir = @import("dump.zig").directory(&dir_buf, dir_setting); return readRestoreIn(gpa, path, dir); } fn readRestoreIn(gpa: std.mem.Allocator, path: []const u8, dir: ?[]const u8) ![]u8 { if (!std.fs.path.isAbsolute(path)) { if (dir) |base| { var buf: [4096]u8 = undefined; if (std.fmt.bufPrint(&buf, "{s}/{s}", .{ base, path })) |candidate| { if (readFile(gpa, candidate)) |bytes| return bytes else |err| switch (err) { error.FileNotFound => {}, else => return err, } } else |_| {} } } return readFile(gpa, path); } test "Restore prefers default directory then falls back to original path" { if (!platform_has_fs) return error.SkipZigTest; const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const relative = try std.fmt.allocPrint(gpa, ".zig-cache/tmp/{s}/a.dump", .{tmp.sub_path}); defer gpa.free(relative); const base = try std.fmt.allocPrint(gpa, ".zig-cache/tmp/{s}/dumps", .{tmp.sub_path}); defer gpa.free(base); const nested = try std.fmt.allocPrint(gpa, "dumps/.zig-cache/tmp/{s}", .{tmp.sub_path}); defer gpa.free(nested); try tmp.dir.createDirPath(std.testing.io, nested); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "dumps/a.dump", .data = "short name" }); const short = try readRestoreIn(gpa, "a.dump", base); defer gpa.free(short); try std.testing.expectEqualStrings("short name", short); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "a.dump", .data = "original" }); const fallback = try readRestoreIn(gpa, relative, base); defer gpa.free(fallback); try std.testing.expectEqualStrings("original", fallback); const candidate = try std.fmt.allocPrint(gpa, "{s}/{s}", .{ base, relative }); defer gpa.free(candidate); try writeFile(candidate, "default"); const preferred = try readRestoreIn(gpa, relative, base); defer gpa.free(preferred); try std.testing.expectEqualStrings("default", preferred); const absolute = try tmp.dir.realPathFileAlloc(std.testing.io, "a.dump", gpa); defer gpa.free(absolute); const direct = try readRestoreIn(gpa, absolute, base); defer gpa.free(direct); try std.testing.expectEqualStrings("original", direct); try std.testing.expectError(error.FileNotFound, readRestoreIn(gpa, "missing-restore-test.dump", base)); } fn readFileLimit(gpa: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 { if (std.mem.indexOfScalar(u8, path, 0) != null) return error.OpenFailed; if (!platform_has_fs or std.mem.startsWith(u8, path, "/virtual/")) { const archive_path = if (std.mem.startsWith(u8, path, "/virtual/")) path[9..] else path; var normalized_buf: [4096]u8 = undefined; const normalized = normalizeVirtualPath(archive_path, &normalized_buf) orelse return error.OpenFailed; const source = findEmbeddedSource(normalized, true) orelse return error.OpenFailed; if (source.contents.len > limit) return error.FileTooLarge; return gpa.dupe(u8, source.contents); } var pathbuf: [4096]u8 = undefined; const native = localPath(path) orelse return error.OpenFailed; const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{native}, 0) catch return error.PathTooLong; // The path may be a mount this editor serves: the turn goes out with the // syscalls, and the bytes come back into memory of the caller's own. pardes.turn.yield(); defer pardes.turn.back(); const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .NONBLOCK = true }); if (fd < 0) return switch (libc.errno(fd)) { .ACCES, .PERM => error.PermissionDenied, .NOENT => error.FileNotFound, .ISDIR => error.IsDirectory, .NAMETOOLONG => error.PathTooLong, else => error.OpenFailed, }; defer _ = libc.close(fd); const end = libc.lseek(fd, 0, libc.SEEK.END); if (end < 0 and libc.errno(end) == .SPIPE) return error.NotAFile; const size: usize = if (end < 0) 0 else @intCast(end); if (end >= 0 and libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed; if (size == 0) { const max_bytes = @min(limit, limits.max_stream_bytes); var stream: std.Io.Writer.Allocating = .init(gpa); errdefer stream.deinit(); var chunk: [16 * 1024]u8 = undefined; while (true) { const n = libc.read(fd, &chunk, @min(chunk.len, max_bytes - stream.written().len + 1)); if (n < 0) { if (libc.errno(n) == .INTR) continue; if (libc.errno(n) == .AGAIN) { if (stream.written().len == 0) return error.NotAFile; return stream.toOwnedSlice(); } return error.ReadFailed; } if (n == 0) return stream.toOwnedSlice(); const received: usize = @intCast(n); if (received > max_bytes - stream.written().len) return error.FileTooLarge; try stream.writer.writeAll(chunk[0..received]); } } if (size > limit) return error.FileTooLarge; var buf = try gpa.alloc(u8, size); errdefer gpa.free(buf); var len: usize = 0; while (len < buf.len) { const n = libc.read(fd, buf[len..].ptr, buf.len - len); if (n < 0) { if (libc.errno(n) == .INTR) continue; return error.ReadFailed; } if (n == 0) break; len += @intCast(n); } if (len != buf.len) buf = try gpa.realloc(buf, len); return buf; } pub const Namespace = struct { socket_path: []const u8 = "", tcp_address: ?std.Io.net.IpAddress = null, quic_address: ?std.Io.net.IpAddress = null, mounts: std.ArrayList(Mount) = .empty, node_name: [16]u8 = undefined, os_paths: std.ArrayList(OsPath) = .empty, /// What each open holds between its open and its release. opens: [tree.open_slots]tree.Open = @splat(.{}), out: std.ArrayList(u8) = .empty, listeners: u16 = 0, origin: u8 = 'K', /// A ctl write is running builtins: one that would open a prompt for /// its argument refuses (`refused`), since nobody is at the prompt, and /// the first error one reports (`failure`) fails the write, as acme's /// ctl answers a command's error (editors/acme/xfid.c:700). no_prompt: bool = false, /// The root ctl is running a session builtin: its messages are the /// session's, logged `msg -`, though shown on the active pane. session_write: bool = false, refused: bool = false, failure: [256]u8 = undefined, failure_len: u16 = 0, /// What went wrong performing a write's effects after it was answered /// by the core (a save the host could not do): the 9P write, which /// waits for them, fails with it (src/9p_io.zig). /// A header tag truncated by an open not yet released (ninep/cols.zig): /// 0 the workspace's, a column's its index + 1; and whether the newline /// that ended its last write is held back. header_rewrite: ?u32 = null, /// A look or exec a 9P write made found every pane slot taken; the /// write fails with what was said, kept at the head of `ename`. no_pane_slot: bool = false, no_pane_slot_len: u16 = 0, header_held: bool = false, late_failure: [256]u8 = undefined, late_failure_len: u16 = 0, /// A refusal that quotes the message it refuses, as Plan 9's cmderror /// does (kernel/misc/parse.c:82); answered at once (src/9p_io.zig). ename: [320]u8 = undefined, /// Something a held read may be waiting on changed since they were last /// answered: a record queued, a run answered, a pane gone. news: bool = false, /// The editor-wide event ring: panes made, renamed, saved and closed, and /// what the editor said. Recorded whether or not anyone reads /log. log: tree.events.Queue = .{ .cap = limits.log_bytes }, /// What the last look or exec created or touched, answered by reading /// either of those files. results: [MAX_PANES]u32 = undefined, results_len: u8 = 0, /// Process start, the mtime of everything that has no edit time of its own. started: u32 = 0, pub fn deinit(st: *Namespace, gpa: std.mem.Allocator) void { for (st.mounts.items) |entry| { gpa.free(entry.name); gpa.free(entry.dial); } st.mounts.deinit(gpa); for (st.os_paths.items) |entry| gpa.free(entry.path); st.os_paths.deinit(gpa); for (&st.opens) |*o| o.deinit(gpa); st.log.deinit(gpa); st.out.deinit(gpa); } pub fn mount(st: *Namespace, gpa: std.mem.Allocator, name: []const u8, dial: []const u8) !void { if (!validMountName(name)) return error.BadMountName; if (comptime pardes.hosted) try ninep_io.Client.validateDial(dial); if (dial.len == 0 or std.mem.indexOfScalar(u8, dial, 0) != null) return error.BadDial; for (st.mounts.items) |entry| if (std.mem.eql(u8, name, entry.name)) return error.AlreadyMounted; if (st.mounts.items.len == max_mounts) return error.TooManyMounts; const saved_name = try gpa.dupe(u8, name); errdefer gpa.free(saved_name); const saved_dial = try gpa.dupe(u8, dial); errdefer gpa.free(saved_dial); try st.mounts.append(gpa, .{ .name = saved_name, .dial = saved_dial }); } pub fn stage(st: *Namespace, gpa: std.mem.Allocator) *std.ArrayList(u8) { st.out.clearRetainingCapacity(); st.out.ensureTotalCapacity(gpa, tree.out_reserve) catch {}; return &st.out; } }; pub fn unmount(p: *Pardes, name: []const u8) !void { for (p.fs.mounts.items, 0..) |entry, index| { if (!std.mem.eql(u8, name, entry.name)) continue; var prefix_buf: [name_capacity + 4]u8 = undefined; const prefix = try std.fmt.bufPrint(&prefix_buf, "/n/{s}", .{name}); for (p.panes) |slot| { const pane = slot orelse continue; const paths = [_][]const u8{ if (pane.file) |file| file.path else "", if (comptime pardes.pdf_enabled) (if (pane.pdf) |pdf| pdf.path else "") else "", if (pane.image) |image| image.path else "", Pardes.paneDir(pane), }; for (paths) |path| if (std.mem.startsWith(u8, path, prefix) and (path.len == prefix.len or path[prefix.len] == '/')) return error.MountInUse; } for (0..p.effects_len) |i| { const effect = p.effects[(p.effects_head + i) % p.effects.len]; if (effect != .save_text) continue; const path = effect.save_text.path.slice(); if (std.mem.startsWith(u8, path, prefix) and (path.len == prefix.len or path[prefix.len] == '/')) return error.MountInUse; } const removed = p.fs.mounts.orderedRemove(index); p.gpa.free(removed.name); p.gpa.free(removed.dial); return; } return error.NotMounted; } test { _ = tree; }