Artifact 19949a5f308295e3915734e7325f3c57d4c47b3e5826282b441e491384ca4ef0:
- File
src/cmd/records.zig
— part of check-in
[baa15c3362]
at
2026-08-24 00:31:18
on branch ncard-rule
— N card: follow fossil's per-type write rule (#136)
landing hardcoded N text/plain with no way to say otherwise, overriding the text/x-fossil-wiki default of artifact-format 4.7.12 on every record it wrote. It now takes --mimetype and, minting a forum artifact, follows forum's rule.
The wider bug was in what landing copied. Verified against fossil's source: wiki (wiki.c:2084) and forum (forum.c:1784) write N only when the mimetype is present, non-empty and NOT the default; technote (event.c:284) writes it whenever non-empty, with no suppression. zit wrote it unconditionally for all three, so an explicit --mimetype text/x-fossil-wiki minted different bytes, and a different artifact id, than fossil produces from the same input.
The draft states the READ default, not when the card is written. Only the source shows the suppression, and only the source shows that the three types disagree -- so tests pin the divergence rather than tidying it away. (user: dmestas, size: 34003) [annotate] [blame] [check-ins using] [more...]
//! `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; const forum_help = main.forum_help; const technote_help = main.technote_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); } 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); } 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 { 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 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; } 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, 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); } 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 #75's 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); } /// 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. fn resolveThreadRoot( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, amb: cli.Ambient, 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.writeAll("zit forum: cannot read the repository\n") catch {}; return null; }; defer file.close(io); const st = file.stat(io) catch { streams.err.writeAll("zit forum: cannot read the repository\n") catch {}; return null; }; var fs = storage.FileStorage.init(io, file, st.size); var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch { streams.err.writeAll("zit forum: cannot read the repository\n") 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 forum: '{s}' is not an artifact hash\n", .{post_hash}) catch {}; return null; }; const rid = log.lookup(digest) orelse { streams.err.print("zit forum: unknown post '{s}'\n", .{post_hash}) catch {}; return null; }; const bytes = (log.content(gpa, rid) catch { streams.err.print("zit forum: unknown post '{s}'\n", .{post_hash}) catch {}; return null; }) orelse { streams.err.print("zit forum: unknown post '{s}'\n", .{post_hash}) catch {}; return null; }; defer gpa.free(bytes); var parsed = artifact.parse(gpa, bytes) catch { streams.err.print("zit forum: '{s}' is not a forum post\n", .{post_hash}) catch {}; return null; }; defer parsed.deinit(); if (parsed.kind != .forum) { streams.err.print("zit forum: '{s}' is not a forum post\n", .{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]; }