Artifact 36ff5739435d2e52740014653ada0e1d8e99f866fd8a495e37710e5edc515162:
- File
src/git_fast_export.zig
— part of check-in
[1f21f96558]
at
2026-07-31 03:13:01
on branch sub-manifest
— fast-export: accept an author with an empty email
parseIdentity refused an identity carrying an empty address twice over, once as `open + 1 == close` and again as `email.len == 0`. That is not malformed input. Setting `user.email` to the empty string is ordinary git configuration; `git fast-export` writes the identity with an empty angle-bracket pair, and `git fast-import` reads that same stream back to the same commit. The construct is valid by git's own definition on both sides of its own tooling, and we rejected it.
The cost was not cosmetic. Two such commits, reachable only through a merge's second parent, aborted the import of an entire 437 commit repository with `import-git: import failed: Malformed`, naming neither the line nor the rule.
Only the email clauses are relaxed. The name clauses stay: git's own porcelain refuses an empty ident name, and the importer maps the identity's name onto the U card login, so a nameless identity would produce a nameless check-in. The two questions were conflated in a single condition, which is a large part of why this went unnoticed, so the guard now separates them and says why.
This is the third defect on this branch of the same shape: a property of our own fixtures promoted into a rule about the world. The first two were bounds tuned to the width of our benchmark corpus. This one is sharper, a refusal of input the tool we integrate with emits by default. Every identity fixture in the parser tests carried a non-empty email, so a uniform corpus could not falsify a rule that keys on emptiness.
Both tests are witnesses rather than decoration: each fails with Malformed against the previous parser and passes against this one. The unit test pins the construct and the empty-name refusal beside it; the differential test has git itself produce the commit and then asserts the imported check-in carries the login mohye77 rather than an empty user. (user: dmestas, size: 30479) [annotate] [blame] [check-ins using]
const std = @import("std"); pub const Limits = struct { max_line: usize, max_data: u64, max_path: usize, max_merges_per_commit: usize = 1_024, max_events: u64 = 1_000_000, max_total_data: u64 = 1 << 40, }; /// 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. /// /// `max_total_data` is the parser's authorization for bytes it may hold in an /// event arena, but `admitData` charges only `data <n>` payloads against it. /// A file command's list slot and duplicated path are arena bytes no budget /// sees, so an unbounded command loop grows the arena without ever touching /// `max_total_data`. The ceiling closes that gap by restating the same /// authorization in commands: the most whose minimum retention still fits /// inside the budget. It moves when the byte budget moves and at no other time. pub fn fileCommandCeiling(limits: Limits) usize { const ceiling = limits.max_total_data / 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 — which knows the /// flag that raises the budget — 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, payload_budget_bytes: u64, min_bytes_per_command: u64 = min_retained_bytes_per_file_command, }; 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, }; 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 one bound whose error name cannot carry its own /// numbers. Null until `FileCommandLimitExceeded` is returned. file_command_overrun: ?FileCommandOverrun = null, 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 error.Malformed; 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 error.Malformed; try self.admitEvent(); continue; } if (!self.feature_done_seen) return error.Malformed; 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 error.Malformed; 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(); 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); } } 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 { const next_data_bytes_seen = std.math.add(u64, self.data_bytes_seen, size) catch return error.DataLimitExceeded; if (next_data_bytes_seen > self.limits.max_total_data) return error.DataLimitExceeded; self.data_bytes_seen = next_data_bytes_seen; } 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 parseMarkLine(try self.requiredLine()); var original_oid: ?[]const u8 = null; while (true) { const line = try self.requiredLine(); if (std.mem.startsWith(u8, line, "original-oid ")) { if (original_oid != null) return error.Malformed; original_oid = try parseOid(allocator, 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 error.Malformed; } } 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 parseMarkLine(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 ")) { if (original_oid != null) return error.Malformed; original_oid = try parseOid(allocator, line["original-oid ".len..]); } else if (std.mem.startsWith(u8, line, "author ")) { if (author != null) return error.Malformed; author = try parseIdentity(self, allocator, line["author ".len..]); } else if (std.mem.startsWith(u8, line, "committer ")) { if (committer != null) return error.Malformed; 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 error.Malformed; } const parsed_author = author orelse return error.Malformed; const parsed_committer = committer orelse return error.Malformed; const parsed_message = message orelse return error.Malformed; 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 error.Malformed; from = try parseObjectRef(allocator, line["from ".len..]); } else if (std.mem.startsWith(u8, line, "merge ")) { if (merges.items.len >= self.limits.max_merges_per_commit) return error.MergeLimitExceeded; const merge = try parseObjectRef(allocator, line["merge ".len..]); 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, .payload_budget_bytes = self.limits.max_total_data, }; 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 = parsed_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 error.Malformed; const target = try parseObjectRef(allocator, line["from ".len..]); const separator = try self.requiredLine(); if (separator.len == 0) return .{ .ref = ref, .target = target }; if (!isTopLevelRecord(separator)) return error.Malformed; 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 error.Malformed; mark = try parseMarkLine(line); } else if (std.mem.startsWith(u8, line, "from ")) { if (target != null) return error.Malformed; target = try parseObjectRef(allocator, line["from ".len..]); } else if (std.mem.startsWith(u8, line, "original-oid ")) { if (original_oid != null) return error.Malformed; original_oid = try parseOid(allocator, line["original-oid ".len..]); } else if (std.mem.startsWith(u8, line, "tagger ")) { if (tagger != null) return error.Malformed; 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 error.Malformed, .original_oid = original_oid, .tagger = tagger orelse return error.Malformed, .message = try self.readData(allocator, line), }; } else return error.Malformed; } } 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 error.Malformed; } fn parseModify(self: *Reader, allocator: std.mem.Allocator, body: []const u8) Error!FileCommand { const mode_end = std.mem.indexOfScalar(u8, body, ' ') orelse return error.Malformed; const mode = try parseMode(body[0..mode_end]); var index = mode_end + 1; const data_text = takeBareToken(body, &index) orelse return error.Malformed; const data = try parseObjectRef(allocator, data_text); if (index >= body.len or body[index] != ' ') return error.Malformed; index += 1; const path = try self.parsePathToken(allocator, body, &index); if (index != body.len) return error.Malformed; 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 error.Malformed; 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 error.Malformed; index += 1; const new = try self.parsePathToken(allocator, body, &index); if (index != body.len) return error.Malformed; 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 error.Malformed; 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 error.Malformed; } 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 or std.mem.indexOfScalar(u8, path, 0) != null) return error.Malformed; 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 error.Malformed; 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 error.Malformed; const second = encoded[index]; const third = encoded[index + 1]; if (second < '0' or second > '7' or third < '0' or third > '7') return error.Malformed; 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 error.Malformed; break :blk @intCast(octal); }, else => return error.Malformed, }; try appendPathByte(self, &decoded, value); } if (decoded.items.len == 0) return error.Malformed; 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 error.Malformed; 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 error.Malformed; 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 error.Malformed; const size = try parseUnsigned(line["data ".len..]); if (size > self.limits.max_data or size > @as(u64, std.math.maxInt(usize))) return error.DataLimitExceeded; 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 error.Malformed; 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); } fn parseIdentity(reader: *Reader, allocator: std.mem.Allocator, text: []const u8) Error!Identity { const close = std.mem.lastIndexOfScalar(u8, text, '>') orelse return error.Malformed; const open = std.mem.lastIndexOfScalar(u8, text[0..close], '<') orelse return error.Malformed; if (open == 0 or text[open - 1] != ' ' or close + 1 >= text.len or text[close + 1] != ' ') return error.Malformed; const name = text[0 .. open - 1]; const email = text[open + 1 .. close]; // An empty email is valid input: `git fast-export` writes `name <>` for a // commit whose author email is unset, and `git fast-import` round-trips // that stream back to the same commit. An empty NAME is not -- git's own // porcelain refuses it ("empty ident name not allowed") -- and the // importer maps the name onto the U card login, so it stays required. if (name.len == 0) return error.Malformed; const stamp_and_zone = text[close + 2 ..]; const separator = std.mem.indexOfScalar(u8, stamp_and_zone, ' ') orelse return error.Malformed; const timestamp = try parseSigned(stamp_and_zone[0..separator]); const zone = stamp_and_zone[separator + 1 ..]; if (zone.len != 5 or (zone[0] != '+' and zone[0] != '-')) return error.Malformed; const hours = try parseUnsigned(zone[1..3]); const minutes = try parseUnsigned(zone[3..5]); if (hours > 23 or minutes > 59) return error.Malformed; const raw_offset: i16 = @intCast(hours * 60 + minutes); return .{ .name = try reader.duplicate(allocator, name), .email = try reader.duplicate(allocator, email), .timestamp = timestamp, .timezone_offset_minutes = if (zone[0] == '-') -raw_offset else raw_offset, }; } 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.*]; }