From 1551e409c31992437cb2fa864f576d45c8433801 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 16 Aug 2026 15:49:12 -0300 Subject: big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web + snapshot refresh --- src/dump.zig | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) (limited to 'src/dump.zig') diff --git a/src/dump.zig b/src/dump.zig index 44f61cf4..566cf03c 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -40,6 +40,13 @@ pub const magic = "pardes-dump"; pub const version: u32 = 1; pub const max_panes: usize = 16; pub const max_cols: usize = 6; +/// Bounds the only user-editable, schema-owned tag fragment. Keep this beside +/// the dump limits so readers can reject data before copying it into a pane. +pub const max_tag_tail: usize = 4096; +/// 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. +pub const max_origin_arg: usize = max_tag_tail; pub const Size = struct { cols: u16, @@ -77,9 +84,19 @@ pub const File = struct { origin_arg: []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 { @@ -90,6 +107,9 @@ pub const Pane = struct { cols: u16 = 0, rows: u16 = 0, vweight: f32 = 1, + /// 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, @@ -118,12 +138,20 @@ pub fn validate(state: State) !void { if (state.columns.len == 0 or state.columns.len > max_cols) return error.BadDumpColumns; if (state.active >= state.panes.len) return error.BadDumpActive; 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 > max_tag_tail) + return error.BadDumpTagTail; + if (pane.file) |file| if (file.origin_arg.len > max_origin_arg) + return error.BadDumpOriginArg; switch (pane.kind) { .terminal => if (pane.terminal == null) return error.BadDumpPaneKind, .file => if (pane.file == null) return error.BadDumpPaneKind, @@ -132,6 +160,37 @@ pub fn validate(state: State) !void { } } +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); @@ -257,3 +316,96 @@ test "dump zon roundtrip" { try std.testing.expectEqualStrings("alpha\nbeta\n", bytes); } } + +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 = "" }, + \\ }}, + \\} + ; + const old = try readZon(gpa, legacy, "legacy-image"); + defer free(gpa, old); + const old_image = old.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.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); + const parsed = try readZon(gpa, out.written(), "new-image"); + defer free(gpa, parsed); + const restored = parsed.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.panes[0].tag_tail != null); + try std.testing.expectEqualStrings("", parsed.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: [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..max_tag_tail]; + try validate(state); +} -- cgit v1.3