//! `zit wiki` / `zit ticket` — record wiki and ticket-change artifacts.
//!
//! MACHINE VERBS. Quarry's subprocess boundary (quarry RFC 0001 §2) makes zit
//! the sole writer of `.zit` bytes, so quarry's wiki and ticket pages call
//! these instead of constructing artifact bytes themselves. That consumer
//! shapes every contract here: stdout carries ONLY the machine-read result
//! (the artifact hash, plus the minted ticket id for `ticket new`), human
//! diagnostics go to stderr, and the exit codes are exact — 0 recorded,
//! 1 user error, 2 usage. It also shapes their VISIBILITY: neither verb is
//! listed in the human `zit help` command list, deliberately — they are
//! plumbing, and a person browsing the porcelain should not meet them there.
//! Each still answers `zit wiki --help` / `zit help wiki` in full.
//!
//! Both operate on a bare store: `-R <repo>` needs no working tree, because
//! quarry serves bare stores. The write lands through `cli.openStoreAppend`,
//! so two racing quarry requests serialize on the store's own lock —
//! last-write-wins per fold semantics, never a torn log.
//!
//! The bytes come from `artifact.create`, the one canonical writer, and are
//! round-tripped through `artifact.parse` before storing IN EVERY BUILD MODE:
//! commit's parse self-check is safety-mode-only because its inputs are the
//! checkout's own state, but these inputs arrive from another process, and a
//! malformed artifact minted here would be quarry's data-loss bug to discover.
const std = @import("std");
const zit = @import("zit");
const cli = @import("zit").cli;
const artifact = @import("zit").artifact;
const logstore = @import("zit").logstore;
const storage = @import("zit").storage;
const cmd_sync = @import("sync.zig");
const main = @import("../main.zig");
const stateOom = main.stateOom;
const not_a_repo_msg = main.not_a_repo_msg;
const wiki_help = main.wiki_help;
const ticket_help = main.ticket_help;
const forum_help = main.forum_help;
const technote_help = main.technote_help;
const attach_help = main.attach_help;
const digest_len = main.digest_len;
const hexToDigest = main.hexToDigest;
/// One bound for both bodies: a wiki page or ticket change beyond this is not
/// authoring, it is a bulk import that should arrive some other way.
const max_body_bytes: usize = 16 * 1024 * 1024;
/// Upper bound on `technote add --tag` repetitions (TigerStyle: every loop and
/// buffer is finite by inspection). Fossil's own technote editor takes one
/// space-separated field, so a note wearing dozens of tags is already past any
/// real use; the cap exists so the T-run buffer is a fixed array.
const max_technote_tags: usize = 64;
/// The N card fossil would write, or null when fossil would omit it.
///
/// The rule is PER TYPE, and the divergence is fossil's, not an inconsistency
/// to tidy away (artifact-format section 4.7.12; verified against the source,
/// not inferred from the draft -- the draft states the READ default, which is
/// a different rule from when the card is WRITTEN):
///
/// * wiki (`wiki.c:2084`) and forum (`forum.c:1784`) write N only when the
/// mimetype is present, non-empty, AND NOT `text/x-fossil-wiki`. The
/// default is never spelled out redundantly.
/// * technote (`event.c:284`) writes N whenever it is present and non-empty,
/// with no default-suppression at all.
///
/// Emitting `N text/x-fossil-wiki` on a wiki page or forum post would produce
/// different BYTES, and so a different artifact id, than fossil mints from the
/// same input -- which is the whole compatibility claim.
pub fn nCard(mimetype: ?[]const u8, suppress_default: bool) ?artifact.NCard {
const mt = mimetype orelse return null;
if (mt.len == 0) return null; // fossil tests zMimetype[0]
if (suppress_default and std.mem.eql(u8, mt, fossil_wiki_mimetype)) return null;
return .{ .mimetype = mt };
}
/// The mimetype every W-card-bearing type falls back to when no N card is
/// present (artifact-format section 4.7.12).
pub const fossil_wiki_mimetype = "text/x-fossil-wiki";
pub fn runWiki(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
amb: cli.Ambient,
) cli.Exit {
var title: ?[]const u8 = null;
var user: ?[]const u8 = null;
var mimetype: ?[]const u8 = null;
var saw_commit = false;
var i: usize = 0;
while (i < verb_args.len) : (i += 1) {
const tok = verb_args[i];
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
streams.out.writeAll(wiki_help) catch {};
return .ok;
} else if (std.mem.eql(u8, tok, "--user")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "wiki", .unknown_option, "--user");
user = verb_args[i];
} else if (std.mem.eql(u8, tok, "--mimetype")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "wiki", .unknown_option, "--mimetype");
mimetype = verb_args[i];
} else if (tok.len > 1 and tok[0] == '-') {
return cli.usageError(streams, "wiki", .unknown_option, tok);
} else if (!saw_commit) {
if (!std.mem.eql(u8, tok, "commit")) return cli.usageError(streams, "wiki", .unknown_option, tok);
saw_commit = true;
} else if (title == null) {
title = tok;
} else {
return cli.usageError(streams, "wiki", .extra_argument, tok);
}
}
if (!saw_commit or title == null) {
streams.err.writeAll("zit wiki: usage: zit wiki commit <title> [-R repo] [--user NAME] [--mimetype TYPE] < body\n") catch {};
return .usage;
}
if (title.?.len == 0) {
streams.err.writeAll("zit wiki: the title must not be empty\n") catch {};
return .failure;
}
const author = resolveAuthor(user, amb) orelse {
streams.err.writeAll("zit wiki: --user is required (no checkout default here)\n") catch {};
return .failure;
};
// The body IS stdin, whole: quarry pipes the page and closes the write
// end, exactly the read-to-EOF contract this states.
const stdin = std.Io.File.stdin();
var read_buf: [64 * 1024]u8 = undefined;
var reader = stdin.readerStreaming(io, &read_buf);
const body = reader.interface.allocRemaining(gpa, .limited(max_body_bytes)) catch {
streams.err.writeAll("zit wiki: cannot read the body from stdin\n") catch {};
return .failure;
};
defer gpa.free(body);
var ts_buf: [23]u8 = undefined;
const ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
var m: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .wiki,
.a = null,
.b = null,
.c = null,
.d = .{ .timestamp = ts },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = .{ .title = title.? },
.n = nCard(mimetype, true), // wiki: suppresses the default
.r = null,
.u = .{ .login = author },
.z = null,
.w = body,
.f = &.{},
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.s = &.{},
.t = &.{},
};
defer m.deinit();
return recordAndPrint(gpa, io, arena, streams, amb, "wiki", &m, null, null);
}
pub fn runTicket(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
amb: cli.Ambient,
) cli.Exit {
const Mode = enum { new, set };
var mode: ?Mode = null;
var ticket_id: ?[]const u8 = null;
var user: ?[]const u8 = null;
var fields: std.ArrayList(artifact.JCard) = .empty;
defer fields.deinit(gpa);
var i: usize = 0;
while (i < verb_args.len) : (i += 1) {
const tok = verb_args[i];
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
streams.out.writeAll(ticket_help) catch {};
return .ok;
} else if (std.mem.eql(u8, tok, "--user")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "ticket", .unknown_option, "--user");
user = verb_args[i];
} else if (tok.len > 1 and tok[0] == '-') {
return cli.usageError(streams, "ticket", .unknown_option, tok);
} else if (mode == null) {
if (std.mem.eql(u8, tok, "new")) {
mode = .new;
} else if (std.mem.eql(u8, tok, "set")) {
mode = .set;
} else {
return cli.usageError(streams, "ticket", .unknown_option, tok);
}
} else if (mode == .set and ticket_id == null) {
ticket_id = tok;
} else if (std.mem.indexOfScalar(u8, tok, '=')) |eq| {
const field = tok[0..eq];
const value = tok[eq + 1 ..];
if (field.len == 0) {
streams.err.print("zit ticket: empty field name in '{s}'\n", .{tok}) catch {};
return .failure;
}
fields.append(gpa, .{ .field = field, .value = value }) catch return stateOom(streams, "ticket");
} else {
return cli.usageError(streams, "ticket", .extra_argument, tok);
}
}
const chosen = mode orelse {
streams.err.writeAll("zit ticket: usage: zit ticket new|set <ticket-id> [-R repo] [--user NAME] field=value ...\n") catch {};
return .usage;
};
if (chosen == .set and ticket_id == null) {
streams.err.writeAll("zit ticket: set needs a <ticket-id>\n") catch {};
return .usage;
}
if (fields.items.len == 0) {
streams.err.writeAll("zit ticket: at least one field=value is required\n") catch {};
return .failure;
}
const author = resolveAuthor(user, amb) orelse {
streams.err.writeAll("zit ticket: --user is required (no checkout default here)\n") catch {};
return .failure;
};
// §4.5.2: J cards strictly ascend by field name. Sorting here is the
// service; a DUPLICATE field is the caller's ambiguity ("which value
// wins?") and is refused rather than resolved by position.
std.mem.sort(artifact.JCard, fields.items, {}, jLessThan);
var fi: usize = 1;
while (fi < fields.items.len) : (fi += 1) {
if (std.mem.eql(u8, fields.items[fi - 1].field, fields.items[fi].field)) {
streams.err.print("zit ticket: field '{s}' given twice\n", .{fields.items[fi].field}) catch {};
return .failure;
}
}
// The id: minted for `new` (20 random bytes as 40-hex, fossil's shape),
// validated and REQUIRED KNOWN for `set` — a typo'd id must not silently
// open a second ticket.
var id_buf: [40]u8 = undefined;
const id: []const u8 = switch (chosen) {
.new => blk: {
var raw: [20]u8 = undefined;
io.randomSecure(&raw) catch {
streams.err.writeAll("zit ticket: no entropy source for a fresh ticket id\n") catch {};
return .failure;
};
const hex = std.fmt.bytesToHex(raw, .lower);
@memcpy(&id_buf, &hex);
break :blk &id_buf;
},
.set => ticket_id.?,
};
if (id.len != 40 or !isLowerHex(id)) {
streams.err.print("zit ticket: '{s}' is not a ticket id (40 lowercase hex)\n", .{id}) catch {};
return .failure;
}
var ts_buf: [23]u8 = undefined;
const ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
var m: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .ticket,
.a = null,
.b = null,
.c = null,
.d = .{ .timestamp = ts },
.e = null,
.g = null,
.h = null,
.i = null,
.k = .{ .ticket_id = id },
.l = null,
.n = null,
.r = null,
.u = .{ .login = author },
.z = null,
.w = null,
.f = &.{},
.j = fields.items,
.m = &.{},
.p = &.{},
.q = &.{},
.s = &.{},
.t = &.{},
};
defer m.deinit();
return recordAndPrint(gpa, io, arena, streams, amb, "ticket", &m, if (chosen == .new) id else null, null);
}
fn jLessThan(_: void, a: artifact.JCard, b: artifact.JCard) bool {
return artifact.compareBytes(a.field, b.field) == .lt;
}
fn isLowerHex(s: []const u8) bool {
for (s) |c| {
const ok = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f');
if (!ok) return false;
}
return true;
}
/// The author: `--user` wins, else the ambient OS user exactly as `commit`
/// defaults it. Empty is a refusal either way — real-world stores carry
/// manifests with empty U tokens from other writers' bugs, and this writer
/// will not mint more.
fn resolveAuthor(user: ?[]const u8, amb: cli.Ambient) ?[]const u8 {
const chosen = user orelse (amb.user_env orelse return null);
if (chosen.len == 0) return null;
return chosen;
}
/// Serialize, self-check, store under the lock, report. `mint` is the fresh
/// ticket id `ticket new` prepends to its stdout line; everything else prints
/// the artifact hash alone. `verb` names the diagnostics.
fn recordAndPrint(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
amb: cli.Ambient,
verb: []const u8,
m: *artifact.Manifest,
mint: ?[]const u8,
/// A blob to store before the record, in the same log open and
/// commit, so an attachment never names bytes the store does not hold.
/// Its hash is printed after the record's.
pre_blob: ?[]const u8,
) cli.Exit {
const bytes = artifact.create(m, gpa) catch return stateOom(streams, verb);
defer gpa.free(bytes);
// Round-trip in EVERY build mode (module doc says why). classify() ran
// inside parse; the kind equality is the whole claim.
var check = artifact.parse(gpa, bytes) catch {
streams.err.print("zit {s}: refusing to store a malformed artifact (a zit bug — please report the invocation)\n", .{verb}) catch {};
return .failure;
};
const kind_ok = check.kind == m.kind;
check.deinit();
if (!kind_ok) {
streams.err.print("zit {s}: refusing to store a malformed artifact (a zit bug — please report the invocation)\n", .{verb}) catch {};
return .failure;
}
const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return stateOom(streams, verb)) orelse {
main.reportNoRoot(streams, io, verb, amb.repo_override);
return .failure;
};
var root_dir = root.dir;
defer root_dir.close(io);
// `set` only exists for a ticket that exists: scan the store for any
// artifact carrying this K card before appending. A full-store read, and
// deliberately so — the store has no ticket index yet, and a wrong
// "unknown" that silently minted a SECOND ticket would be worse than a
// slow check. Quarry-scale stores read in milliseconds.
// (For `new` the freshly minted id is absent by construction.)
const knowledge_needed = mint == null and m.kind == .ticket;
var store_append = cli.openStoreAppend(io, root_dir) catch |e| {
if (e == error.StoreBusy) return .failure; // the lock notice already said it
streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
return .failure;
};
defer store_append.close(io);
const file = store_append.file;
const st = file.stat(io) catch {
streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
return .failure;
};
var fs = storage.FileStorage.init(io, file, st.size);
var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch {
streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
return .failure;
};
defer log.deinit(gpa);
if (knowledge_needed) {
const known = ticketKnown(gpa, &log, m.k.?.ticket_id) catch return stateOom(streams, verb);
if (!known) {
streams.err.print("zit ticket: unknown ticket '{s}'\n", .{m.k.?.ticket_id}) catch {};
return .failure;
}
}
var digest: [32]u8 = undefined;
var blob_name_buf: [64]u8 = undefined;
var blob_name: ?[]const u8 = null;
if (pre_blob) |blob| {
var blob_digest: [32]u8 = undefined;
std.crypto.hash.sha3.Sha3_256.hash(blob, &blob_digest, .{});
blob_name = artifact.artifactName(.sha3_256, blob, &blob_name_buf);
_ = log.putArtifact(&blob_digest, blob, null) catch {
streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
return .failure;
};
}
std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{});
var name_buf: [64]u8 = undefined;
const name = artifact.artifactName(.sha3_256, bytes, &name_buf);
_ = log.putArtifact(&digest, bytes, null) catch {
streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
return .failure;
};
log.commit() catch {
streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
return .failure;
};
if (mint) |id| {
streams.out.print("{s} {s}\n", .{ id, name }) catch {};
} else {
streams.out.print("{s}\n", .{name}) catch {};
}
if (blob_name) |b| streams.out.print("{s}\n", .{b}) catch {};
return .ok;
}
/// Whether any stored artifact carries `K <id>`. The canonical writer puts
/// exactly one space and a trailing LF around the id and a D card always
/// precedes K, so the byte pattern `\nK <id>\n` is exact — no parse needed.
fn ticketKnown(gpa: std.mem.Allocator, log: *logstore.LogStore, id: []const u8) error{OutOfMemory}!bool {
std.debug.assert(id.len == 40);
var needle_buf: [44]u8 = undefined;
needle_buf[0] = '\n';
needle_buf[1] = 'K';
needle_buf[2] = ' ';
@memcpy(needle_buf[3..43], id);
needle_buf[43] = '\n';
const needle = needle_buf[0..44];
var rid: logstore.Rid = 1;
const count: logstore.Rid = @intCast(log.entries.items.len);
while (rid <= count) : (rid += 1) {
const bytes = (log.content(gpa, rid) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
// A damaged or phantom entry cannot be the ticket's birth; skip
// it rather than failing the write — verify owns damage reports.
else => continue,
}) orelse continue;
defer gpa.free(bytes);
if (std.mem.indexOf(u8, bytes, needle) != null) return true;
}
return false;
}
pub fn runForum(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
amb: cli.Ambient,
) cli.Exit {
const Mode = enum { post, reply };
var mode: ?Mode = null;
var reply_to: ?[]const u8 = null;
var user: ?[]const u8 = null;
var title: ?[]const u8 = null;
var mimetype: ?[]const u8 = null;
var i: usize = 0;
while (i < verb_args.len) : (i += 1) {
const tok = verb_args[i];
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
streams.out.writeAll(forum_help) catch {};
return .ok;
} else if (std.mem.eql(u8, tok, "--user")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "forum", .unknown_option, "--user");
user = verb_args[i];
} else if (std.mem.eql(u8, tok, "--mimetype")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "forum", .unknown_option, "--mimetype");
mimetype = verb_args[i];
} else if (std.mem.eql(u8, tok, "--title")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "forum", .unknown_option, "--title");
title = verb_args[i];
} else if (tok.len > 1 and tok[0] == '-') {
return cli.usageError(streams, "forum", .unknown_option, tok);
} else if (mode == null) {
if (std.mem.eql(u8, tok, "post")) {
mode = .post;
} else if (std.mem.eql(u8, tok, "reply")) {
mode = .reply;
} else {
return cli.usageError(streams, "forum", .unknown_option, tok);
}
} else if (mode == .reply and reply_to == null) {
reply_to = tok;
} else {
return cli.usageError(streams, "forum", .extra_argument, tok);
}
}
const chosen = mode orelse {
streams.err.writeAll("zit forum: usage: zit forum post|reply <post-hash> [-R repo] [--user NAME] [--title TITLE] [--mimetype TYPE] < body\n") catch {};
return .usage;
};
if (chosen == .reply and reply_to == null) {
streams.err.writeAll("zit forum: reply needs a <post-hash>\n") catch {};
return .usage;
}
if (chosen == .reply and title != null) {
// §5.4 — a forum artifact carries H XOR I: a thread has one title, on
// its root. Refused rather than dropped, so the caller learns it.
streams.err.writeAll("zit forum: --title belongs to 'post'; a reply inherits its thread's title\n") catch {};
return .usage;
}
const author = resolveAuthor(user, amb) orelse {
streams.err.writeAll("zit forum: --user is required (no checkout default here)\n") catch {};
return .failure;
};
const stdin = std.Io.File.stdin();
var read_buf: [64 * 1024]u8 = undefined;
var reader = stdin.readerStreaming(io, &read_buf);
const body = reader.interface.allocRemaining(gpa, .limited(max_body_bytes)) catch {
streams.err.writeAll("zit forum: cannot read the body from stdin\n") catch {};
return .failure;
};
defer gpa.free(body);
if (body.len == 0) {
streams.err.writeAll("zit forum: the body must not be empty\n") catch {};
return .failure;
}
// A reply names its thread: G is the thread ROOT (the replied-to post's
// own G when it has one, else that post itself), I is the replied-to
// post. Resolved against the store before anything is built, so an
// unknown hash is a clean exit-1 refusal.
var root_hash_buf: [64]u8 = undefined;
var g_hash: ?[]const u8 = null;
if (chosen == .reply) {
g_hash = resolveThreadRoot(gpa, io, arena, streams, amb, "forum", reply_to.?, &root_hash_buf) orelse return .failure;
}
var ts_buf: [23]u8 = undefined;
const ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
var m: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .forum,
.a = null,
.b = null,
.c = null,
.d = .{ .timestamp = ts },
.e = null,
.g = if (g_hash) |g| .{ .hash = g } else null,
.h = if (title) |t| .{ .title = t } else null,
.i = if (chosen == .reply) .{ .hash = reply_to.? } else null,
.k = null,
.l = null,
.n = nCard(mimetype, true), // forum: suppresses the default
.r = null,
.u = .{ .login = author },
.z = null,
.w = body,
.f = &.{},
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.s = &.{},
.t = &.{},
};
defer m.deinit();
return recordAndPrint(gpa, io, arena, streams, amb, "forum", &m, null, null);
}
pub fn runTechnote(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
amb: cli.Ambient,
) cli.Exit {
var saw_add = false;
var user: ?[]const u8 = null;
var mimetype: ?[]const u8 = null;
var event_id: ?[]const u8 = null;
var when: ?[]const u8 = null;
var comment: ?[]const u8 = null;
var tag_names: [max_technote_tags][]const u8 = undefined;
var tag_count: usize = 0;
var i: usize = 0;
while (i < verb_args.len) : (i += 1) {
const tok = verb_args[i];
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
streams.out.writeAll(technote_help) catch {};
return .ok;
} else if (std.mem.eql(u8, tok, "--user")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--user");
user = verb_args[i];
} else if (std.mem.eql(u8, tok, "--comment")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--comment");
comment = verb_args[i];
} else if (std.mem.eql(u8, tok, "--tag")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--tag");
if (tag_count == max_technote_tags) {
streams.err.print("zit technote: at most {d} --tag values\n", .{max_technote_tags}) catch {};
return .usage;
}
tag_names[tag_count] = verb_args[i];
tag_count += 1;
} else if (std.mem.eql(u8, tok, "--mimetype")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--mimetype");
mimetype = verb_args[i];
} else if (std.mem.eql(u8, tok, "--event")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--event");
event_id = verb_args[i];
} else if (std.mem.eql(u8, tok, "--time")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--time");
when = verb_args[i];
} else if (tok.len > 1 and tok[0] == '-') {
return cli.usageError(streams, "technote", .unknown_option, tok);
} else if (!saw_add) {
if (!std.mem.eql(u8, tok, "add")) return cli.usageError(streams, "technote", .unknown_option, tok);
saw_add = true;
} else {
return cli.usageError(streams, "technote", .extra_argument, tok);
}
}
if (!saw_add) {
streams.err.writeAll("zit technote: usage: zit technote add [-R repo] [--user NAME] [--comment TEXT] [--tag NAME] [--mimetype TYPE] [--event EVENT-ID] [--time TIMESTAMP] < body\n") catch {};
return .usage;
}
const author = resolveAuthor(user, amb) orelse {
streams.err.writeAll("zit technote: --user is required (no checkout default here)\n") catch {};
return .failure;
};
// The C card is what a timeline renders as the note's title, and no later
// verb can attach one — so an empty value is refused here rather than
// minting a note that is titled with the empty string forever.
if (comment) |c| {
if (c.len == 0) {
streams.err.writeAll("zit technote: --comment must not be empty\n") catch {};
return .failure;
}
}
for (tag_names[0..tag_count]) |name| {
if (!technoteTagUsable(name)) {
streams.err.print("zit technote: '{s}' is not usable as a tag (empty, or contains whitespace)\n", .{name}) catch {};
return .failure;
}
}
const tag_cards = composeTechnoteTags(arena, tag_names[0..tag_count]) catch |e| switch (e) {
error.OutOfMemory => {
streams.err.writeAll("zit technote: out of memory\n") catch {};
return .failure;
},
error.DuplicateTag => {
streams.err.writeAll("zit technote: the same --tag was given twice\n") catch {};
return .failure;
},
};
// The event id: minted fresh, or the caller's (an edit of an existing
// technote — latest wins at fold time, which the millisecond D cards
// keep honest).
var id_buf: [40]u8 = undefined;
const id: []const u8 = event_id orelse blk: {
var raw: [20]u8 = undefined;
io.randomSecure(&raw) catch {
streams.err.writeAll("zit technote: no entropy source for a fresh event id\n") catch {};
return .failure;
};
const hex = std.fmt.bytesToHex(raw, .lower);
@memcpy(&id_buf, &hex);
break :blk &id_buf;
};
if (!((id.len == 40 or id.len == 64) and isLowerHex(id))) {
streams.err.print("zit technote: '{s}' is not an event id (40 or 64 lowercase hex)\n", .{id}) catch {};
return .failure;
}
const stdin = std.Io.File.stdin();
var read_buf: [64 * 1024]u8 = undefined;
var reader = stdin.readerStreaming(io, &read_buf);
const body = reader.interface.allocRemaining(gpa, .limited(max_body_bytes)) catch {
streams.err.writeAll("zit technote: cannot read the body from stdin\n") catch {};
return .failure;
};
defer gpa.free(body);
var ts_buf: [23]u8 = undefined;
const d_ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
var e_ts_buf: [23]u8 = undefined;
const e_ts: []const u8 = if (when) |w| blk: {
if (artifact.classifyTimestamp(w) == null) {
streams.err.print("zit technote: '{s}' is not a timestamp the format accepts\n", .{w}) catch {};
return .failure;
}
break :blk w;
} else cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &e_ts_buf);
var m: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .event,
.a = null,
.b = null,
.c = if (comment) |c| .{ .comment = c } else null,
.d = .{ .timestamp = d_ts },
.e = .{ .timestamp = e_ts, .technote_id = id },
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = nCard(mimetype, false), // technote: fossil does NOT suppress here
.r = null,
.u = .{ .login = author },
.z = null,
.w = body,
.f = &.{},
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.s = &.{},
.t = tag_cards,
};
defer m.deinit();
return recordAndPrint(gpa, io, arena, streams, amb, "technote", &m, id, null);
}
// ---------------------------------------------------------------------------
// `zit attach` — the write side of the A card, and the stub-aware
// listing.
// ---------------------------------------------------------------------------
/// The most `attach add` will read from FILE or stdin before the size limit
/// is even consulted; a limit above this is the same as unlimited.
const max_attachment_read: usize = 1 << 30;
pub fn runAttach(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
amb: cli.Ambient,
) cli.Exit {
const Mode = enum { add, rm, list };
var mode: ?Mode = null;
var user: ?[]const u8 = null;
var name_opt: ?[]const u8 = null;
var comment: ?[]const u8 = null;
var mimetype: ?[]const u8 = null;
var pos: [2]?[]const u8 = .{ null, null };
var npos: usize = 0;
var i: usize = 0;
while (i < verb_args.len) : (i += 1) {
const tok = verb_args[i];
if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
streams.out.writeAll(attach_help) catch {};
return .ok;
} else if (std.mem.eql(u8, tok, "--user")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "attach", .unknown_option, "--user");
user = verb_args[i];
} else if (std.mem.eql(u8, tok, "--name")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "attach", .unknown_option, "--name");
name_opt = verb_args[i];
} else if (std.mem.eql(u8, tok, "--comment")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "attach", .unknown_option, "--comment");
comment = verb_args[i];
} else if (std.mem.eql(u8, tok, "--mimetype")) {
i += 1;
if (i >= verb_args.len) return cli.usageError(streams, "attach", .unknown_option, "--mimetype");
mimetype = verb_args[i];
} else if (tok.len > 1 and tok[0] == '-') {
// A lone `-` is FILE meaning stdin, not an option.
return cli.usageError(streams, "attach", .unknown_option, tok);
} else if (mode == null) {
if (std.mem.eql(u8, tok, "add")) {
mode = .add;
} else if (std.mem.eql(u8, tok, "rm")) {
mode = .rm;
} else if (std.mem.eql(u8, tok, "list")) {
mode = .list;
} else {
return cli.usageError(streams, "attach", .unknown_option, tok);
}
} else if (npos < pos.len) {
pos[npos] = tok;
npos += 1;
} else {
return cli.usageError(streams, "attach", .extra_argument, tok);
}
}
const chosen = mode orelse {
streams.err.writeAll("zit attach: usage: zit attach add|rm|list ... (see zit attach --help)\n") catch {};
return .usage;
};
return switch (chosen) {
.add => attachAdd(gpa, io, arena, streams, amb, pos[0], pos[1], name_opt, comment, mimetype, user),
.rm => attachRm(gpa, io, arena, streams, amb, pos[0], pos[1], user),
.list => attachList(gpa, io, arena, streams, amb, pos[0]),
};
}
/// The effective `attachment-size-limit` in bytes (0 = unlimited):
/// the resolved setting parsed fossil's way, or zit's 1 MiB default when it
/// is unset. An unparsable value is refused rather than read as unlimited.
fn attachmentSizeLimit(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
root_dir: std.Io.Dir,
amb: cli.Ambient,
streams: cli.Streams,
) error{ OutOfMemory, Refused }!u64 {
const resolved = cmd_sync.resolveSetting(gpa, io, arena, root_dir, amb, .attachment_size_limit) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return zit.settings.attachment_size_limit_default,
};
return zit.settings.parseByteLimit(resolved.value) orelse {
streams.err.print("zit attach: attachment-size-limit '{s}' is not a byte count\n", .{resolved.value}) catch {};
return error.Refused;
};
}
fn attachAdd(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
amb: cli.Ambient,
target_opt: ?[]const u8,
file_opt: ?[]const u8,
name_opt: ?[]const u8,
comment: ?[]const u8,
mimetype: ?[]const u8,
user: ?[]const u8,
) cli.Exit {
const target = target_opt orelse {
streams.err.writeAll("zit attach: add needs TARGET and FILE\n") catch {};
return .usage;
};
const file = file_opt orelse {
streams.err.writeAll("zit attach: add needs TARGET and FILE\n") catch {};
return .usage;
};
const author = resolveAuthor(user, amb) orelse {
streams.err.writeAll("zit attach: --user is required (no checkout default here)\n") catch {};
return .failure;
};
const from_stdin = std.mem.eql(u8, file, "-");
// §4.7.2 — only the final path component is the stored name; a directory
// prefix, from --name or from FILE, is discarded.
const raw_name = name_opt orelse blk: {
if (from_stdin) {
streams.err.writeAll("zit attach: --name is required when FILE is -\n") catch {};
return .usage;
}
break :blk file;
};
const name = std.fs.path.basename(raw_name);
if (name.len == 0) {
streams.err.print("zit attach: '{s}' has no file name\n", .{raw_name}) catch {};
return .failure;
}
const bytes: []u8 = if (from_stdin) blk: {
var stdin = std.Io.File.stdin();
var read_buf: [64 * 1024]u8 = undefined;
var reader = stdin.readerStreaming(io, &read_buf);
break :blk reader.interface.allocRemaining(gpa, .limited(max_attachment_read)) catch {
streams.err.writeAll("zit attach: cannot read the attachment from stdin\n") catch {};
return .failure;
};
} else std.Io.Dir.cwd().readFileAlloc(io, file, gpa, .limited(max_attachment_read)) catch {
streams.err.print("zit attach: cannot read '{s}'\n", .{file}) catch {};
return .failure;
};
defer gpa.free(bytes);
// Fossil: "attachments must have a non-0 size".
if (bytes.len == 0) {
streams.err.print("zit attach: '{s}' is empty; an attachment must have content\n", .{if (from_stdin) name else file}) catch {};
return .failure;
}
const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return stateOom(streams, "attach")) orelse {
main.reportNoRoot(streams, io, "attach", amb.repo_override);
return .failure;
};
var root_dir = root.dir;
defer root_dir.close(io);
const limit = attachmentSizeLimit(gpa, io, arena, root_dir, amb, streams) catch |e| switch (e) {
error.OutOfMemory => return stateOom(streams, "attach"),
error.Refused => return .failure,
};
if (limit != 0 and bytes.len > limit) {
streams.err.print(
"zit attach: '{s}' is {d} bytes; attachment-size-limit is {d} bytes (zit settings attachment-size-limit 0 lifts it)\n",
.{ name, bytes.len, limit },
) catch {};
return .failure;
}
var blob_name_buf: [64]u8 = undefined;
const blob_name = artifact.artifactName(.sha3_256, bytes, &blob_name_buf);
const a_card: artifact.ACard = .{ .name = name, .target = target, .source = blob_name };
var ts_buf: [23]u8 = undefined;
const d_ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
var m: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .attachment,
.a = a_card,
.b = null,
.c = if (comment) |c| .{ .comment = c } else null,
.d = .{ .timestamp = d_ts },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = nCard(mimetype, false),
.r = null,
.u = .{ .login = author },
.z = null,
.w = null,
.f = &.{},
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.s = &.{},
.t = &.{},
};
defer m.deinit();
return recordAndPrint(gpa, io, arena, streams, amb, "attach", &m, null, bytes);
}
fn attachRm(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
amb: cli.Ambient,
target_opt: ?[]const u8,
name_opt: ?[]const u8,
user: ?[]const u8,
) cli.Exit {
const target = target_opt orelse {
streams.err.writeAll("zit attach: rm needs TARGET and NAME\n") catch {};
return .usage;
};
const raw_name = name_opt orelse {
streams.err.writeAll("zit attach: rm needs TARGET and NAME\n") catch {};
return .usage;
};
const author = resolveAuthor(user, amb) orelse {
streams.err.writeAll("zit attach: --user is required (no checkout default here)\n") catch {};
return .failure;
};
const name = std.fs.path.basename(raw_name);
if (name.len == 0) {
streams.err.print("zit attach: '{s}' has no file name\n", .{raw_name}) catch {};
return .failure;
}
// A sourceless A card: the association ends; nothing leaves the store.
const a_card: artifact.ACard = .{ .name = name, .target = target, .source = null };
const comment: ?[]const u8 = null;
const mimetype: ?[]const u8 = null;
var ts_buf: [23]u8 = undefined;
const d_ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
var m: artifact.Manifest = .{
.arena_state = std.heap.ArenaAllocator.init(gpa),
.kind = .attachment,
.a = a_card,
.b = null,
.c = if (comment) |c| .{ .comment = c } else null,
.d = .{ .timestamp = d_ts },
.e = null,
.g = null,
.h = null,
.i = null,
.k = null,
.l = null,
.n = nCard(mimetype, false),
.r = null,
.u = .{ .login = author },
.z = null,
.w = null,
.f = &.{},
.j = &.{},
.m = &.{},
.p = &.{},
.q = &.{},
.s = &.{},
.t = &.{},
};
defer m.deinit();
return recordAndPrint(gpa, io, arena, streams, amb, "attach", &m, null, null);
}
/// One live attachment after the latest-wins fold: the newest A card for a
/// (target, name), by D timestamp then persisted order.
const LiveAttachment = struct {
target: []const u8,
name: []const u8,
source: ?[]const u8,
date: []const u8,
user: []const u8,
mimetype: ?[]const u8,
rid: logstore.Rid,
};
fn liveLess(_: void, a: LiveAttachment, b: LiveAttachment) bool {
const t = std.mem.order(u8, a.target, b.target);
if (t != .eq) return t == .lt;
return std.mem.lessThan(u8, a.name, b.name);
}
/// A listed TARGET matches a stored one exactly, or — when both are
/// hash-shaped — as a prefix, the way every hash argument abbreviates.
fn targetMatches(stored: []const u8, wanted: []const u8) bool {
if (std.mem.eql(u8, stored, wanted)) return true;
if (wanted.len < 4 or wanted.len > stored.len) return false;
for (wanted) |c| if (!std.ascii.isHex(c)) return false;
for (stored) |c| if (!std.ascii.isHex(c)) return false;
return std.mem.startsWith(u8, stored, wanted);
}
fn attachList(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
amb: cli.Ambient,
target_opt: ?[]const u8,
) cli.Exit {
const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return stateOom(streams, "attach")) orelse {
main.reportNoRoot(streams, io, "attach", amb.repo_override);
return .failure;
};
var root_dir = root.dir;
defer root_dir.close(io);
var file = cli.openStoreRead(io, root_dir) catch {
streams.err.writeAll("zit attach: cannot read the repository\n") catch {};
return .failure;
};
defer file.close(io);
const st = file.stat(io) catch {
streams.err.writeAll("zit attach: cannot read the repository\n") catch {};
return .failure;
};
var fs = storage.FileStorage.init(io, file, st.size);
var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| switch (e) {
error.OutOfMemory => return stateOom(streams, "attach"),
else => {
streams.err.writeAll("zit attach: cannot read the repository\n") catch {};
return .failure;
},
};
defer log.deinit(gpa);
// Latest-wins fold over every attachment artifact. An attachment's first
// card is A (letter order), so two bytes reject everything else before a
// parse.
var latest: std.StringHashMapUnmanaged(LiveAttachment) = .empty;
defer latest.deinit(gpa);
var rid: logstore.Rid = 1;
const count: logstore.Rid = @intCast(log.entries.items.len);
while (rid <= count) : (rid += 1) {
const bytes = (log.content(gpa, rid) catch |e| switch (e) {
error.OutOfMemory => return stateOom(streams, "attach"),
else => continue,
}) orelse continue;
defer gpa.free(bytes);
if (bytes.len < 2 or bytes[0] != 'A' or bytes[1] != ' ') continue;
var m = artifact.parse(gpa, bytes) catch continue;
defer m.deinit();
if (m.kind != .attachment) continue;
const a = m.a orelse continue;
const date = if (m.d) |d| d.timestamp else "";
const key = std.mem.concat(arena, u8, &.{ a.target, "\x00", a.name }) catch return stateOom(streams, "attach");
const gop = latest.getOrPut(gpa, key) catch return stateOom(streams, "attach");
if (gop.found_existing) {
const have = gop.value_ptr.*;
const newer = switch (std.mem.order(u8, date, have.date)) {
.gt => true,
.lt => false,
.eq => rid > have.rid,
};
if (!newer) continue;
}
gop.value_ptr.* = .{
.target = arena.dupe(u8, a.target) catch return stateOom(streams, "attach"),
.name = arena.dupe(u8, a.name) catch return stateOom(streams, "attach"),
.source = if (a.source) |src| (arena.dupe(u8, src) catch return stateOom(streams, "attach")) else null,
.date = arena.dupe(u8, date) catch return stateOom(streams, "attach"),
.user = arena.dupe(u8, if (m.u) |u| u.login else "") catch return stateOom(streams, "attach"),
.mimetype = if (m.n) |n| (arena.dupe(u8, n.mimetype) catch return stateOom(streams, "attach")) else null,
.rid = rid,
};
}
var live: std.ArrayListUnmanaged(LiveAttachment) = .empty;
defer live.deinit(gpa);
var it = latest.valueIterator();
while (it.next()) |v| {
if (v.source == null) continue; // a sourceless card means gone
if (target_opt) |t| if (!targetMatches(v.target, t)) continue;
live.append(gpa, v.*) catch return stateOom(streams, "attach");
}
std.mem.sort(LiveAttachment, live.items, {}, liveLess);
for (live.items) |v| {
const src = v.source.?;
// The blob's size, or — for a quarry stub — what it points at.
var size: u64 = 0;
var external: ?zit.attachstub.Stub = null;
var blob: ?[]u8 = null;
defer if (blob) |b| gpa.free(b);
var digest_buf: [32]u8 = undefined;
if (std.fmt.hexToBytes(&digest_buf, src)) |digest| {
if (log.lookup(digest)) |brid| {
blob = log.content(gpa, brid) catch null;
if (blob) |b| {
size = b.len;
external = zit.attachstub.parse(b);
}
}
} else |_| {}
if (target_opt == null) streams.out.print("{s} ", .{v.target}) catch {};
if (external) |ext| {
streams.out.print("{s} {d} {s} external:sha256:{s} {s} {s}\n", .{
v.name, ext.size, if (ext.mimetype.len != 0) ext.mimetype else (v.mimetype orelse "-"), ext.sha256, v.date, v.user,
}) catch {};
} else {
streams.out.print("{s} {d} {s} {s} {s} {s}\n", .{
v.name, size, v.mimetype orelse "-", src, v.date, v.user,
}) catch {};
}
}
return .ok;
}
/// Compose a technote's T run from `--tag` values: one `T +sym-NAME *` card
/// each, sorted ascending and rejecting duplicates.
///
/// The shape is fossil's, byte for byte — authoring a technote there with
/// `--technote-tags "alpha beta"` yields `T +sym-alpha *` / `T +sym-beta *`.
/// Singleton (`+`) because a technote has no descendants to propagate
/// through; self-referential target (null, written `*`) because §4.7.16's
/// target is "the artifact carrying the card" in that spelling; `sym-`
/// prefixed because these are names a technote is found by.
///
/// Sorted and deduplicated because §4.5.2 requires a strictly ascending run:
/// a repeated `--tag` would otherwise mint an artifact that fails its own
/// admission check. Pure; bounded by `max_technote_tags`.
fn composeTechnoteTags(
arena: std.mem.Allocator,
names: []const []const u8,
) error{ OutOfMemory, DuplicateTag }![]artifact.TCard {
std.debug.assert(names.len <= max_technote_tags);
const cards = try arena.alloc(artifact.TCard, names.len);
for (names, 0..) |name, idx| {
cards[idx] = .{
.sign = .add,
.name = try std.mem.concat(arena, u8, &.{ artifact.sym_tag_prefix, name }),
.target = null,
.value = null,
};
}
std.mem.sort(artifact.TCard, cards, {}, artifact.tagTokenLess);
var idx: usize = 1;
while (idx < cards.len) : (idx += 1) {
if (std.mem.eql(u8, cards[idx - 1].name, cards[idx].name)) return error.DuplicateTag;
}
return cards;
}
/// Whether `name` is usable as a technote tag: non-empty, and free of the
/// whitespace fossil's own space-separated tag field could never carry (it
/// would re-split such a name on read, so accepting it would author a note
/// fossil and zit disagree about).
fn technoteTagUsable(name: []const u8) bool {
if (name.len == 0) return false;
for (name) |c| {
if (c == ' ' or c == '\t' or c == '\n' or c == '\r') return false;
}
return true;
}
/// The thread root a reply's G card names: the replied-to post's own G when
/// it carries one, else the post itself. Null (with the diagnostic printed)
/// when the hash is unknown or does not parse as a forum post — replying
/// into the void must be a refusal, not a dangling thread.
pub fn resolveThreadRoot(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
streams: cli.Streams,
amb: cli.Ambient,
cmd: []const u8,
post_hash: []const u8,
out: *[64]u8,
) ?[]const u8 {
const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return null) orelse {
main.reportNoRoot(streams, io, "forum", amb.repo_override);
return null;
};
var root_dir = root.dir;
defer root_dir.close(io);
var file = cli.openStoreRead(io, root_dir) catch {
streams.err.print("zit {s}: cannot read the repository\n", .{cmd}) catch {};
return null;
};
defer file.close(io);
const st = file.stat(io) catch {
streams.err.print("zit {s}: cannot read the repository\n", .{cmd}) catch {};
return null;
};
var fs = storage.FileStorage.init(io, file, st.size);
var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch {
streams.err.print("zit {s}: cannot read the repository\n", .{cmd}) catch {};
return null;
};
defer log.deinit(gpa);
var digest_buf: [digest_len]u8 = undefined;
const digest = hexToDigest(post_hash, &digest_buf) orelse {
streams.err.print("zit {s}: '{s}' is not an artifact hash\n", .{ cmd, post_hash }) catch {};
return null;
};
const rid = log.lookup(digest) orelse {
streams.err.print("zit {s}: unknown post '{s}'\n", .{ cmd, post_hash }) catch {};
return null;
};
const bytes = (log.content(gpa, rid) catch {
streams.err.print("zit {s}: unknown post '{s}'\n", .{ cmd, post_hash }) catch {};
return null;
}) orelse {
streams.err.print("zit {s}: unknown post '{s}'\n", .{ cmd, post_hash }) catch {};
return null;
};
defer gpa.free(bytes);
var parsed = artifact.parse(gpa, bytes) catch {
streams.err.print("zit {s}: '{s}' is not a forum post\n", .{ cmd, post_hash }) catch {};
return null;
};
defer parsed.deinit();
if (parsed.kind != .forum) {
streams.err.print("zit {s}: '{s}' is not a forum post\n", .{ cmd, post_hash }) catch {};
return null;
}
const chosen: []const u8 = if (parsed.g) |g| g.hash else post_hash;
std.debug.assert(chosen.len <= out.len);
@memcpy(out[0..chosen.len], chosen);
return out[0..chosen.len];
}