Skip to content
zit Artifact [8c64b82385]
Login

Artifact [8c64b82385]

Artifact 8c64b823854bf4c645a5da017c851cd12a4cb46d88e5b94fdf5853b4968076bf:


//! Incremental import's durable, source-free roll-forward protocol.
//! The immutable prepared bundle is recovery evidence, never a scratch journal.
//! Only an exact prefix of its tail may already exist. Foreign bytes are never
//! truncated. Callers hold the resolved store writer lock throughout mutation.
const std = @import("std");
const storage = @import("storage.zig");
const Hooks = @import("publication.zig").PublicationHooks;
const Hash = std.crypto.hash.sha2.Sha256;

pub const pending_name = ".zit-import-pending";
pub const staging_name = ".zit-import-staging";
const completed_name = ".zit-import-completed";
const map_name = ".zit-import-map";
const metadata_name = "PREPARED";
const metadata_size = 200;

pub fn requireIdle(io: std.Io, dir: std.Io.Dir) !void {
    try requireAbsent(io, dir, pending_name);
    // An unsynced retirement rename may roll back after power loss. Keep
    // other writers fenced until explicit recovery durably acknowledges it.
    try requireAbsent(io, dir, completed_name);
}

fn requireAbsent(io: std.Io, dir: std.Io.Dir, name: []const u8) !void {
    _ = dir.statFile(io, name, .{ .follow_symlinks = false }) catch |err| switch (err) {
        error.FileNotFound => return,
        else => return err,
    };
    return error.ImportRecoveryRequired;
}

/// An append-only virtual log: existing bytes stay on their retained descriptor,
/// and new bytes go exclusively to the staging tail. No whole-store copy.
pub const Overlay = struct {
    base: storage.StorageFile,
    tail: storage.StorageFile,
    base_len: u64,

    pub fn file(self: *Overlay) storage.StorageFile {
        return .{ .context = self, .vtable = &vtable };
    }
    const vtable: storage.StorageFile.VTable = .{
        .append = append,
        .pread = pread,
        .sync = sync,
        .size = size,
        .truncate = truncate,
        .refresh_end = refresh,
    };
    fn get(ctx: *anyopaque) *Overlay {
        return @ptrCast(@alignCast(ctx));
    }
    fn append(ctx: *anyopaque, bytes: []const u8) storage.StorageError!void {
        return get(ctx).tail.append(bytes);
    }
    fn pread(ctx: *anyopaque, offset: u64, bytes: []u8) storage.StorageError!usize {
        const self = get(ctx);
        if (offset >= self.base_len) return self.tail.pread(offset - self.base_len, bytes);
        const n = try self.base.pread(offset, bytes[0..@min(bytes.len, self.base_len - offset)]);
        if (offset + n != self.base_len or n == bytes.len) return n;
        return n + try self.tail.pread(0, bytes[n..]);
    }
    fn sync(ctx: *anyopaque) storage.StorageError!void {
        return get(ctx).tail.sync();
    }
    fn size(ctx: *anyopaque) storage.StorageError!u64 {
        const self = get(ctx);
        return std.math.add(u64, self.base_len, try self.tail.size()) catch error.Io;
    }
    fn truncate(ctx: *anyopaque, len: u64) storage.StorageError!void {
        const self = get(ctx);
        if (len < self.base_len) return error.Io;
        return self.tail.truncate(len - self.base_len);
    }
    fn refresh(_: *anyopaque) storage.StorageError!void {}
};

pub fn digest(io: std.Io, file: std.Io.File, offset: u64, len: u64) ![32]u8 {
    var hasher = Hash.init(.{});
    var buffer: [64 * 1024]u8 = undefined;
    var done: u64 = 0;
    while (done < len) {
        const chunk = buffer[0..@min(buffer.len, len - done)];
        if (try file.readPositionalAll(io, chunk, offset + done) != chunk.len) return error.ShortRead;
        hasher.update(chunk);
        done += chunk.len;
    }
    return hasher.finalResult();
}

pub fn digestStorage(file: storage.StorageFile, len: u64) ![32]u8 {
    var hasher = Hash.init(.{});
    var buffer: [64 * 1024]u8 = undefined;
    var done: u64 = 0;
    while (done < len) {
        const chunk = buffer[0..@min(buffer.len, len - done)];
        if (try file.pread(done, chunk) != chunk.len) return error.ShortRead;
        hasher.update(chunk);
        done += chunk.len;
    }
    return hasher.finalResult();
}

fn copy(io: std.Io, source: std.Io.File, dest: std.Io.File, source_offset: u64, dest_offset: u64, len: u64, hooks: Hooks) !void {
    var buffer: [64 * 1024]u8 = undefined;
    var done: u64 = 0;
    while (done < len) {
        const chunk = buffer[0..@min(buffer.len, len - done)];
        if (try source.readPositionalAll(io, chunk, source_offset + done) != chunk.len) return error.ShortRead;
        try dest.writePositionalAll(io, chunk, dest_offset + done);
        done += chunk.len;
        try hooks.check(.into_append_chunk);
    }
}

fn copyMember(io: std.Io, source_dir: std.Io.Dir, source_name: []const u8, dest_dir: std.Io.Dir, dest_name: []const u8) !void {
    const source = try source_dir.openFile(io, source_name, .{});
    defer source.close(io);
    const dest = try dest_dir.createFile(io, dest_name, .{ .exclusive = true });
    defer dest.close(io);
    try copy(io, source, dest, 0, 0, (try source.stat(io)).size, .{});
    try dest.sync(io);
}

/// Called under the writer lock and only while no canonical pending bundle exists.
pub fn begin(io: std.Io, dir: std.Io.Dir) !std.Io.Dir {
    try requireIdle(io, dir);
    try dir.deleteTree(io, staging_name);
    try dir.deleteTree(io, completed_name);
    try storage.syncDirectory(io, dir);
    try dir.createDir(io, staging_name, .default_dir);
    return dir.openDir(io, staging_name, .{});
}

/// All members are complete and synced before PREPARED is made canonical.
/// From the rename attempt onward, failure means inspect/recover, never rollback.
pub fn prepare(io: std.Io, dir: std.Io.Dir, stage: std.Io.Dir, base: std.Io.File, hooks: Hooks) !void {
    try hooks.check(.into_before_prepare);
    try copyMember(io, dir, map_name, stage, "old-map");
    var bytes: [metadata_size]u8 = undefined;
    @memcpy(bytes[0..8], "ZITIMTX2");
    const tail = try stage.openFile(io, "tail", .{});
    defer tail.close(io);
    const old_map = try stage.openFile(io, "old-map", .{});
    defer old_map.close(io);
    const new_map = try stage.openFile(io, map_name, .{});
    defer new_map.close(io);
    const files = [_]std.Io.File{ base, tail, old_map, new_map };
    for (files, 0..) |member, i| {
        const len = (try member.stat(io)).size;
        std.mem.writeInt(u64, bytes[8 + i * 8 ..][0..8], len, .little);
        @memcpy(bytes[40 + i * 32 ..][0..32], &(try digest(io, member, 0, len)));
        try member.sync(io);
    }
    Hash.hash(bytes[0..168], bytes[168..200], .{});
    const metadata = try stage.createFile(io, metadata_name, .{ .exclusive = true });
    defer metadata.close(io);
    try metadata.writePositionalAll(io, &bytes, 0);
    try metadata.sync(io);
    try hooks.check(.into_before_prepare_sync);
    try storage.syncDirectory(io, stage);
    try dir.rename(staging_name, dir, pending_name, io);
    try hooks.check(.into_after_prepare_rename);
    try storage.syncDirectory(io, dir);
    try hooks.check(.into_after_prepare);
}

const Prepared = struct {
    sizes: [4]u64,
    hashes: [4][32]u8,
};

fn readPrepared(io: std.Io, bundle: std.Io.Dir) !Prepared {
    var bytes: [metadata_size + 1]u8 = undefined;
    const raw = try bundle.readFile(io, metadata_name, &bytes);
    if (raw.len != metadata_size or !std.mem.eql(u8, raw[0..8], "ZITIMTX2")) return error.DamagedImportJournal;
    var checksum: [32]u8 = undefined;
    Hash.hash(raw[0..168], &checksum, .{});
    if (!std.mem.eql(u8, &checksum, raw[168..200])) return error.DamagedImportJournal;
    var prepared: Prepared = undefined;
    for (0..4) |i| {
        prepared.sizes[i] = std.mem.readInt(u64, raw[8 + i * 8 ..][0..8], .little);
        @memcpy(&prepared.hashes[i], raw[40 + i * 32 ..][0..32]);
    }
    _ = std.math.add(u64, prepared.sizes[0], prepared.sizes[1]) catch return error.DamagedImportJournal;
    return prepared;
}

fn validateMember(io: std.Io, member: std.Io.File, size: u64, hash: [32]u8) !void {
    if ((try member.stat(io)).size != size) return error.DamagedImportJournal;
    if (!std.mem.eql(u8, &(try digest(io, member, 0, size)), &hash)) return error.DamagedImportJournal;
}

/// Returns false on an already complete destination. The caller has acquired
/// writer exclusion, but has NOT opened the log through its tail-repair API.
pub fn recover(io: std.Io, dir: std.Io.Dir, hooks: Hooks) !bool {
    var bundle = dir.openDir(io, pending_name, .{}) catch |err| switch (err) {
        error.FileNotFound => {
            try requireAbsent(io, dir, pending_name);
            // A previous completion may have failed after the retirement rename.
            // Acknowledge that directory transition before erasing its evidence.
            try storage.syncDirectory(io, dir);
            try dir.deleteTree(io, completed_name);
            try storage.syncDirectory(io, dir);
            return false;
        },
        else => return err,
    };
    defer bundle.close(io);
    // The protocol never leaves both directories; only outside interference
    // does. Refuse it permanently rather than invite futile retries.
    if (dir.statFile(io, completed_name, .{ .follow_symlinks = false })) |_| {
        return error.ConflictingImportState;
    } else |err| switch (err) {
        error.FileNotFound => {},
        else => return err,
    }
    const prepared = try readPrepared(io, bundle);
    const tail = try bundle.openFile(io, "tail", .{});
    defer tail.close(io);
    const old_map = try bundle.openFile(io, "old-map", .{});
    defer old_map.close(io);
    const new_map = try bundle.openFile(io, map_name, .{});
    defer new_map.close(io);
    try validateMember(io, tail, prepared.sizes[1], prepared.hashes[1]);
    try validateMember(io, old_map, prepared.sizes[2], prepared.hashes[2]);
    try validateMember(io, new_map, prepared.sizes[3], prepared.hashes[3]);
    const live = try dir.openFile(io, ".zit", .{ .mode = .read_write });
    defer live.close(io);
    const live_size = (try live.stat(io)).size;
    const base_len = prepared.sizes[0];
    const final_len = base_len + prepared.sizes[1];
    if (live_size < base_len or live_size > final_len) return error.ForeignImportSuffix;
    if (!std.mem.eql(u8, &(try digest(io, live, 0, base_len)), &prepared.hashes[0])) return error.ImportBaseMismatch;
    const prefix_len = live_size - base_len;
    if (!std.mem.eql(u8, &(try digest(io, live, base_len, prefix_len)), &(try digest(io, tail, 0, prefix_len)))) return error.ForeignImportSuffix;
    const current_map = try dir.openFile(io, map_name, .{});
    defer current_map.close(io);
    const current_size = (try current_map.stat(io)).size;
    const current_hash = try digest(io, current_map, 0, current_size);
    const is_old = current_size == prepared.sizes[2] and std.mem.eql(u8, &current_hash, &prepared.hashes[2]);
    const is_new = current_size == prepared.sizes[3] and std.mem.eql(u8, &current_hash, &prepared.hashes[3]);
    if (!is_old and !is_new) return error.ImportMapMismatch;
    if (is_new and live_size != final_len) return error.DamagedImportJournal;
    // Preparation may have stopped after rename but before directory sync.
    // Recovery must make its canonical fence durable before touching live bytes.
    try hooks.check(.into_before_recovery_sync);
    try storage.syncDirectory(io, dir);
    try hooks.check(.into_after_recovery_sync);
    try hooks.check(.into_before_append);
    try copy(io, tail, live, prefix_len, live_size, final_len - live_size, hooks);
    try hooks.check(.into_before_live_sync);
    try live.sync(io);
    try hooks.check(.into_after_live_sync);
    // The only prepared map must never be renamed: keep immutable evidence
    // through ambiguous replace/fsync results and retry from a separate copy.
    var publication = try dir.createFileAtomic(io, map_name, .{ .replace = true });
    defer publication.deinit(io);
    try copy(io, new_map, publication.file, 0, 0, prepared.sizes[3], .{});
    try publication.file.sync(io);
    try hooks.check(.into_before_map_replace);
    try publication.replace(io);
    try hooks.check(.into_after_map_replace);
    try storage.syncDirectory(io, dir);
    try hooks.check(.into_after_map_sync);
    // Retire atomically; never erase members while the canonical marker exists.
    try dir.rename(pending_name, dir, completed_name, io);
    try hooks.check(.into_after_retire);
    try storage.syncDirectory(io, dir);
    try hooks.check(.into_after_retire_sync);
    try dir.deleteTree(io, completed_name);
    try storage.syncDirectory(io, dir);
    return true;
}

/// Publication capture is read-only. A v2 map's fresh token detects a complete
/// import (including marker ABA) between the two observations. Every changed
/// incremental publication writes v2, so two v1 observations cannot straddle
/// one: a legacy map needs only a fixed-size witness, never a whole-map hash
/// on every read.
/// The map is optional to readers: a damaged or unreadable one yields a fixed
/// sentinel so the repository stays readable and verifiable; only an import or
/// `info --imported-head` depends on the map's contents, and each rejects it.
const unreadable_witness: [32]u8 = "unreadable import map witness...".*;

fn witness(io: std.Io, dir: std.Io.Dir) !?[32]u8 {
    const file = dir.openFile(io, map_name, .{}) catch |err| switch (err) {
        error.FileNotFound => return null,
        else => return unreadable_witness,
    };
    defer file.close(io);
    var header: [42]u8 = undefined;
    const n = file.readPositionalAll(io, &header, 0) catch return unreadable_witness;
    if (n >= 10 and std.mem.eql(u8, header[0..8], "ZITGIMAP") and std.mem.readInt(u16, header[8..10], .little) == 2) {
        if (n != header.len) return unreadable_witness;
        return header[10..42].*;
    }
    var legacy: [32]u8 = @splat(0);
    @memcpy(legacy[0..@min(n, legacy.len)], header[0..@min(n, legacy.len)]);
    return legacy;
}

pub const Snapshot = struct {
    file: std.Io.File,
    size: u64,
    accepted_stat: std.Io.File.Stat,
    publication_witness: ?[32]u8,

    pub fn close(self: Snapshot, io: std.Io) void {
        self.file.close(io);
    }
    pub fn stat(self: Snapshot, _: std.Io) error{}!std.Io.File.Stat {
        return self.accepted_stat;
    }
};
pub fn capture(io: std.Io, dir: std.Io.Dir) !Snapshot {
    for (0..4) |_| {
        const before = try witness(io, dir);
        try requireIdle(io, dir);
        const file = try dir.openFile(io, ".zit", .{});
        errdefer file.close(io);
        const stat = try file.stat(io);
        try requireIdle(io, dir);
        const after = try witness(io, dir);
        const path = try dir.statFile(io, ".zit", .{});
        if (std.meta.eql(before, after) and stat.inode == path.inode and stat.size == path.size and stat.kind == .file) return .{ .file = file, .size = stat.size, .accepted_stat = stat, .publication_witness = before };
        file.close(io);
    }
    return error.ImportSnapshotChanged;
}

fn preparedFixture(io: std.Io, dir: std.Io.Dir) !void {
    try dir.writeFile(io, .{ .sub_path = ".zit", .data = "base" });
    try dir.writeFile(io, .{ .sub_path = map_name, .data = "old map" });
    var stage = try begin(io, dir);
    defer stage.close(io);
    try stage.writeFile(io, .{ .sub_path = "tail", .data = "prepared tail" });
    try stage.writeFile(io, .{ .sub_path = map_name, .data = "new map" });
    const base = try dir.openFile(io, ".zit", .{});
    defer base.close(io);
    try prepare(io, dir, stage, base, .{});
}

test "recovery accepts every exact tail prefix including a restored file identity" {
    const io = std.testing.io;
    for (0.."prepared tail".len + 1) |prefix| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try preparedFixture(io, tmp.dir);
        const replacement = try tmp.dir.createFile(io, "restored", .{});
        try replacement.writePositionalAll(io, "base", 0);
        try replacement.writePositionalAll(io, "prepared tail"[0..prefix], 4);
        replacement.close(io);
        try tmp.dir.rename("restored", tmp.dir, ".zit", io);
        try std.testing.expect(try recover(io, tmp.dir, .{}));
        var bytes: [64]u8 = undefined;
        try std.testing.expectEqualStrings("baseprepared tail", try tmp.dir.readFile(io, ".zit", &bytes));
        try std.testing.expectEqualStrings("new map", try tmp.dir.readFile(io, map_name, &bytes));
        try std.testing.expect(!try recover(io, tmp.dir, .{}));
        try std.testing.expect(!try recover(io, tmp.dir, .{}));
    }
}

test "recovery preserves foreign suffixes and refuses damaged evidence or stale maps" {
    const io = std.testing.io;
    for (0..5) |damage| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try preparedFixture(io, tmp.dir);
        switch (damage) {
            0 => try tmp.dir.writeFile(io, .{ .sub_path = ".zit", .data = "baseforeign" }),
            1 => try tmp.dir.writeFile(io, .{ .sub_path = ".zit", .data = "baseprepared tailforeign" }),
            2 => try tmp.dir.writeFile(io, .{ .sub_path = pending_name ++ "/tail", .data = "damaged tail" }),
            3 => try tmp.dir.writeFile(io, .{ .sub_path = pending_name ++ "/PREPARED", .data = "damaged" }),
            4 => try tmp.dir.writeFile(io, .{ .sub_path = map_name, .data = "foreign map" }),
            else => unreachable,
        }
        const original = try tmp.dir.readFileAlloc(io, ".zit", std.testing.allocator, .limited(1024));
        defer std.testing.allocator.free(original);
        if (recover(io, tmp.dir, .{})) |_| return error.AcceptedDamagedEvidence else |_| {}
        var bytes: [64]u8 = undefined;
        try std.testing.expectEqualStrings(original, try tmp.dir.readFile(io, ".zit", &bytes));
        try std.testing.expectError(error.ImportRecoveryRequired, requireIdle(io, tmp.dir));
    }
}

test "publication capture refuses pending state without creating reader files" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = ".zit", .data = "base" });
    const snapshot = try capture(io, tmp.dir);
    defer snapshot.close(io);
    try tmp.dir.createDir(io, pending_name, .default_dir);
    try std.testing.expectError(error.ImportRecoveryRequired, capture(io, tmp.dir));
    var fs = storage.FileStorage.init(io, snapshot, snapshot.size);
    const raw = try tmp.dir.openFile(io, ".zit", .{ .mode = .read_write });
    defer raw.close(io);
    try raw.writePositionalAll(io, "uncommitted", 4);
    try fs.storageFile().refreshEnd();
    var bytes: [32]u8 = undefined;
    try std.testing.expectEqual(@as(usize, 4), try fs.storageFile().pread(0, &bytes));
}

test "recovery makes an ambiguous preparation durable before appending" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = ".zit", .data = "base" });
    try tmp.dir.writeFile(io, .{ .sub_path = map_name, .data = "old map" });
    var stage = try begin(io, tmp.dir);
    defer stage.close(io);
    try stage.writeFile(io, .{ .sub_path = "tail", .data = "tail" });
    try stage.writeFile(io, .{ .sub_path = map_name, .data = "new map" });
    const base = try tmp.dir.openFile(io, ".zit", .{});
    defer base.close(io);
    try std.testing.expectError(error.InjectedPublicationFailure, prepare(io, tmp.dir, stage, base, .{ .fail_at = .into_after_prepare_rename }));
    try std.testing.expectError(error.InjectedPublicationFailure, recover(io, tmp.dir, .{ .fail_at = .into_before_recovery_sync }));
    var bytes: [32]u8 = undefined;
    try std.testing.expectEqualStrings("base", try tmp.dir.readFile(io, ".zit", &bytes));
    const Order = struct {
        synced: bool = false,
        appended: bool = false,
        invalid: bool = false,
        fn observe(ctx: *anyopaque, point: Hooks.FailurePoint) void {
            const self: *@This() = @ptrCast(@alignCast(ctx));
            if (point == .into_after_recovery_sync) self.synced = true;
            if (point == .into_before_append) {
                self.appended = true;
                if (!self.synced) self.invalid = true;
            }
        }
    };
    var order: Order = .{};
    try std.testing.expect(try recover(io, tmp.dir, .{ .observer = .{ .context = &order, .notify = Order.observe } }));
    try std.testing.expect(order.synced and order.appended and !order.invalid);
}

test "uncertain retirement keeps admission fenced until directory acknowledgement" {
    const io = std.testing.io;
    for ([_]bool{ false, true }) |rename_rolled_back| {
        var tmp = std.testing.tmpDir(.{});
        defer tmp.cleanup();
        try preparedFixture(io, tmp.dir);
        try std.testing.expectError(error.InjectedPublicationFailure, recover(io, tmp.dir, .{ .fail_at = .into_after_retire }));
        try std.testing.expectError(error.ImportRecoveryRequired, requireIdle(io, tmp.dir));
        if (rename_rolled_back) try tmp.dir.rename(completed_name, tmp.dir, pending_name, io);
        try std.testing.expectEqual(rename_rolled_back, try recover(io, tmp.dir, .{}));
        try requireIdle(io, tmp.dir);
        var bytes: [64]u8 = undefined;
        try std.testing.expectEqualStrings("baseprepared tail", try tmp.dir.readFile(io, ".zit", &bytes));
        try std.testing.expectEqualStrings("new map", try tmp.dir.readFile(io, map_name, &bytes));
    }
}

test "legacy witness is fixed-size while every v2 publication token is observed" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const header = "ZITGIMAP" ++ [_]u8{ 1, 0 } ++ "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
    try tmp.dir.writeFile(io, .{ .sub_path = map_name, .data = header ++ "first legacy body" });
    const first = (try witness(io, tmp.dir)).?;
    try tmp.dir.writeFile(io, .{ .sub_path = map_name, .data = header ++ "a much longer legacy body that differs entirely from the first one" });
    try std.testing.expectEqualSlices(u8, &first, &(try witness(io, tmp.dir)).?);
    var v2 = "ZITGIMAP".* ++ [_]u8{ 2, 0 } ++ @as([32]u8, @splat(7));
    try tmp.dir.writeFile(io, .{ .sub_path = map_name, .data = &v2 });
    const token = (try witness(io, tmp.dir)).?;
    try std.testing.expect(!std.mem.eql(u8, &first, &token));
    v2[10] = 8;
    try tmp.dir.writeFile(io, .{ .sub_path = map_name, .data = &v2 });
    try std.testing.expect(!std.mem.eql(u8, &token, &(try witness(io, tmp.dir)).?));
}

test "a damaged optional import map never makes the repository unreadable" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = ".zit", .data = "base" });
    try tmp.dir.writeFile(io, .{ .sub_path = map_name, .data = "ZITGIMAP" ++ [_]u8{ 2, 0 } ++ "short" });
    const truncated = try capture(io, tmp.dir);
    truncated.close(io);
    try tmp.dir.deleteFile(io, map_name);
    try tmp.dir.createDir(io, map_name, .default_dir);
    const directory = try capture(io, tmp.dir);
    directory.close(io);
}

test "pending and completed together is a permanent conflict, not a retry" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try preparedFixture(io, tmp.dir);
    try tmp.dir.createDir(io, completed_name, .default_dir);
    try std.testing.expectError(error.ConflictingImportState, recover(io, tmp.dir, .{}));
    var bytes: [64]u8 = undefined;
    try std.testing.expectEqualStrings("base", try tmp.dir.readFile(io, ".zit", &bytes));
}

test "import overlay never mutates its base" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const base = try tmp.dir.createFile(io, "base", .{ .read = true });
    defer base.close(io);
    try base.writePositionalAll(io, "base", 0);
    const tail = try tmp.dir.createFile(io, "tail", .{ .read = true });
    defer tail.close(io);
    var base_storage = storage.FileStorage.initReadOnly(io, base, 4);
    var tail_storage = storage.FileStorage.init(io, tail, 0);
    var overlay: Overlay = .{ .base = base_storage.storageFile(), .tail = tail_storage.storageFile(), .base_len = 4 };
    const file = overlay.file();
    try file.append("tail");
    var bytes: [8]u8 = undefined;
    try std.testing.expectEqual(@as(usize, 8), try file.pread(0, &bytes));
    try std.testing.expectEqualStrings("basetail", &bytes);
    try std.testing.expectError(error.Io, file.truncate(3));
    try std.testing.expectEqual(@as(u64, 4), (try base.stat(io)).size);
}