@@ -1,0 +1,354 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Cycle4FixesTest do
@moduledoc """
Tests for the 5 fixes made in red team cycle 4:
1. NEW-C4-1: collect_reachable depth counter regression — commit chains >64 should work
2. NEW-HIGH-2: OFS_DELTA negative offset validation in reader.ex
3. NEW-HIGH-1: diag/3 tail-recursion in myers.ex
4. NEW-C4-3: read_varint continuation byte limit in delta.ex
5. NEW-C4-2: SHA hex validation in receive_pack.ex
"""
use ExUnit.Case, async: true
alias ExGitObjectstore.{Object, Repo}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Diff.Myers
alias ExGitObjectstore.Pack.{Delta, Reader}
alias ExGitObjectstore.Protocol.{PktLine, ReceivePack, UploadPack}
alias ExGitObjectstore.Storage.Memory
# ============================================================================
# Fix 1: collect_reachable depth counter regression
#
# In cycle 3, collect_reachable incremented depth for parent commit traversal,
# meaning repos with >64 commits would fail clone. The depth limit should only
# apply to tree nesting depth, not commit chain length.
# ============================================================================
describe "Fix 1: collect_reachable handles long commit chains" do
test "upload_pack clone succeeds with >64 commits in chain" do
repo = create_memory_repo()
ExGitObjectstore.init(repo)
# Build a chain of 70 commits (exceeds old @max_tree_depth 64 limit)
{:ok, blob_sha} = Object.write(repo, Blob.from_content("content\n"))
tree = Tree.new([%{mode: "100644", name: "file.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
# Create 70 sequential commits
{:ok, first_sha} =
Object.write(repo, %Commit{
tree: tree_sha,
parents: [],
author: "Test <t@t> 1700000000 +0000",
committer: "Test <t@t> 1700000000 +0000",
message: "commit 0\n"
})
last_sha =
Enum.reduce(1..69, first_sha, fn i, parent_sha ->
{:ok, sha} =
Object.write(repo, %Commit{
tree: tree_sha,
parents: [parent_sha],
author: "Test <t@t> #{1_700_000_000 + i} +0000",
committer: "Test <t@t> #{1_700_000_000 + i} +0000",
message: "commit #{i}\n"
})
sha
end)
:ok = ExGitObjectstore.Ref.put(repo, "refs/heads/main", last_sha, nil)
{_advert, state} = UploadPack.init(repo)
want_line = PktLine.encode("want #{last_sha}")
request = want_line <> PktLine.flush() <> PktLine.encode("done")
{response, final_state} = UploadPack.feed(state, request)
assert UploadPack.done?(final_state)
# Should get a valid pack response, not an error
assert byte_size(response) > 0
# Should NOT contain an ERR line
refute String.contains?(response, "ERR")
end
test "upload_pack clone still enforces tree depth limit" do
repo = create_memory_repo()
ExGitObjectstore.init(repo)
# Build a tree nested 70 levels deep (exceeds @max_tree_depth 64)
{:ok, blob_sha} = Object.write(repo, Blob.from_content("deep\n"))
{:ok, deepest_sha} =
Object.write(repo, Tree.new([%{mode: "100644", name: "leaf.txt", sha: blob_sha}]))
{:ok, root_tree_sha} =
Enum.reduce(1..69, {:ok, deepest_sha}, fn _i, {:ok, child_sha} ->
Object.write(repo, Tree.new([%{mode: "40000", name: "d", sha: child_sha}]))
end)
{:ok, commit_sha} =
Object.write(repo, %Commit{
tree: root_tree_sha,
parents: [],
author: "Test <t@t> 1700000000 +0000",
committer: "Test <t@t> 1700000000 +0000",
message: "deep tree\n"
})
:ok = ExGitObjectstore.Ref.put(repo, "refs/heads/main", commit_sha, nil)
{_advert, state} = UploadPack.init(repo)
want_line = PktLine.encode("want #{commit_sha}")
request = want_line <> PktLine.flush() <> PktLine.encode("done")
{response, final_state} = UploadPack.feed(state, request)
assert UploadPack.done?(final_state)
# Should get an error about tree depth
assert String.contains?(response, "ERR") or String.contains?(response, "max_tree_depth")
end
end
# ============================================================================
# Fix 2: OFS_DELTA negative offset validation
#
# When neg_offset > offset, base_offset goes negative, which would crash
# on binary pattern match. Now returns {:error, :invalid_ofs_delta_offset}.
# ============================================================================
describe "Fix 2: OFS_DELTA negative offset validation" do
test "parse_ofs_delta_offset with valid small offset succeeds" do
assert {:ok, 5, 1, _rest} = Reader.parse_ofs_delta_offset(<<5, "rest">>)
end
test "parse_ofs_delta_offset correctly computes offset" do
# Two-byte offset: first byte 0x81 (MSB set, value 1), second byte 0x02
# offset = (1 + 1) << 7 + 2 = 258
assert {:ok, 258, 2, _rest} = Reader.parse_ofs_delta_offset(<<0x81, 0x02, "rest">>)
end
end
# ============================================================================
# Fix 3: diag/3 tail-recursion in Myers diff
#
# diag/3 was building a list via [head | recursive_call], which is not
# tail-recursive and causes stack overflow for large diffs. Now uses
# accumulator-based diag_acc/4.
# ============================================================================
describe "Fix 3: diag/3 tail-recursion" do
test "Myers diff works correctly for small inputs" do
result = Myers.diff(~w[a b c], ~w[a c])
assert result == [{:eq, "a"}, {:del, "b"}, {:eq, "c"}]
end
test "Myers diff works for identical inputs" do
input = ~w[a b c d e]
result = Myers.diff(input, input)
assert result == Enum.map(input, &{:eq, &1})
end
test "Myers diff works for completely different inputs" do
result = Myers.diff(~w[a b], ~w[c d])
# Should have dels and ins, no eqs
assert Enum.all?(result, fn {op, _} -> op in [:del, :ins] end)
end
test "Myers diff handles large equal sequences without stack overflow" do
# 10,000 equal lines — would stack overflow with non-tail-recursive diag
large_list = Enum.map(1..10_000, &"line #{&1}")
result = Myers.diff(large_list, large_list)
assert length(result) == 10_000
assert Enum.all?(result, fn {op, _} -> op == :eq end)
end
test "Myers diff_lines works with large texts" do
# Build two texts with a large common prefix and one change
lines = Enum.map(1..5_000, &"line #{&1}\n") |> Enum.join()
text_a = lines <> "old line\n"
text_b = lines <> "new line\n"
result = Myers.diff_lines(text_a, text_b)
# Should have many :eq entries and exactly one :del + one :ins
dels = Enum.count(result, fn {op, _} -> op == :del end)
inss = Enum.count(result, fn {op, _} -> op == :ins end)
assert dels == 1
assert inss == 1
end
end
# ============================================================================
# Fix 4: read_varint continuation byte limit in delta.ex
#
# read_varint had no limit on continuation bytes. Crafted deltas with
# many continuation bytes (MSB set) could cause infinite recursion.
# Now limited to @max_varint_bytes (10).
# ============================================================================
describe "Fix 4: delta read_varint continuation limit" do
test "Delta.apply works for valid simple delta" do
base = "Hello, World!"
# Build a delta that copies the entire base:
# base_size varint: 13 (0x0D)
# target_size varint: 13 (0x0D)
# copy instruction: cmd=0x90 (MSB set, bit 4 => size byte 0 present, no offset bytes)
# no offset bytes (offset defaults to 0)
# size_byte0 = 13 (size = 13)
delta = <<13, 13, 0x90, 13>>
assert {:ok, ^base} = Delta.apply(base, delta)
end
test "Delta.apply rejects truncated varint" do
# Empty delta data
assert {:error, :truncated_varint} = Delta.apply("base", <<>>)
end
test "Delta.apply rejects excessively long varint" do
# Build a varint with 12 continuation bytes (all with MSB set) + 1 final byte
# This exceeds the @max_varint_bytes = 10 limit
continuation_bytes = :binary.copy(<<0x81>>, 12)
bad_delta = continuation_bytes <> <<0x01>>
assert {:error, :varint_too_long} = Delta.apply("base data here!", bad_delta)
end
test "Delta.apply accepts varint at exactly the limit" do
# 10 continuation bytes + final byte = reads OK
# First byte starts the varint (consumed=0), then 9 more continuation (consumed=1..9)
# consumed reaches 9 which is < 10, then final byte
# Actually: consumed starts at 0, increments after each continuation.
# Guard: consumed >= 10 triggers error. So 10 continuations means consumed=10 on 11th byte => error
# 9 continuation bytes means consumed=9 on 10th byte => OK (9 < 10)
continuation_bytes = :binary.copy(<<0x80>>, 9)
data = continuation_bytes <> <<0x01>>
# This is just the base_size varint — will fail on target_size, but parse should succeed
# We can't easily test this without knowing the full delta format, so just verify
# that more than 10 fails and fewer doesn't crash
result = Delta.apply("x", data)
# Should fail with something other than :varint_too_long (e.g., size mismatch or truncation)
assert {:error, reason} = result
refute reason == :varint_too_long
end
end
# ============================================================================
# Fix 5: SHA hex validation in receive_pack
#
# receive_pack's parse_command_line now validates that old_sha and new_sha
# are valid 40-char lowercase hex strings, matching upload_pack's validation.
# ============================================================================
describe "Fix 5: receive_pack SHA validation" do
test "receive_pack accepts valid lowercase hex SHAs" do
repo = create_memory_repo()
ExGitObjectstore.init(repo)
{_advert, state} = ReceivePack.init(repo)
old_sha = String.duplicate("0", 40)
new_sha = String.duplicate("a", 40)
cmd_line = PktLine.encode("#{old_sha} #{new_sha} refs/heads/main\0report-status")
request = cmd_line <> PktLine.flush()
# Should not error on SHA validation — will proceed to pack phase
{_response, next_state} = ReceivePack.feed(state, request)
# State should advance (not error out in commands phase)
refute ReceivePack.done?(next_state) or next_state.phase == :commands
end
test "receive_pack rejects uppercase hex in SHAs" do
repo = create_memory_repo()
ExGitObjectstore.init(repo)
{_advert, state} = ReceivePack.init(repo)
old_sha = String.duplicate("0", 40)
new_sha = String.duplicate("A", 40)
cmd_line = PktLine.encode("#{old_sha} #{new_sha} refs/heads/main\0report-status")
request = cmd_line <> PktLine.flush()
{response, final_state} = ReceivePack.feed(state, request)
assert ReceivePack.done?(final_state)
# Should report an error
assert byte_size(response) > 0
end
test "receive_pack rejects non-hex characters in SHAs" do
repo = create_memory_repo()
ExGitObjectstore.init(repo)
{_advert, state} = ReceivePack.init(repo)
old_sha = String.duplicate("0", 40)
new_sha = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
cmd_line = PktLine.encode("#{old_sha} #{new_sha} refs/heads/main\0report-status")
request = cmd_line <> PktLine.flush()
{response, final_state} = ReceivePack.feed(state, request)
assert ReceivePack.done?(final_state)
assert byte_size(response) > 0
end
test "receive_pack accepts valid delete command (zero SHAs)" do
repo = create_memory_repo()
ExGitObjectstore.init(repo)
# Create a ref so we can delete it
{:ok, blob_sha} = Object.write(repo, Blob.from_content("content\n"))
tree = Tree.new([%{mode: "100644", name: "file.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
{:ok, commit_sha} =
Object.write(repo, %Commit{
tree: tree_sha,
parents: [],
author: "Test <t@t> 1700000000 +0000",
committer: "Test <t@t> 1700000000 +0000",
message: "initial\n"
})
:ok = ExGitObjectstore.Ref.put(repo, "refs/heads/main", commit_sha, nil)
{_advert, state} = ReceivePack.init(repo)
old_sha = commit_sha
new_sha = String.duplicate("0", 40)
cmd_line = PktLine.encode("#{old_sha} #{new_sha} refs/heads/main\0report-status")
request = cmd_line <> PktLine.flush()
{response, final_state} = ReceivePack.feed(state, request)
assert ReceivePack.done?(final_state)
# Delete should succeed — response should contain "ok"
assert String.contains?(response, "ok")
end
end
# ============================================================================
# Helpers
# ============================================================================
defp create_memory_repo do
{:ok, pid} = Memory.start_link()
Repo.new("cycle4-test-#{:erlang.unique_integer([:positive])}",
storage: {Memory, Memory.config(pid)}
)
end
end