a73x

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));
}