diff options
Diffstat (limited to 'src/dump.zig')
| -rw-r--r-- | src/dump.zig | 223 |
1 files changed, 170 insertions, 53 deletions
diff --git a/src/dump.zig b/src/dump.zig index f8740cdb..bb692481 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const limits = @import("limits.zig"); +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 @@ -74,13 +74,12 @@ pub const File = struct { path: []const u8 = "", content: []const u8 = "", content_b64: []const u8 = "", - /// non-empty = an output buffer (no file behind the name): the WORD of the - /// command that opened it, plus that command's argument — see - /// output_pane.Origin. A word rather than an integer for the reason every - /// other command in here is a word: reordering builtins.zig stays free, - /// and a dump stays something a person can read. + dirty: bool = false, + // 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 { @@ -119,6 +118,8 @@ pub const Column = struct { panes: []const usize = &.{}, }; +pub const Mount = struct { name: []const u8, dial: []const u8 }; + pub const State = struct { magic: []const u8 = magic, version: u32 = version, @@ -128,6 +129,7 @@ pub const State = struct { theme: []const u8 = "dark", columns: []const Column = &.{}, panes: []const Pane = &.{}, + mounts: []const Mount = &.{}, }; pub fn validate(state: State) !void { @@ -136,6 +138,15 @@ pub fn validate(state: State) !void { 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; @@ -151,6 +162,11 @@ pub fn validate(state: State) !void { 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, @@ -201,42 +217,37 @@ pub fn writeFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8, state: St try file.writeStreamingAll(io, out.written()); } -pub fn readFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !State { - const bytes = try std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(64 * 1024 * 1024)); - defer gpa.free(bytes); - return readZon(gpa, bytes, path); -} +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) !State { +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); - return readZonZ(gpa, source, label); -} -pub fn readZonZ(gpa: std.mem.Allocator, source: [:0]const u8, label: []const u8) !State { - if (builtin.os.tag == .emscripten or builtin.os.tag == .freestanding) { - const state = std.zon.parse.fromSliceAlloc(State, gpa, source, null, .{}) catch |err| { - log.err("parse dump {s}: {s}", .{ label, @errorName(err) }); + 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; }; - errdefer std.zon.parse.free(gpa, state); - try validate(state); - return state; - } - - var diag: std.zon.parse.Diagnostics = .{}; - defer diag.deinit(gpa); - const state = std.zon.parse.fromSliceAlloc(State, gpa, source, &diag, .{}) catch |err| { - log.err("parse dump {s}: {f}", .{ label, diag }); - return err; }; - errdefer std.zon.parse.free(gpa, state); - try validate(state); - return state; -} - -pub fn free(gpa: std.mem.Allocator, state: State) void { - std.zon.parse.free(gpa, state); + try validate(parsed.value); + return parsed; } pub fn encodeBytes(alloc: std.mem.Allocator, bytes: []const u8) ![]const u8 { @@ -276,7 +287,7 @@ test "dump zon roundtrip" { .body = " 1 alpha", .cols = 20, .rows = 5, - .file = .{ .path = "/tmp/a.txt", .content = "alpha\nbeta\n", .content_b64 = file_b64 }, + .file = .{ .path = "/tmp/a.txt", .content = "alpha\nbeta\n", .content_b64 = file_b64, .dirty = true }, }, }; const col_panes = [_]usize{ 0, 1 }; @@ -292,18 +303,14 @@ test "dump zon roundtrip" { var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); - const source = try out.toOwnedSliceSentinel(0); - defer gpa.free(source); - - var diag: std.zon.parse.Diagnostics = .{}; - defer diag.deinit(gpa); - const parsed = try std.zon.parse.fromSliceAlloc(State, gpa, source, &diag, .{}); - defer std.zon.parse.free(gpa, parsed); - try validate(parsed); + 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); @@ -316,6 +323,116 @@ test "dump zon roundtrip" { } } +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 = @@ -335,13 +452,13 @@ test "version-one image records default old fields and roundtrip new state" { \\ }}, \\} ; - const old = try readZon(gpa, legacy, "legacy-image"); - defer free(gpa, old); - const old_image = old.panes[0].image.?; + 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.panes[0].tag_tail == null); + try std.testing.expect(old.value.panes[0].tag_tail == null); const pane = Pane{ .kind = .image, @@ -366,14 +483,14 @@ test "version-one image records default old fields and roundtrip new state" { 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.?; + 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.panes[0].tag_tail != null); - try std.testing.expectEqualStrings("", parsed.panes[0].tag_tail.?); + 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" { |
