//! The read side of a resident store: what changed since a cursor, which
//! branches have which open leaves, and an artifact's bytes by name. The
//! daemon answers `feed`, `status` and `get` from here; a C ABI can too,
//! since nothing below reaches the verb layer.
//!
//! A `Reader` holds one repository view and the store file's identity as of
//! that view. Every call first asks whether the store moved (another writer's
//! append, this process's own commit, a `pack` that installed a new file);
//! only then is the view reopened. Asking costs one `stat`, so a client that
//! polls an idle store pays a syscall, not a derive.
//!
//! The cursor is `zit frames`' own: `COUNT TOKEN`, where COUNT is how many
//! stored artifacts in persisted order the reader has consumed and TOKEN is
//! the log key of that prefix. A rewrite relocates frames and fails the
//! token's check, which is how a consumer learns to start again from "0".
const std = @import("std");
const repeat = @import("../repeat.zig").repeat;
const artifact = @import("../artifact.zig");
const logstore = @import("../logstore.zig");
const repostate = @import("../repostate.zig");
const repository = @import("../repository.zig");
const Repository = repository.Repository;
const repoview = @import("../repoview.zig");
const thinned = @import("../thinned.zig");
const checkin = @import("checkin.zig");
pub const Diag = checkin.Diag;
/// `COUNT ` plus the four dash-joined log-key fields, each at most 20 digits.
pub const cursor_max: usize = 20 + 1 + 4 * 21;
pub const Error = error{
OutOfMemory,
/// The store could not be read.
Storage,
/// The store does not decode.
Corrupt,
/// The store is not one this build can open (a pending import).
CannotOpen,
NotARepo,
/// The cursor is not one this store issued, or the store was rewritten
/// since it was.
CursorInvalid,
/// An artifact name that is not 40 or 64 lowercase hex digits.
BadName,
/// A check-in name that resolves to nothing, to several things, or to
/// something that is not a check-in.
NotFound,
};
/// What a check-in did to one path, in `log PATH -F json`'s words: a rename
/// is a `modified` row on the new name carrying `prior`, beside a `removed`
/// row on the old one.
pub const State = enum { added, modified, removed };
pub const FileRow = struct {
name: []const u8,
/// The name this check-in renamed the file from; null on every other row.
prior: ?[]const u8,
/// The file's content in the check-in; null for a removal.
uuid: ?[]const u8,
state: State,
};
pub const Change = struct {
checkin: []const u8,
/// The primary parent; null for a root check-in.
parent: ?[]const u8,
branch: []const u8,
user: ?[]const u8,
comment: ?[]const u8,
/// Milliseconds since the epoch; null when the view has no event for it.
time_ms: ?repostate.Mtime,
/// Null when the change set cannot be computed from what the store holds
/// (an absent parent, an unresolvable baseline).
files: ?[]const FileRow,
};
/// One path that differs between two check-ins, in `diff -F json`'s words:
/// `uuid` is the `to` side's content and `parent` the `from` side's, each
/// null where the path is absent.
pub const DiffRow = struct {
name: []const u8,
/// On an `added` row, the name the `to` check-in's card says the file
/// was renamed from, when that name is a `removed` row of the same
/// comparison; null otherwise. Rows are `diff`'s either way: a rename is
/// a removal of the old name and an addition of the new one.
prior: ?[]const u8,
uuid: ?[]const u8,
parent: ?[]const u8,
state: State,
};
pub const Diff = struct {
from: []const u8,
to: []const u8,
/// In the store's canonical name order.
files: []const DiffRow,
};
/// Two effective file sets compared in one linear walk, as `zit diff --from
/// --to` compares them: both are in canonical name order, a name only on the
/// `from` side is removed, only on the `to` side added, and on both sides
/// modified when its content or mode differs.
pub const FileSetDiff = struct {
from: Repository.EffectiveFiles,
to: Repository.EffectiveFiles,
a: ?artifact.FCard,
b: ?artifact.FCard,
pub const Row = struct {
state: State,
name: []const u8,
/// The `from` side's content; null when added.
parent: ?[]const u8,
/// The `to` side's card; null when removed.
to_card: ?artifact.FCard,
};
pub fn init(from: Repository.EffectiveFiles, to: Repository.EffectiveFiles) FileSetDiff {
var d: FileSetDiff = .{ .from = from, .to = to, .a = null, .b = null };
d.a = d.from.next();
d.b = d.to.next();
return d;
}
pub fn next(self: *FileSetDiff) ?Row {
while (self.a != null or self.b != null) {
const order: std.math.Order = if (self.a == null) .gt else if (self.b == null) .lt else artifact.compareBytes(self.a.?.filename, self.b.?.filename);
switch (order) {
.lt => {
const a = self.a.?;
self.a = self.from.next();
return .{ .state = .removed, .name = a.filename, .parent = a.hash, .to_card = null };
},
.gt => {
const b = self.b.?;
self.b = self.to.next();
return .{ .state = .added, .name = b.filename, .parent = null, .to_card = b };
},
.eq => {
const a = self.a.?;
const b = self.b.?;
self.a = self.from.next();
self.b = self.to.next();
if (a.mode != b.mode or !std.mem.eql(u8, a.hash orelse "", b.hash orelse ""))
return .{ .state = .modified, .name = b.filename, .parent = a.hash, .to_card = b };
},
}
}
return null;
}
};
pub const Fork = struct {
branch: []const u8,
/// Newest first.
leaves: []const []const u8,
};
pub const Feed = struct {
cursor: []const u8,
changes: []const Change,
forks: []const Fork,
};
pub const Branch = struct {
name: []const u8,
/// The newest open leaf.
tip: []const u8,
/// Every open leaf, newest first.
leaves: []const []const u8,
};
/// A stored check-in `lookup` found.
pub const Looked = struct {
hash: []const u8,
/// Null for a root check-in.
parent: ?[]const u8,
branch: []const u8,
forked: bool,
};
pub const Status = struct {
branches: []const Branch,
cursor: []const u8,
};
/// Why an artifact's bytes are not here.
pub const Absence = enum {
/// `purge artifacts` thinned it.
purged,
shunned,
/// The store's cone or horizon leaves it out.
elided,
/// No record of it at all: never stored, or lost.
missing,
};
pub const Got = union(enum) {
/// Owned by the allocator `get` was given.
bytes: []u8,
absent: Absence,
};
/// Whether the store's cone or horizon elides an artifact. The classifier
/// walks the whole store and lives with the sync machinery, so a caller that
/// has it supplies it; without one, an elided artifact reads as missing.
pub const ElidedHook = struct {
ctx: ?*anyopaque = null,
elided: *const fn (ctx: ?*anyopaque, gpa: std.mem.Allocator, io: std.Io, store_dir: std.Io.Dir, name: []const u8) bool,
};
/// Pinned once opened: the repository view points into it.
pub const Reader = struct {
gpa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
repo: Repository,
repo_open: bool,
/// The store file the view was opened on: a different inode or size at
/// the store's path means the view is behind.
seen_inode: std.Io.File.INode,
seen_size: u64,
/// The view's stored artifacts in persisted order, phantoms excluded:
/// the clock a cursor counts on.
persisted: []logstore.Rid,
cursor_buf: [cursor_max]u8,
cursor_len: usize,
elided_hook: ?ElidedHook,
/// Why an artifact is absent, answered once per view: the classes are
/// store-wide facts (the thinned list, the shun table, the cone), and the
/// elision answer classifies the whole store to give it.
absence_memo: std.StringHashMapUnmanaged(Absence),
thinned_set: ?thinned.Set,
/// The view's check-ins by D time, for `lookup`: built on first use,
/// dropped with the view.
stored_index: ?checkin.StoredIndex,
/// Open the store under `root_dir`, which is borrowed and must outlive
/// the reader.
pub fn open(self: *Reader, gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, elided_hook: ?ElidedHook, diag: *Diag) Error!void {
diag.clear();
self.* = .{
.gpa = gpa,
.io = io,
.root_dir = root_dir,
.repo = undefined,
.repo_open = false,
.seen_inode = undefined,
.seen_size = 0,
.persisted = &.{},
.cursor_buf = undefined,
.cursor_len = 0,
.elided_hook = elided_hook,
.absence_memo = .empty,
.thinned_set = null,
.stored_index = null,
};
try self.openView(diag);
}
pub fn deinit(self: *Reader) void {
self.closeView();
self.* = undefined;
}
/// The cursor naming everything the current view holds.
pub fn cursor(self: *const Reader) []const u8 {
return self.cursor_buf[0..self.cursor_len];
}
fn openView(self: *Reader, diag: *Diag) Error!void {
std.debug.assert(!self.repo_open);
self.repo.open(self.gpa, self.io, self.root_dir, .{ .mode = .full }) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.NotARepo => return error.NotARepo,
error.CannotRead => {
diag.set("cannot read repository: input/output error", .{});
return error.Storage;
},
error.Corrupt => {
diag.set("cannot read repository: repository is corrupt", .{});
return error.Corrupt;
},
error.ImportPending => {
diag.set("an incremental import is pending; run 'zit import-git --recover DIR'", .{});
return error.CannotOpen;
},
};
self.repo_open = true;
errdefer self.closeView();
const st = self.repo.snapshot_file.stat(self.io) catch return storageFault(diag);
self.seen_inode = st.inode;
self.seen_size = self.repo.snapshot_size;
const log = try self.logOf(diag);
self.persisted = try log.ridsInPersistedOrder(self.gpa);
const key = cursorKey(log, self.persisted) catch return storageFault(diag);
const text = formatCursor(&self.cursor_buf, self.persisted.len, key);
self.cursor_len = text.len;
}
/// The log key of the persisted prefix. When its last artifact is the
/// log's last frame, as after an ordinary commit, that is the key of the
/// whole log, read from the tail; otherwise the frames are walked to it.
fn cursorKey(log: *logstore.LogStore, persisted: []const logstore.Rid) logstore.LogStore.KeyError!logstore.LogStore.LogKey {
if (persisted.len == 0) return log.logKey();
const last = persisted[persisted.len - 1];
if (log.offsetOf(last) + log.frameSpanOf(last) == log.end) return log.logKey();
return log.logKeyAt(last);
}
fn closeView(self: *Reader) void {
var names = self.absence_memo.keyIterator();
while (names.next()) |n| self.gpa.free(n.*);
self.absence_memo.clearAndFree(self.gpa);
if (self.thinned_set) |*t| t.deinit(self.gpa);
self.thinned_set = null;
if (self.stored_index) |*index| index.deinit(self.gpa);
self.stored_index = null;
if (!self.repo_open) return;
self.gpa.free(self.persisted);
self.persisted = &.{};
self.repo.deinit(self.gpa);
self.repo_open = false;
}
fn logOf(self: *Reader, diag: *Diag) Error!*logstore.LogStore {
return self.repo.logStore(self.gpa) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.Corrupt => {
diag.set("cannot read repository: repository is corrupt", .{});
return error.Corrupt;
},
else => return storageFault(diag),
};
}
/// True when the store file at the store's path is still the one the
/// view was opened on, at the size it had: appends only grow a store, and
/// a rewrite installs a new file. One `stat`, no allocation.
pub fn current(self: *Reader) bool {
if (!self.repo_open) return false;
const st = self.repo.root_dir.statFile(self.io, repository.repo_filename, .{}) catch return false;
return st.inode == self.seen_inode and st.size == self.seen_size;
}
/// Bring the view up to the store as it is now.
pub fn refresh(self: *Reader, diag: *Diag) Error!void {
diag.clear();
if (self.current()) return;
self.closeView();
try self.openView(diag);
}
/// The nothing-new answer, when it applies: `since` names everything the
/// store holds and the store has not moved. No allocation, so a client
/// polling an idle store costs one `stat`.
pub fn unchangedSince(self: *Reader, since: []const u8) bool {
return std.mem.eql(u8, since, self.cursor()) and self.current();
}
/// The check-ins stored after `since` (`"0"` for all of them), in store
/// order, each with what it did to each path, and every branch with more
/// than one open leaf. Everything returned lives on `arena`.
pub fn feed(self: *Reader, arena: std.mem.Allocator, since: []const u8, diag: *Diag) Error!Feed {
if (self.unchangedSince(since)) return .{ .cursor = since, .changes = &.{}, .forks = &.{} };
try self.refresh(diag);
const log = try self.logOf(diag);
const count = try self.consumed(log, since, diag);
var collect: Collect = .{ .arena = arena, .reader = self, .log = log };
repoview.eachChangeSetOf(self.gpa, log, self.persisted[count..], collect.sink()) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
};
const forks = try self.forksOf(arena);
return .{
.cursor = try arena.dupe(u8, self.cursor()),
.changes = collect.changes.items,
.forks = forks,
};
}
/// How many persisted artifacts `since` has consumed, once its token
/// proves this store issued it over the frames it still holds.
fn consumed(self: *Reader, log: *logstore.LogStore, since: []const u8, diag: *Diag) Error!usize {
const parsed = parseCursor(since) orelse {
diag.set("cursor invalid: the cursor is not one this store issued; re-read from \"0\"", .{});
return error.CursorInvalid;
};
if (parsed.count > self.persisted.len) {
diag.set("cursor invalid: the cursor names artifacts this store does not hold; re-read from \"0\"", .{});
return error.CursorInvalid;
}
if (parsed.key) |key| if (!logstore.LogStore.validatePrefix(log.file, key)) {
diag.set("cursor invalid: the store was rewritten since the cursor was issued; re-read from \"0\"", .{});
return error.CursorInvalid;
};
return @intCast(parsed.count);
}
/// Every open branch's leaves, and the cursor they are true as of.
pub fn status(self: *Reader, arena: std.mem.Allocator, diag: *Diag) Error!Status {
try self.refresh(diag);
return .{ .branches = try self.branchesOf(arena), .cursor = try arena.dupe(u8, self.cursor()) };
}
fn forksOf(self: *Reader, arena: std.mem.Allocator) error{OutOfMemory}![]const Fork {
const branches = try self.branchesOf(arena);
var forks: std.ArrayList(Fork) = .empty;
for (branches) |b| {
if (b.leaves.len > 1) try forks.append(arena, .{ .branch = b.name, .leaves = b.leaves });
}
return forks.items;
}
/// Open leaves grouped by branch, branches by name, leaves newest first.
fn branchesOf(self: *Reader, arena: std.mem.Allocator) error{OutOfMemory}![]const Branch {
const state = self.repo.state();
const Leaf = struct { branch: []const u8, rid: repostate.Rid, mtime: repostate.Mtime };
var open_leaves: std.ArrayList(Leaf) = .empty;
var it = state.leaves();
while (it.next()) |rid| {
if (!state.isOpenLeaf(rid)) continue;
const branch = state.activeTagValue(rid, "branch") orelse continue;
try open_leaves.append(arena, .{ .branch = branch, .rid = rid, .mtime = state.mtimeOf(rid) orelse 0 });
}
const Order = struct {
state: *const repostate.RepoState,
fn lessThan(ctx: @This(), a: Leaf, b: Leaf) bool {
switch (std.mem.order(u8, a.branch, b.branch)) {
.lt => return true,
.gt => return false,
.eq => {},
}
if (a.mtime != b.mtime) return a.mtime > b.mtime;
return std.mem.order(u8, ctx.state.hashOf(a.rid), ctx.state.hashOf(b.rid)) == .lt;
}
};
std.mem.sort(Leaf, open_leaves.items, Order{ .state = state }, Order.lessThan);
var branches: std.ArrayList(Branch) = .empty;
var i: usize = 0;
while (i < open_leaves.items.len) {
var j = i;
while (j < open_leaves.items.len and std.mem.eql(u8, open_leaves.items[j].branch, open_leaves.items[i].branch)) j += 1;
const leaves = try arena.alloc([]const u8, j - i);
for (open_leaves.items[i..j], leaves) |l, *out| out.* = try arena.dupe(u8, state.hashOf(l.rid));
try branches.append(arena, .{ .name = try arena.dupe(u8, open_leaves.items[i].branch), .tip = leaves[0], .leaves = leaves });
i = j;
}
return branches.items;
}
/// One answer per identity, in order: the stored check-in a commit resend carrying it
/// would be answered with (`checkin.findStored`, the very test the resend
/// makes), or null; all from one view, caught up with every append so
/// far. Takes no lock and writes nothing. Everything returned lives on
/// `arena`.
pub fn lookup(self: *Reader, arena: std.mem.Allocator, ids: []const checkin.Identity, diag: *Diag) Error![]const ?Looked {
const out = try arena.alloc(?Looked, ids.len);
try self.refresh(diag);
const log = try self.logOf(diag);
if (self.stored_index == null) self.stored_index = try checkin.StoredIndex.build(self.gpa, &self.repo);
for (ids, out) |id, *o| {
const found = try checkin.findStored(&self.repo, log, &self.stored_index.?, id, diag) orelse {
o.* = null;
continue;
};
o.* = .{
.hash = try arena.dupe(u8, found.name.slice()),
.parent = if (found.parent.len == 0) null else try arena.dupe(u8, found.parent.slice()),
.branch = try arena.dupe(u8, found.branch),
.forked = found.forked,
};
}
return out;
}
/// The base `zit merge B` uses from a checkout of A with no merge
/// pending (`Repository.mergeBaseOf`), or null when A and B share no
/// history. Over what this store holds: the CLI's horizon deepening,
/// which asks a remote, is not a reader's to do.
pub fn mergeBase(self: *Reader, arena: std.mem.Allocator, a: []const u8, b: []const u8, diag: *Diag) Error!?[]const u8 {
try self.refresh(diag);
const a_rid = try self.checkinNamed(a, diag);
const b_rid = try self.checkinNamed(b, diag);
const base = try self.repo.mergeBaseOf(self.gpa, a_rid, b_rid) orelse return null;
return try arena.dupe(u8, self.repo.hashOf(base));
}
/// What changed from `from` to `to`: `diff --from --to -F json`'s rows,
/// without the hunks. Everything returned lives on `arena`.
pub fn diff(self: *Reader, arena: std.mem.Allocator, from: []const u8, to: []const u8, diag: *Diag) Error!Diff {
try self.refresh(diag);
const rids = [2]repostate.Rid{ try self.checkinNamed(from, diag), try self.checkinNamed(to, diag) };
var lists: [2]Repository.PrefixList = undefined;
var opened: usize = 0;
defer for (lists[0..opened]) |*l| l.deinit(self.gpa);
for (rids, &lists) |rid, *list| {
list.* = (self.repo.filesWithPrefix(self.gpa, rid, "") catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.ElidedByCone => {
diag.set("part of check-in {s}'s tree is elided by the store's sparse cone", .{self.repo.hashOf(rid)});
return error.Corrupt;
},
error.CannotRead => return storageFault(diag),
else => {
diag.set("cannot read check-in {s}: repository is corrupt", .{self.repo.hashOf(rid)});
return error.Corrupt;
},
}) orelse {
diag.set("cannot read check-in {s}: its manifest is not in the store", .{self.repo.hashOf(rid)});
return error.Corrupt;
};
opened += 1;
}
var rows: std.ArrayList(DiffRow) = .empty;
var old_names: std.ArrayList(?[]const u8) = .empty;
var removed: std.StringHashMapUnmanaged(void) = .empty;
defer removed.deinit(self.gpa);
var walk: FileSetDiff = .init(lists[0].iterator(), lists[1].iterator());
while (walk.next()) |r| {
try rows.append(arena, .{
.name = try arena.dupe(u8, r.name),
.prior = null,
.uuid = if (r.to_card) |c| try arena.dupe(u8, c.hash.?) else null,
.parent = if (r.parent) |h| try arena.dupe(u8, h) else null,
.state = r.state,
});
const old: ?[]const u8 = if (r.state == .added) (if (r.to_card.?.old_name) |o| try arena.dupe(u8, o) else null) else null;
try old_names.append(arena, old);
if (r.state == .removed) try removed.put(self.gpa, rows.items[rows.items.len - 1].name, {});
}
// A rename shows only once both of its rows are known.
for (rows.items, old_names.items) |*row, old| {
const o = old orelse continue;
if (removed.contains(o)) row.prior = o;
}
return .{
.from = try arena.dupe(u8, self.repo.hashOf(rids[0])),
.to = try arena.dupe(u8, self.repo.hashOf(rids[1])),
.files = rows.items,
};
}
fn checkinNamed(self: *Reader, name: []const u8, diag: *Diag) Error!repostate.Rid {
const rid = self.repo.resolve(name, .checkin) catch |e| {
switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.NotFound => diag.set("check-in '{s}' not found", .{name}),
error.Ambiguous => diag.set("name '{s}' is ambiguous", .{name}),
}
return error.NotFound;
};
return self.repo.checkinRid(self.repo.hashOf(rid)) orelse {
diag.set("'{s}' is not a check-in", .{name});
return error.NotFound;
};
}
/// The artifact named `name`, verified against its name, or why it is
/// not here.
pub fn get(self: *Reader, gpa: std.mem.Allocator, name: []const u8, diag: *Diag) Error!Got {
if (!isName(name)) return error.BadName;
try self.refresh(diag);
if (self.repo.blob(gpa, name)) |found| {
if (found) |bytes| return .{ .bytes = bytes };
} else |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.Corrupt => {
diag.set("stored artifact {s} does not hash to its name", .{name});
return error.Corrupt;
},
else => return storageFault(diag),
}
return .{ .absent = try self.absenceOf(name, diag) };
}
fn absenceOf(self: *Reader, name: []const u8, diag: *Diag) Error!Absence {
if (self.absence_memo.get(name)) |known| return known;
const why = try self.classifyAbsence(name, diag);
const owned = try self.gpa.dupe(u8, name);
errdefer self.gpa.free(owned);
try self.absence_memo.put(self.gpa, owned, why);
return why;
}
fn classifyAbsence(self: *Reader, name: []const u8, diag: *Diag) Error!Absence {
if (self.thinned_set == null) self.thinned_set = thinned.load(self.gpa, self.io, self.repo.root_dir) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => .{},
};
if (self.thinned_set.?.contains(name)) return .purged;
var digest_buf: [32]u8 = undefined;
const digest = digest_buf[0 .. name.len / 2];
_ = std.fmt.hexToBytes(digest, name) catch unreachable; // `isName` checked it
const log = try self.logOf(diag);
if (log.isShunned(digest)) return .shunned;
if (self.elided_hook) |hook| if (hook.elided(hook.ctx, self.gpa, self.io, self.repo.root_dir, name)) return .elided;
return .missing;
}
/// The derived-view rid of a log rid: the two spaces differ (the view
/// interns phantoms by first sight), and the name is what both agree on.
fn viewRid(self: *Reader, log: *logstore.LogStore, log_rid: logstore.Rid, hex_buf: *[64]u8) ?repostate.Rid {
const digest = log.hashOf(log_rid);
if (digest.len != 32 and digest.len != 20) return null;
const hex = std.fmt.bufPrint(hex_buf, "{x}", .{digest}) catch unreachable;
return self.repo.lookup(hex);
}
};
/// The change-set sink `feed` hands the walk: one `Change` per check-in.
const Collect = struct {
arena: std.mem.Allocator,
reader: *Reader,
log: *logstore.LogStore,
changes: std.ArrayList(Change) = .empty,
fn sink(self: *Collect) repoview.ChangeSetSink {
return .{ .ctx = self, .emit = emit };
}
fn emit(ctx: *anyopaque, rid: logstore.Rid, files: ?[]const repoview.ChangedFile) error{OutOfMemory}!void {
const self: *Collect = @ptrCast(@alignCast(ctx));
const a = self.arena;
var hex_buf: [64]u8 = undefined;
const view_rid = self.reader.viewRid(self.log, rid, &hex_buf);
const hash = try a.dupe(u8, std.fmt.bufPrint(&hex_buf, "{x}", .{self.log.hashOf(rid)}) catch unreachable);
var change: Change = .{
.checkin = hash,
.parent = null,
.branch = "",
.user = null,
.comment = null,
.time_ms = null,
.files = null,
};
if (view_rid) |vr| {
const repo = &self.reader.repo;
const state = repo.state();
if (state.primaryParentOf(vr)) |p| change.parent = try a.dupe(u8, repo.hashOf(p));
change.branch = try a.dupe(u8, repo.branchOf(vr));
if (state.userOf(vr)) |u| change.user = try a.dupe(u8, u);
if (state.commentOf(vr)) |c| change.comment = try a.dupe(u8, c);
change.time_ms = state.mtimeOf(vr);
}
// The view leaves a comment unread when it spilled it; the manifest
// has it.
if (change.comment == null or change.user == null) self.fromManifest(rid, &change) catch |e| {
if (e == error.OutOfMemory) return error.OutOfMemory;
};
if (files) |fs| {
const rows = try a.alloc(FileRow, fs.len);
for (fs, rows) |f, *row| row.* = .{
.name = try a.dupe(u8, f.path),
.prior = if (f.prior) |p| try a.dupe(u8, p) else null,
.uuid = if (f.hash) |h| try a.dupe(u8, h) else null,
.state = switch (f.disp) {
.added => if (f.prior != null) .modified else .added,
.modified => .modified,
.deleted => .removed,
},
};
change.files = rows;
}
try self.changes.append(a, change);
}
fn fromManifest(self: *Collect, rid: logstore.Rid, change: *Change) error{ OutOfMemory, Unreadable }!void {
const gpa = self.reader.gpa;
const bytes = (self.log.content(gpa, rid) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.Unreadable,
}) orelse return error.Unreadable;
defer gpa.free(bytes);
var loaded = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.Unreadable,
};
defer loaded.deinit(gpa);
const m = &loaded.manifest;
if (change.comment == null) if (m.c) |c| {
change.comment = try self.arena.dupe(u8, c.comment);
};
if (change.user == null) if (m.u) |u| {
change.user = try self.arena.dupe(u8, u.login);
};
}
};
fn storageFault(diag: *Diag) Error {
diag.set("cannot read repository: input/output error", .{});
return error.Storage;
}
/// A full artifact name: 40 (SHA-1) or 64 (SHA3-256) lowercase hex digits.
pub fn isName(name: []const u8) bool {
if (name.len != 40 and name.len != 64) return false;
for (name) |c| switch (c) {
'0'...'9', 'a'...'f' => {},
else => return false,
};
return true;
}
pub const ParsedCursor = struct {
count: u64,
/// Null for the bootstrap cursor `"0"`, which needs no proof.
key: ?logstore.LogStore.LogKey,
};
/// `"0"`, or `COUNT TOKEN` as `formatCursor` writes it. Null on any other
/// shape, so a cursor is never half-understood.
pub fn parseCursor(text: []const u8) ?ParsedCursor {
if (std.mem.eql(u8, text, "0")) return .{ .count = 0, .key = null };
const space = std.mem.indexOfScalar(u8, text, ' ') orelse return null;
const count = parseDecimal(u64, text[0..space]) orelse return null;
var it = std.mem.splitScalar(u8, text[space + 1 ..], '-');
const end = parseDecimal(u64, it.next() orelse return null) orelse return null;
const frame_count = parseDecimal(u64, it.next() orelse return null) orelse return null;
const last_frame_offset = parseDecimal(u64, it.next() orelse return null) orelse return null;
const last_header_crc = parseDecimal(u32, it.next() orelse return null) orelse return null;
if (it.next() != null) return null;
return .{ .count = count, .key = .{
.end = end,
.frame_count = frame_count,
.last_frame_offset = last_frame_offset,
.last_header_crc = last_header_crc,
} };
}
/// Plain decimal digits only: `parseInt` would also take a sign or an
/// underscore, and a cursor has one spelling.
fn parseDecimal(comptime T: type, text: []const u8) ?T {
if (text.len == 0) return null;
for (text) |c| if (c < '0' or c > '9') return null;
return std.fmt.parseInt(T, text, 10) catch null;
}
/// `COUNT END-FRAMES-OFFSET-CRC`: the trailer `zit frames` prints after
/// `cursor`, so the same position can be handed to either.
pub fn formatCursor(buf: *[cursor_max]u8, count: usize, key: logstore.LogStore.LogKey) []const u8 {
return std.fmt.bufPrint(buf, "{d} {d}-{d}-{d}-{d}", .{
count, key.end, key.frame_count, key.last_frame_offset, key.last_header_crc,
}) catch unreachable;
}
test "cursor: what formatCursor writes parses back, and nothing else half-parses" {
var buf: [cursor_max]u8 = undefined;
const key: logstore.LogStore.LogKey = .{
.end = std.math.maxInt(u64),
.frame_count = std.math.maxInt(u64),
.last_frame_offset = std.math.maxInt(u64),
.last_header_crc = std.math.maxInt(u32),
};
const text = formatCursor(&buf, std.math.maxInt(u64), key);
const back = parseCursor(text).?;
try std.testing.expectEqual(@as(u64, std.math.maxInt(u64)), back.count);
try std.testing.expectEqual(key, back.key.?);
try std.testing.expectEqual(@as(u64, 0), parseCursor("0").?.count);
try std.testing.expect(parseCursor("0").?.key == null);
for ([_][]const u8{ "", "00", "1", "1 2-3-4", "1 2-3-4-5-6", "+1 2-3-4-5", "1 2-3-4-x", "1 2-3-4-5", "1 2-3-4-99999999999" }) |bad| {
try std.testing.expect(parseCursor(bad) == null);
}
}
test "isName: full lowercase hex of either width" {
try std.testing.expect(isName(repeat("a", 64)));
try std.testing.expect(isName(repeat("0", 40)));
try std.testing.expect(!isName(repeat("A", 64)));
try std.testing.expect(!isName(repeat("a", 63)));
try std.testing.expect(!isName(repeat("g", 40)));
}