src/gui/persistence.zig
Ref: Size: 18.2 KiB History
//! Versioned native workspace intent, independent of terminal layout state.
//! Decode validates the complete graph before owning panes; a failed load seals
//! the writer for this run so a fallback workspace cannot replace the evidence.
const std = @import("std");
const client = @import("client");
const model = @import("workspace.zig");
const Tree = @FieldType(model.Tab, "tree");
const Node = @typeInfo(@FieldType(Tree, "nodes")).array.child;
pub const max_bytes = 1024 * 1024;
const SavedPane = struct { id: model.PaneId, target: client.Target, session: []const u8 };
const SavedTree = struct { root: ?u8, nodes: []const Node, next_divider_id: model.DividerId };
const SavedTab = struct { id: model.TabId, focus: ?model.PaneId, tree: SavedTree, panes: []const SavedPane };
const Document = struct { version: u32, active_tab_id: model.TabId, next_pane_id: model.PaneId, tabs: []const SavedTab };
pub fn encode(alloc: std.mem.Allocator, workspace: *model.Workspace) ![]u8 {
var arena = std.heap.ArenaAllocator.init(alloc);
defer arena.deinit();
const scratch = arena.allocator();
const cwd = try std.process.getCwdAlloc(scratch);
var panes: [model.max_panes]SavedPane = undefined;
var len: usize = 0;
const tab = workspace.tab();
for (tab.panes) |entry| if (entry) |pane| {
panes[len] = .{ .id = pane.id, .target = pane.identity.target, .session = pane.identity.session };
switch (panes[len].target) {
.sock => |path| panes[len].target.sock = try absolutePath(scratch, cwd, path),
.quic => |*q| q.key_path = try absolutePath(scratch, cwd, q.key_path),
.hand => |*h| if (h.cache_path) |path| {
h.cache_path = try absolutePath(scratch, cwd, path);
},
.via => {},
}
// These describe a prior invocation, not a saved transport identity.
if (panes[len].target == .hand) {
panes[len].target.hand.asked = false;
panes[len].target.hand.narrate = false;
panes[len].target.hand.ask_sock = null;
panes[len].target.hand.ask_exe = "";
}
len += 1;
};
const tabs = [_]SavedTab{.{ .id = tab.id, .focus = tab.focus, .tree = .{ .root = tab.tree.root, .nodes = &tab.tree.nodes, .next_divider_id = tab.tree.next_divider_id }, .panes = panes[0..len] }};
return std.json.Stringify.valueAlloc(alloc, Document{ .version = 1, .active_tab_id = workspace.active_tab_id, .next_pane_id = workspace.next_pane_id, .tabs = &tabs }, .{ .whitespace = .indent_2 });
}
fn absolutePath(alloc: std.mem.Allocator, cwd: []const u8, path: []const u8) ![]const u8 {
// Preserve symlink/.. semantics; resolving path components lexically would
// change which file the live transport already uses.
return if (std.fs.path.isAbsolute(path)) path else try std.fs.path.join(alloc, &.{ cwd, path });
}
fn validText(text: []const u8) bool {
return text.len > 0 and std.mem.indexOfScalar(u8, text, 0) == null;
}
fn validTarget(target: client.Target) bool {
return switch (target) {
.sock => |s| validText(s) and std.fs.path.isAbsolute(s),
.via => |s| validText(s),
.quic => |q| validText(q.host_port) and validText(q.key_path) and std.fs.path.isAbsolute(q.key_path) and q.deadline_ms > 0,
.hand => |h| blk: {
if (!validText(h.host) or h.ssh_argv.len == 0 or h.deadline_ms == 0) break :blk false;
for (h.ssh_argv) |arg| if (!validText(arg)) break :blk false;
for (h.asked_argv) |arg| if (!validText(arg)) break :blk false;
if (h.cache_path) |path| if (!validText(path) or !std.fs.path.isAbsolute(path)) break :blk false;
break :blk true;
},
};
}
const Validation = struct {
tab: SavedTab,
seen: [model.max_panes * 2 - 1]bool = @splat(false),
leaves: [model.max_panes]bool = @splat(false),
max_divider: u64 = 0,
fn visit(self: *Validation, index: u8) error{InvalidWorkspace}!void {
if (index >= self.tab.tree.nodes.len or self.seen[index]) return error.InvalidWorkspace;
self.seen[index] = true;
switch (self.tab.tree.nodes[index]) {
.empty => return error.InvalidWorkspace,
.leaf => |id| {
for (self.tab.panes, 0..) |pane, i| if (pane.id == id) {
if (self.leaves[i]) return error.InvalidWorkspace;
self.leaves[i] = true;
return;
};
return error.InvalidWorkspace;
},
.split => |s| {
if (s.id == 0 or s.first == 0 or s.first >= s.total) return error.InvalidWorkspace;
for (self.tab.tree.nodes, 0..) |other, i| if (i != index and other == .split and other.split.id == s.id) return error.InvalidWorkspace;
self.max_divider = @max(self.max_divider, s.id);
try self.visit(s.a);
try self.visit(s.b);
},
}
}
};
pub fn decode(alloc: std.mem.Allocator, bytes: []const u8) !model.Workspace {
if (bytes.len > max_bytes) return error.WorkspaceTooLarge;
const parsed = try std.json.parseFromSlice(Document, alloc, bytes, .{ .allocate = .alloc_always });
defer parsed.deinit();
const doc = parsed.value;
if (doc.version != 1) return error.UnsupportedWorkspaceVersion;
if (doc.tabs.len != 1) return error.UnsupportedWorkspaceTabs;
const tab = doc.tabs[0];
if (tab.id == 0 or doc.active_tab_id != tab.id or tab.panes.len > model.max_panes or tab.tree.nodes.len != model.max_panes * 2 - 1) return error.InvalidWorkspace;
var max_pane: u64 = 0;
for (tab.panes, 0..) |pane, i| {
if (pane.id == 0 or !@import("term").protocol.validSessionName(pane.session) or !validTarget(pane.target)) return error.InvalidWorkspace;
for (tab.panes[0..i]) |other| if (other.id == pane.id) return error.InvalidWorkspace;
max_pane = @max(max_pane, pane.id);
}
if (doc.next_pane_id <= max_pane) return error.InvalidWorkspace;
var validation: Validation = .{ .tab = tab };
if (tab.tree.root) |root| try validation.visit(root);
for (tab.tree.nodes, 0..) |node, i| if ((node != .empty) != validation.seen[i]) return error.InvalidWorkspace;
for (validation.leaves[0..tab.panes.len]) |seen| if (!seen) return error.InvalidWorkspace;
if (tab.tree.next_divider_id <= validation.max_divider) return error.InvalidWorkspace;
if (tab.focus) |focus| {
var found = false;
for (tab.panes) |pane| if (pane.id == focus) {
found = true;
};
if (!found) return error.InvalidWorkspace;
} else if (tab.panes.len != 0) return error.InvalidWorkspace;
var workspace = model.Workspace.init(alloc);
errdefer workspace.deinit();
workspace.active_tab_id = doc.active_tab_id;
workspace.next_pane_id = doc.next_pane_id;
workspace.tab().id = tab.id;
workspace.tab().focus = tab.focus;
workspace.tab().tree.root = tab.tree.root;
workspace.tab().tree.next_divider_id = tab.tree.next_divider_id;
@memcpy(&workspace.tab().tree.nodes, tab.tree.nodes);
for (tab.panes, 0..) |saved, i| {
const pane = try alloc.create(model.Pane);
errdefer alloc.destroy(pane);
var target = saved.target;
if (target == .hand) {
target.hand.asked = false;
target.hand.narrate = false;
target.hand.ask_sock = null;
target.hand.ask_exe = "";
}
pane.* = .{ .id = saved.id, .identity = try model.Identity.init(alloc, target, saved.session) };
workspace.tab().panes[i] = pane;
}
return workspace;
}
pub const Store = struct {
alloc: std.mem.Allocator,
path: []const u8,
lock: std.fs.File,
writable: bool = true,
pub fn open(alloc: std.mem.Allocator, path: []const u8) !Store {
const owned = try alloc.dupe(u8, path);
errdefer alloc.free(owned);
if (std.fs.path.dirname(path)) |parent| {
if (try std.fs.cwd().makePathStatus(parent) == .created) {
var directory = try std.fs.cwd().openDir(parent, .{ .iterate = true });
defer directory.close();
try directory.chmod(0o700);
}
}
const lock_path = try std.fmt.allocPrint(alloc, "{s}.lock", .{path});
defer alloc.free(lock_path);
const lock = std.fs.cwd().createFile(lock_path, .{ .truncate = false, .mode = 0o600, .lock = .exclusive, .lock_nonblocking = true }) catch |err| switch (err) {
error.WouldBlock => return error.WorkspaceAlreadyOpen,
else => return err,
};
return .{ .alloc = alloc, .path = owned, .lock = lock };
}
pub fn deinit(self: *Store) void {
// Never unlink: waiters must always lock this same inode, even when the
// JSON next to it has just been replaced by an atomic rename.
self.lock.close();
self.alloc.free(self.path);
}
pub fn load(self: *Store) !?model.Workspace {
errdefer self.writable = false;
const bytes = std.fs.cwd().readFileAlloc(self.alloc, self.path, max_bytes) catch |err| switch (err) {
error.FileNotFound => return null,
else => return err,
};
defer self.alloc.free(bytes);
return try decode(self.alloc, bytes);
}
pub fn save(self: *Store, workspace: *model.Workspace) !void {
if (!self.writable) return error.WorkspacePreserved;
const bytes = try encode(self.alloc, workspace);
defer self.alloc.free(bytes);
if (bytes.len > max_bytes) return error.WorkspaceTooLarge;
var buffer: [4096]u8 = undefined;
var file = try std.fs.cwd().atomicFile(self.path, .{ .mode = 0o600, .write_buffer = &buffer });
defer file.deinit();
try file.file_writer.interface.writeAll(bytes);
try file.flush();
try file.file_writer.file.sync();
try file.renameIntoPlace();
}
};
test "native state preserves nested weights and identity without transient modes" {
const a = std.testing.allocator;
var workspace = model.Workspace.init(a);
defer workspace.deinit();
const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16, .divider = 2 };
workspace.commit(try workspace.prepare(.{ .sock = "/tmp/native-state-fixture" }, "alpha", 1000, 800, metrics));
workspace.arm(.beside);
workspace.commit(try workspace.prepare(.{ .quic = .{ .host_port = "host:444", .key_path = "/key" } }, "beta", 1000, 800, metrics));
workspace.arm(.stacked);
workspace.commit(try workspace.prepare(.{ .sock = "/tmp/native-state-fixture" }, "gamma", 1000, 800, metrics));
_ = workspace.resizeFocused(.up, 1000, 800, metrics);
_ = workspace.layout(8, 8, metrics);
workspace.arm(.beside);
const bytes = try encode(a, &workspace);
defer a.free(bytes);
var restored = try decode(a, bytes);
defer restored.deinit();
try std.testing.expectEqual(workspace.tab().focus, restored.tab().focus);
try std.testing.expectEqual(workspace.next_pane_id, restored.next_pane_id);
try std.testing.expect(restored.tab().pending == null);
try std.testing.expectEqualDeep(workspace.tab().tree, restored.tab().tree);
try std.testing.expectEqualDeep(workspace.layout(1700, 1200, metrics), restored.layout(1700, 1200, metrics));
}
test "native decoder rejects corrupt graphs, targets and unsupported state" {
const a = std.testing.allocator;
var workspace = model.Workspace.init(a);
defer workspace.deinit();
const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16, .divider = 2 };
workspace.commit(try workspace.prepare(.{ .sock = "/tmp/state" }, "a", 1000, 800, metrics));
workspace.commit(try workspace.prepare(.{ .sock = "/tmp/state" }, "b", 1000, 800, metrics));
const bytes = try encode(a, &workspace);
defer a.free(bytes);
const parsed = try std.json.parseFromSlice(Document, a, bytes, .{});
defer parsed.deinit();
for (0..12) |case| {
var doc = parsed.value;
var tab = doc.tabs[0];
var nodes: [model.max_panes * 2 - 1]Node = undefined;
@memcpy(&nodes, tab.tree.nodes);
var panes: [2]SavedPane = undefined;
@memcpy(&panes, tab.panes);
tab.tree.nodes = &nodes;
tab.panes = &panes;
doc.tabs = @as(*const [1]SavedTab, @ptrCast(&tab));
switch (case) {
0 => doc.version = 999,
1 => doc.active_tab_id = 999,
2 => panes[1].id = panes[0].id,
3 => nodes[0].split.a = 0,
4 => nodes[0].split.b = nodes[0].split.a,
5 => nodes[0].split.first = 0,
6 => nodes[0].split.first = nodes[0].split.total,
7 => nodes[62] = .{ .leaf = panes[0].id },
8 => doc.next_pane_id = panes[1].id,
9 => tab.focus = 999,
10 => panes[0].target = .{ .sock = "relative.sock" },
11 => panes[0].target = .{ .hand = .{ .host = "host", .ssh_argv = &.{"ssh"}, .cache_path = "/bad\x00path" } },
else => unreachable,
}
const bad = try std.json.Stringify.valueAlloc(a, doc, .{});
defer a.free(bad);
if (decode(a, bad)) |value| {
var unexpected = value;
unexpected.deinit();
return error.CorruptStateAccepted;
} else |_| {}
}
}
test "native store locks separate inode and preserves malformed or failed saves" {
const a = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const directory = try tmp.dir.realpathAlloc(a, ".");
defer a.free(directory);
const path = try std.fs.path.join(a, &.{ directory, "native-workspace.json" });
defer a.free(path);
var store = try Store.open(a, path);
defer store.deinit();
try std.testing.expect((try store.load()) == null);
try std.testing.expectError(error.WorkspaceAlreadyOpen, Store.open(a, path));
var workspace = model.Workspace.init(a);
defer workspace.deinit();
try store.save(&workspace);
try std.testing.expectError(error.WorkspaceAlreadyOpen, Store.open(a, path));
const original = try tmp.dir.readFileAlloc(a, "native-workspace.json", max_bytes);
defer a.free(original);
// Make atomic rename fail with a directory at the destination. The prior
// file is retained separately; this case checks failure and temp cleanup.
try tmp.dir.rename("native-workspace.json", "good.json");
try tmp.dir.makeDir("native-workspace.json");
if (store.save(&workspace)) |_| return error.FailedSaveAccepted else |_| {}
const retained = try tmp.dir.readFileAlloc(a, "good.json", max_bytes);
defer a.free(retained);
try std.testing.expectEqualStrings(original, retained);
var listing = try tmp.dir.openDir(".", .{ .iterate = true });
defer listing.close();
var iterator = listing.iterate();
var entries: usize = 0;
while (try iterator.next()) |_| entries += 1;
try std.testing.expectEqual(@as(usize, 3), entries);
try tmp.dir.deleteDir("native-workspace.json");
try tmp.dir.writeFile(.{ .sub_path = "native-workspace.json", .data = "{broken evidence" });
if (store.load()) |value| {
if (value) |ws| {
var unexpected = ws;
unexpected.deinit();
}
return error.CorruptStateAccepted;
} else |_| {}
try std.testing.expectError(error.WorkspacePreserved, store.save(&workspace));
const malformed = try tmp.dir.readFileAlloc(a, "native-workspace.json", max_bytes);
defer a.free(malformed);
try std.testing.expectEqualStrings("{broken evidence", malformed);
}
test "native saved filesystem references are absolute" {
const a = std.testing.allocator;
var workspace = model.Workspace.init(a);
defer workspace.deinit();
const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16, .divider = 2 };
workspace.commit(try workspace.prepare(.{ .sock = "fixtures/socket" }, "a", 1000, 800, metrics));
workspace.commit(try workspace.prepare(.{ .quic = .{ .host_port = "host:44", .key_path = "fixtures/key" } }, "b", 1000, 800, metrics));
const bytes = try encode(a, &workspace);
defer a.free(bytes);
var restored = try decode(a, bytes);
defer restored.deinit();
const cwd = try std.process.getCwdAlloc(a);
defer a.free(cwd);
const sock = try std.fs.path.join(a, &.{ cwd, "fixtures/socket" });
defer a.free(sock);
const key = try std.fs.path.join(a, &.{ cwd, "fixtures/key" });
defer a.free(key);
try std.testing.expectEqualStrings(sock, restored.pane(1).?.identity.target.sock);
try std.testing.expectEqualStrings(key, restored.pane(2).?.identity.target.quic.key_path);
}
fn decodeWithAllocator(alloc: std.mem.Allocator, bytes: []const u8) !void {
var workspace = try decode(alloc, bytes);
defer workspace.deinit();
}
test "native restore cleans up every partially allocated pane" {
const a = std.testing.allocator;
var workspace = model.Workspace.init(a);
defer workspace.deinit();
const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16, .divider = 2 };
workspace.commit(try workspace.prepare(.{ .sock = "/tmp/state" }, "alpha", 1000, 800, metrics));
workspace.commit(try workspace.prepare(.{ .hand = .{ .host = "host", .ssh_argv = &.{ "ssh", "host" }, .cache_path = "/tmp/cache" } }, "beta", 1000, 800, metrics));
const bytes = try encode(a, &workspace);
defer a.free(bytes);
try std.testing.checkAllAllocationFailures(a, decodeWithAllocator, .{bytes});
}
test "exhausted native identity counters restore without allowing an overflow" {
const a = std.testing.allocator;
var workspace = model.Workspace.init(a);
defer workspace.deinit();
const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16, .divider = 2 };
workspace.next_pane_id = std.math.maxInt(u64);
const empty = try encode(a, &workspace);
defer a.free(empty);
var restored = try decode(a, empty);
defer restored.deinit();
try std.testing.expectError(error.IdExhausted, restored.prepare(.{ .sock = "/tmp/state" }, "a", 1000, 800, metrics));
workspace.next_pane_id = 1;
workspace.commit(try workspace.prepare(.{ .sock = "/tmp/state" }, "a", 1000, 800, metrics));
workspace.tab().tree.next_divider_id = std.math.maxInt(u64);
const full = try encode(a, &workspace);
defer a.free(full);
var exhausted = try decode(a, full);
defer exhausted.deinit();
try std.testing.expectError(error.IdExhausted, exhausted.prepare(.{ .sock = "/tmp/state" }, "b", 1000, 800, metrics));
}