const std = @import("std"); const builtin = @import("builtin"); const limits = @import("memory.zig").limits; // scoped, not bare std.log: main.zig's logFn drops the unscoped .default scope // wholesale (ghostty and uucode log there too), and a corrupt dump's parse // diagnostic is the only thing that says WHICH field went bad. const log = std.log.scoped(.dump); /// Where the next dump goes. $PARDES_DUMP wins verbatim (the snapshot harness /// pins it for deterministic goldens); else $XDG_DATA_HOME|~/.local/share /// /pardes/pardes-.zon — timestamped so dumps never overwrite each other. /// Creates the pardes dir (parents assumed; a failure surfaces at open). pub fn outPath(buf: *[1024:0]u8) ?[:0]const u8 { if (std.c.getenv("PARDES_DUMP")) |p| return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(p)}, 0) catch null; var dir_buf: [900]u8 = undefined; const dir = defaultDirectory(&dir_buf) orelse return null; var dz: [901:0]u8 = undefined; @memcpy(dz[0..dir.len], dir); dz[dir.len] = 0; _ = std.c.mkdir(dz[0..dir.len :0], 0o755); // EEXIST is fine var ts: std.c.timespec = undefined; _ = std.c.clock_gettime(.REALTIME, &ts); const es: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(0, ts.sec)) }; const yd = es.getEpochDay().calculateYearDay(); const md = yd.calculateMonthDay(); const ds = es.getDaySeconds(); return std.fmt.bufPrintSentinel(buf, "{s}/pardes-{d:0>4}{d:0>2}{d:0>2}-{d:0>2}{d:0>2}{d:0>2}.zon", .{ dir, yd.year, md.month.numeric(), @as(u8, md.day_index) + 1, ds.getHoursIntoDay(), ds.getMinutesIntoHour(), ds.getSecondsIntoMinute(), }, 0) catch null; } /// The standard dump directory, without creating it or generating a filename. pub fn defaultDirectory(buf: []u8) ?[]const u8 { if (std.c.getenv("XDG_DATA_HOME")) |x| return std.fmt.bufPrint(buf, "{s}/pardes", .{std.mem.span(x)}) catch null; if (std.c.getenv("HOME")) |h| return std.fmt.bufPrint(buf, "{s}/.local/share/pardes", .{std.mem.span(h)}) catch null; return null; } pub const magic = "pardes-dump"; pub const version: u32 = 1; pub const max_panes: usize = 16; pub const max_cols: usize = 6; /// Output arguments are typed in the same bounded one-line tag storage. Keep /// the schema limit named independently so a dump reader can validate it /// without importing the output-pane implementation. The bound itself is /// `limits.max_tag_tail` — the one place a board-shaped capacity is chosen. pub const max_origin_arg: usize = limits.max_tag_tail; pub const Size = struct { cols: u16, rows: u16, }; pub const Cursor = struct { col: u16 = 0, row: u16 = 0, }; pub const PaneKind = enum { terminal, file, image, }; pub const Terminal = struct { cwd: []const u8 = "", stream: []const u8 = "", stream_b64: []const u8 = "", cursor: Cursor = .{}, }; pub const LocationRow = struct { kind: enum { match, context }, declaration: bool = false, path: []const u8, line: usize, col: usize = 0, end_line: usize = 0, end_col: usize = 0, location_end: usize, code_start: usize, colors_b64: []const u8 = "", }; pub const File = struct { path: []const u8 = "", content: []const u8 = "", content_b64: []const u8 = "", dirty: bool = false, tree_context: bool = false, location_rows: []const LocationRow = &.{}, // Builtin names remain stable when enum ordinals change. origin: []const u8 = "", origin_arg: []const u8 = "", mini_source: []const u8 = "", mini_colors_b64: []const u8 = "", }; pub const ImagePalette = enum { commodore, terminal, }; pub const Image = struct { path: []const u8 = "", bytes_b64: []const u8 = "", // Defaults are the renderer's historical state, so version-1 dumps which // predate these fields remain readable without a schema fork. petscii: bool = false, palette: ImagePalette = .commodore, ascii: bool = true, }; pub const Pane = struct { kind: PaneKind, tag: []const u8, body: []const u8, scroll: usize = 0, cols: u16 = 0, rows: u16 = 0, vweight: f32 = 1, collapsed: bool = false, /// Exact editable tail when the user touched it. Null retains the live /// default; a present empty slice deliberately restores an empty tail. tag_tail: ?[]const u8 = null, terminal: ?Terminal = null, file: ?File = null, image: ?Image = null, }; pub const Column = struct { tag: ?[]const u8 = null, weight: f32 = 1, panes: []const usize = &.{}, }; pub const Mount = struct { name: []const u8, dial: []const u8 }; pub const State = struct { magic: []const u8 = magic, version: u32 = version, screen: Size, active: usize = 0, topbar: []const u8 = "", topbar_custom: ?[]const u8 = null, theme: []const u8 = "dark", locations_config: @import("locations_config.zig").Config = .{}, tree_context_tag_style: bool = true, columns: []const Column = &.{}, panes: []const Pane = &.{}, mounts: []const Mount = &.{}, }; fn validateTag(bar: []const u8) !void { if (bar.len > limits.max_tag_tail or std.mem.indexOfAny(u8, bar, "\r\n\x00") != null or !std.unicode.utf8ValidateSlice(bar)) return error.BadDumpTag; } pub fn validate(state: State) !void { if (state.topbar_custom) |bar| try validateTag(bar); for (state.columns) |column| if (column.tag) |bar| try validateTag(bar); if (!std.mem.eql(u8, state.magic, magic)) return error.BadDumpMagic; if (state.version != version) return error.BadDumpVersion; if (state.panes.len == 0 or state.panes.len > max_panes) return error.BadDumpPanes; if (state.columns.len == 0 or state.columns.len > max_cols) return error.BadDumpColumns; if (state.active >= state.panes.len) return error.BadDumpActive; if (state.mounts.len > 8) return error.BadDumpMounts; for (state.mounts, 0..) |mount, i| { if (mount.name.len == 0 or mount.name.len > 255 or mount.dial.len == 0 or std.mem.indexOfScalar(u8, mount.dial, 0) != null) return error.BadDumpMounts; if (std.mem.eql(u8, mount.name, ".") or std.mem.eql(u8, mount.name, "..") or std.mem.eql(u8, mount.name, "os") or std.mem.eql(u8, mount.name, "self")) return error.BadDumpMounts; for (mount.name) |c| if (!std.ascii.isAlphanumeric(c) and c != '_' and c != '-' and c != '.') return error.BadDumpMounts; for (state.mounts[0..i]) |previous| if (std.mem.eql(u8, mount.name, previous.name)) return error.BadDumpMounts; } for (state.columns) |col| { if (!std.math.isFinite(col.weight) or col.weight <= 0) return error.BadDumpColumns; if (col.panes.len == 0 or col.panes.len > max_panes) return error.BadDumpColumns; for (col.panes) |pane| { if (pane >= state.panes.len) return error.BadDumpPaneRef; } } for (state.panes) |pane| { if (!std.math.isFinite(pane.vweight) or pane.vweight <= 0) return error.BadDumpPaneWeight; if (pane.scroll > std.math.maxInt(i32)) return error.BadDumpPaneScroll; if (pane.tag_tail) |tail| if (tail.len > limits.max_tag_tail) return error.BadDumpTagTail; if (pane.file) |file| if (file.origin_arg.len > max_origin_arg) return error.BadDumpOriginArg; if (pane.file) |file| { if (file.mini_source.len > 4096 or std.mem.indexOfScalar(u8, file.mini_source, 0) != null or (file.mini_source.len == 0 and file.mini_colors_b64.len != 0) or (file.mini_source.len != 0 and !std.mem.eql(u8, file.origin, "Mini"))) return error.BadDumpMini; } switch (pane.kind) { .terminal => if (pane.terminal == null) return error.BadDumpPaneKind, .file => if (pane.file == null) return error.BadDumpPaneKind, .image => if (pane.image == null) return error.BadDumpPaneKind, } } } test "oversized recognized output argument is rejected before restore" { var oversized: [max_origin_arg + 1]u8 = @splat('x'); const pane_ids = [_]usize{0}; const columns = [_]Column{.{ .panes = &pane_ids }}; const panes = [_]Pane{.{ .kind = .file, .tag = "+Search", .body = "", .file = .{ .path = "/tmp/+Search", .origin = "Find", .origin_arg = &oversized, }, }}; const state: State = .{ .screen = .{ .cols = 80, .rows = 24 }, .columns = &columns, .panes = &panes, }; // Serialize without writeFile: that writer correctly rejects the state // too, while this exercises the hostile bytes a restore actually reads. var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); defer encoded.deinit(); try std.zon.stringify.serialize(state, .{}, &encoded.writer); try std.testing.expectError( error.BadDumpOriginArg, readZon(std.testing.allocator, encoded.written(), "oversized-origin-arg"), ); } pub fn writeFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8, state: State) !void { try validate(state); var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); var file = try std.Io.Dir.createFileAbsolute(io, path, .{}); defer file.close(io); try file.writeStreamingAll(io, out.written()); } pub const Parsed = struct { value: State, arena: std.heap.ArenaAllocator, pub fn deinit(parsed: *Parsed) void { parsed.arena.deinit(); } }; pub fn readZon(gpa: std.mem.Allocator, bytes: []const u8, label: []const u8) !Parsed { var parsed: Parsed = .{ .value = undefined, .arena = .init(gpa) }; errdefer parsed.deinit(); const arena = parsed.arena.allocator(); const source = try gpa.dupeZ(u8, bytes); defer gpa.free(source); parsed.value = if (builtin.os.tag == .emscripten or builtin.os.tag == .freestanding) std.zon.parse.fromSliceAlloc(State, arena, source, null, .{ .free_on_error = false }) catch |err| { if (err == error.ParseZon) log.err("parse dump {s}: {s}", .{ label, @errorName(err) }); return err; } else blk: { var diag: std.zon.parse.Diagnostics = .{}; defer diag.deinit(arena); break :blk std.zon.parse.fromSliceAlloc(State, arena, source, &diag, .{ .free_on_error = false }) catch |err| { if (err == error.ParseZon) log.err("parse dump {s}: {f}", .{ label, diag }); return err; }; }; try validate(parsed.value); return parsed; } pub fn encodeBytes(alloc: std.mem.Allocator, bytes: []const u8) ![]const u8 { const enc = std.base64.standard.Encoder; const out = try alloc.alloc(u8, enc.calcSize(bytes.len)); return enc.encode(out, bytes); } pub fn decodeBytes(alloc: std.mem.Allocator, b64: []const u8) ![]u8 { const dec = std.base64.standard.Decoder; const n = try dec.calcSizeForSlice(b64); const out = try alloc.alloc(u8, n); errdefer alloc.free(out); try dec.decode(out, b64); return out; } test "dump zon roundtrip" { const gpa = std.testing.allocator; const tty_b64 = try encodeBytes(gpa, "old\nhello\nworld"); defer gpa.free(tty_b64); const file_b64 = try encodeBytes(gpa, "alpha\nbeta\n"); defer gpa.free(file_b64); const panes = [_]Pane{ .{ .kind = .terminal, .tag = "nm /tmp Del", .body = "hello\nworld", .scroll = 1, .cols = 40, .rows = 10, .terminal = .{ .cwd = "/tmp", .stream = "old\nhello\nworld", .stream_b64 = tty_b64, .cursor = .{ .col = 3, .row = 2 } }, }, .{ .kind = .file, .tag = "nm /tmp/a.txt Save Del", .body = " 1 alpha", .cols = 20, .rows = 5, .file = .{ .path = "/tmp/a.txt", .content = "alpha\nbeta\n", .content_b64 = file_b64, .dirty = true }, }, }; const col_panes = [_]usize{ 0, 1 }; const columns = [_]Column{.{ .weight = 1, .panes = &col_panes }}; const state: State = .{ .screen = .{ .cols = 80, .rows = 24 }, .topbar = "Kill Newcol Dump", .theme = "dark", .columns = &columns, .panes = &panes, }; var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); var result = try readZon(gpa, out.written(), "roundtrip"); defer result.deinit(); const parsed = result.value; try std.testing.expectEqual(@as(usize, 2), parsed.panes.len); try std.testing.expectEqualStrings("dark", parsed.theme); try std.testing.expectEqualStrings("old\nhello\nworld", parsed.panes[0].terminal.?.stream); try std.testing.expectEqualStrings("alpha\nbeta\n", parsed.panes[1].file.?.content); try std.testing.expect(parsed.panes[1].file.?.dirty); { const bytes = try decodeBytes(gpa, parsed.panes[0].terminal.?.stream_b64); defer gpa.free(bytes); try std.testing.expectEqualStrings("old\nhello\nworld", bytes); } { const bytes = try decodeBytes(gpa, parsed.panes[1].file.?.content_b64); defer gpa.free(bytes); try std.testing.expectEqualStrings("alpha\nbeta\n", bytes); } } test "omitted dump defaults roundtrip without borrowing input" { const gpa = std.testing.allocator; const fixture = \\.{ \\ .screen = .{ .cols = 80, .rows = 24 }, \\ .columns = .{.{ .panes = .{0, 1, 2} }}, \\ .panes = .{ \\ .{ .kind = .terminal, .tag = "terminal", .body = "", .terminal = .{} }, \\ .{ .kind = .file, .tag = "file", .body = "", .file = .{ .path = "file.zig", .content = "const café = 1;" } }, \\ .{ .kind = .image, .tag = "image", .body = "", .image = .{} }, \\ }, \\} ; const input = try gpa.dupe(u8, fixture); defer gpa.free(input); var parsed = try readZon(gpa, input, "omitted-defaults"); defer parsed.deinit(); @memset(input, 'x'); const value = parsed.value; try std.testing.expectEqualStrings(magic, value.magic); try std.testing.expectEqualStrings("dark", value.theme); try std.testing.expectEqualStrings("", value.topbar); try std.testing.expectEqual(@as(usize, 0), value.mounts.len); try std.testing.expectEqual(@as(usize, 3), value.panes.len); try std.testing.expectEqualStrings("file.zig", value.panes[1].file.?.path); try std.testing.expectEqualStrings("const café = 1;", value.panes[1].file.?.content); try std.testing.expect(!value.panes[1].file.?.dirty); try std.testing.expect(value.panes[2].image.?.ascii); var encoded: std.Io.Writer.Allocating = .init(gpa); defer encoded.deinit(); try std.zon.stringify.serialize(value, .{}, &encoded.writer); var again = try readZon(gpa, encoded.written(), "default-roundtrip"); defer again.deinit(); try std.testing.expectEqualDeep(value, again.value); } test "dump parser releases its arena at every allocation failure" { const Check = struct { fn run(gpa: std.mem.Allocator) !void { var parsed = try readZon(gpa, \\.{ \\ .screen = .{ .cols = 80, .rows = 24 }, \\ .columns = .{.{ .panes = .{0} }}, \\ .panes = .{.{ .kind = .file, .tag = "file", .body = "", .file = .{ .content = "owned" } }}, \\} , "allocation-cleanup"); defer parsed.deinit(); try std.testing.expectEqualStrings("owned", parsed.value.panes[0].file.?.content); } }; try std.testing.checkAllAllocationFailures(std.testing.allocator, Check.run, .{}); } test "dump mount validation bounds names ownership inputs and duplicates" { const panes = [_]Pane{.{ .kind = .file, .tag = "", .body = "", .file = .{} }}; const ids = [_]usize{0}; const columns = [_]Column{.{ .panes = &ids }}; var state: State = .{ .screen = .{ .cols = 80, .rows = 24 }, .columns = &columns, .panes = &panes, }; try validate(state); for ([_]Mount{ .{ .name = "peer", .dial = "/tmp/peer.sock" }, .{ .name = "build-1.local", .dial = "tcp!127.0.0.1!5640" }, }) |mount| { state.mounts = &.{mount}; try validate(state); } for ([_]Mount{ .{ .name = "", .dial = "/tmp/peer.sock" }, .{ .name = ".", .dial = "/tmp/peer.sock" }, .{ .name = "..", .dial = "/tmp/peer.sock" }, .{ .name = "os", .dial = "/tmp/peer.sock" }, .{ .name = "self", .dial = "/tmp/peer.sock" }, .{ .name = "two/parts", .dial = "/tmp/peer.sock" }, .{ .name = "two parts", .dial = "/tmp/peer.sock" }, .{ .name = "peer", .dial = "" }, .{ .name = "peer", .dial = "unix!/tmp/peer\x00.sock" }, }) |mount| { state.mounts = &.{mount}; try std.testing.expectError(error.BadDumpMounts, validate(state)); } const duplicate = Mount{ .name = "peer", .dial = "/tmp/peer.sock" }; state.mounts = &.{ duplicate, duplicate }; try std.testing.expectError(error.BadDumpMounts, validate(state)); var name: [256]u8 = @splat('x'); state.mounts = &.{.{ .name = name[0..255], .dial = "/tmp/peer.sock" }}; try validate(state); state.mounts = &.{.{ .name = &name, .dial = "/tmp/peer.sock" }}; try std.testing.expectError(error.BadDumpMounts, validate(state)); const mounts = [_]Mount{ .{ .name = "a", .dial = "/tmp/a" }, .{ .name = "b", .dial = "/tmp/b" }, .{ .name = "c", .dial = "/tmp/c" }, .{ .name = "d", .dial = "/tmp/d" }, .{ .name = "e", .dial = "/tmp/e" }, .{ .name = "f", .dial = "/tmp/f" }, .{ .name = "g", .dial = "/tmp/g" }, .{ .name = "h", .dial = "/tmp/h" }, .{ .name = "i", .dial = "/tmp/i" }, }; state.mounts = mounts[0..8]; try validate(state); state.mounts = &mounts; try std.testing.expectError(error.BadDumpMounts, validate(state)); } test "version-one image records default old fields and roundtrip new state" { const gpa = std.testing.allocator; const legacy = \\.{ \\ .magic = "pardes-dump", \\ .version = 1, \\ .screen = .{ .cols = 80, .rows = 24 }, \\ .active = 0, \\ .topbar = "", \\ .theme = "dark", \\ .columns = .{.{ .panes = .{0} }}, \\ .panes = .{.{ \\ .kind = .image, \\ .tag = "img /tmp/old.ppm New Del", \\ .body = "", \\ .image = .{ .path = "/tmp/old.ppm", .bytes_b64 = "" }, \\ }}, \\} ; var old = try readZon(gpa, legacy, "legacy-image"); defer old.deinit(); const old_image = old.value.panes[0].image.?; try std.testing.expect(!old_image.petscii); try std.testing.expectEqual(ImagePalette.commodore, old_image.palette); try std.testing.expect(old_image.ascii); try std.testing.expect(old.value.panes[0].tag_tail == null); const pane = Pane{ .kind = .image, .tag = "img petscii:on palette:terminal ascii:off /tmp/new.ppm", .body = "", .tag_tail = "", .image = .{ .path = "/tmp/new.ppm", .petscii = true, .palette = .terminal, .ascii = false, }, }; const ids = [_]usize{0}; const columns = [_]Column{.{ .panes = &ids }}; const panes = [_]Pane{pane}; const state = State{ .screen = .{ .cols = 80, .rows = 24 }, .columns = &columns, .panes = &panes, }; var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); var parsed = try readZon(gpa, out.written(), "new-image"); defer parsed.deinit(); const restored = parsed.value.panes[0].image.?; try std.testing.expect(restored.petscii); try std.testing.expectEqual(ImagePalette.terminal, restored.palette); try std.testing.expect(!restored.ascii); try std.testing.expect(parsed.value.panes[0].tag_tail != null); try std.testing.expectEqualStrings("", parsed.value.panes[0].tag_tail.?); } test "validation bounds pane restore state" { const ids = [_]usize{0}; const columns = [_]Column{.{ .panes = &ids }}; var panes = [_]Pane{.{ .kind = .terminal, .tag = "term", .body = "", .terminal = .{}, }}; const state = State{ .screen = .{ .cols = 80, .rows = 24 }, .columns = &columns, .panes = &panes, }; try validate(state); panes[0].vweight = 0; try std.testing.expectError(error.BadDumpPaneWeight, validate(state)); panes[0].vweight = std.math.inf(f32); try std.testing.expectError(error.BadDumpPaneWeight, validate(state)); panes[0].vweight = 1; panes[0].scroll = @as(usize, @intCast(std.math.maxInt(i32))) + 1; try std.testing.expectError(error.BadDumpPaneScroll, validate(state)); panes[0].scroll = 0; var oversized_tail: [limits.max_tag_tail + 1]u8 = @splat('x'); panes[0].tag_tail = &oversized_tail; try std.testing.expectError(error.BadDumpTagTail, validate(state)); panes[0].tag_tail = oversized_tail[0..limits.max_tag_tail]; try validate(state); }