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.*];
}