Skip to content
zit Artifact [36df8b4f04]
Login

Artifact [36df8b4f04]

Artifact 36df8b4f043b0f5e6d62e4e390fa1190bc2b24e574164e85b61119d60f03c573:


const std = @import("std");
const artifact = @import("artifact.zig");

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

/// True when `name` holds an ASCII control byte (0x00-0x1f, 0x7f). Such a
/// path, login or branch is legal to a filesystem or a shell but breaks every
/// line-oriented reader of a manifest or a listing, so a writer that mints
/// check-ins refuses it.
pub fn hasControlByte(name: []const u8) bool {
    for (name) |c| if (c < ' ' or c == 0x7f) return true;
    return false;
}

/// 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;
    }
}
pub const NewPathError = WorktreePathError || error{
    /// A control byte, a backslash, bytes that are not UTF-8, or a character
    /// Fossil will not take in a new name.
    IllegalCharacters,
};

/// The one check for a path that is about to enter history as a NEW name
/// (`add`, `mv`, a direct commit). Strict where reading is lenient: a manifest
/// Fossil made may carry a name this refuses, and `validateWorktreePath` still
/// checks that one out.
///
/// The shape and zit's reserved roots are `validateWorktreePath`. On top come
/// Fossil's reserved names at any depth (`artifact.isReservedFilename`, file.c
/// `file_is_reserved_name`), Fossil's strict-UTF-8 rule for new names (file.c
/// `file_is_simple_pathname(z, 1)`), and control bytes, which Fossil admits but
/// which break every line-oriented reader of a listing.
pub fn validateNewPath(path: []const u8) NewPathError!void {
    if (!hasNewNameCharacters(path)) return error.IllegalCharacters;
    try validateWorktreePath(path);
    if (artifact.isReservedFilename(path)) return error.ReservedPath;
}

/// The byte rule of `validateNewPath`: no control byte, no backslash, and
/// only UTF-8 that Fossil's file.c `file_is_simple_pathname` takes when
/// `bStrictUtf8` is set -- no overlong form, nothing above U+FFFF, no
/// surrogate, no private-use U+E000..U+F8FF, no noncharacter U+FDD0..U+FDEF
/// or U+FFFE..U+FFFF.
fn hasNewNameCharacters(path: []const u8) bool {
    var i: usize = 0;
    while (i < path.len) {
        const c = path[i];
        if (c < 0x80) {
            if (c < ' ' or c == 0x7f or c == '\\') return false;
            i += 1;
            continue;
        }
        if (c < 0xc2 or c >= 0xf0) return false;
        if (i + 1 >= path.len or path[i + 1] & 0xc0 != 0x80) return false;
        if (c < 0xe0) {
            i += 2;
            continue;
        }
        if (i + 2 >= path.len or path[i + 2] & 0xc0 != 0x80) return false;
        const u: u16 = (@as(u16, c & 0x0f) << 12) | (@as(u16, path[i + 1] & 0x3f) << 6) | (path[i + 2] & 0x3f);
        if (u <= 0x07ff) return false;
        if (u >= 0xd800 and u <= 0xdfff) return false;
        if (u >= 0xe000 and u <= 0xf8ff) return false;
        if (u >= 0xfdd0 and u <= 0xfdef) return false;
        if (u >= 0xfffe) return false;
        i += 3;
    }
    return true;
}

/// 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 (the store, and the checkout
    /// container carrying the record, managed set, undo image and bisect
    /// session).
    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,
};

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

/// The whole root-name policy. The legacy tier (absorbed
/// container siblings, the pre-directory cache/vstat spellings, retired
/// scratch spellings, import provenance) was deleted after the consolidation's
/// migration release: a leftover in a never-re-opened old checkout is now
/// visible to `zit extras` and deletable by `zit clean` — the correct end
/// state. A legacy AUTHORITATIVE sibling still migrates losslessly: the
/// container's byte-1 dispatch folds `.zit-managed`/`.zit-undo`/`.zit-bisect`
/// in on first contact (checkout.zig §8); only the reservation is gone.
pub const reserved = [_]Reserved{
    .{ .name = ".zit", .tier = .authoritative },
    .{ .name = ".zit-checkout", .tier = .authoritative },
    .{ .name = ".zit-stash", .tier = .authoritative },
    // What an imported destination holds of its Git source, which a later
    // `import-git --into` needs; the check-ins carry no Git identity, so
    // losing it costs a full re-import.
    .{ .name = ".zit-import-map", .tier = .authoritative },
    .{ .name = ".zit-import-pending", .tier = .authoritative },
    .{ .name = ".zit-import-staging", .tier = .scratch },
    .{ .name = ".zit-import-completed", .tier = .authoritative },
    // The file versions `purge artifacts` removed on purpose. Not rebuildable:
    // losing it lets the next pull fetch every thinned version back.
    .{ .name = ".zit-thinned", .tier = .authoritative },
    .{ .name = ".zit-cache", .tier = .advisory },
    .{ .name = ".zit-precedent", .tier = .advisory },
    .{ .name = ".zit-tmp-", .tier = .scratch, .prefix = true },
    // The writer lock and its break lock (`storelock`).
    .{ .name = ".zit.lock", .tier = .scratch, .prefix = true },
};

/// 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",
        // Deleted legacy tier, pinned unreserved: these are ordinary
        // working-tree names now.
        ".zit-managed",
        ".zit-undo",
        ".zit-bisect",
        ".zit-vstat",
        ".zit-pack-tmp",
        ".zit-update-txn-x",
        ".zit-import-tmp-x",
        ".zit-import-map.jsonl",
        "refs",
        "refs/heads/main",
    };
    for (negatives) |path| try std.testing.expect(!isReservedRoot(path));
}

test "a new path follows Fossil's strict rule, refuses control bytes, and keeps every reserved name" {
    const ok = [_][]const u8{
        "a.txt",
        "dir/sub/file",
        "with space",
        "caf\xc3\xa9", // U+00E9, two bytes
        "\xe2\x82\xac", // U+20AC, three bytes
        "\xef\xbf\xbd", // U+FFFD, the last character below the noncharacters
        "\xef\xb7\x8f", // U+FDCF, just below U+FDD0
        "\xef\xa4\x80", // U+F900, just past the private-use block
        "sub/x_FOSSIL_", // Fossil reserves the tail only after a '/'
    };
    for (ok) |p| try validateNewPath(p);

    const illegal = [_][]const u8{
        "a\nb", "a\rb", "a\x00b", "a\x01b", "a\x1fb", "a\x7fb", "\tlead", // control bytes
        "a\\b", // backslash
        "\x80", "a\xc3", "\xc0\xaf", "\xc1\xbf", // stray continuation, truncated, overlong 2-byte
        "\xe0\x9f\xbf", // overlong 3-byte
        "\xf0\x9f\x98\x80", // U+1F600, above U+FFFF
        "\xed\xa0\x80", // U+D800 surrogate
        "\xee\x80\x80", "\xef\xa3\xbf", // U+E000, U+F8FF private use
        "\xef\xb7\x90", "\xef\xb7\xaf", // U+FDD0, U+FDEF noncharacters
        "\xef\xbf\xbe", "\xef\xbf\xbf", // U+FFFE, U+FFFF
        "\xe2\x82", // truncated 3-byte
    };
    for (illegal) |p| {
        validateNewPath(p) catch |e| {
            try std.testing.expectEqual(error.IllegalCharacters, e);
            continue;
        };
        std.debug.print("accepted: {f}\n", .{std.zig.fmtString(p)});
        return error.TestUnexpectedResult;
    }

    const shape = [_][]const u8{ "", "/abs", "./a", "../a", "a/../b", "a/./b", "a//b", "a/" };
    for (shape) |p| try std.testing.expectError(error.InvalidPath, validateNewPath(p));

    // Fossil's names at any depth, with one sidecar suffix, any case; and
    // zit's own roots.
    const reserved_names = [_][]const u8{ "_FOSSIL_", ".fslckout", "sub/_fossil_", "a/b/.FSLCKOUT", "_FOSSIL_-journal", "sub/.fslckout-wal", "x_FOSSIL_-shm", ".zit", ".zit-tmp-x/f" };
    for (reserved_names) |p| try std.testing.expectError(error.ReservedPath, validateNewPath(p));
}