Skip to content
zit Artifact [6920ddff87]
Login

Artifact [6920ddff87]

Artifact 6920ddff87a2a146ea6f41f589cbed58f508fb4fa770a72826ae6e1f702d9648:


const std = @import("std");

pub const WorktreePathError = error{
    InvalidPath,
    ReservedPath,
};

/// Valid relative working-tree paths use `/` separators, contain no empty or
/// dot components, and never claim a reserved root. Import and update share
/// this check so neither can admit a path the other would later reject.
pub fn validateWorktreePath(path: []const u8) WorktreePathError!void {
    if (path.len == 0 or std.fs.path.isAbsolute(path)) return error.InvalidPath;
    if (std.mem.indexOfScalar(u8, path, '\\') != null) return error.InvalidPath;

    var components = std.mem.splitScalar(u8, path, '/');
    const root = components.next() orelse return error.InvalidPath;
    if (root.len == 0 or std.mem.eql(u8, root, ".") or std.mem.eql(u8, root, "..")) return error.InvalidPath;
    if (isReservedRoot(path)) return error.ReservedPath;
    while (components.next()) |component| {
        if (component.len == 0 or std.mem.eql(u8, component, ".") or std.mem.eql(u8, component, "..")) return error.InvalidPath;
    }
}
/// What losing the file costs -- the property that dictates its format, its
/// durability discipline, and who may delete it. The registry below is the
/// single authority on which names zit owns at a checkout root; every other
/// list (extras exclusions, the ignore-glob pins, the case tables) is derived
/// from or tested against it, so a name added here without its glob line or
/// case coverage is a TEST FAILURE, not quiet drift.
pub const Tier = enum {
    /// Cannot be rebuilt: losing it loses work (checkout record, managed set,
    /// undo image, bisect session, and the store itself).
    authoritative,
    /// Discardable by construction, rebuilt on demand; deleting it must never
    /// change an answer (repocache, vstat).
    advisory,
    /// Exists only while an operation is in flight; a crash strands one, and a
    /// stranger must be able to identify and sweep it without understanding it.
    scratch,
    /// Import provenance the consolidation plan retires from the root.
    legacy,
};

pub const Reserved = struct {
    name: []const u8,
    tier: Tier,
    /// A prefix entry reserves every root beginning with `name`, not one name.
    prefix: bool = false,
};

pub const reserved = [_]Reserved{
    .{ .name = ".zit", .tier = .authoritative },
    .{ .name = ".zit-checkout", .tier = .authoritative },
    .{ .name = ".zit-managed", .tier = .authoritative },
    .{ .name = ".zit-undo", .tier = .authoritative },
    .{ .name = ".zit-bisect", .tier = .authoritative },
    .{ .name = ".zit-cache", .tier = .advisory },
    // Legacy: the pre-directory vstat root name, reserved until the removal
    // release; the live sidecar is `.zit-cache/vstat`.
    .{ .name = ".zit-vstat", .tier = .legacy },
    .{ .name = ".zit-tmp-", .tier = .scratch, .prefix = true },
    // Legacy scratch spellings: still reserved so a dir a crashed OLD binary
    // stranded stays untrackable and extras-invisible until the removal
    // release. `.zit-import-tmp-` was never reserved at all -- a crashed
    // import's staging directory was admissible working-tree content, which
    // this row closes.
    .{ .name = ".zit-pack-tmp", .tier = .legacy },
    .{ .name = ".zit-update-txn-", .tier = .legacy, .prefix = true },
    .{ .name = ".zit-import-tmp-", .tier = .legacy, .prefix = true },
    .{ .name = ".zit-import-map.jsonl", .tier = .legacy },
    .{ .name = "refs", .tier = .legacy },
};

/// Root-level checkout metadata names that imported and materialized paths
/// must never own. Inspects only the first slash-delimited path component.
pub fn isReservedRoot(path: []const u8) bool {
    const slash = std.mem.indexOfScalar(u8, path, '/') orelse path.len;
    const root = path[0..slash];
    for (reserved) |entry| {
        if (entry.prefix) {
            if (asciiStartsWithIgnoreCase(root, entry.name)) return true;
        } else if (asciiEqlIgnoreCase(root, entry.name)) return true;
    }
    return false;
}

fn asciiEqlIgnoreCase(a: []const u8, b: []const u8) bool {
    if (a.len != b.len) return false;
    for (a, b) |left, right| {
        if (std.ascii.toLower(left) != std.ascii.toLower(right)) return false;
    }
    return true;
}

fn asciiStartsWithIgnoreCase(value: []const u8, prefix: []const u8) bool {
    return value.len >= prefix.len and asciiEqlIgnoreCase(value[0..prefix.len], prefix);
}

test "control path policy reserves complete roots case-insensitively" {
    // Generated from the registry, so a new entry gets its coverage by
    // existing: the bare name, a child path, a case-flipped copy, and the
    // near-miss (`name ++ "x"`) -- which for a PREFIX entry must be reserved
    // rather than refused, that being what a prefix means.
    inline for (reserved) |entry| {
        try std.testing.expect(isReservedRoot(entry.name));
        try std.testing.expect(isReservedRoot(entry.name ++ "/sub"));
        var flipped: [entry.name.len]u8 = undefined;
        for (entry.name, 0..) |c, i| {
            flipped[i] = if (std.ascii.isLower(c)) std.ascii.toUpper(c) else std.ascii.toLower(c);
        }
        try std.testing.expect(isReservedRoot(&flipped));
        try std.testing.expectEqual(entry.prefix, isReservedRoot(entry.name ++ "x"));
    }

    // Hand-written rows the generator cannot derive: shapes that LOOK like
    // control names and must not be.
    const negatives = [_][]const u8{
        "",
        "zit",
        ".zit-other/data",
        ".zit-cacheable/data",
        ".zit-vstats/x",
        ".zit-checkout-user/file",
        ".zit-update-txn",
        ".zit-update-txns/user",
        "refs-old/heads/main",
        "sub/.zit/object",
    };
    for (negatives) |path| try std.testing.expect(!isReservedRoot(path));
}