Artifact 88cd53d4c500b2077a6a679979a3e3bfc27421780628f74467fdc4607d262cca:
- File
src/cmd/records.zig
— part of check-in
[fe8d14d754]
at
2026-08-15 23:20:49
on branch trunk
— wiki and ticket authoring verbs (#71)
zit wiki commit and zit ticket new/set record wiki and ticket-change artifacts on a bare store via -R, under the store write lock. Machine verbs: hash-only stdout, exact exit codes, unlisted in the human help but fully documented via their own --help. (user: dmestas, size: 15896) [annotate] [blame] [check-ins using]
//! `zit wiki` / `zit ticket` — record wiki and ticket-change artifacts (#71). //! //! 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 (#59) — //! 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 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; /// 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; 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: [19]u8 = undefined; const ts = cli.formatWriterTimestamp(amb.now_epoch, &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 = if (mimetype) |mt| .{ .mimetype = mt } else null, .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); } 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: [19]u8 = undefined; const ts = cli.formatWriterTimestamp(amb.now_epoch, &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); } 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, ) 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 { streams.err.writeAll(not_a_repo_msg) catch {}; 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 file = cli.openStoreAppend(io, root_dir) catch { streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {}; return .failure; }; defer file.close(io); 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; 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 {}; } 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; }