@@ -73,7 +73,7 @@
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{response, state} = ReceivePack.feed(state, commands <> pack_data)
{response, state} = rp_feed_and_flush(state, commands <> pack_data)
assert ReceivePack.done?(state)
assert_report_ok(response, ["refs/heads/main"])
@@ -115,7 +115,7 @@
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{response, state} = rp_feed_and_flush(state, commands <> pack_data)
{response, state} = ReceivePack.feed(state, commands <> pack_data)
assert ReceivePack.done?(state)
assert_report_ok(response, ["refs/heads/main"])
@@ -150,7 +150,7 @@
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{response, state} = ReceivePack.feed(state, commands <> pack_data)
{response, state} = rp_feed_and_flush(state, commands <> pack_data)
assert ReceivePack.done?(state)
assert_report_ok(response, ["refs/heads/main"])
@@ -179,7 +179,7 @@
repo = make_filesystem_repo(base, "target_incr")
{_advert, state} = ReceivePack.init(repo)
commands1 = build_receive_commands([{@zero_sha, c1_sha, "refs/heads/main"}])
{response1, _state} = rp_feed_and_flush(state, commands1 <> pack1)
{response1, _state} = ReceivePack.feed(state, commands1 <> pack1)
assert_report_ok(response1, ["refs/heads/main"])
# Second commit (modifies the file — thin pack references first blob as delta base)
@@ -194,7 +194,7 @@
# Push incremental update
{_advert2, state2} = ReceivePack.init(repo)
commands2 = build_receive_commands([{c1_sha, c2_sha, "refs/heads/main"}])
{response2, state2} = ReceivePack.feed(state2, commands2 <> pack2)
{response2, state2} = rp_feed_and_flush(state2, commands2 <> pack2)
assert ReceivePack.done?(state2)
assert_report_ok(response2, ["refs/heads/main"])
@@ -239,7 +239,7 @@
{@zero_sha, feature_sha, "refs/heads/feature"}
])
{response, state} = ReceivePack.feed(state, commands <> pack_data)
{response, state} = rp_feed_and_flush(state, commands <> pack_data)
assert ReceivePack.done?(state)
assert_report_ok(response, ["refs/heads/main", "refs/heads/feature"])
@@ -265,7 +265,7 @@
repo = make_filesystem_repo(base, "target_force")
{_advert, state} = ReceivePack.init(repo)
cmd1 = build_receive_commands([{@zero_sha, c1_sha, "refs/heads/main"}])
{r1, _} = rp_feed_and_flush(state, cmd1 <> pack1)
{r1, _} = ReceivePack.feed(state, cmd1 <> pack1)
assert_report_ok(r1, ["refs/heads/main"])
# Amend commit (rewrites history — different SHA for same ref)
@@ -281,7 +281,7 @@
# Force push: old_sha=c1, new_sha=c2 (non-fast-forward)
{_advert2, state2} = ReceivePack.init(repo)
cmd2 = build_receive_commands([{c1_sha, c2_sha, "refs/heads/main"}])
{r2, state2} = ReceivePack.feed(state2, cmd2 <> pack2)
{r2, state2} = rp_feed_and_flush(state2, cmd2 <> pack2)
assert ReceivePack.done?(state2)
assert_report_ok(r2, ["refs/heads/main"])
@@ -321,7 +321,7 @@
repo = make_filesystem_repo(base, "target_merge")
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, merge_sha, "refs/heads/main"}])
{response, state} = rp_feed_and_flush(state, commands <> pack_data)
{response, state} = ReceivePack.feed(state, commands <> pack_data)
assert ReceivePack.done?(state)
assert_report_ok(response, ["refs/heads/main"])
@@ -358,7 +358,7 @@
{@zero_sha, tag_sha, "refs/tags/v1.0"}
])
{response, state} = ReceivePack.feed(state, commands <> pack_data)
{response, state} = rp_feed_and_flush(state, commands <> pack_data)
assert ReceivePack.done?(state)
assert_report_ok(response, ["refs/heads/main", "refs/tags/v1.0"])
@@ -387,7 +387,7 @@
repo = make_filesystem_repo(base, "upload_single")
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{r, _} = ReceivePack.feed(state, commands <> pack_data)
{r, _} = rp_feed_and_flush(state, commands <> pack_data)
assert_report_ok(r, ["refs/heads/main"])
# Now fetch via UploadPack
@@ -428,7 +428,7 @@
repo = make_filesystem_repo(base, "upload_multi")
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{r, _} = rp_feed_and_flush(state, commands <> pack_data)
{r, _} = ReceivePack.feed(state, commands <> pack_data)
assert_report_ok(r, ["refs/heads/main"])
# Fetch via UploadPack
@@ -484,7 +484,7 @@
repo = make_filesystem_repo(base, "roundtrip")
{_advert, rp_state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{r, _} = ReceivePack.feed(rp_state, commands <> pack_data)
{r, _} = rp_feed_and_flush(rp_state, commands <> pack_data)
assert_report_ok(r, ["refs/heads/main"])
# Fetch back via UploadPack
@@ -541,7 +541,7 @@
{@zero_sha, feature_sha, "refs/heads/feature"}
])
{r, _} = rp_feed_and_flush(rp_state, commands <> pack_data)
{r, _} = ReceivePack.feed(rp_state, commands <> pack_data)
assert_report_ok(r, ["refs/heads/main", "refs/heads/feature"])
# Fetch feature branch via UploadPack
@@ -569,7 +569,7 @@
# ============================================================================
describe "adversarial packs" do
test "corrupted pack checksum is rejected (stays incomplete)", ctx do
test "corrupted pack checksum is rejected with structured error", ctx do
%{work_dir: work_dir, base: base} = ctx
File.write!(Path.join(work_dir, "file.txt"), "content\n")
@@ -589,15 +589,21 @@
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{response, state} = ReceivePack.feed(state, commands <> corrupted)
{response, state} = rp_feed_and_flush(state, commands <> corrupted)
# Bad checksum surfaces from Pack.Reader.parse as a structured error
# at flush time. The state machine transitions to :done with an
# error result — it does NOT sit in :pack waiting for more data
# Corrupted checksum means pack appears incomplete — state machine waits for more data
refute ReceivePack.done?(state)
assert response == <<>>
assert state.phase == :pack
# (the previous SHA-verify-per-chunk trick made it look like that,
# which was the cheat).
assert ReceivePack.done?(state)
assert match?({:error, _}, state.result)
# Report-status carries the unpack failure to the client.
assert byte_size(response) > 0
assert response =~ "unpack "
end
test "truncated pack data is rejected with structured error", ctx do
test "truncated pack data is rejected (stays incomplete)", ctx do
%{work_dir: work_dir, base: base} = ctx
File.write!(Path.join(work_dir, "file.txt"), "content\n")
@@ -615,10 +621,15 @@
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{response, state} = ReceivePack.feed(state, commands <> truncated)
{response, state} = rp_feed_and_flush(state, commands <> truncated)
# Truncated pack: Pack.Reader.parse fails (either the trailer SHA
# mismatches or an entry parse runs off the end). flush surfaces
# Truncated pack won't have valid checksum — stays incomplete
# that as a structured error and transitions the state machine to
# :done so we don't hang on a closed channel.
assert ReceivePack.done?(state)
assert match?({:error, _}, state.result)
assert byte_size(response) > 0
assert response =~ "unpack "
refute ReceivePack.done?(state)
assert response == <<>>
end
end
@@ -649,13 +660,13 @@
{@zero_sha, commit_sha, "refs/heads/to-delete"}
])
{r1, _} = rp_feed_and_flush(state, cmd1 <> pack_data)
{r1, _} = ReceivePack.feed(state, cmd1 <> pack_data)
assert_report_ok(r1, ["refs/heads/main", "refs/heads/to-delete"])
# Now delete the branch (no pack needed for delete-only)
{_advert2, state2} = ReceivePack.init(repo)
cmd2 = build_receive_commands([{commit_sha, @zero_sha, "refs/heads/to-delete"}])
{r2, state2} = ReceivePack.feed(state2, cmd2)
{r2, state2} = rp_feed_and_flush(state2, cmd2)
assert ReceivePack.done?(state2)
assert_report_ok(r2, ["refs/heads/to-delete"])
@@ -677,7 +688,7 @@
repo = make_filesystem_repo(base, "advert_check")
{_advert, state} = ReceivePack.init(repo)
commands = build_receive_commands([{@zero_sha, commit_sha, "refs/heads/main"}])
{r, _} = rp_feed_and_flush(state, commands <> pack_data)
{r, _} = ReceivePack.feed(state, commands <> pack_data)
assert_report_ok(r, ["refs/heads/main"])
# Now check UploadPack advertisement
@@ -781,6 +792,17 @@
assert "ok #{ref}" in data_lines,
"Expected 'ok #{ref}' in report, got: #{inspect(data_lines)}"
end
end
# ReceivePack.feed/2 is now absorb-only: it never finalizes the state
# machine. Tests that fed a complete pack and expected `done?` true
# immediately go through this helper, which mirrors what real SSH/HTTP
# callers do once they observe end-of-stream (SSH client EOF, end of
# HTTP request body).
defp rp_feed_and_flush(state, data) do
{feed_resp, state} = ReceivePack.feed(state, data)
{flush_resp, state} = ReceivePack.flush(state)
{feed_resp <> flush_resp, state}
end
defp git_index_pack!(base, pack_data) do