Skip to content
zit Artifact [88cd53d4c5]
Login

Artifact [88cd53d4c5]

Artifact 88cd53d4c500b2077a6a679979a3e3bfc27421780628f74467fdc4607d262cca:


//! `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;
}