Skip to content
zit Artifact [94513a50fc]
Login

Artifact [94513a50fc]

Artifact 94513a50fcbb109334690e0cc52a239d9e5f645cb5b5708bfe44a622c4aadf6e:


const std = @import("std");

pub const Limits = struct {
    max_line: usize,
    max_data: u64,
    max_path: usize,
    max_merges_per_commit: usize = 1_024,
    /// No default: this is a runaway-stream tripwire, and the right ceiling is
    /// the caller's to state. It carried 1,000,000 -- a round number with no
    /// basis, which was removed at the CLI only to leave armed here, so any new
    /// caller silently re-inherited the exact cap that had just been deleted.
    /// A required field makes that impossible: a caller that wants no ceiling
    /// says `maxInt`, and says it where a reader can see it.
    max_events: u64,
    max_total_data: u64 = 1 << 40,
    /// Bytes one commit's retained file commands may occupy. See
    /// `fileCommandCeiling` for why this is its own budget and not a share of
    /// `max_total_data`.
    max_command_arena_bytes: u64 = @as(u64, 4) * 1024 * 1024 * 1024,
};

/// Floor on the bytes one retained file command occupies: its slot in the
/// per-commit command list plus the shortest path it can carry. `parsePathToken`
/// rejects the empty path, so one byte is that floor. Nothing smaller is
/// representable, which is what makes it safe to divide a byte budget by.
pub const min_retained_bytes_per_file_command: u64 = @sizeOf(FileCommand) + 1;

/// The per-commit file-command ceiling, derived rather than declared.
///
/// A file command's list slot and duplicated path are arena bytes no byte
/// budget charges: `admitData` sees only `data <n>` payloads, so an unbounded
/// command loop grows the arena without ever touching one. The ceiling closes
/// that gap by restating a byte authorization in commands — the most whose
/// minimum retention still fits.
///
/// It draws on `max_command_arena_bytes` rather than `max_total_data` because
/// the two bound different things and only one of them is memory. Cumulative
/// payload is streamed and released per event; the per-commit command list is
/// held whole. While they shared a number, raising the cumulative budget so an
/// ordinary repository could import also raised how much one commit may hold —
/// a 1 TiB payload allowance silently became a 1 TiB arena allowance. Separated,
/// each can take the value its own risk justifies.
pub fn fileCommandCeiling(limits: Limits) usize {
    const ceiling = limits.max_command_arena_bytes / min_retained_bytes_per_file_command;
    return std.math.cast(usize, ceiling) orelse std.math.maxInt(usize);
}

/// The numbers a `FileCommandLimitExceeded` diagnostic must name. The parser
/// has no output channel, so it retains them and the caller does the
/// formatting.
pub const FileCommandOverrun = struct {
    /// The refused commit carried at least this many commands: the ceiling
    /// plus the one that did not fit. Parsing stops there, so the true count
    /// is unknown and this is a lower bound, never an estimate.
    observed_at_least: usize,
    ceiling: usize,
    arena_budget_bytes: u64,
    min_bytes_per_command: u64 = min_retained_bytes_per_file_command,
};

/// The numbers a `DataLimitExceeded` diagnostic must name. Two unrelated bounds
/// share that one error, and they differ in the only respect an operator cares
/// about: whether a flag raises them. `max_total_data` is the operator's
/// `--max-export-payload-bytes`; `max_data` is a fixed per-payload ceiling no
/// flag reaches. Reporting the wrong one sends an operator to a knob that
/// cannot help. Retained here for the same reason `FileCommandOverrun` is: the
/// parser has no output channel.
pub const PayloadOverrun = struct {
    pub const Bound = enum {
        /// One `data <n>` payload against the fixed per-payload ceiling.
        single_payload,
        /// The running charged total against the operator's byte budget.
        cumulative_total,
    };
    bound: Bound,
    /// For `single_payload` the refused payload's exact size. For
    /// `cumulative_total` the total the refused payload would have reached:
    /// parsing stops there, so the stream's true payload is larger and this is
    /// a lower bound, never an estimate.
    observed_at_least: u64,
    ceiling: u64,
};

pub const Error = error{
    ActiveLease,
    Malformed,
    DataLimitExceeded,
    EventLimitExceeded,
    FileCommandLimitExceeded,
    LineLimitExceeded,
    MergeLimitExceeded,
    PathLimitExceeded,
    MissingDone,
    UnexpectedEof,
    TrailingData,
    UnsupportedFeature,
    UnknownMode,
    OutOfMemory,
    Io,
};

pub const ObjectRef = union(enum) {
    mark: u64,
    oid: []const u8,
};

/// Modes a Git tree entry can carry. `gitlink` is mode 160000 -- a recorded
/// submodule commit. Zit has no equivalent, but that is an import-policy
/// question, not a parsing one: the parser reports what the stream said and
/// the importer decides what to do with it. Only modes Git never emits reach
/// `error.UnknownMode`.
pub const FileMode = enum {
    regular,
    executable,
    symlink,
    gitlink,
};

pub const FileCommand = union(enum) {
    modify: struct {
        mode: FileMode,
        data: ObjectRef,
        path: []const u8,
    },
    delete: []const u8,
    rename: struct {
        old: []const u8,
        new: []const u8,
    },
    copy: struct {
        old: []const u8,
        new: []const u8,
    },
    delete_all,
};

pub const Identity = struct {
    name: []const u8,
    email: []const u8,
    timestamp: i64,
    timezone_offset_minutes: i16,
    /// Set when the source's line was not `NAME <EMAIL> SECONDS ZONE` and
    /// was read as Git itself reads it: what was wrong, for the import
    /// report. A missing date reads as 0, which the importer replaces.
    repaired: ?[]const u8 = null,
    /// The line had no date Git could read.
    date_missing: bool = false,
};

pub const Blob = struct {
    mark: u64,
    original_oid: ?[]const u8,
    data: []const u8,
};

pub const Commit = struct {
    ref: []const u8,
    mark: u64,
    original_oid: ?[]const u8,
    author: Identity,
    committer: Identity,
    message: []const u8,
    from: ?ObjectRef,
    merges: []const ObjectRef,
    commands: []const FileCommand,
};

pub const Reset = struct {
    ref: []const u8,
    target: ?ObjectRef,
};

pub const Tag = struct {
    name: []const u8,
    mark: ?u64,
    target: ObjectRef,
    original_oid: ?[]const u8,
    tagger: Identity,
    message: []const u8,
};

pub const Event = union(enum) {
    blob: Blob,
    commit: Commit,
    reset: Reset,
    tag: Tag,
    done,
};

pub const EventLease = struct {
    value: Event,
    reader: *Reader,
    released: bool = false,

    pub fn deinit(self: *EventLease) void {
        if (self.released) return;
        self.released = true;
        self.reader.releaseLease();
    }
};

pub const Reader = struct {
    gpa: std.mem.Allocator,
    input: std.Io.Reader,
    limits: Limits,
    line: std.ArrayList(u8),
    event_arena: ?std.heap.ArenaAllocator = null,
    active_lease: bool = false,
    feature_done_seen: bool = false,
    records_started: bool = false,
    done_seen: bool = false,
    eof_seen: bool = false,
    pending_top_level: ?[]u8 = null,
    events_seen: u64 = 0,
    data_bytes_seen: u64 = 0,
    /// Retained by the bounds whose error names cannot carry their own
    /// numbers. Null until the matching error is returned.
    file_command_overrun: ?FileCommandOverrun = null,
    payload_overrun: ?PayloadOverrun = null,
    /// What a `Malformed` refusal broke, and the record it was reading: kind,
    /// mark, and original-oid when the stream had given them. Read by
    /// `writeRefusal`; null until a refusal names its rule.
    malformed_rule: ?[]const u8 = null,
    record_kind: []const u8 = "the stream header",
    record_mark: u64 = 0,
    record_oid_buf: [64]u8 = undefined,
    record_oid_len: u8 = 0,

    pub fn init(gpa: std.mem.Allocator, input: std.Io.Reader, limits: Limits) Reader {
        return .{
            .gpa = gpa,
            .input = input,
            .limits = limits,
            .line = .empty,
        };
    }

    pub fn deinit(self: *Reader) void {
        if (self.event_arena) |arena| arena.deinit();
        self.event_arena = null;
        self.active_lease = false;
        self.line.deinit(self.gpa);
        if (self.pending_top_level) |line| self.gpa.free(line);
        self.pending_top_level = null;
    }

    pub fn next(self: *Reader) Error!?EventLease {
        if (self.active_lease) return error.ActiveLease;
        if (self.eof_seen) return null;

        if (self.done_seen) {
            try self.requireEof();
            self.eof_seen = true;
            return null;
        }

        while (true) {
            const line = try self.readTopLevelLine();
            if (std.mem.startsWith(u8, line, "feature ")) {
                if (self.records_started or self.feature_done_seen) return self.malformed("a feature line comes after the first record or twice");
                if (!std.mem.eql(u8, line, "feature done")) return error.UnsupportedFeature;
                self.feature_done_seen = true;
                continue;
            }
            if (std.mem.startsWith(u8, line, "progress ")) {
                if (!self.feature_done_seen) return self.malformed("the stream does not open with 'feature done'");
                try self.admitEvent();
                continue;
            }
            if (!self.feature_done_seen) return self.malformed("the stream does not open with 'feature done'");
            const is_event = std.mem.eql(u8, line, "blob") or
                std.mem.startsWith(u8, line, "commit ") or
                std.mem.startsWith(u8, line, "reset ") or
                std.mem.startsWith(u8, line, "tag ") or
                std.mem.eql(u8, line, "done");
            if (!is_event) return self.malformed("a line between records is not a record git fast-export writes");

            self.records_started = true;
            try self.admitEvent();
            self.event_arena = std.heap.ArenaAllocator.init(self.gpa);
            errdefer {
                self.event_arena.?.deinit();
                self.event_arena = null;
            }
            const allocator = self.eventAllocator();
            self.beginRecord(if (std.mem.eql(u8, line, "blob"))
                "blob"
            else if (std.mem.startsWith(u8, line, "commit "))
                "commit"
            else if (std.mem.startsWith(u8, line, "reset "))
                "reset"
            else if (std.mem.startsWith(u8, line, "tag "))
                "tag"
            else
                "done");
            const event: Event = if (std.mem.eql(u8, line, "blob"))
                .{ .blob = try self.parseBlob(allocator) }
            else if (std.mem.startsWith(u8, line, "commit "))
                .{ .commit = try self.parseCommit(allocator, line["commit ".len..]) }
            else if (std.mem.startsWith(u8, line, "reset "))
                .{ .reset = try self.parseReset(allocator, line["reset ".len..]) }
            else if (std.mem.startsWith(u8, line, "tag "))
                .{ .tag = try self.parseTag(allocator, line["tag ".len..]) }
            else if (std.mem.eql(u8, line, "done")) blk: {
                self.done_seen = true;
                break :blk .done;
            } else unreachable;

            return self.lease(event);
        }
    }

    /// One line naming the record a `Malformed` refusal was reading and the
    /// rule it broke, e.g. `commit febf7ea4bedc: from is not a mark or an
    /// object name`.
    pub fn writeRefusal(self: *const Reader, w: *std.Io.Writer) std.Io.Writer.Error!void {
        try w.writeAll(self.record_kind);
        if (self.record_oid_len != 0) {
            try w.print(" {s}", .{self.record_oid_buf[0..@min(self.record_oid_len, 12)]});
        } else if (self.record_mark != 0) {
            try w.print(" :{d}", .{self.record_mark});
        }
        try w.print(": {s}", .{self.malformed_rule orelse "the export stream is not one git fast-export writes"});
    }

    fn malformed(self: *Reader, rule: []const u8) Error {
        self.malformed_rule = rule;
        return error.Malformed;
    }

    /// Names `rule` for a `Malformed` a helper returned without one.
    fn ruled(self: *Reader, rule: []const u8, err: Error) Error {
        if (err == error.Malformed and self.malformed_rule == null) self.malformed_rule = rule;
        return err;
    }

    fn beginRecord(self: *Reader, kind: []const u8) void {
        self.record_kind = kind;
        self.record_mark = 0;
        self.record_oid_len = 0;
    }

    fn noteMark(self: *Reader, line: []const u8) Error!u64 {
        const mark = parseMarkLine(line) catch |e| return self.ruled("its mark line is not 'mark :N' with N above 0", e);
        self.record_mark = mark;
        return mark;
    }

    fn noteOriginalOid(self: *Reader, allocator: std.mem.Allocator, held: ?[]const u8, text: []const u8) Error![]const u8 {
        if (held != null) return self.malformed("it carries two original-oid lines");
        const oid = parseOid(allocator, text) catch |e| return self.ruled("its original-oid is not a 40- or 64-digit hex name", e);
        @memcpy(self.record_oid_buf[0..oid.len], oid);
        self.record_oid_len = @intCast(oid.len);
        return oid;
    }

    fn objectRef(self: *Reader, allocator: std.mem.Allocator, text: []const u8, rule: []const u8) Error!ObjectRef {
        return parseObjectRef(allocator, text) catch |e| return self.ruled(rule, e);
    }

    fn lease(self: *Reader, value: Event) EventLease {
        self.active_lease = true;
        return .{ .value = value, .reader = self };
    }

    fn releaseLease(self: *Reader) void {
        std.debug.assert(self.active_lease);
        self.event_arena.?.deinit();
        self.event_arena = null;
        self.active_lease = false;
    }

    fn eventAllocator(self: *Reader) std.mem.Allocator {
        return self.event_arena.?.allocator();
    }

    fn admitEvent(self: *Reader) Error!void {
        const next_events_seen = std.math.add(u64, self.events_seen, 1) catch return error.EventLimitExceeded;
        if (next_events_seen > self.limits.max_events) return error.EventLimitExceeded;
        self.events_seen = next_events_seen;
    }

    fn admitData(self: *Reader, size: u64) Error!void {
        // Saturating on overflow rather than propagating it: the report wants
        // the total this payload reached for, and a u64 wrap is not that
        // number. The bound is exceeded either way.
        const next_data_bytes_seen = std.math.add(u64, self.data_bytes_seen, size) catch
            return self.refusePayload(.cumulative_total, std.math.maxInt(u64), self.limits.max_total_data);
        if (next_data_bytes_seen > self.limits.max_total_data)
            return self.refusePayload(.cumulative_total, next_data_bytes_seen, self.limits.max_total_data);
        self.data_bytes_seen = next_data_bytes_seen;
    }

    fn refusePayload(
        self: *Reader,
        bound: PayloadOverrun.Bound,
        observed_at_least: u64,
        ceiling: u64,
    ) Error {
        self.payload_overrun = .{
            .bound = bound,
            .observed_at_least = observed_at_least,
            .ceiling = ceiling,
        };
        return error.DataLimitExceeded;
    }

    fn readTopLevelLine(self: *Reader) Error![]const u8 {
        if (self.pending_top_level) |owned| {
            defer self.gpa.free(owned);
            self.pending_top_level = null;
            self.line.clearRetainingCapacity();
            self.line.appendSlice(self.gpa, owned) catch return error.OutOfMemory;
            return self.line.items;
        }
        return self.readLine() catch |err| switch (err) {
            error.EndOfStream => return error.MissingDone,
            else => |e| return e,
        };
    }

    fn requiredLine(self: *Reader) Error![]const u8 {
        return self.readLine() catch |err| switch (err) {
            error.EndOfStream => return error.UnexpectedEof,
            else => |e| return e,
        };
    }

    fn readLine(self: *Reader) (Error || error{EndOfStream})![]const u8 {
        self.line.clearRetainingCapacity();
        while (true) {
            const byte = self.input.takeByte() catch |err| switch (err) {
                error.EndOfStream => return error.EndOfStream,
                error.ReadFailed => return error.Io,
            };
            if (byte == '\n') return self.line.items;
            if (self.line.items.len == self.limits.max_line) return error.LineLimitExceeded;
            self.line.append(self.gpa, byte) catch return error.OutOfMemory;
        }
    }

    fn requireEof(self: *Reader) Error!void {
        _ = self.input.takeByte() catch |err| switch (err) {
            error.EndOfStream => return,
            error.ReadFailed => return error.Io,
        };
        return error.TrailingData;
    }

    fn duplicate(_: *Reader, allocator: std.mem.Allocator, bytes: []const u8) Error![]const u8 {
        return allocator.dupe(u8, bytes) catch return error.OutOfMemory;
    }

    fn parseBlob(self: *Reader, allocator: std.mem.Allocator) Error!Blob {
        const mark = try self.noteMark(try self.requiredLine());
        var original_oid: ?[]const u8 = null;

        while (true) {
            const line = try self.requiredLine();
            if (std.mem.startsWith(u8, line, "original-oid ")) {
                original_oid = try self.noteOriginalOid(allocator, original_oid, line["original-oid ".len..]);
                continue;
            }
            if (std.mem.startsWith(u8, line, "data ")) {
                return .{
                    .mark = mark,
                    .original_oid = original_oid,
                    .data = try self.readData(allocator, line),
                };
            }
            return self.malformed("a blob line is not original-oid or data");
        }
    }

    fn parseCommit(self: *Reader, allocator: std.mem.Allocator, ref_text: []const u8) Error!Commit {
        const ref = try self.parseRef(allocator, ref_text);
        const mark = try self.noteMark(try self.requiredLine());
        var original_oid: ?[]const u8 = null;
        var author: ?Identity = null;
        var committer: ?Identity = null;
        var message: ?[]const u8 = null;

        while (true) {
            const line = try self.requiredLine();
            if (std.mem.startsWith(u8, line, "original-oid ")) {
                original_oid = try self.noteOriginalOid(allocator, original_oid, line["original-oid ".len..]);
            } else if (std.mem.startsWith(u8, line, "author ")) {
                if (author != null) return self.malformed("it carries two author lines");
                author = try parseIdentity(self, allocator, line["author ".len..]);
            } else if (std.mem.startsWith(u8, line, "committer ")) {
                if (committer != null) return self.malformed("it carries two committer lines");
                committer = try parseIdentity(self, allocator, line["committer ".len..]);
            } else if (std.mem.startsWith(u8, line, "data ")) {
                message = try self.readCommitData(allocator, line);
                break;
            } else return self.malformed("a header line is not original-oid, author, committer or data");
        }

        // git fast-export writes an author line only when the commit has
        // one; Git's own reading of a commit without it is its committer.
        const parsed_committer = committer orelse return self.malformed("it has no committer line");
        var parsed_author = author orelse parsed_committer;
        var dated_committer = parsed_committer;
        // A line with no readable date takes the other's, as the one date
        // the commit does carry; with neither, the importer dates it.
        if (parsed_author.date_missing and !dated_committer.date_missing) parsed_author.timestamp = dated_committer.timestamp;
        if (dated_committer.date_missing and !parsed_author.date_missing) dated_committer.timestamp = parsed_author.timestamp;
        const parsed_message = message orelse return self.malformed("it has no data line");
        var from: ?ObjectRef = null;
        var merges: std.ArrayList(ObjectRef) = .empty;
        var commands: std.ArrayList(FileCommand) = .empty;
        const file_command_ceiling = fileCommandCeiling(self.limits);

        // Exact-count data may have an optional trailing LF. Only the first
        // following empty line is ambiguous with the commit terminator.
        var first_command_line = true;
        command_loop: while (true) {
            var line = try self.requiredLine();
            if (line.len == 0) {
                if (!first_command_line) break;
                line = self.readLine() catch |err| switch (err) {
                    error.EndOfStream => break :command_loop,
                    else => |e| return e,
                };
                if (line.len == 0) break;
                if (isTopLevelRecord(line)) {
                    self.pending_top_level = self.gpa.dupe(u8, line) catch return error.OutOfMemory;
                    break;
                }
            }
            first_command_line = false;
            if (std.mem.startsWith(u8, line, "from ")) {
                if (from != null) return self.malformed("it carries two from lines");
                from = try self.objectRef(allocator, line["from ".len..], "its from is not a mark or a hex object name");
            } else if (std.mem.startsWith(u8, line, "merge ")) {
                if (merges.items.len >= self.limits.max_merges_per_commit) return error.MergeLimitExceeded;
                const merge = try self.objectRef(allocator, line["merge ".len..], "a merge is not a mark or a hex object name");
                merges.append(allocator, merge) catch return error.OutOfMemory;
            } else {
                if (commands.items.len >= file_command_ceiling) {
                    self.file_command_overrun = .{
                        .observed_at_least = file_command_ceiling +| 1,
                        .ceiling = file_command_ceiling,
                        .arena_budget_bytes = self.limits.max_command_arena_bytes,
                    };
                    return error.FileCommandLimitExceeded;
                }
                const command = try self.parseFileCommand(allocator, line);
                commands.append(allocator, command) catch return error.OutOfMemory;
            }
        }

        return .{
            .ref = ref,
            .mark = mark,
            .original_oid = original_oid,
            .author = parsed_author,
            .committer = dated_committer,

            .message = parsed_message,
            .from = from,
            .merges = merges.toOwnedSlice(allocator) catch return error.OutOfMemory,
            .commands = commands.toOwnedSlice(allocator) catch return error.OutOfMemory,
        };
    }

    fn isTopLevelRecord(line: []const u8) bool {
        return std.mem.eql(u8, line, "blob") or
            std.mem.startsWith(u8, line, "commit ") or
            std.mem.startsWith(u8, line, "reset ") or
            std.mem.startsWith(u8, line, "tag ") or
            std.mem.startsWith(u8, line, "progress ") or
            std.mem.eql(u8, line, "done");
    }

    fn parseReset(self: *Reader, allocator: std.mem.Allocator, ref_text: []const u8) Error!Reset {
        const ref = try self.parseRef(allocator, ref_text);
        const line = try self.requiredLine();
        if (line.len == 0) return .{ .ref = ref, .target = null };
        if (isTopLevelRecord(line)) {
            self.pending_top_level = self.gpa.dupe(u8, line) catch return error.OutOfMemory;
            return .{ .ref = ref, .target = null };
        }
        if (!std.mem.startsWith(u8, line, "from ")) return self.malformed("the line after it is not from, a blank line or a record");
        const target = try self.objectRef(allocator, line["from ".len..], "its from is not a mark or a hex object name");
        const separator = try self.requiredLine();
        if (separator.len == 0) return .{ .ref = ref, .target = target };
        if (!isTopLevelRecord(separator)) return self.malformed("its from is followed by neither a blank line nor a record");
        self.pending_top_level = self.gpa.dupe(u8, separator) catch return error.OutOfMemory;
        return .{ .ref = ref, .target = target };
    }

    fn parseTag(self: *Reader, allocator: std.mem.Allocator, name_text: []const u8) Error!Tag {
        const name = try self.parseRef(allocator, name_text);
        var mark: ?u64 = null;
        var target: ?ObjectRef = null;
        var original_oid: ?[]const u8 = null;
        var tagger: ?Identity = null;

        while (true) {
            const line = try self.requiredLine();
            if (std.mem.startsWith(u8, line, "mark ")) {
                if (mark != null) return self.malformed("it carries two mark lines");
                mark = try self.noteMark(line);
            } else if (std.mem.startsWith(u8, line, "from ")) {
                if (target != null) return self.malformed("it carries two from lines");
                target = try self.objectRef(allocator, line["from ".len..], "its from is not a mark or a hex object name");
            } else if (std.mem.startsWith(u8, line, "original-oid ")) {
                original_oid = try self.noteOriginalOid(allocator, original_oid, line["original-oid ".len..]);
            } else if (std.mem.startsWith(u8, line, "tagger ")) {
                if (tagger != null) return self.malformed("it carries two tagger lines");
                tagger = try parseIdentity(self, allocator, line["tagger ".len..]);
            } else if (std.mem.startsWith(u8, line, "data ")) {
                return .{
                    .name = name,
                    .mark = mark,
                    .target = target orelse return self.malformed("it has no from line"),
                    .original_oid = original_oid,
                    // Git writes a tag with no tagger line too; its tagger is
                    // then no one, dated by the importer.
                    .tagger = tagger orelse .{ .name = "", .email = "", .timestamp = 0, .timezone_offset_minutes = 0, .repaired = "has no tagger line", .date_missing = true },
                    .message = try self.readData(allocator, line),
                };
            } else return self.malformed("a tag line is not mark, from, original-oid, tagger or data");
        }
    }

    fn parseFileCommand(self: *Reader, allocator: std.mem.Allocator, line: []const u8) Error!FileCommand {
        if (std.mem.eql(u8, line, "deleteall")) return .delete_all;
        if (std.mem.startsWith(u8, line, "M ")) return self.parseModify(allocator, line[2..]);
        if (std.mem.startsWith(u8, line, "D ")) return .{ .delete = try self.parseSinglePath(allocator, line[2..]) };
        if (std.mem.startsWith(u8, line, "R ")) return self.parsePair(allocator, line[2..], .rename);
        if (std.mem.startsWith(u8, line, "C ")) return self.parsePair(allocator, line[2..], .copy);
        return self.malformed("a file command is not M, D, R, C or deleteall");
    }

    fn parseModify(self: *Reader, allocator: std.mem.Allocator, body: []const u8) Error!FileCommand {
        const mode_end = std.mem.indexOfScalar(u8, body, ' ') orelse return self.malformed("an M command has no mode");
        const mode = try parseMode(body[0..mode_end]);
        var index = mode_end + 1;
        const data_text = takeBareToken(body, &index) orelse return self.malformed("an M command has no data reference");
        const data = try self.objectRef(allocator, data_text, "an M command's data is not a mark or a hex object name");
        if (index >= body.len or body[index] != ' ') return self.malformed("an M command has no path");
        index += 1;
        const path = try self.parsePathToken(allocator, body, &index);
        if (index != body.len) return self.malformed("an M command has bytes after its path");
        return .{ .modify = .{ .mode = mode, .data = data, .path = path } };
    }

    fn parseSinglePath(self: *Reader, allocator: std.mem.Allocator, body: []const u8) Error![]const u8 {
        var index: usize = 0;
        const path = try self.parsePathToken(allocator, body, &index);
        if (index != body.len) return self.malformed("a D command has bytes after its path");
        return path;
    }

    fn parsePair(self: *Reader, allocator: std.mem.Allocator, body: []const u8, comptime kind: enum { rename, copy }) Error!FileCommand {
        var index: usize = 0;
        const old = try self.parsePathToken(allocator, body, &index);
        if (index >= body.len or body[index] != ' ') return self.malformed("an R or C command has one path");
        index += 1;
        const new = try self.parsePathToken(allocator, body, &index);
        if (index != body.len) return self.malformed("an R or C command has bytes after its paths");
        return switch (kind) {
            .rename => .{ .rename = .{ .old = old, .new = new } },
            .copy => .{ .copy = .{ .old = old, .new = new } },
        };
    }

    fn parsePathToken(self: *Reader, allocator: std.mem.Allocator, input: []const u8, index: *usize) Error![]const u8 {
        if (index.* >= input.len) return self.malformed("a file command's path is missing");
        const start = index.*;
        if (input[start] == '"') {
            index.* += 1;
            var escaped = false;
            while (index.* < input.len) : (index.* += 1) {
                const byte = input[index.*];
                if (escaped) {
                    escaped = false;
                    continue;
                }
                if (byte == '\\') {
                    escaped = true;
                    continue;
                }
                if (byte == '"') {
                    index.* += 1;
                    return self.decodeQuotedPath(allocator, input[start + 1 .. index.* - 1]);
                }
            }
            return self.malformed("a quoted path has no closing quote");
        }
        while (index.* < input.len and input[index.*] != ' ') : (index.* += 1) {}
        return self.copyPath(allocator, input[start..index.*]);
    }

    fn copyPath(self: *Reader, allocator: std.mem.Allocator, path: []const u8) Error![]const u8 {
        if (path.len == 0) return self.malformed("a path is empty");
        if (std.mem.indexOfScalar(u8, path, 0) != null) return self.malformed("a path holds a NUL byte");
        if (path.len > self.limits.max_path) return error.PathLimitExceeded;
        return self.duplicate(allocator, path);
    }

    fn decodeQuotedPath(self: *Reader, allocator: std.mem.Allocator, encoded: []const u8) Error![]const u8 {
        var decoded: std.ArrayList(u8) = .empty;
        var index: usize = 0;
        while (index < encoded.len) {
            const byte = encoded[index];
            index += 1;
            if (byte != '\\') {
                try appendPathByte(self, &decoded, byte);
                continue;
            }
            if (index == encoded.len) return self.malformed("a quoted path ends in a backslash");
            const escape = encoded[index];
            index += 1;
            const value: u8 = switch (escape) {
                '"', '\\' => escape,
                'a' => '\x07',
                'b' => '\x08',
                'f' => '\x0c',
                'n' => '\n',
                'r' => '\r',
                't' => '\t',
                'v' => '\x0b',
                '0'...'7' => blk: {
                    if (index + 2 > encoded.len) return self.malformed("a quoted path's octal escape is short");
                    const second = encoded[index];
                    const third = encoded[index + 1];
                    if (second < '0' or second > '7' or third < '0' or third > '7') return self.malformed("a quoted path's octal escape is short");
                    index += 2;
                    const octal: u16 = @as(u16, escape - '0') * 64 + @as(u16, second - '0') * 8 + @as(u16, third - '0');
                    if (octal > @as(u16, std.math.maxInt(u8))) return self.malformed("a quoted path's octal escape is above \\377");
                    break :blk @intCast(octal);
                },
                else => return self.malformed("a quoted path has an escape Git does not write"),
            };
            try appendPathByte(self, &decoded, value);
        }
        if (decoded.items.len == 0) return self.malformed("a path is empty");
        return decoded.toOwnedSlice(allocator) catch return error.OutOfMemory;
    }

    fn parseRef(self: *Reader, allocator: std.mem.Allocator, text: []const u8) Error![]const u8 {
        if (text.len == 0 or std.mem.indexOfScalar(u8, text, ' ') != null or std.mem.indexOfScalar(u8, text, 0) != null) return self.malformed("its ref name is empty or holds a space or NUL");
        return self.duplicate(allocator, text);
    }

    fn readData(self: *Reader, allocator: std.mem.Allocator, line: []const u8) Error![]const u8 {
        const data = try self.readDataExact(allocator, line);
        const terminator = self.input.takeByte() catch |err| switch (err) {
            error.EndOfStream => return error.UnexpectedEof,
            error.ReadFailed => return error.Io,
        };
        if (terminator != '\n') return self.malformed("its data is not followed by a newline");
        return data;
    }

    fn readCommitData(self: *Reader, allocator: std.mem.Allocator, line: []const u8) Error![]const u8 {
        return self.readDataExact(allocator, line);
    }

    fn readDataExact(self: *Reader, allocator: std.mem.Allocator, line: []const u8) Error![]const u8 {
        if (!std.mem.startsWith(u8, line, "data ")) return self.malformed("a data line is expected");
        const size = parseUnsigned(line["data ".len..]) catch |e| return self.ruled("its data length is not a decimal number", e);
        // `maxInt(usize)` is the addressability floor on a 32-bit host, not a
        // policy; on 64-bit it never binds. Either way `max_data` is the
        // ceiling worth naming, since it is the one the code declares.
        if (size > self.limits.max_data or size > @as(u64, std.math.maxInt(usize)))
            return self.refusePayload(.single_payload, size, self.limits.max_data);
        try self.admitData(size);
        const count: usize = @intCast(size);
        const data = allocator.alloc(u8, count) catch return error.OutOfMemory;
        self.input.readSliceAll(data) catch |err| switch (err) {
            error.EndOfStream => return error.UnexpectedEof,
            error.ReadFailed => return error.Io,
        };
        return data;
    }
};

fn appendPathByte(reader: *Reader, list: *std.ArrayList(u8), byte: u8) Error!void {
    if (byte == 0) return reader.malformed("a path holds a NUL byte");
    if (list.items.len == reader.limits.max_path) return error.PathLimitExceeded;
    list.append(reader.eventAllocator(), byte) catch return error.OutOfMemory;
}

fn parseMarkLine(line: []const u8) Error!u64 {
    if (!std.mem.startsWith(u8, line, "mark :")) return error.Malformed;
    const mark = try parseUnsigned(line["mark :".len..]);
    if (mark == 0) return error.Malformed;
    return mark;
}

fn parseObjectRef(allocator: std.mem.Allocator, text: []const u8) Error!ObjectRef {
    if (text.len == 0) return error.Malformed;
    if (text[0] == ':') return .{ .mark = try parseMark(text) };
    return .{ .oid = try parseOid(allocator, text) };
}

fn parseMark(text: []const u8) Error!u64 {
    if (text.len < 2 or text[0] != ':') return error.Malformed;
    const mark = try parseUnsigned(text[1..]);
    if (mark == 0) return error.Malformed;
    return mark;
}

fn parseOid(allocator: std.mem.Allocator, text: []const u8) Error![]const u8 {
    if (text.len != 40 and text.len != 64) return error.Malformed;
    for (text) |byte| if (!std.ascii.isHex(byte)) return error.Malformed;
    return allocator.dupe(u8, text) catch return error.OutOfMemory;
}

fn parseUnsigned(text: []const u8) Error!u64 {
    if (text.len == 0) return error.Malformed;
    var value: u64 = 0;
    for (text) |byte| {
        if (byte < '0' or byte > '9') return error.Malformed;
        const digit: u64 = byte - '0';
        if (value > (std.math.maxInt(u64) - digit) / 10) return error.Malformed;
        value = value * 10 + digit;
    }
    return value;
}

fn parseSigned(text: []const u8) Error!i64 {
    if (text.len == 0) return error.Malformed;
    const negative = text[0] == '-';
    const digits = if (negative or text[0] == '+') text[1..] else text;
    const unsigned = try parseUnsigned(digits);
    if (negative) {
        const minimum_magnitude = @as(u64, std.math.maxInt(i64)) + 1;
        if (unsigned > minimum_magnitude) return error.Malformed;
        if (unsigned == minimum_magnitude) return std.math.minInt(i64);
        return -@as(i64, @intCast(unsigned));
    }
    if (unsigned > std.math.maxInt(i64)) return error.Malformed;
    return @intCast(unsigned);
}

/// `NAME <EMAIL> SECONDS ZONE`, as Git writes an identity. A line in that
/// form reads exactly as it always has, so every check-in it names keeps its
/// hash. One that is not reads the way Git's own `split_ident_line` reads it
/// and is marked `repaired`: old histories hold lines with no email, no space
/// before `<`, a date Git cannot read or a zone past +2359, and Git stores,
/// fetches and exports every one of them. A commit is never refused for its
/// metadata.
fn parseIdentity(reader: *Reader, allocator: std.mem.Allocator, text: []const u8) Error!Identity {
    const parsed = strictIdentity(text) orelse lenientIdentity(text);
    return .{
        .name = try reader.duplicate(allocator, parsed.name),
        .email = try reader.duplicate(allocator, parsed.email),
        .timestamp = parsed.timestamp,
        .timezone_offset_minutes = parsed.timezone_offset_minutes,
        .repaired = parsed.repaired,
        .date_missing = parsed.date_missing,
    };
}

/// The identity a line names, borrowing from it.
const ParsedIdentity = struct {
    name: []const u8,
    email: []const u8,
    timestamp: i64 = 0,
    timezone_offset_minutes: i16 = 0,
    repaired: ?[]const u8 = null,
    date_missing: bool = false,
};

fn strictIdentity(text: []const u8) ?ParsedIdentity {
    const close = std.mem.lastIndexOfScalar(u8, text, '>') orelse return null;
    const open = std.mem.lastIndexOfScalar(u8, text[0..close], '<') orelse return null;
    if (open == 0 or text[open - 1] != ' ' or close + 1 >= text.len or text[close + 1] != ' ') return null;
    // An empty name or email is valid input: Git's object format and `git
    // fast-import` accept `author  <EMAIL>` and `NAME <>`, and old histories
    // (the Linux kernel's among them) hold both. The importer names the U
    // card from the email when the name is empty.
    const name = text[0 .. open - 1];
    const email = text[open + 1 .. close];

    const stamp_and_zone = text[close + 2 ..];
    const separator = std.mem.indexOfScalar(u8, stamp_and_zone, ' ') orelse return null;
    const timestamp = parseSigned(stamp_and_zone[0..separator]) catch return null;
    const zone = stamp_and_zone[separator + 1 ..];
    if (zone.len != 5 or (zone[0] != '+' and zone[0] != '-')) return null;
    const hours = parseUnsigned(zone[1..3]) catch return null;
    const minutes = parseUnsigned(zone[3..5]) catch return null;
    if (hours > 23 or minutes > 59) return null;
    const raw_offset: i16 = @intCast(hours * 60 + minutes);
    return .{
        .name = name,
        .email = email,
        .timestamp = timestamp,
        .timezone_offset_minutes = if (zone[0] == '-') -raw_offset else raw_offset,
    };
}

/// Git's reading: the name is what precedes the first `<`, the email runs to
/// the next `>`, and the date and zone follow the last `>`, each read as far
/// as its digits go. A line with no `<` at all keeps a trailing date and zone
/// if it ends in them and names the rest.
fn lenientIdentity(text: []const u8) ParsedIdentity {
    var out: ParsedIdentity = .{ .name = std.mem.trim(u8, text, " "), .email = "" };
    var rest: []const u8 = "";
    if (std.mem.indexOfScalar(u8, text, '<')) |lt| {
        out.name = std.mem.trim(u8, text[0..lt], " ");
        if (std.mem.indexOfScalarPos(u8, text, lt + 1, '>')) |gt| {
            out.email = text[lt + 1 .. gt];
            rest = text[std.mem.lastIndexOfScalar(u8, text, '>').? + 1 ..];
            out.repaired = "is not NAME <EMAIL> SECONDS ZONE";
        } else {
            out.email = text[lt + 1 ..];
            out.repaired = "has no '>' closing its email";
        }
    } else {
        out.repaired = "has no <EMAIL>";
        // `NAME SECONDS ZONE`: the last two words, when they read as a date.
        var words = std.mem.splitBackwardsScalar(u8, out.name, ' ');
        const zone = words.next() orelse "";
        const stamp = words.next() orelse "";
        if (stamp.len != 0 and zone.len > 1 and (zone[0] == '+' or zone[0] == '-') and
            readDigits(stamp).len == stamp.len and readDigits(zone[1..]).len == zone.len - 1)
        {
            rest = out.name[out.name.len - stamp.len - 1 - zone.len ..];
            out.name = std.mem.trim(u8, out.name[0 .. out.name.len - stamp.len - 1 - zone.len], " ");
        }
    }
    const date = std.mem.trimStart(u8, rest, " ");
    const stamp = readDigits(date);
    const timestamp = std.fmt.parseUnsigned(i64, stamp, 10) catch {
        out.date_missing = true;
        out.repaired = "has no date Git can read";
        return out;
    };
    out.timestamp = timestamp;
    const after = std.mem.trimStart(u8, date[stamp.len..], " ");
    if (after.len < 2 or (after[0] != '+' and after[0] != '-')) return out;
    // Git reads the zone as one decimal HHMM, whatever its width: +51800 is
    // 518 hours. Past what an i16 of minutes holds, it is no zone.
    const digits = readDigits(after[1..]);
    const value = std.fmt.parseUnsigned(u64, digits[0..@min(digits.len, 12)], 10) catch return out;
    const minutes = (value / 100) * 60 + value % 100;
    if (minutes > std.math.maxInt(i16)) return out;
    const signed: i16 = @intCast(minutes);
    out.timezone_offset_minutes = if (after[0] == '-') -signed else signed;
    return out;
}

/// The leading run of ASCII digits in `text`.
fn readDigits(text: []const u8) []const u8 {
    var end: usize = 0;
    while (end < text.len and text[end] >= '0' and text[end] <= '9') end += 1;
    return text[0..end];
}

fn parseMode(text: []const u8) Error!FileMode {
    if (std.mem.eql(u8, text, "100644")) return .regular;
    if (std.mem.eql(u8, text, "100755")) return .executable;
    if (std.mem.eql(u8, text, "120000")) return .symlink;
    if (std.mem.eql(u8, text, "160000")) return .gitlink;
    return error.UnknownMode;
}

fn takeBareToken(input: []const u8, index: *usize) ?[]const u8 {
    if (index.* >= input.len) return null;
    const start = index.*;
    while (index.* < input.len and input[index.*] != ' ') : (index.* += 1) {}
    if (start == index.*) return null;
    return input[start..index.*];
}