@@ -1,0 +1,370 @@
# 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.Cycle3FixesTest do
@moduledoc """
Tests for the 4 fixes made in red team cycle 3:
1. HIGH-3: diff_tree_entries tree depth limit (diff.ex)
2. HIGH-4: collect_reachable/collect_tree_objects tree depth limit (upload_pack.ex)
3. HIGH-1: parse_ofs_continuation depth limit (reader.ex)
4. HIGH-5: SHA hex format validation in want/have lines (upload_pack.ex)
"""
use ExUnit.Case, async: true
alias ExGitObjectstore.{Diff, Object, Repo}
alias ExGitObjectstore.Object.{Blob, Tree}
alias ExGitObjectstore.Pack.Reader
alias ExGitObjectstore.Protocol.{PktLine, UploadPack}
alias ExGitObjectstore.Storage.Memory
# ============================================================================
# Fix 1: diff_tree_entries tree depth limit
#
# diff_tree_entries now has a @max_tree_depth 64 limit matching merge.ex.
# Recursion beyond depth 64 returns {:error, :max_tree_depth_exceeded}.
# ============================================================================
describe "Fix 1: diff_tree_entries depth limit" do
test "diff_trees works for normal shallow tree structures" do
repo = create_memory_repo()
# Create two simple trees with different blobs
{:ok, old_blob_sha} = Object.write(repo, Blob.from_content("old content\n"))
{:ok, new_blob_sha} = Object.write(repo, Blob.from_content("new content\n"))
old_tree = Tree.new([%{mode: "100644", name: "file.txt", sha: old_blob_sha}])
{:ok, old_tree_sha} = Object.write(repo, old_tree)
new_tree = Tree.new([%{mode: "100644", name: "file.txt", sha: new_blob_sha}])
{:ok, new_tree_sha} = Object.write(repo, new_tree)
assert {:ok, changes} = Diff.diff_trees(repo, old_tree_sha, new_tree_sha)
assert length(changes) == 1
assert hd(changes).status == :modified
assert hd(changes).path == "file.txt"
end
test "diff_trees works with nested directories up to reasonable depth" do
repo = create_memory_repo()
# Build a tree 5 levels deep
{:ok, blob_sha} = Object.write(repo, Blob.from_content("leaf\n"))
# Build from leaf to root
{:ok, deepest_tree_sha} =
Object.write(repo, Tree.new([%{mode: "100644", name: "leaf.txt", sha: blob_sha}]))
{:ok, tree_sha} =
Enum.reduce(1..5, {:ok, deepest_tree_sha}, fn i, {:ok, child_sha} ->
tree = Tree.new([%{mode: "40000", name: "dir#{i}", sha: child_sha}])
Object.write(repo, tree)
end)
# Diff against empty tree — should recurse through all 5 levels
assert {:ok, changes} = Diff.diff_trees(repo, nil, tree_sha)
assert length(changes) > 0
# The leaf file should be found
assert Enum.any?(changes, fn c -> String.contains?(c.path, "leaf.txt") end)
end
test "diff_trees returns nil-to-tree diff correctly" do
repo = create_memory_repo()
{:ok, blob_sha} = Object.write(repo, Blob.from_content("content\n"))
tree = Tree.new([%{mode: "100644", name: "a.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
assert {:ok, changes} = Diff.diff_trees(repo, nil, tree_sha)
assert length(changes) == 1
assert hd(changes).status == :added
end
test "diff_trees returns tree-to-nil diff correctly" do
repo = create_memory_repo()
{:ok, blob_sha} = Object.write(repo, Blob.from_content("content\n"))
tree = Tree.new([%{mode: "100644", name: "a.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
assert {:ok, changes} = Diff.diff_trees(repo, tree_sha, nil)
assert length(changes) == 1
assert hd(changes).status == :deleted
end
end
# ============================================================================
# Fix 2: upload_pack collect_reachable tree depth limit
#
# collect_reachable, collect_tree_objects, and collect_tree_entry_objects
# now have a @max_tree_depth 64 depth parameter. The depth limit is enforced
# via raise, caught by the existing try/rescue in collect_objects.
# ============================================================================
describe "Fix 2: upload_pack tree depth limit" do
test "upload_pack works for normal repos with shallow trees" do
# Create a real git repo and verify upload_pack handles it
tmp_dir = System.tmp_dir!()
dir = Path.join(tmp_dir, "cycle3_up_#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(dir)
try do
git!(dir, ["init", "--bare"])
repo = create_filesystem_repo(dir)
{advert, state} = UploadPack.init(repo)
# Advertisement should contain valid pkt-lines (at least capabilities)
assert byte_size(advert) > 0
assert not UploadPack.done?(state)
after
File.rm_rf!(dir)
end
end
test "upload_pack clone with nested directories succeeds" do
repo = create_memory_repo()
ExGitObjectstore.init(repo)
# Create nested tree: a/b/c/deep.txt
{:ok, blob_sha} = Object.write(repo, Blob.from_content("deep content\n"))
c_tree = Tree.new([%{mode: "100644", name: "deep.txt", sha: blob_sha}])
{:ok, c_sha} = Object.write(repo, c_tree)
b_tree = Tree.new([%{mode: "40000", name: "c", sha: c_sha}])
{:ok, b_sha} = Object.write(repo, b_tree)
a_tree = Tree.new([%{mode: "40000", name: "b", sha: b_sha}])
{:ok, a_sha} = Object.write(repo, a_tree)
root_tree = Tree.new([%{mode: "40000", name: "a", sha: a_sha}])
{:ok, root_tree_sha} = Object.write(repo, root_tree)
commit = %ExGitObjectstore.Object.Commit{
tree: root_tree_sha,
parents: [],
author: "Test <t@t> 1700000000 +0000",
committer: "Test <t@t> 1700000000 +0000",
message: "nested dirs\n"
}
{:ok, commit_sha} = Object.write(repo, commit)
: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)
assert byte_size(response) > 0
end
end
# ============================================================================
# Fix 3: parse_ofs_continuation depth limit
#
# parse_ofs_continuation now has a guard matching @max_header_continuation_bytes
# (10 bytes). OFS delta offsets longer than 10 continuation bytes are rejected
# with {:error, :ofs_offset_too_long}.
# ============================================================================
describe "Fix 3: parse_ofs_continuation depth limit" do
test "valid OFS delta offset with small values parses correctly" do
# Single byte: value < 128 (no continuation)
assert {:ok, 5, 1, _rest} = Reader.parse_ofs_delta_offset(<<5, "rest">>)
end
test "valid OFS delta offset with continuation bytes parses correctly" do
# Two bytes: first has MSB set, second doesn't
# First byte: 0x80 | 0x01 = 0x81 -> offset starts as 1
# Second byte: 0x02 -> offset = (1 + 1) << 7 + 2 = 258
assert {:ok, 258, 2, _rest} = Reader.parse_ofs_delta_offset(<<0x81, 0x02, "rest">>)
end
test "empty input returns error" do
assert {:error, :empty_ofs_offset} = Reader.parse_ofs_delta_offset(<<>>)
end
test "truncated continuation returns error" do
# First byte has MSB set but no second byte
assert {:error, :truncated_ofs_offset} = Reader.parse_ofs_delta_offset(<<0x80>>)
end
test "excessively long OFS delta offset is rejected" do
# Build a binary with >10 continuation bytes (all with MSB set)
# First byte has MSB set to start continuation, then 12 more continuation bytes
continuation_bytes = :binary.copy(<<0x81>>, 12)
# Final byte without MSB
data = <<0x81>> <> continuation_bytes <> <<0x01>>
# First byte is parsed by parse_ofs_delta_offset (consumed=0),
# then parse_ofs_continuation starts with consumed=1 and reads 12 more
# When consumed > 10, it returns :ofs_offset_too_long
assert {:error, :ofs_offset_too_long} = Reader.parse_ofs_delta_offset(data)
end
test "OFS delta offset at exactly the limit still works" do
# Build data with exactly 10 continuation bytes after the first byte
# First byte parsed by parse_ofs_delta_offset, then 10 continuation bytes
# consumed starts at 1, guard fires when consumed > 10
# So 10 continuation bytes means consumed reaches 10, which is still OK
continuation_bytes = :binary.copy(<<0x81>>, 9)
data = <<0x81>> <> continuation_bytes <> <<0x01>>
result = Reader.parse_ofs_delta_offset(data)
assert {:ok, _, _, _} = result
end
end
# ============================================================================
# Fix 4: SHA hex format validation in want/have lines
#
# parse_want_lines and parse_have_lines now validate that SHAs are exactly
# 40 lowercase hex characters before accepting them. Invalid SHAs return
# {:error, {:invalid_want_sha, sha}} or {:error, {:invalid_have_sha, sha}}.
# ============================================================================
describe "Fix 4: SHA validation in upload_pack want/have lines" do
test "valid 40-char hex SHA in want line is accepted" do
sha = String.duplicate("a", 40)
want_line = PktLine.encode("want #{sha}")
request = want_line <> PktLine.flush() <> PktLine.encode("done")
repo = create_memory_repo()
ExGitObjectstore.init(repo)
{_advert, state} = UploadPack.init(repo)
# Should not error on parsing — the SHA is valid format
# (may error on object lookup, but parsing succeeds)
{_response, _state} = UploadPack.feed(state, request)
end
test "invalid SHA in want line causes NAK and done state" do
# SHA with uppercase characters — protocol expects lowercase
sha = String.duplicate("A", 40)
want_line = PktLine.encode("want #{sha}")
request = want_line <> PktLine.flush() <> PktLine.encode("done")
repo = create_memory_repo()
ExGitObjectstore.init(repo)
{_advert, state} = UploadPack.init(repo)
{response, final_state} = UploadPack.feed(state, request)
assert UploadPack.done?(final_state)
# Response should contain NAK (error path)
assert String.contains?(response, "NAK")
end
test "too-short SHA in want line is rejected" do
sha = String.duplicate("a", 20)
want_line = PktLine.encode("want #{sha}")
request = want_line <> PktLine.flush() <> PktLine.encode("done")
repo = create_memory_repo()
ExGitObjectstore.init(repo)
{_advert, state} = UploadPack.init(repo)
{response, final_state} = UploadPack.feed(state, request)
assert UploadPack.done?(final_state)
assert String.contains?(response, "NAK")
end
test "SHA with non-hex characters in want line is rejected" do
sha = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
want_line = PktLine.encode("want #{sha}")
request = want_line <> PktLine.flush() <> PktLine.encode("done")
repo = create_memory_repo()
ExGitObjectstore.init(repo)
{_advert, state} = UploadPack.init(repo)
{response, final_state} = UploadPack.feed(state, request)
assert UploadPack.done?(final_state)
assert String.contains?(response, "NAK")
end
test "valid SHA with capabilities in want line is accepted" do
sha = String.duplicate("b", 40)
want_line = PktLine.encode("want #{sha} multi_ack side-band-64k")
request = want_line <> PktLine.flush() <> PktLine.encode("done")
repo = create_memory_repo()
ExGitObjectstore.init(repo)
{_advert, state} = UploadPack.init(repo)
# Should not error on format validation
{_response, _state} = UploadPack.feed(state, request)
end
test "invalid SHA in have line causes error during negotiation" do
# Valid want, but invalid have
want_sha = String.duplicate("a", 40)
have_sha = String.duplicate("X", 40)
want_line = PktLine.encode("want #{want_sha}")
have_line = PktLine.encode("have #{have_sha}")
request = want_line <> PktLine.flush() <> have_line <> PktLine.encode("done")
repo = create_memory_repo()
ExGitObjectstore.init(repo)
{_advert, state} = UploadPack.init(repo)
{response, final_state} = UploadPack.feed(state, request)
assert UploadPack.done?(final_state)
assert String.contains?(response, "NAK")
end
test "multiple valid want SHAs are all accepted" do
sha1 = String.duplicate("1", 40)
sha2 = String.duplicate("2", 40)
sha3 = String.duplicate("3", 40)
request =
PktLine.encode("want #{sha1}") <>
PktLine.encode("want #{sha2}") <>
PktLine.encode("want #{sha3}") <>
PktLine.flush() <>
PktLine.encode("done")
repo = create_memory_repo()
ExGitObjectstore.init(repo)
{_advert, state} = UploadPack.init(repo)
# Format validation passes; may error on object resolution
{_response, _state} = UploadPack.feed(state, request)
end
end
# ============================================================================
# Helpers
# ============================================================================
defp create_memory_repo do
{:ok, pid} = Memory.start_link()
Repo.new("cycle3-test-#{:erlang.unique_integer([:positive])}", storage: {Memory, Memory.config(pid)})
end
defp create_filesystem_repo(root) do
repo_id = "cycle3-fs-#{:erlang.unique_integer([:positive])}"
Repo.new(repo_id, storage: {ExGitObjectstore.Storage.Filesystem, %{root: root}})
end
defp git!(dir, args) do
{output, status} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)
if status != 0, do: raise("git #{Enum.join(args, " ")} failed: #{output}")
output
end
end