Artifact a23067622e7f2e24938ab9eb692023d1339623672f3841cfcc11987048baec8f:
- File
src/git_fast_export_test.zig
— part of check-in
[8ae7041c2c]
at
2026-07-31 20:55:36
on branch sub-manifest
— import-git: pack the refs, and split the budget they shared with payload
zit now imports sqlite at defaults: 36,416 commits, 2,109 refs, 44m35s, no flags. Two independent walls stood in front of that.
Refs (issue 21). One file per ref made a ref's name a path, so the filesystem got a vote on which repositories are representable. sqlite has refs/remotes/origin/EQP-improvements and .../eqp-improvements at different commits -- legal, distinct git refs -- and on APFS the second landed on the first as a bare PathAlreadyExists, raised during publication after the whole import had run. Case was only the reachable instance; path length, reserved device names and each platform's own Unicode fold table are the same bug in other clothes.
They are one sorted file now, "refs", whose lines pair a canonical name with its target. Nothing reads it back -- "refs" is a reserved root the working-tree scan skips, and branches resolve through repostate's sym- tags -- so it is interop output and its job is to be legible. Sorting makes it byte-identical for a given ref set, so two imports of one repository can be compared.
This supersedes the ASCII-fold refusal from the previous commit. That was the right interim while a ref was a filename; with one file the collision is representable, so refusing it would be gratuitous. What survives is the genuine collision -- distinct sources normalizing to a byte-identical canonical name, refs/heads/./release against refs/heads/release -- which no layout can hold, and which now names both sources instead of only failing.
Budget (issue 20). The 4 GiB payload default refused sqlite's 12.2 GiB stream, and could not simply be raised: fileCommandCeiling divided the same number, so a larger allowance for a whole history was also a larger allowance for one commit's retained command list. Those bound different things and only one is memory -- payload streams and is released per event (sqlite's import held 21 MiB), while a commit's command list is held whole. The arena has max_command_arena_bytes of its own now and keeps the old 4 GiB; the cumulative total goes to 1 TiB.
The file-command ceiling is therefore structural: about 104 million commands in one commit, and no flag raises it. Its diagnostic says so rather than pointing at a knob that no longer moves it. The help text claimed the payload flag fixed both; it only ever fixed one. (user: dmestas, size: 33055) [annotate] [blame] [check-ins using]
const std = @import("std"); const fast_export = @import("git_fast_export.zig"); const Limits = fast_export.Limits; const Reader = fast_export.Reader; const Event = fast_export.Event; const oid = "0123456789abcdef0123456789abcdef01234567"; const second_oid = "89abcdef0123456789abcdef0123456789abcdef"; var stream_bytes: []const u8 = &.{}; var stream_split: usize = 0; var stream_offset: usize = 0; fn splitStream(_: *std.Io.Reader, writer: *std.Io.Writer, limit: std.Io.Limit) std.Io.Reader.StreamError!usize { if (stream_offset == stream_bytes.len) return error.EndOfStream; const boundary = if (stream_offset < stream_split) stream_split else stream_bytes.len; const available = boundary - stream_offset; const count = @min(available, @intFromEnum(limit)); if (count == 0) return error.EndOfStream; try writer.writeAll(stream_bytes[stream_offset..][0..count]); stream_offset += count; return count; } fn initSplitInput(bytes: []const u8, split: usize, buffer: *[1]u8) std.Io.Reader { stream_bytes = bytes; stream_split = split; stream_offset = 0; return .{ .vtable = &.{ .stream = splitStream }, .buffer = buffer, .seek = 0, .end = 0, }; } fn defaultLimits() Limits { return .{ .max_line = 4096, .max_data = 4096, .max_path = 4096, }; } fn expectBlobThenDone(reader: *Reader) !void { var blob_lease = (try reader.next()).?; defer blob_lease.deinit(); switch (blob_lease.value) { .blob => |blob| { try std.testing.expectEqual(@as(u64, 1), blob.mark); try std.testing.expectEqualStrings(oid, blob.original_oid.?); try std.testing.expectEqualSlices(u8, "a\n\x00bc", blob.data); }, else => return error.TestUnexpectedResult, } blob_lease.deinit(); var done_lease = (try reader.next()).?; defer done_lease.deinit(); switch (done_lease.value) { .done => {}, else => return error.TestUnexpectedResult, } done_lease.deinit(); try std.testing.expect((try reader.next()) == null); } test "git fast-export blob framing survives every split position" { const stream = "feature done\n" ++ "blob\n" ++ "mark :1\n" ++ "original-oid " ++ oid ++ "\n" ++ "data 5\n" ++ "a\n\x00bc\n" ++ "done\n"; for (0..stream.len + 1) |split| { var input_buffer: [1]u8 = undefined; var reader = Reader.init(std.testing.allocator, initSplitInput(stream, split, &input_buffer), defaultLimits()); defer reader.deinit(); try expectBlobThenDone(&reader); } } test "git fast-export leases block advancement and release event storage" { const stream = "feature done\nblob\nmark :1\ndata 1\nx\ndone\n"; const input = std.Io.Reader.fixed(stream); var reader = Reader.init(std.testing.allocator, input, defaultLimits()); defer reader.deinit(); var lease = (try reader.next()).?; try std.testing.expectError(error.ActiveLease, reader.next()); lease.deinit(); var done_lease = (try reader.next()).?; defer done_lease.deinit(); switch (done_lease.value) { .done => {}, else => return error.TestUnexpectedResult, } } test "git fast-export skips top-level progress directives" { const stream = "feature done\n" ++ "progress before blob\n" ++ "blob\nmark :1\ndata 0\n\n" ++ "progress after blob\n" ++ "done\n"; const input = std.Io.Reader.fixed(stream); var reader = Reader.init(std.testing.allocator, input, defaultLimits()); defer reader.deinit(); var blob_lease = (try reader.next()).?; switch (blob_lease.value) { .blob => {}, else => return error.TestUnexpectedResult, } blob_lease.deinit(); var done_lease = (try reader.next()).?; defer done_lease.deinit(); switch (done_lease.value) { .done => {}, else => return error.TestUnexpectedResult, } done_lease.deinit(); try std.testing.expect((try reader.next()) == null); } test "git fast-export commit decodes identities modes paths and ordered commands" { const stream = "feature done\n" ++ "commit refs/heads/main\n" ++ "mark :2\n" ++ "original-oid " ++ oid ++ "\n" ++ "author Alice Example <[email protected]> 1700000000 -0230\n" ++ "committer Bob Example <[email protected]> 1700000001 +0530\n" ++ "data 4\n" ++ "msg\n" ++ "from :1\n" ++ "merge " ++ second_oid ++ "\n" ++ "R \"old\\\\path\" \"space path\\t\\377\"\n" ++ "M 100755 :2 \"space path\\t\\377\"\n" ++ "M 100644 :1 plain\n" ++ "M 120000 :3 link\n" ++ "D -leading-dash\n" ++ "D \"line\\nfeed\"\n" ++ "D \"bells\\a\\v\"\n" ++ "C plain copy\n" ++ "deleteall\n" ++ "\n" ++ "done\n"; const input = std.Io.Reader.fixed(stream); var reader = Reader.init(std.testing.allocator, input, defaultLimits()); defer reader.deinit(); var lease = (try reader.next()).?; defer lease.deinit(); switch (lease.value) { .commit => |commit| { try std.testing.expectEqualStrings("refs/heads/main", commit.ref); try std.testing.expectEqual(@as(u64, 2), commit.mark); try std.testing.expectEqualStrings(oid, commit.original_oid.?); try std.testing.expectEqualStrings("Alice Example", commit.author.name); try std.testing.expectEqualStrings("[email protected]", commit.author.email); try std.testing.expectEqual(@as(i64, 1700000000), commit.author.timestamp); try std.testing.expectEqual(@as(i16, -150), commit.author.timezone_offset_minutes); try std.testing.expectEqualStrings("Bob Example", commit.committer.name); try std.testing.expectEqual(@as(i16, 330), commit.committer.timezone_offset_minutes); try std.testing.expectEqualSlices(u8, "msg\n", commit.message); try std.testing.expectEqual(@as(usize, 1), commit.merges.len); try std.testing.expectEqualStrings(second_oid, commit.merges[0].oid); try std.testing.expectEqual(@as(usize, 9), commit.commands.len); switch (commit.commands[0]) { .rename => |rename| { try std.testing.expectEqualStrings("old\\path", rename.old); try std.testing.expectEqualSlices(u8, "space path\t\xff", rename.new); }, else => return error.TestUnexpectedResult, } switch (commit.commands[1]) { .modify => |modify| { try std.testing.expectEqual(.executable, modify.mode); try std.testing.expectEqual(@as(u64, 2), modify.data.mark); try std.testing.expectEqualSlices(u8, "space path\t\xff", modify.path); }, else => return error.TestUnexpectedResult, } switch (commit.commands[5]) { .delete => |path| try std.testing.expectEqualSlices(u8, "line\nfeed", path), else => return error.TestUnexpectedResult, } switch (commit.commands[6]) { .delete => |path| try std.testing.expectEqualSlices(u8, "bells\x07\x0b", path), else => return error.TestUnexpectedResult, } switch (commit.commands[8]) { .delete_all => {}, else => return error.TestUnexpectedResult, } }, else => return error.TestUnexpectedResult, } } test "git fast-export accepts an empty reset directly before a commit" { const stream = "feature done\n" ++ "reset refs/heads/main\n" ++ "commit refs/heads/main\n" ++ "mark :2\n" ++ "author A <[email protected]> 1 +0000\n" ++ "committer C <[email protected]> 2 +0000\n" ++ "data 0\n\n" ++ "done\n"; const input = std.Io.Reader.fixed(stream); var reader = Reader.init(std.testing.allocator, input, defaultLimits()); defer reader.deinit(); var reset_lease = (try reader.next()).?; switch (reset_lease.value) { .reset => |reset| { try std.testing.expectEqualStrings("refs/heads/main", reset.ref); try std.testing.expect(reset.target == null); }, else => return error.TestUnexpectedResult, } reset_lease.deinit(); var commit_lease = (try reader.next()).?; defer commit_lease.deinit(); switch (commit_lease.value) { .commit => |commit| try std.testing.expectEqual(@as(u64, 2), commit.mark), else => return error.TestUnexpectedResult, } } test "git fast-export accepts a reset target directly before a commit" { const stream = "feature done\n" ++ "reset refs/heads/main\n" ++ "from :2\n" ++ "commit refs/heads/main\n" ++ "mark :3\n" ++ "author A <[email protected]> 1 +0000\n" ++ "committer C <[email protected]> 2 +0000\n" ++ "data 0\n\n" ++ "done\n"; const input = std.Io.Reader.fixed(stream); var reader = Reader.init(std.testing.allocator, input, defaultLimits()); defer reader.deinit(); var reset_lease = (try reader.next()).?; switch (reset_lease.value) { .reset => |reset| try std.testing.expectEqual(@as(u64, 2), reset.target.?.mark), else => return error.TestUnexpectedResult, } reset_lease.deinit(); var commit_lease = (try reader.next()).?; defer commit_lease.deinit(); switch (commit_lease.value) { .commit => |commit| try std.testing.expectEqual(@as(u64, 3), commit.mark), else => return error.TestUnexpectedResult, } } test "git fast-export reset and tag retain metadata" { const stream = "feature done\n" ++ "reset refs/heads/main\n" ++ "from :2\n\n" ++ "tag v1.0\n" ++ "mark :3\n" ++ "from :2\n" ++ "original-oid " ++ oid ++ "\n" ++ "tagger Release Bot <[email protected]> 1700000002 +0000\n" ++ "data 3\n" ++ "tag\n" ++ "done\n"; const input = std.Io.Reader.fixed(stream); var reader = Reader.init(std.testing.allocator, input, defaultLimits()); defer reader.deinit(); var reset_lease = (try reader.next()).?; defer reset_lease.deinit(); switch (reset_lease.value) { .reset => |reset| { try std.testing.expectEqualStrings("refs/heads/main", reset.ref); try std.testing.expectEqual(@as(u64, 2), reset.target.?.mark); }, else => return error.TestUnexpectedResult, } reset_lease.deinit(); var tag_lease = (try reader.next()).?; defer tag_lease.deinit(); switch (tag_lease.value) { .tag => |tag| { try std.testing.expectEqualStrings("v1.0", tag.name); try std.testing.expectEqual(@as(u64, 3), tag.mark.?); try std.testing.expectEqual(@as(u64, 2), tag.target.mark); try std.testing.expectEqualStrings(oid, tag.original_oid.?); try std.testing.expectEqualStrings("Release Bot", tag.tagger.name); try std.testing.expectEqualSlices(u8, "tag", tag.message); }, else => return error.TestUnexpectedResult, } } fn expectNextError(bytes: []const u8, limits: Limits, expected: anyerror) !void { const input = std.Io.Reader.fixed(bytes); var reader = Reader.init(std.testing.allocator, input, limits); defer reader.deinit(); try std.testing.expectError(expected, reader.next()); } test "git fast-export rejects malformed framing and configured bounds" { try expectNextError("feature done\nblob\nmark :1\ndata -1\n", defaultLimits(), error.Malformed); try expectNextError("feature done\nblob\nmark :1\ndata 18446744073709551616\n", defaultLimits(), error.Malformed); try expectNextError("feature done\nblob\nmark :1\ndata 1\nx", defaultLimits(), error.UnexpectedEof); try expectNextError("feature done\nblob\nmark :1\ndata 1\nxx", defaultLimits(), error.Malformed); try expectNextError("feature done\nblob\nmark :x\ndata 0\n\n", defaultLimits(), error.Malformed); try expectNextError("feature relative-marks\n", defaultLimits(), error.UnsupportedFeature); try expectNextError("feature done\nblob\nmark :1\ndata 2\nxx\n", .{ .max_line = 64, .max_data = 1, .max_path = 64 }, error.DataLimitExceeded); try expectNextError("feature done\n", .{ .max_line = 3, .max_data = 64, .max_path = 64 }, error.LineLimitExceeded); try expectNextError( "feature done\ncommit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +0000\ncommitter C <[email protected]> 2 +0000\ndata 0\nD \"\\400\"\n\n", defaultLimits(), error.Malformed, ); try expectNextError( "feature done\ncommit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +0000\ncommitter C <[email protected]> 2 +0000\ndata 0\nD four\n\n", .{ .max_line = 128, .max_data = 64, .max_path = 3 }, error.PathLimitExceeded, ); const trailing = "feature done\ndone\ntrailing"; const input = std.Io.Reader.fixed(trailing); var reader = Reader.init(std.testing.allocator, input, defaultLimits()); defer reader.deinit(); var done_lease = (try reader.next()).?; done_lease.deinit(); try std.testing.expectError(error.TrailingData, reader.next()); } test "git fast-export requires a terminal done followed by eof" { const stream = "feature done\nblob\nmark :1\ndata 0\n\n"; const input = std.Io.Reader.fixed(stream); var reader = Reader.init(std.testing.allocator, input, defaultLimits()); defer reader.deinit(); var blob_lease = (try reader.next()).?; blob_lease.deinit(); try std.testing.expectError(error.MissingDone, reader.next()); } test "git fast-export rejects invalid commit semantics at the grammar boundary" { const prefix = "feature done\ncommit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +0000\ncommitter C <[email protected]> 2 +0000\ndata 0\n"; try expectNextError(prefix ++ "M 100600 :1 unknown\n\n", defaultLimits(), error.UnknownMode); try expectNextError(prefix ++ "M 100644 nonsense file\n\n", defaultLimits(), error.Malformed); try expectNextError("feature done\ncommit refs/heads/main\nmark :2\nauthor A <[email protected]> 999999999999999999999999 +0000\n", defaultLimits(), error.Malformed); try expectNextError("feature done\ncommit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +2460\n", defaultLimits(), error.Malformed); try expectNextError("feature done\ncommit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +0000\ncommitter C <[email protected]> 2 +0000\n\n", defaultLimits(), error.Malformed); try expectNextError("feature done\ncommit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +0000\ncommitter C <[email protected]> 2 +0000\ndata 0\n", defaultLimits(), error.UnexpectedEof); try expectNextError("feature done\ncommit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +0000\ncommitter C <[email protected]> 2 +0000\ndata 4\nabc", defaultLimits(), error.UnexpectedEof); // The name half of the identity guard stays strict: git's own porcelain // refuses an empty ident name, and the importer maps the name onto the // U card login. try expectNextError("feature done\ncommit refs/heads/main\nmark :2\nauthor <[email protected]> 1 +0000\n", defaultLimits(), error.Malformed); } test "git fast-export accepts an author with an empty email" { // `git config user.email ""` makes git export `mohye77 <>`, and `git // fast-import` reads that same stream back to the same commit, so the // construct is valid by git's definition on both sides. Refusing it // rejected input git writes by default. The 437-commit repository that // surfaced this reduces to these bytes. const stream = "feature done\n" ++ "blob\nmark :1\ndata 3\nhi\n\n" ++ "commit refs/heads/main\nmark :2\n" ++ "author mohye77 <> 1785464161 -0500\n" ++ "committer mohye77 <> 1785464161 -0500\n" ++ "data 19\nempty email commit\n" ++ "M 100644 :1 f.txt\n\n" ++ "done\n"; const input = std.Io.Reader.fixed(stream); var reader = Reader.init(std.testing.allocator, input, defaultLimits()); defer reader.deinit(); var blob_lease = (try reader.next()).?; blob_lease.deinit(); var lease = (try reader.next()).?; defer lease.deinit(); switch (lease.value) { .commit => |parsed| { try std.testing.expectEqualStrings("mohye77", parsed.author.name); try std.testing.expectEqualStrings("", parsed.author.email); try std.testing.expectEqualStrings("mohye77", parsed.committer.name); try std.testing.expectEqualStrings("", parsed.committer.email); try std.testing.expectEqual(@as(usize, 1), parsed.commands.len); }, else => return error.TestUnexpectedResult, } } test "git fast-export reports a submodule entry as a gitlink mode" { // Mode 160000 is a real thing a healthy repository exports. Refusing it is // an import decision, so the parser hands it to the importer intact. const stream = "feature done\n" ++ "commit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +0000\ncommitter C <[email protected]> 2 +0000\ndata 0\n" ++ "M 160000 " ++ oid ++ " nested\nM 100644 :1 keep\n\n" ++ "done\n"; const input = std.Io.Reader.fixed(stream); var reader = Reader.init(std.testing.allocator, input, defaultLimits()); defer reader.deinit(); var lease = (try reader.next()).?; defer lease.deinit(); switch (lease.value) { .commit => |parsed| { try std.testing.expectEqual(@as(usize, 2), parsed.commands.len); const gitlink = parsed.commands[0].modify; try std.testing.expectEqual(fast_export.FileMode.gitlink, gitlink.mode); try std.testing.expectEqualStrings("nested", gitlink.path); try std.testing.expectEqualStrings(oid, gitlink.data.oid); // The rest of the commit still parses normally around it. try std.testing.expectEqual(fast_export.FileMode.regular, parsed.commands[1].modify.mode); try std.testing.expectEqualStrings("keep", parsed.commands[1].modify.path); }, else => return error.TestUnexpectedResult, } } test "git fast-export leaves mark declaration and reference resolution to the importer" { const stream = "feature done\n" ++ "blob\nmark :1\ndata 0\n\n" ++ "blob\nmark :1\ndata 0\n\n" ++ "commit refs/heads/main\nmark :2\nauthor A <[email protected]> 1 +0000\ncommitter C <[email protected]> 2 +0000\ndata 0\nfrom :999\n\n" ++ "done\n"; const input = std.Io.Reader.fixed(stream); var reader = Reader.init(std.testing.allocator, input, defaultLimits()); defer reader.deinit(); for (0..2) |_| { var blob_lease = (try reader.next()).?; switch (blob_lease.value) { .blob => {}, else => return error.TestUnexpectedResult, } blob_lease.deinit(); } var commit_lease = (try reader.next()).?; defer commit_lease.deinit(); switch (commit_lease.value) { .commit => |commit| try std.testing.expectEqual(@as(u64, 999), commit.from.?.mark), else => return error.TestUnexpectedResult, } } test "git fast-export accepts exact per-commit merge and derived file command limits" { const commit_prefix = "feature done\n" ++ "commit refs/heads/main\n" ++ "mark :1\n" ++ "author A <[email protected]> 1 +0000\n" ++ "committer C <[email protected]> 2 +0000\n" ++ "data 0\n"; var merge_limits = defaultLimits(); merge_limits.max_merges_per_commit = 2; const merge_stream = commit_prefix ++ "merge :2\nmerge :3\n\n"; { const input = std.Io.Reader.fixed(merge_stream); var reader = Reader.init(std.testing.allocator, input, merge_limits); defer reader.deinit(); var lease = (try reader.next()).?; defer lease.deinit(); switch (lease.value) { .commit => |commit| try std.testing.expectEqual(@as(usize, 2), commit.merges.len), else => return error.TestUnexpectedResult, } } try expectNextError( commit_prefix ++ "merge :2\nmerge :3\nmerge :4\n\n", merge_limits, error.MergeLimitExceeded, ); // The file-command ceiling is derived, so the way to pin it at two is to // authorize exactly two commands' worth of minimum retention. No count // appears in the limits at all. It draws on the per-commit arena budget, // not the cumulative payload one: a commit's command list is held whole // while payload is released per event, so the two cannot share a number. var command_limits = defaultLimits(); command_limits.max_command_arena_bytes = 2 * fast_export.min_retained_bytes_per_file_command; try std.testing.expectEqual( @as(usize, 2), fast_export.fileCommandCeiling(command_limits), ); const command_stream = commit_prefix ++ "D one\nD two\n\n"; { const input = std.Io.Reader.fixed(command_stream); var reader = Reader.init(std.testing.allocator, input, command_limits); defer reader.deinit(); var lease = (try reader.next()).?; defer lease.deinit(); switch (lease.value) { .commit => |commit| try std.testing.expectEqual(@as(usize, 2), commit.commands.len), else => return error.TestUnexpectedResult, } try std.testing.expect(reader.file_command_overrun == null); } { const input = std.Io.Reader.fixed(commit_prefix ++ "D one\nD two\nD three\n\n"); var reader = Reader.init(std.testing.allocator, input, command_limits); defer reader.deinit(); try std.testing.expectError(error.FileCommandLimitExceeded, reader.next()); // The refusal retains the numbers its diagnostic must name. const overrun = reader.file_command_overrun.?; try std.testing.expectEqual(@as(usize, 3), overrun.observed_at_least); try std.testing.expectEqual(@as(usize, 2), overrun.ceiling); try std.testing.expectEqual(command_limits.max_command_arena_bytes, overrun.arena_budget_bytes); try std.testing.expectEqual( fast_export.min_retained_bytes_per_file_command, overrun.min_bytes_per_command, ); } // Raising the arena budget raises the ceiling with it: nothing else does, // and in particular the cumulative payload budget no longer moves it. var doubled = command_limits; doubled.max_total_data *= 2; try std.testing.expectEqual(@as(usize, 2), fast_export.fileCommandCeiling(doubled)); doubled.max_command_arena_bytes *= 2; try std.testing.expectEqual(@as(usize, 4), fast_export.fileCommandCeiling(doubled)); } test "git fast-export imports a commit far above the retired 200,000 file wall" { // The retired ceiling was the benchmark corpus width promoted to policy. // A root commit that drops a whole monorepo in at once is an ordinary // source shape, and `git fast-export` lists the entire tree for it, so a // commit past that number must import rather than fail. const retired_ceiling: usize = 200_000; const command_count: usize = retired_ceiling + 1; var stream: std.ArrayList(u8) = .empty; defer stream.deinit(std.testing.allocator); try stream.appendSlice(std.testing.allocator, "feature done\n" ++ "commit refs/heads/main\n" ++ "mark :1\n" ++ "author A <[email protected]> 1 +0000\n" ++ "committer C <[email protected]> 2 +0000\n" ++ "data 0\n"); var line_buffer: [32]u8 = undefined; for (0..command_count) |index| { const line = try std.fmt.bufPrint(&line_buffer, "D f{d}\n", .{index}); try stream.appendSlice(std.testing.allocator, line); } try stream.appendSlice(std.testing.allocator, "\ndone\n"); var limits = defaultLimits(); // The production payload budget, not a value picked to make this pass. limits.max_total_data = @as(u64, 4) * 1024 * 1024 * 1024; try std.testing.expect(fast_export.fileCommandCeiling(limits) > command_count); const input = std.Io.Reader.fixed(stream.items); var reader = Reader.init(std.testing.allocator, input, limits); defer reader.deinit(); var lease = (try reader.next()).?; switch (lease.value) { .commit => |commit| try std.testing.expectEqual(command_count, commit.commands.len), else => return error.TestUnexpectedResult, } lease.deinit(); try std.testing.expect(reader.file_command_overrun == null); var done_lease = (try reader.next()).?; defer done_lease.deinit(); switch (done_lease.value) { .done => {}, else => return error.TestUnexpectedResult, } } test "git fast-export bounds total events and payload bytes before allocation" { var event_limits = defaultLimits(); event_limits.max_events = 4; // `feature done` configures the stream; progress, blobs, and done each // consume one event slot. const event_stream = "feature done\n" ++ "progress before blobs\n" ++ "blob\nmark :1\ndata 0\n\n" ++ "blob\nmark :2\ndata 0\n\n" ++ "done\n"; { const input = std.Io.Reader.fixed(event_stream); var reader = Reader.init(std.testing.allocator, input, event_limits); defer reader.deinit(); for (0..2) |_| { var lease = (try reader.next()).?; switch (lease.value) { .blob => {}, else => return error.TestUnexpectedResult, } lease.deinit(); } var done_lease = (try reader.next()).?; switch (done_lease.value) { .done => {}, else => return error.TestUnexpectedResult, } done_lease.deinit(); try std.testing.expect((try reader.next()) == null); } { const overflow_stream = "feature done\n" ++ "progress before blobs\n" ++ "blob\nmark :1\ndata 0\n\n" ++ "blob\nmark :2\ndata 0\n\n" ++ "blob\nmark :3\ndata 0\n\n" ++ "done\n"; const input = std.Io.Reader.fixed(overflow_stream); var reader = Reader.init(std.testing.allocator, input, event_limits); defer reader.deinit(); for (0..3) |_| { var lease = (try reader.next()).?; lease.deinit(); } try std.testing.expectError(error.EventLimitExceeded, reader.next()); } var payload_limits = defaultLimits(); payload_limits.max_events = 3; payload_limits.max_total_data = 3; const payload_stream = "feature done\n" ++ "blob\nmark :1\ndata 2\nab\n" ++ "blob\nmark :2\ndata 1\nc\n" ++ "done\n"; for (0..payload_stream.len + 1) |split| { var input_buffer: [1]u8 = undefined; var reader = Reader.init( std.testing.allocator, initSplitInput(payload_stream, split, &input_buffer), payload_limits, ); defer reader.deinit(); for (0..2) |_| { var lease = (try reader.next()).?; switch (lease.value) { .blob => {}, else => return error.TestUnexpectedResult, } lease.deinit(); } var done_lease = (try reader.next()).?; switch (done_lease.value) { .done => {}, else => return error.TestUnexpectedResult, } done_lease.deinit(); try std.testing.expect((try reader.next()) == null); } { const input = std.Io.Reader.fixed( "feature done\nblob\nmark :1\ndata 2\nab\nblob\nmark :2\ndata 2\ncd\n", ); var reader = Reader.init(std.testing.allocator, input, payload_limits); defer reader.deinit(); var first_blob = (try reader.next()).?; first_blob.deinit(); try std.testing.expectError(error.DataLimitExceeded, reader.next()); } } test "git fast-export payload refusals name which bound refused them" { // One error carries both bounds, and only one of them has a flag behind // it. A caller that reports the wrong one sends an operator to a knob that // cannot help, so the record — not the error name — is the contract. { // Cumulative: the first blob's two bytes admit, the second reaches four // against a budget of three. Parsing stops there, so four is a lower // bound on the stream's payload rather than its total. const input = std.Io.Reader.fixed( "feature done\nblob\nmark :1\ndata 2\nab\nblob\nmark :2\ndata 2\ncd\n", ); var reader = Reader.init(std.testing.allocator, input, .{ .max_line = 64, .max_data = 64, .max_path = 64, .max_total_data = 3, }); defer reader.deinit(); var first_blob = (try reader.next()).?; first_blob.deinit(); try std.testing.expectError(error.DataLimitExceeded, reader.next()); const overrun = reader.payload_overrun.?; try std.testing.expectEqual(fast_export.PayloadOverrun.Bound.cumulative_total, overrun.bound); try std.testing.expectEqual(@as(u64, 4), overrun.observed_at_least); try std.testing.expectEqual(@as(u64, 3), overrun.ceiling); } { // Single payload: refused on its own size before it is charged against // the cumulative budget, which here is wide open. const input = std.Io.Reader.fixed("feature done\nblob\nmark :1\ndata 2\nab\n"); var reader = Reader.init(std.testing.allocator, input, .{ .max_line = 64, .max_data = 1, .max_path = 64, .max_total_data = 1 << 20, }); defer reader.deinit(); try std.testing.expectError(error.DataLimitExceeded, reader.next()); const overrun = reader.payload_overrun.?; try std.testing.expectEqual(fast_export.PayloadOverrun.Bound.single_payload, overrun.bound); try std.testing.expectEqual(@as(u64, 2), overrun.observed_at_least); try std.testing.expectEqual(@as(u64, 1), overrun.ceiling); } } test "git fast-export commit data framing accepts both legal forms across every split" { const prefix = "feature done\n" ++ "commit refs/heads/main\n" ++ "mark :1\n" ++ "author A <[email protected]> 1 +0000\n" ++ "committer C <[email protected]> 2 +0000\n"; const suffix = "from :2\n\ndone\n"; const Form = struct { stream: []const u8, message: []const u8, }; const forms = [_]Form{ .{ .stream = prefix ++ "data 3\nabc\n" ++ suffix, .message = "abc" }, .{ .stream = prefix ++ "data 4\nabc\n" ++ suffix, .message = "abc\n" }, .{ .stream = prefix ++ "data 0\n" ++ suffix, .message = "" }, }; for (forms) |form| { const stream = form.stream; for (0..stream.len + 1) |split| { var input_buffer: [1]u8 = undefined; var reader = Reader.init(std.testing.allocator, initSplitInput(stream, split, &input_buffer), defaultLimits()); defer reader.deinit(); var commit_lease = (try reader.next()).?; switch (commit_lease.value) { .commit => |commit| { try std.testing.expectEqualStrings(form.message, commit.message); try std.testing.expectEqual(@as(u64, 2), commit.from.?.mark); }, else => return error.TestUnexpectedResult, } commit_lease.deinit(); var done_lease = (try reader.next()).?; defer done_lease.deinit(); switch (done_lease.value) { .done => {}, else => return error.TestUnexpectedResult, } } } } test "git fast-export commit data count permits an immediately following command" { const stream = "feature done\n" ++ "commit refs/heads/main\n" ++ "mark :1\n" ++ "author A <[email protected]> 1 +0000\n" ++ "committer C <[email protected]> 2 +0000\n" ++ "data 1\nxfrom :2\n\n" ++ "done\n"; const input = std.Io.Reader.fixed(stream); var reader = Reader.init(std.testing.allocator, input, defaultLimits()); defer reader.deinit(); var lease = (try reader.next()).?; defer lease.deinit(); switch (lease.value) { .commit => |commit| { try std.testing.expectEqualStrings("x", commit.message); try std.testing.expectEqual(@as(u64, 2), commit.from.?.mark); }, else => return error.TestUnexpectedResult, } }