//! Transactional working-tree materialization for `zit update CHECKIN`.
//!
//! This layer is deliberately the sole owner of update's filesystem effects:
//! it validates and expands the requested manifest, stages every payload under
//! an exclusive worktree-local transaction directory, proves the current tree
//! clean, then writes `.zit-managed` before atomically committing
//! `.zit-checkout`.
const std = @import("std");
const artifact = @import("artifact.zig");
const checkout = @import("checkout.zig");
const logstore = @import("logstore.zig");
const managed = @import("managed.zig");
const repostate = @import("repostate.zig");
/// Errors intentionally describe the caller-visible update decision rather
/// than leaking individual `std.Io` platform errors.
pub const ApplyError = error{
InvalidCheckinId,
CheckinNotFound,
CheckinContentMissing,
CheckinNameMismatch,
TargetNotManifest,
BaselineNotFound,
BaselineContentMissing,
BaselineNameMismatch,
BaselineNotManifest,
BaselineIsDelta,
StateRecordInvalid,
InvalidPath,
ReservedPath,
SymlinkUnsupported,
CurrentStateInvalid,
WorktreeNotClean,
UnmanagedCollision,
Storage,
Corrupt,
OutOfMemory,
TransactionUnavailable,
RollbackFailed,
CleanupFailed,
};
const ParentError = ApplyError || error{PathMissing};
const TestFault = enum {
after_materialize,
before_cleanup,
};
// Tests use these boundaries to deterministically exercise the transactional
// branches that ordinary filesystem failures cannot reliably reach. Production
// builds always return false and therefore retain their normal behavior.
var test_fault: ?TestFault = null;
fn shouldInjectTestFault(point: TestFault) bool {
if (comptime @import("builtin").is_test) return test_fault == point;
return false;
}
const PlanEntry = struct {
path: []const u8,
name: []const u8,
digest: [logstore.max_hash_len]u8,
digest_len: u8,
mode: artifact.FileMode,
fn deinit(self: *PlanEntry, gpa: std.mem.Allocator) void {
gpa.free(self.path);
gpa.free(self.name);
self.* = undefined;
}
fn digestSlice(self: *const PlanEntry) []const u8 {
return self.digest[0..self.digest_len];
}
};
const Plan = struct {
entries: std.ArrayList(PlanEntry) = .empty,
fn deinit(self: *Plan, gpa: std.mem.Allocator) void {
for (self.entries.items) |*entry| entry.deinit(gpa);
self.entries.deinit(gpa);
self.* = undefined;
}
fn add(self: *Plan, gpa: std.mem.Allocator, file: artifact.FCard) ApplyError!void {
const name = file.hash orelse return;
if (file.mode == .symlink) return error.SymlinkUnsupported;
try validateWorktreePath(file.filename);
var digest: [logstore.max_hash_len]u8 = undefined;
const decoded = try decodeValidatedName(name, &digest);
const path = gpa.dupe(u8, file.filename) catch return error.OutOfMemory;
errdefer gpa.free(path);
const owned_name = gpa.dupe(u8, name) catch return error.OutOfMemory;
errdefer gpa.free(owned_name);
self.entries.append(gpa, .{
.path = path,
.name = owned_name,
.digest = digest,
.digest_len = @intCast(decoded.len),
.mode = file.mode,
}) catch return error.OutOfMemory;
}
fn validateTopology(self: *const Plan) ApplyError!void {
if (self.entries.items.len < 2) return;
for (self.entries.items[1..], self.entries.items[0 .. self.entries.items.len - 1]) |entry, previous| {
if (std.mem.eql(u8, entry.path, previous.path)) return error.InvalidPath;
if (isPathPrefix(previous.path, entry.path)) return error.InvalidPath;
}
}
};
const LoadedManifest = struct {
bytes: []u8,
manifest: artifact.Manifest,
fn deinit(self: *LoadedManifest, gpa: std.mem.Allocator) void {
self.manifest.deinit();
gpa.free(self.bytes);
self.* = undefined;
}
};
const BaselineContext = struct {
expected_hash: ?[]const u8 = null,
manifest: ?*const artifact.Manifest = null,
};
const StateAssembly = struct {
gpa: std.mem.Allocator,
mem: *repostate.MemStore,
state: repostate.RepoState,
target_rid: repostate.Rid,
fn deinit(self: *StateAssembly) void {
self.state.deinit();
self.mem.deinit();
self.gpa.destroy(self.mem);
self.* = undefined;
}
};
const OpenedParent = struct {
dir: std.Io.Dir,
name: []const u8,
owned: bool,
fn close(self: OpenedParent, io: std.Io) void {
if (self.owned) self.dir.close(io);
}
};
const ExistingPath = union(enum) {
missing,
stat: std.Io.File.Stat,
};
/// Directories made while materializing the target are held as borrowed
/// slices into the already-owned plan. Capacity is reserved before any
/// worktree mutation so recording a successful `createDir` cannot fail.
const CreatedDirectories = struct {
paths: std.ArrayList([]const u8) = .empty,
fn init(gpa: std.mem.Allocator, plan: *const Plan) ApplyError!CreatedDirectories {
var capacity: usize = 0;
for (plan.entries.items) |entry| {
for (entry.path) |byte| {
if (byte == '/') capacity += 1;
}
}
var result = CreatedDirectories{};
result.paths.ensureTotalCapacity(gpa, capacity) catch return error.OutOfMemory;
return result;
}
fn deinit(self: *CreatedDirectories, gpa: std.mem.Allocator) void {
self.paths.deinit(gpa);
self.* = undefined;
}
fn record(self: *CreatedDirectories, path: []const u8) void {
self.paths.appendAssumeCapacity(path);
}
fn remove(self: *const CreatedDirectories, io: std.Io, worktree: std.Io.Dir) ApplyError!void {
var index = self.paths.items.len;
while (index > 0) {
index -= 1;
const path = self.paths.items[index];
const parent = openParentNoFollow(io, worktree, path, false, null) catch |err| switch (err) {
error.PathMissing => continue,
else => return @errorCast(err),
};
defer parent.close(io);
parent.dir.deleteDir(io, parent.name) catch |err| switch (err) {
error.FileNotFound => {},
error.DirNotEmpty => return error.Storage,
else => return error.Storage,
};
}
}
};
/// Materialize canonical `target_id` into `worktree`.
///
/// `target_id` is a public canonical artifact name. It is decoded to a raw
/// digest for every `LogStore.lookup`; textual names are never used as log
/// index keys.
pub fn applyCheckin(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
log: *logstore.LogStore,
target_id: []const u8,
) ApplyError!void {
var target = try buildPlan(gpa, log, target_id);
defer target.deinit(gpa);
var old_managed = managed.ManagedSet.read(gpa, io, worktree) catch |err| return mapManagedError(err);
defer old_managed.deinit();
var old = try requireClean(gpa, io, worktree, log, &old_managed);
defer old.deinit(gpa);
const branch = try deriveTargetBranch(gpa, log, target_id);
defer gpa.free(branch);
var desired_managed = managed.ManagedSet.init(gpa);
defer desired_managed.deinit();
for (target.entries.items) |entry| {
_ = desired_managed.insert(entry.path) catch |err| return mapManagedError(err);
}
const txn_name = try createTransaction(gpa, io, worktree);
defer gpa.free(txn_name);
var transaction_live = true;
defer if (transaction_live) worktree.deleteTree(io, txn_name) catch {};
var txn = worktree.openDir(io, txn_name, .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage;
defer txn.close(io);
txn.createDir(io, "next", .default_dir) catch return error.Storage;
txn.createDir(io, "previous", .default_dir) catch return error.Storage;
var next = txn.openDir(io, "next", .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage;
defer next.close(io);
var previous = txn.openDir(io, "previous", .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage;
defer previous.close(io);
// No worktree data is touched until both the desired and rollback payloads
// are verified and durably staged in this same-filesystem transaction dir.
try stagePlan(gpa, io, log, next, &target);
try stagePlan(gpa, io, log, previous, &old);
try preflight(gpa, io, worktree, &target, &old_managed);
var created_directories = try CreatedDirectories.init(gpa, &target);
defer created_directories.deinit(gpa);
commitMaterialization(gpa, io, worktree, next, previous, &target, &old, &desired_managed, &old_managed, &created_directories, .{
.version = target_id,
.branch = branch,
}) catch |err| {
rollbackMaterialization(gpa, io, worktree, previous, &target, &old, &old_managed, &created_directories) catch {
// The staged `previous/` tree is the only complete recovery copy.
// Retain it for recovery rather than letting the deferred cleanup
// destroy it after a failed inverse.
transaction_live = false;
return error.RollbackFailed;
};
return err;
};
// `.zit-checkout` is the commit point. A post-commit cleanup error is
// reported but must never roll the committed tree or metadata back.
transaction_live = false;
if (shouldInjectTestFault(.before_cleanup)) return error.CleanupFailed;
worktree.deleteTree(io, txn_name) catch return error.CleanupFailed;
}
fn buildPlan(gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8) ApplyError!Plan {
if (!artifact.validateName(target_id)) return error.InvalidCheckinId;
var target_digest: [logstore.max_hash_len]u8 = undefined;
const raw_target = try decodeValidatedName(target_id, &target_digest);
const target_rid = log.lookup(raw_target) orelse return error.CheckinNotFound;
const target_bytes = log.content(gpa, target_rid) catch |err| return mapContentError(err);
if (target_bytes == null) return error.CheckinContentMissing;
var target = LoadedManifest{
.bytes = target_bytes.?,
.manifest = undefined,
};
{
errdefer gpa.free(target.bytes);
if (!artifact.verify(target_id, target.bytes)) return error.CheckinNameMismatch;
target.manifest = artifact.parse(gpa, target.bytes) catch |err| return mapTargetParseError(err);
}
defer target.deinit(gpa);
if (target.manifest.kind != .manifest) return error.TargetNotManifest;
var baseline: ?LoadedManifest = null;
defer if (baseline) |*item| item.deinit(gpa);
var baseline_context = BaselineContext{};
if (target.manifest.b) |b| {
var digest: [logstore.max_hash_len]u8 = undefined;
const raw = decodeValidatedName(b.hash, &digest) catch return error.BaselineNotFound;
const rid = log.lookup(raw) orelse return error.BaselineNotFound;
const bytes = log.content(gpa, rid) catch |err| return mapContentError(err);
if (bytes == null) return error.BaselineContentMissing;
var item = LoadedManifest{ .bytes = bytes.?, .manifest = undefined };
{
errdefer gpa.free(item.bytes);
if (!artifact.verify(b.hash, item.bytes)) return error.BaselineNameMismatch;
item.manifest = artifact.parse(gpa, item.bytes) catch |err| return mapBaselineParseError(err);
}
if (item.manifest.kind != .manifest) {
item.deinit(gpa);
return error.BaselineNotManifest;
}
baseline = item;
baseline_context.expected_hash = b.hash;
baseline_context.manifest = &baseline.?.manifest;
}
const target_files = manifestFiles(&target.manifest);
var walk = artifact.enumerate(target_files, .{
.ctx = @ptrCast(&baseline_context),
.resolveFn = resolveBaseline,
}) catch |err| switch (err) {
error.BaselineUnresolvable => return error.BaselineNotFound,
error.BaselineIsDelta => return error.BaselineIsDelta,
};
var result = Plan{};
errdefer result.deinit(gpa);
while (walk.next()) |file| {
if (file.hash == null) continue;
try result.add(gpa, file);
}
try result.validateTopology();
return result;
}
fn manifestFiles(manifest: *const artifact.Manifest) artifact.ManifestFiles {
return .{
.baseline_hash = if (manifest.b) |b| b.hash else null,
.files = manifest.f,
};
}
fn resolveBaseline(context: *anyopaque, hash: []const u8) ?artifact.ManifestFiles {
const baseline: *const BaselineContext = @ptrCast(@alignCast(context));
const expected = baseline.expected_hash orelse return null;
const manifest = baseline.manifest orelse return null;
if (!std.mem.eql(u8, expected, hash)) return null;
return manifestFiles(manifest);
}
fn requireClean(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
log: *logstore.LogStore,
current_managed: *const managed.ManagedSet,
) ApplyError!Plan {
for (current_managed.iterate()) |path| {
validateWorktreePath(path) catch return error.CurrentStateInvalid;
}
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const current = checkout.read(io, worktree, &checkout_buf) catch |err| return mapCheckoutError(err);
if (current == null) {
if (current_managed.iterate().len == 0) return Plan{};
return error.WorktreeNotClean;
}
var plan = buildPlan(gpa, log, current.?.version) catch |err| switch (err) {
error.OutOfMemory, error.Storage, error.Corrupt => return err,
else => return error.CurrentStateInvalid,
};
errdefer plan.deinit(gpa);
if (!sameMembership(&plan, current_managed)) return error.WorktreeNotClean;
for (plan.entries.items) |entry| {
if (!try pathMatchesEntry(gpa, io, worktree, log, &entry)) return error.WorktreeNotClean;
}
return plan;
}
fn sameMembership(plan: *const Plan, current: *const managed.ManagedSet) bool {
const paths = current.iterate();
if (paths.len != plan.entries.items.len) return false;
for (paths, plan.entries.items) |path, entry| {
if (!std.mem.eql(u8, path, entry.path)) return false;
}
return true;
}
fn pathMatchesEntry(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
log: *logstore.LogStore,
entry: *const PlanEntry,
) ApplyError!bool {
const existing = try statExisting(io, worktree, entry.path);
const stat = switch (existing) {
.missing => return false,
.stat => |value| value,
};
if (stat.kind != .file or !modeMatches(stat, entry.mode)) return false;
const rid = log.lookup(entry.digestSlice()) orelse return error.CurrentStateInvalid;
const expected = log.content(gpa, rid) catch |err| return mapContentError(err);
if (expected == null) return error.CurrentStateInvalid;
defer gpa.free(expected.?);
if (!artifact.verify(entry.name, expected.?)) return error.CurrentStateInvalid;
if (stat.size != expected.?.len) return false;
const parent = openParentNoFollow(io, worktree, entry.path, false, null) catch |err| switch (err) {
error.PathMissing => return false,
else => return @errorCast(err),
};
defer parent.close(io);
var file = parent.dir.openFile(io, parent.name, .{
.allow_directory = false,
.follow_symlinks = false,
.resolve_beneath = true,
}) catch return false;
defer file.close(io);
const actual = gpa.alloc(u8, expected.?.len) catch return error.OutOfMemory;
defer gpa.free(actual);
const n = file.readPositionalAll(io, actual, 0) catch return error.Storage;
return n == actual.len and std.mem.eql(u8, actual, expected.?);
}
fn deriveTargetBranch(gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8) ApplyError![]u8 {
var assembly = try assembleState(gpa, log, target_id);
defer assembly.deinit();
var config = log.readConfig(gpa) catch |err| return mapContentError(err);
defer if (config) |*owned| owned.deinit(gpa);
// Storage deliberately leaves an absent config to its caller. The
// repository-state convention is the same `trunk` fallback used by the
// existing state tests; a present config always overrides it.
const main_branch = if (config) |owned| owned.config.default_branch else "trunk";
const branch = assembly.state.branchOf(assembly.target_rid, main_branch);
return gpa.dupe(u8, branch) catch error.OutOfMemory;
}
/// State-record classification is intentionally conservative: any successful
/// classified manifest is crosslinked, any successful classified control
/// artifact ingests only tag effects, and all other successful classifications
/// are opaque. A `SyntaxError` in an unrelated stored payload is also opaque:
/// the log frame format has no structural-kind bit to distinguish a blob from
/// malformed cards. Integrity failures and non-syntax parse/classification
/// failures remain fatal.
fn assembleState(gpa: std.mem.Allocator, log: *logstore.LogStore, target_id: []const u8) ApplyError!StateAssembly {
const mem = gpa.create(repostate.MemStore) catch return error.OutOfMemory;
mem.* = repostate.MemStore.init(gpa);
errdefer {
mem.deinit();
gpa.destroy(mem);
}
var state = repostate.RepoState.init(gpa, mem.store());
errdefer state.deinit();
var target_rid: ?repostate.Rid = null;
const persisted_rids = log.ridsInPersistedOrder(gpa) catch return error.OutOfMemory;
defer gpa.free(persisted_rids);
var raw_name: [64]u8 = undefined;
for (persisted_rids) |rid| {
const name = encodeName(log.hashOf(rid), &raw_name);
const bytes = log.content(gpa, rid) catch |err| return mapContentError(err);
if (bytes == null) continue;
defer gpa.free(bytes.?);
var parsed = artifact.parse(gpa, bytes.?) catch |err| switch (err) {
error.SyntaxError => continue,
error.OutOfMemory => return error.OutOfMemory,
else => return error.StateRecordInvalid,
};
defer parsed.deinit();
switch (parsed.kind) {
.manifest => {
if (!artifact.verify(name, bytes.?)) return error.Corrupt;
const record = try manifestRecord(gpa, name, &parsed);
defer {
gpa.free(record.parents);
gpa.free(record.tags);
}
const state_rid = state.crosslink(record) catch return error.OutOfMemory;
if (std.mem.eql(u8, name, target_id)) target_rid = state_rid;
},
.control => {
if (!artifact.verify(name, bytes.?)) return error.Corrupt;
const record = try controlRecord(gpa, &parsed);
defer gpa.free(record.tags);
_ = state.ingestControlTags(name, record.mtime, record.tags) catch return error.OutOfMemory;
},
else => {},
}
}
return .{
.gpa = gpa,
.mem = mem,
.state = state,
.target_rid = target_rid orelse return error.StateRecordInvalid,
};
}
const StateRecordParts = struct {
mtime: repostate.Mtime,
parents: []const []const u8 = &.{},
tags: []const repostate.TagOp = &.{},
};
fn manifestRecord(gpa: std.mem.Allocator, name: []const u8, manifest: *const artifact.Manifest) ApplyError!repostate.Record {
const parts = try stateRecordParts(gpa, manifest, true);
return .{ .hash = name, .mtime = parts.mtime, .parents = parts.parents, .tags = parts.tags };
}
fn controlRecord(gpa: std.mem.Allocator, manifest: *const artifact.Manifest) ApplyError!StateRecordParts {
return stateRecordParts(gpa, manifest, false);
}
fn stateRecordParts(gpa: std.mem.Allocator, manifest: *const artifact.Manifest, include_parents: bool) ApplyError!StateRecordParts {
const date = manifest.d orelse return error.StateRecordInvalid;
const mtime = repostate.parseMtime(date.timestamp) orelse return error.StateRecordInvalid;
var parents = std.ArrayList([]const u8).empty;
errdefer parents.deinit(gpa);
if (include_parents) {
for (manifest.p) |card| {
var it = card.hashes();
while (it.next()) |hash| {
parents.append(gpa, hash) catch return error.OutOfMemory;
}
}
}
const tags = gpa.alloc(repostate.TagOp, manifest.t.len) catch return error.OutOfMemory;
errdefer gpa.free(tags);
for (manifest.t, tags) |card, *out| {
out.* = .{
.type = switch (card.sign) {
.add => .singleton,
.cancel => .cancel,
.propagate => .propagate,
},
.name = card.name,
.target = card.target,
.value = card.value,
};
}
return .{ .mtime = mtime, .parents = parents.toOwnedSlice(gpa) catch return error.OutOfMemory, .tags = tags };
}
fn stagePlan(gpa: std.mem.Allocator, io: std.Io, log: *logstore.LogStore, stage: std.Io.Dir, plan: *const Plan) ApplyError!void {
for (plan.entries.items) |entry| {
const rid = log.lookup(entry.digestSlice()) orelse return error.Corrupt;
const bytes = log.content(gpa, rid) catch |err| return mapContentError(err);
if (bytes == null) return error.Corrupt;
defer gpa.free(bytes.?);
if (!artifact.verify(entry.name, bytes.?)) return error.Corrupt;
try writePayload(io, stage, entry.path, bytes.?, entry.mode, true, null);
}
}
fn preflight(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
target: *const Plan,
current_managed: *const managed.ManagedSet,
) ApplyError!void {
for (target.entries.items) |entry| {
var replaces_managed_ancestor = false;
var component_end: usize = 0;
while (std.mem.indexOfScalarPos(u8, entry.path, component_end, '/')) |slash| {
const parent = entry.path[0..slash];
const existing = try statExisting(io, worktree, parent);
switch (existing) {
.missing => {},
.stat => |stat| switch (stat.kind) {
.directory => {},
.file => if (current_managed.contains(parent)) {
replaces_managed_ancestor = true;
break;
} else return error.UnmanagedCollision,
else => return error.UnmanagedCollision,
},
}
component_end = slash + 1;
}
if (replaces_managed_ancestor) continue;
const existing = try statExisting(io, worktree, entry.path);
switch (existing) {
.missing => {},
.stat => |stat| switch (stat.kind) {
.file => if (!current_managed.contains(entry.path)) return error.UnmanagedCollision,
.directory => if (!try directoryContainsOnlyManaged(gpa, io, worktree, entry.path, current_managed)) return error.UnmanagedCollision,
else => return error.UnmanagedCollision,
},
}
}
}
fn commitMaterialization(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
next: std.Io.Dir,
previous: std.Io.Dir,
target: *const Plan,
old: *const Plan,
desired_managed: *const managed.ManagedSet,
old_managed: *const managed.ManagedSet,
created_directories: *CreatedDirectories,
next_checkout: checkout.CheckoutState,
) ApplyError!void {
_ = previous;
_ = old_managed;
try removePlanFiles(io, worktree, old, true);
try materializePlan(gpa, io, next, worktree, target, created_directories);
if (shouldInjectTestFault(.after_materialize)) return error.Storage;
desired_managed.write(io, worktree) catch |err| return mapManagedError(err);
checkout.write(io, worktree, next_checkout) catch |err| return mapCheckoutError(err);
}
fn rollbackMaterialization(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
previous: std.Io.Dir,
target: *const Plan,
old: *const Plan,
old_managed: *const managed.ManagedSet,
created_directories: *const CreatedDirectories,
) ApplyError!void {
try removePlanFiles(io, worktree, target, false);
try created_directories.remove(io, worktree);
try materializePlan(gpa, io, previous, worktree, old, null);
old_managed.write(io, worktree) catch |err| return mapManagedError(err);
}
fn removePlanFiles(io: std.Io, worktree: std.Io.Dir, plan: *const Plan, prune_empty_ancestors: bool) ApplyError!void {
for (plan.entries.items) |entry| {
const removed = blk: {
const parent = openParentNoFollow(io, worktree, entry.path, false, null) catch |err| switch (err) {
error.PathMissing => break :blk false,
else => return @errorCast(err),
};
defer parent.close(io);
parent.dir.deleteFile(io, parent.name) catch |err| switch (err) {
error.FileNotFound => break :blk false,
else => return error.Storage,
};
break :blk true;
};
if (prune_empty_ancestors and removed) try pruneEmptyPlanAncestors(io, worktree, entry.path);
}
}
/// Deletes only empty ancestors of a removed managed file. A non-empty
/// directory is never recursed into, so unmanaged entries remain untouched.
fn pruneEmptyPlanAncestors(io: std.Io, worktree: std.Io.Dir, path: []const u8) ApplyError!void {
var index = path.len;
while (index > 0) {
index -= 1;
if (path[index] != '/') continue;
const ancestor = path[0..index];
const parent = openParentNoFollow(io, worktree, ancestor, false, null) catch |err| switch (err) {
error.PathMissing => continue,
else => return @errorCast(err),
};
defer parent.close(io);
parent.dir.deleteDir(io, parent.name) catch |err| switch (err) {
error.FileNotFound, error.DirNotEmpty => {},
else => return error.Storage,
};
}
}
fn materializePlan(
gpa: std.mem.Allocator,
io: std.Io,
stage: std.Io.Dir,
worktree: std.Io.Dir,
plan: *const Plan,
created_directories: ?*CreatedDirectories,
) ApplyError!void {
for (plan.entries.items) |entry| {
try prepareDestination(io, worktree, entry.path);
const source_parent = openParentNoFollow(io, stage, entry.path, false, null) catch |err| switch (err) {
error.PathMissing => return error.Storage,
else => return @errorCast(err),
};
defer source_parent.close(io);
var source = source_parent.dir.openFile(io, source_parent.name, .{
.allow_directory = false,
.follow_symlinks = false,
.resolve_beneath = true,
}) catch return error.Storage;
defer source.close(io);
const stat = source.stat(io) catch return error.Storage;
const bytes = gpa.alloc(u8, stat.size) catch return error.OutOfMemory;
defer gpa.free(bytes);
const n = source.readPositionalAll(io, bytes, 0) catch return error.Storage;
if (n != bytes.len) return error.Storage;
try writePayload(io, worktree, entry.path, bytes, entry.mode, true, created_directories);
}
}
fn prepareDestination(io: std.Io, worktree: std.Io.Dir, path: []const u8) ApplyError!void {
const existing = try statExisting(io, worktree, path);
switch (existing) {
.missing => {},
.stat => |stat| switch (stat.kind) {
.directory => {
const parent = openParentNoFollow(io, worktree, path, false, null) catch |err| switch (err) {
error.PathMissing => return error.Storage,
else => return @errorCast(err),
};
defer parent.close(io);
parent.dir.deleteDir(io, parent.name) catch |err| switch (err) {
error.DirNotEmpty => return error.UnmanagedCollision,
else => return error.Storage,
};
},
else => return error.UnmanagedCollision,
},
}
}
fn writePayload(
io: std.Io,
root: std.Io.Dir,
path: []const u8,
bytes: []const u8,
mode: artifact.FileMode,
exclusive: bool,
created_directories: ?*CreatedDirectories,
) ApplyError!void {
const parent = openParentNoFollow(io, root, path, true, created_directories) catch |err| switch (err) {
error.PathMissing => return error.Storage,
else => return @errorCast(err),
};
defer parent.close(io);
var file = parent.dir.createFile(io, parent.name, .{
.read = false,
.exclusive = exclusive,
.truncate = true,
.permissions = switch (mode) {
.regular => .default_file,
.executable => .executable_file,
.symlink => return error.SymlinkUnsupported,
},
.resolve_beneath = true,
}) catch return error.Storage;
defer file.close(io);
file.writePositionalAll(io, bytes, 0) catch return error.Storage;
file.sync(io) catch return error.Storage;
}
/// The closed error set `restoreFile` surfaces: an I/O failure writing the
/// payload, or a mode the working tree cannot represent as a plain file.
pub const RestoreError = error{ Storage, SymlinkUnsupported };
/// Overwrite the single worktree file `path` under `root` with `bytes` at
/// `mode` (artifact §8.4 effective-file restore), creating any missing parent
/// directories. Unlike the transactional `applyCheckin`, this is a targeted,
/// non-exclusive write of ONE already-resolved file: `zit revert` reconstructs
/// the check-in's blob and hands it here, leaving the managed set untouched.
/// A `.symlink` F card the writer cannot materialize is `SymlinkUnsupported`;
/// every other failure is `Storage`. The write is fsync'd before returning.
pub fn restoreFile(
io: std.Io,
root: std.Io.Dir,
path: []const u8,
bytes: []const u8,
mode: artifact.FileMode,
) RestoreError!void {
writePayload(io, root, path, bytes, mode, false, null) catch |e| switch (e) {
error.SymlinkUnsupported => return error.SymlinkUnsupported,
// `writePayload` with `exclusive=false`, `created_directories=null`
// reaches only `Storage`/`SymlinkUnsupported` (it neither allocates nor
// resolves check-in ids); every residual ApplyError variant collapses
// to the honest "could not write it" report.
else => return error.Storage,
};
}
fn statExisting(io: std.Io, root: std.Io.Dir, path: []const u8) ApplyError!ExistingPath {
const parent = openParentNoFollow(io, root, path, false, null) catch |err| switch (err) {
error.PathMissing => return .missing,
else => return @errorCast(err),
};
defer parent.close(io);
const stat = parent.dir.statFile(io, parent.name, .{ .follow_symlinks = false }) catch |err| switch (err) {
error.FileNotFound => return .missing,
else => return error.Storage,
};
return .{ .stat = stat };
}
fn openParentNoFollow(
io: std.Io,
root: std.Io.Dir,
path: []const u8,
create: bool,
created_directories: ?*CreatedDirectories,
) ParentError!OpenedParent {
var current = root;
var owns_current = false;
errdefer if (owns_current) current.close(io);
var start: usize = 0;
while (std.mem.indexOfScalarPos(u8, path, start, '/')) |slash| {
const component = path[start..slash];
const next = current.openDir(io, component, .{ .access_sub_paths = true, .follow_symlinks = false }) catch |err| switch (err) {
error.FileNotFound => if (create) blk: {
var created = true;
current.createDir(io, component, .default_dir) catch |create_err| switch (create_err) {
error.PathAlreadyExists => created = false,
else => return error.Storage,
};
if (created) if (created_directories) |directories| directories.record(path[0..slash]);
break :blk current.openDir(io, component, .{ .access_sub_paths = true, .follow_symlinks = false }) catch return error.Storage;
} else return error.PathMissing,
else => return error.Storage,
};
if (owns_current) current.close(io);
current = next;
owns_current = true;
start = slash + 1;
}
return .{ .dir = current, .name = path[start..], .owned = owns_current };
}
fn directoryContainsOnlyManaged(
gpa: std.mem.Allocator,
io: std.Io,
root: std.Io.Dir,
path: []const u8,
current_managed: *const managed.ManagedSet,
) ApplyError!bool {
const parent = openParentNoFollow(io, root, path, false, null) catch |err| switch (err) {
error.PathMissing => return error.Storage,
else => return @errorCast(err),
};
defer parent.close(io);
var dir = parent.dir.openDir(io, parent.name, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }) catch return error.Storage;
defer dir.close(io);
return directoryEntriesAreManaged(gpa, io, dir, path, current_managed);
}
fn directoryEntriesAreManaged(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
prefix: []const u8,
current_managed: *const managed.ManagedSet,
) ApplyError!bool {
var iterator = dir.iterate();
while (iterator.next(io) catch return error.Storage) |entry| {
const full = joinPath(gpa, prefix, entry.name) catch return error.OutOfMemory;
defer gpa.free(full);
switch (entry.kind) {
.file => if (!current_managed.contains(full)) return false,
.directory => {
var child = dir.openDir(io, entry.name, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false }) catch return error.Storage;
defer child.close(io);
if (!try directoryEntriesAreManaged(gpa, io, child, full, current_managed)) return false;
},
else => return false,
}
}
return true;
}
fn joinPath(gpa: std.mem.Allocator, prefix: []const u8, name: []const u8) ![]u8 {
const out = try gpa.alloc(u8, prefix.len + 1 + name.len);
@memcpy(out[0..prefix.len], prefix);
out[prefix.len] = '/';
@memcpy(out[prefix.len + 1 ..], name);
return out;
}
fn createTransaction(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) ApplyError![]const u8 {
var random: [12]u8 = undefined;
var encoded: [64]u8 = undefined;
var attempt: usize = 0;
while (attempt < 32) : (attempt += 1) {
io.randomSecure(&random) catch return error.Storage;
const suffix = encodeName(&random, &encoded);
var name: [41]u8 = undefined;
const prefix = ".zit-update-txn-";
@memcpy(name[0..prefix.len], prefix);
@memcpy(name[prefix.len .. prefix.len + suffix.len], suffix);
// Allocate the caller-owned name before the filesystem side effect:
// an ordinary allocation failure must leave no transaction directory.
const owned_name = gpa.dupe(u8, name[0 .. prefix.len + suffix.len]) catch return error.OutOfMemory;
worktree.createDir(io, owned_name, .default_dir) catch |err| {
gpa.free(owned_name);
switch (err) {
error.PathAlreadyExists => continue,
else => return error.Storage,
}
};
return owned_name;
}
return error.TransactionUnavailable;
}
fn validateWorktreePath(path: []const u8) ApplyError!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 it = std.mem.splitScalar(u8, path, '/');
const first = it.next() orelse return error.InvalidPath;
if (first.len == 0 or isReservedRootName(first)) return error.ReservedPath;
while (it.next()) |component| {
if (component.len == 0 or std.mem.eql(u8, component, ".") or std.mem.eql(u8, component, "..")) return error.InvalidPath;
}
if (std.mem.eql(u8, first, ".") or std.mem.eql(u8, first, "..")) return error.InvalidPath;
}
fn isReservedRootName(name: []const u8) bool {
return asciiEqlIgnoreCase(name, checkout.filename) or
asciiEqlIgnoreCase(name, managed.filename) or
asciiStartsWithIgnoreCase(name, ".zit-update-txn-");
}
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 {
if (value.len < prefix.len) return false;
return asciiEqlIgnoreCase(value[0..prefix.len], prefix);
}
fn isPathPrefix(prefix: []const u8, path: []const u8) bool {
return path.len > prefix.len and std.mem.startsWith(u8, path, prefix) and path[prefix.len] == '/';
}
fn modeMatches(stat: std.Io.File.Stat, mode: artifact.FileMode) bool {
if (comptime std.Io.File.Permissions.has_executable_bit) {
const executable = stat.permissions.toMode() & 0o111 != 0;
return switch (mode) {
.regular => !executable,
.executable => executable,
.symlink => false,
};
}
return mode != .symlink;
}
fn decodeValidatedName(name: []const u8, out: *[logstore.max_hash_len]u8) ApplyError![]const u8 {
if (!artifact.validateName(name)) return error.InvalidCheckinId;
const len = name.len / 2;
var i: usize = 0;
while (i < len) : (i += 1) {
out[i] = (hexNibble(name[i * 2]) << 4) | hexNibble(name[i * 2 + 1]);
}
return out[0..len];
}
fn hexNibble(c: u8) u8 {
return if (c >= '0' and c <= '9') c - '0' else c - 'a' + 10;
}
fn encodeName(raw: []const u8, out: *[64]u8) []const u8 {
const alphabet = "0123456789abcdef";
for (raw, 0..) |byte, i| {
out[i * 2] = alphabet[byte >> 4];
out[i * 2 + 1] = alphabet[byte & 0x0f];
}
return out[0 .. raw.len * 2];
}
fn mapContentError(err: logstore.ContentError) ApplyError {
return switch (err) {
error.OutOfMemory => error.OutOfMemory,
error.Storage => error.Storage,
error.Corrupt => error.Corrupt,
};
}
fn mapManagedError(err: managed.ManagedError) ApplyError {
return switch (err) {
error.OutOfMemory => error.OutOfMemory,
error.Storage => error.Storage,
error.Corrupt, error.TooLong, error.TooMany => error.CurrentStateInvalid,
};
}
fn mapCheckoutError(err: checkout.CheckoutError) ApplyError {
return switch (err) {
error.Storage => error.Storage,
error.Corrupt => error.CurrentStateInvalid,
};
}
fn mapTargetParseError(err: artifact.ParseError) ApplyError {
return switch (err) {
error.OutOfMemory => error.OutOfMemory,
else => error.TargetNotManifest,
};
}
fn mapBaselineParseError(err: artifact.ParseError) ApplyError {
return switch (err) {
error.OutOfMemory => error.OutOfMemory,
else => error.BaselineNotManifest,
};
}
const TestBaseline = struct {
files: []const artifact.FCard,
fn resolve(context: *anyopaque, hash: []const u8) ?artifact.ManifestFiles {
const self: *const TestBaseline = @ptrCast(@alignCast(context));
if (!std.mem.eql(u8, hash, "b" ** 40)) return null;
return .{ .baseline_hash = null, .files = self.files };
}
};
fn completeTestArtifact(gpa: std.mem.Allocator, body: []const u8) ![]u8 {
var checksum_buf: [32]u8 = undefined;
const checksum = artifact.selfChecksum(body, &checksum_buf);
return std.fmt.allocPrint(gpa, "{s}Z {s}\n", .{ body, checksum });
}
fn putTestArtifact(log: *logstore.LogStore, name: []const u8, bytes: []const u8) !void {
var digest: [logstore.max_hash_len]u8 = undefined;
const raw = try decodeValidatedName(name, &digest);
_ = try log.put(raw, bytes);
}
fn putCompleteTestArtifact(
gpa: std.mem.Allocator,
log: *logstore.LogStore,
body: []const u8,
id_buf: *[64]u8,
) ![]const u8 {
const bytes = try completeTestArtifact(gpa, body);
defer gpa.free(bytes);
const id = artifact.artifactName(.sha1, bytes, id_buf);
try putTestArtifact(log, id, bytes);
return id;
}
fn putTestPayload(
log: *logstore.LogStore,
bytes: []const u8,
id_buf: *[64]u8,
) ![]const u8 {
const id = artifact.artifactName(.sha1, bytes, id_buf);
try putTestArtifact(log, id, bytes);
return id;
}
fn expectFileState(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
path: []const u8,
expected_bytes: []const u8,
expected_mode: artifact.FileMode,
) !void {
const stat = try worktree.statFile(io, path, .{ .follow_symlinks = false });
try std.testing.expectEqual(.file, stat.kind);
try std.testing.expect(modeMatches(stat, expected_mode));
var file = try worktree.openFile(io, path, .{
.allow_directory = false,
.follow_symlinks = false,
.resolve_beneath = true,
});
defer file.close(io);
const bytes = try gpa.alloc(u8, stat.size);
defer gpa.free(bytes);
const n = try file.readPositionalAll(io, bytes, 0);
try std.testing.expectEqual(expected_bytes.len, n);
try std.testing.expectEqualStrings(expected_bytes, bytes);
}
const RootEntrySet = struct {
names: std.ArrayList([]u8) = .empty,
fn deinit(self: *RootEntrySet, gpa: std.mem.Allocator) void {
for (self.names.items) |name| gpa.free(name);
self.names.deinit(gpa);
self.* = undefined;
}
fn contains(self: *const RootEntrySet, name: []const u8) bool {
for (self.names.items) |candidate| {
if (std.mem.eql(u8, candidate, name)) return true;
}
return false;
}
};
fn snapshotRootEntries(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir) !RootEntrySet {
var entries = RootEntrySet{};
errdefer entries.deinit(gpa);
var iterator = worktree.iterate();
while (try iterator.next(io)) |entry| {
const name = try gpa.dupe(u8, entry.name);
entries.names.append(gpa, name) catch |err| {
gpa.free(name);
return err;
};
}
return entries;
}
fn expectSameRootEntries(gpa: std.mem.Allocator, io: std.Io, worktree: std.Io.Dir, expected: *const RootEntrySet) !void {
var actual = try snapshotRootEntries(gpa, io, worktree);
defer actual.deinit(gpa);
try std.testing.expectEqual(expected.names.items.len, actual.names.items.len);
for (expected.names.items) |name| try std.testing.expect(actual.contains(name));
}
fn setupMutatingFailureFixture(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
log: *logstore.LogStore,
target_id_buf: *[64]u8,
old_id_buf: *[64]u8,
) ![]const u8 {
var old_payload_id_buf: [64]u8 = undefined;
const old_payload_id = try putTestPayload(log, "old executable bytes", &old_payload_id_buf);
var target_payload_id_buf: [64]u8 = undefined;
const target_payload_id = try putTestPayload(log, "new regular bytes", &target_payload_id_buf);
const old_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:09:59\nF old-file {s} x\n", .{old_payload_id});
defer gpa.free(old_body);
const old_id = try putCompleteTestArtifact(gpa, log, old_body, old_id_buf);
const target_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:10:00\nF new-file {s}\n", .{target_payload_id});
defer gpa.free(target_body);
const target_id = try putCompleteTestArtifact(gpa, log, target_body, target_id_buf);
try checkout.write(io, worktree, .{ .version = old_id, .branch = "old-branch" });
var old_managed = managed.ManagedSet.init(gpa);
defer old_managed.deinit();
_ = try old_managed.insert("old-file");
try old_managed.write(io, worktree);
try writePayload(io, worktree, "old-file", "old executable bytes", .executable, true, null);
return target_id;
}
fn setupPathTransitionFixture(
gpa: std.mem.Allocator,
io: std.Io,
worktree: std.Io.Dir,
log: *logstore.LogStore,
old_path: []const u8,
target_path: []const u8,
target_id_buf: *[64]u8,
old_id_buf: *[64]u8,
) ![]const u8 {
var old_payload_id_buf: [64]u8 = undefined;
const old_payload_id = try putTestPayload(log, "old bytes", &old_payload_id_buf);
var target_payload_id_buf: [64]u8 = undefined;
const target_payload_id = try putTestPayload(log, "target bytes", &target_payload_id_buf);
const old_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:09:59\nF {s} {s}\n", .{ old_path, old_payload_id });
defer gpa.free(old_body);
const old_id = try putCompleteTestArtifact(gpa, log, old_body, old_id_buf);
const target_body = try std.fmt.allocPrint(gpa, "D 2007-07-21T14:10:00\nF {s} {s}\n", .{ target_path, target_payload_id });
defer gpa.free(target_body);
const target_id = try putCompleteTestArtifact(gpa, log, target_body, target_id_buf);
try checkout.write(io, worktree, .{ .version = old_id, .branch = "old-branch" });
var old_managed = managed.ManagedSet.init(gpa);
defer old_managed.deinit();
_ = try old_managed.insert(old_path);
try old_managed.write(io, worktree);
try writePayload(io, worktree, old_path, "old bytes", .regular, true, null);
return target_id;
}
test "update transaction creation leaves no directory on name allocation failure" {
const t = std.testing;
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var entries_before = try snapshotRootEntries(t.allocator, io, tmp.dir);
defer entries_before.deinit(t.allocator);
var failing_allocator = std.testing.FailingAllocator.init(t.allocator, .{ .fail_index = 0 });
try t.expectError(error.OutOfMemory, createTransaction(failing_allocator.allocator(), io, tmp.dir));
try t.expect(failing_allocator.has_induced_failure);
try expectSameRootEntries(t.allocator, io, tmp.dir, &entries_before);
}
test "update rollback removes created ancestors before restoring an old file" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
var target_id_buf: [64]u8 = undefined;
var old_id_buf: [64]u8 = undefined;
const target_id = try setupPathTransitionFixture(
t.allocator,
io,
tmp.dir,
&log,
"a",
"a/b/c",
&target_id_buf,
&old_id_buf,
);
var entries_before = try snapshotRootEntries(t.allocator, io, tmp.dir);
defer entries_before.deinit(t.allocator);
test_fault = .after_materialize;
defer test_fault = null;
try t.expectError(error.Storage, applyCheckin(t.allocator, io, tmp.dir, &log, target_id));
try expectFileState(t.allocator, io, tmp.dir, "a", "old bytes", .regular);
try expectSameRootEntries(t.allocator, io, tmp.dir, &entries_before);
var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
defer current_managed.deinit();
try t.expectEqual(@as(usize, 1), current_managed.iterate().len);
try t.expect(current_managed.contains("a"));
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
try t.expectEqualStrings(old_id_buf[0..40], state.version);
try t.expectEqualStrings("old-branch", state.branch);
}
test "update replaces managed directory hierarchy with a file" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
var target_id_buf: [64]u8 = undefined;
var old_id_buf: [64]u8 = undefined;
const target_id = try setupPathTransitionFixture(
t.allocator,
io,
tmp.dir,
&log,
"a/b/c",
"a",
&target_id_buf,
&old_id_buf,
);
try applyCheckin(t.allocator, io, tmp.dir, &log, target_id);
try expectFileState(t.allocator, io, tmp.dir, "a", "target bytes", .regular);
var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
defer current_managed.deinit();
try t.expectEqual(@as(usize, 1), current_managed.iterate().len);
try t.expect(current_managed.contains("a"));
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
try t.expectEqualStrings(target_id, state.version);
try t.expectEqualStrings("trunk", state.branch);
}
test "update restores mutated bytes mode managed set and checkout after pre-commit failure" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
var target_id_buf: [64]u8 = undefined;
var old_id_buf: [64]u8 = undefined;
const target_id = try setupMutatingFailureFixture(t.allocator, io, tmp.dir, &log, &target_id_buf, &old_id_buf);
test_fault = .after_materialize;
defer test_fault = null;
try t.expectError(error.Storage, applyCheckin(t.allocator, io, tmp.dir, &log, target_id));
try expectFileState(t.allocator, io, tmp.dir, "old-file", "old executable bytes", .executable);
try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "new-file", .{ .follow_symlinks = false }));
var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
defer current_managed.deinit();
try t.expectEqual(@as(usize, 1), current_managed.iterate().len);
try t.expect(current_managed.contains("old-file"));
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
try t.expectEqualStrings(old_id_buf[0..40], state.version);
try t.expectEqualStrings("old-branch", state.branch);
}
test "update retains the committed checkout after post-checkout cleanup failure" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
var target_id_buf: [64]u8 = undefined;
var old_id_buf: [64]u8 = undefined;
const target_id = try setupMutatingFailureFixture(t.allocator, io, tmp.dir, &log, &target_id_buf, &old_id_buf);
test_fault = .before_cleanup;
defer test_fault = null;
try t.expectError(error.CleanupFailed, applyCheckin(t.allocator, io, tmp.dir, &log, target_id));
try expectFileState(t.allocator, io, tmp.dir, "new-file", "new regular bytes", .regular);
try t.expectError(error.FileNotFound, tmp.dir.statFile(io, "old-file", .{ .follow_symlinks = false }));
var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
defer current_managed.deinit();
try t.expectEqual(@as(usize, 1), current_managed.iterate().len);
try t.expect(current_managed.contains("new-file"));
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
const state = (try checkout.read(io, tmp.dir, &checkout_buf)).?;
try t.expectEqualStrings(target_id, state.version);
try t.expectEqualStrings("trunk", state.branch);
}
test "update delta expansion skips unmatched delete markers" {
const files = [_]artifact.FCard{
.{ .filename = "gone", .hash = null, .perm = null, .old_name = null, .mode = .regular },
.{ .filename = "kept", .hash = "a" ** 40, .perm = null, .old_name = null, .mode = .regular },
};
var baseline = TestBaseline{ .files = &.{} };
var walk = try artifact.enumerate(.{ .baseline_hash = "b" ** 40, .files = &files }, .{
.ctx = @ptrCast(&baseline),
.resolveFn = TestBaseline.resolve,
});
var materialized: usize = 0;
while (walk.next()) |file| {
if (file.hash == null) continue;
materialized += 1;
try std.testing.expectEqualStrings("kept", file.filename);
}
try std.testing.expectEqual(@as(usize, 1), materialized);
}
test "update bootstrap clean state leaves checkout metadata absent" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
var current_managed = try managed.ManagedSet.read(t.allocator, io, tmp.dir);
defer current_managed.deinit();
var plan = try requireClean(t.allocator, io, tmp.dir, &log, ¤t_managed);
defer plan.deinit(t.allocator);
try t.expectEqual(@as(usize, 0), plan.entries.items.len);
var checkout_buf: [checkout.max_ck_payload + 1]u8 = undefined;
try t.expectEqual(@as(?checkout.CheckoutState, null), try checkout.read(io, tmp.dir, &checkout_buf));
try t.expectEqual(@as(usize, 0), current_managed.iterate().len);
}
test "update applies path admission before staging" {
try std.testing.expectError(error.ReservedPath, validateWorktreePath(".ZiT-Managed"));
try std.testing.expectError(error.ReservedPath, validateWorktreePath(".ZIT-UPDATE-TXN-deadbeef/file"));
try std.testing.expectError(error.InvalidPath, validateWorktreePath("../outside"));
try std.testing.expectError(error.InvalidPath, validateWorktreePath("a//b"));
}
test "update state assembly rejects a manifest stored under another name" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{"a" ** 40});
defer t.allocator.free(target_body);
const target_bytes = try completeTestArtifact(t.allocator, target_body);
defer t.allocator.free(target_bytes);
var target_id_buf: [64]u8 = undefined;
const target_id = artifact.artifactName(.sha1, target_bytes, &target_id_buf);
try putTestArtifact(&log, target_id, target_bytes);
const misnamed_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:10:00\nF other {s}\n", .{"b" ** 40});
defer t.allocator.free(misnamed_body);
const misnamed_bytes = try completeTestArtifact(t.allocator, misnamed_body);
defer t.allocator.free(misnamed_bytes);
var wrong_name_buf: [64]u8 = undefined;
const wrong_name = artifact.artifactName(.sha1, "a different artifact", &wrong_name_buf);
try putTestArtifact(&log, wrong_name, misnamed_bytes);
try t.expectError(error.Corrupt, assembleState(t.allocator, &log, target_id));
}
test "update state assembly rejects a control stored under another name" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{"a" ** 40});
defer t.allocator.free(target_body);
const target_bytes = try completeTestArtifact(t.allocator, target_body);
defer t.allocator.free(target_bytes);
var target_id_buf: [64]u8 = undefined;
const target_id = artifact.artifactName(.sha1, target_bytes, &target_id_buf);
try putTestArtifact(&log, target_id, target_bytes);
const control_bytes = try completeTestArtifact(t.allocator, "D 2007-07-21T14:10:00\nT +branch cccccccccccccccccccccccccccccccccccccccc trunk\nU alice\n");
defer t.allocator.free(control_bytes);
var wrong_name_buf: [64]u8 = undefined;
const wrong_name = artifact.artifactName(.sha1, "a different control artifact", &wrong_name_buf);
try putTestArtifact(&log, wrong_name, control_bytes);
try t.expectError(error.Corrupt, assembleState(t.allocator, &log, target_id));
}
test "update state assembly retains the stable MemStore backing RepoState" {
const t = std.testing;
const test_storage = @import("storage.zig");
var tmp = t.tmpDir(.{});
defer tmp.cleanup();
const io = t.io;
var raw_file = try tmp.dir.createFile(io, "repo.zit", .{ .read = true });
defer raw_file.close(io);
var file_storage = test_storage.FileStorage.init(io, raw_file, 0);
var log = try logstore.LogStore.open(t.allocator, file_storage.storageFile(), 0);
defer log.deinit(t.allocator);
const target_body = try std.fmt.allocPrint(t.allocator, "D 2007-07-21T14:09:59\nF target {s}\n", .{"a" ** 40});
defer t.allocator.free(target_body);
const target_bytes = try completeTestArtifact(t.allocator, target_body);
defer t.allocator.free(target_bytes);
var target_id_buf: [64]u8 = undefined;
const target_id = artifact.artifactName(.sha1, target_bytes, &target_id_buf);
try putTestArtifact(&log, target_id, target_bytes);
var assembly = try assembleState(t.allocator, &log, target_id);
defer assembly.deinit();
try t.expectEqual(@intFromPtr(assembly.mem), @intFromPtr(assembly.state.store.context));
const added = try assembly.state.crosslink(.{
.hash = "b" ** 40,
.mtime = 1,
});
try t.expectEqual(added, assembly.mem.store().lookup("b" ** 40).?);
}