@@ -1,0 +1,540 @@
# 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.Cycle2FixesTest do
@moduledoc """
Tests for the 3 fixes made in red team cycle 2:
1. CRITICAL: try_decompress_prefix uses safeInflate (Reader)
2. HIGH: SHA cache passed through resolve_delta_entries (Reader)
3. HIGH: encode_raw_from_type validates type atoms (Object)
"""
use ExUnit.Case, async: true
alias ExGitObjectstore.Pack.{Writer, Reader}
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Tree, Commit, Tag}
# ============================================================================
# Fix 1: try_decompress_prefix uses safeInflate
#
# The try_decompress_prefix function previously used unsafe :zlib.inflate
# which bypassed the decompression size limit. Now it uses :zlib.safeInflate
# with inflateEnd verification.
# ============================================================================
describe "Fix 1: safeInflate in try_decompress_prefix - roundtrip correctness" do
test "Reader.parse roundtrips a single blob through Writer" do
content = "Hello, World! This is a safeInflate roundtrip test.\n"
blob = Blob.from_content(content)
sha = Object.hash(blob)
{pack_data, _pack_sha} = Writer.generate([{:blob, content, sha}])
assert {:ok, entries} = Reader.parse(pack_data)
assert length(entries) == 1
entry = hd(entries)
assert entry.type == :blob
assert entry.data == content
end
test "Reader.parse roundtrips multiple blobs with varying sizes" do
# Small content, medium content, and larger content to exercise
# the binary search in find_compressed_length which calls try_decompress_prefix
objects =
for i <- 1..5 do
content = String.duplicate("data-#{i}-", i * 100) <> "\n"
blob = Blob.from_content(content)
sha = Object.hash(blob)
{content, sha}
end
writer_entries = Enum.map(objects, fn {content, sha} -> {:blob, content, sha} end)
{pack_data, _pack_sha} = Writer.generate(writer_entries)
assert {:ok, entries} = Reader.parse(pack_data)
assert length(entries) == 5
parsed_contents = Enum.map(entries, & &1.data) |> Enum.sort()
expected_contents = Enum.map(objects, &elem(&1, 0)) |> Enum.sort()
assert parsed_contents == expected_contents
end
test "Reader.parse roundtrips all four object types" do
# Blob
blob_content = "blob content for cycle2 test\n"
blob = Blob.from_content(blob_content)
blob_sha = Object.hash(blob)
# Tree
tree = Tree.new([%{mode: "100644", name: "file.txt", sha: blob_sha}])
tree_content = Tree.encode_content(tree)
tree_sha = Object.hash(tree)
# Commit
commit = %Commit{
tree: tree_sha,
parents: [],
author: "Test User <test@example.com> 1700000000 +0000",
committer: "Test User <test@example.com> 1700000000 +0000",
message: "cycle2 test commit\n"
}
commit_content = Commit.encode_content(commit)
commit_sha = Object.hash(commit)
# Tag
tag = %Tag{
object: commit_sha,
type: "commit",
tag: "v1.0.0",
tagger: "Test User <test@example.com> 1700000000 +0000",
message: "cycle2 test tag\n"
}
tag_content = Tag.encode_content(tag)
tag_sha = Object.hash(tag)
writer_entries = [
{:blob, blob_content, blob_sha},
{:tree, tree_content, tree_sha},
{:commit, commit_content, commit_sha},
{:tag, tag_content, tag_sha}
]
{pack_data, _pack_sha} = Writer.generate(writer_entries)
assert {:ok, entries} = Reader.parse(pack_data)
assert length(entries) == 4
types = Enum.map(entries, & &1.type) |> Enum.sort()
assert types == [:blob, :commit, :tag, :tree]
# Verify each object's data matches
blob_entry = Enum.find(entries, &(&1.type == :blob))
assert blob_entry.data == blob_content
commit_entry = Enum.find(entries, &(&1.type == :commit))
assert commit_entry.data == commit_content
tag_entry = Enum.find(entries, &(&1.type == :tag))
assert tag_entry.data == tag_content
end
test "Reader.parse correctly decompresses data with find_compressed_length" do
# This specifically exercises the binary search in find_compressed_length
# which calls try_decompress_prefix repeatedly. We use content that produces
# a non-trivial compressed length to ensure the binary search converges.
content = :crypto.strong_rand_bytes(4096)
blob = Blob.from_content(content)
sha = Object.hash(blob)
{pack_data, _pack_sha} = Writer.generate([{:blob, content, sha}])
assert {:ok, entries} = Reader.parse(pack_data)
assert length(entries) == 1
assert hd(entries).data == content
end
end
describe "Fix 1: safeInflate decompression size limit enforcement" do
test "Reader.parse succeeds for data within 128MB limit" do
# Normal-sized data should parse fine
content = String.duplicate("x", 10_000)
blob = Blob.from_content(content)
sha = Object.hash(blob)
{pack_data, _pack_sha} = Writer.generate([{:blob, content, sha}])
assert {:ok, entries} = Reader.parse(pack_data)
assert hd(entries).data == content
end
test "crafted packfile with oversized decompressed data is rejected" do
# Build a packfile containing a blob whose decompressed size exceeds
# the @max_decompressed_size (128MB). We craft this by compressing
# highly compressible data that expands past the limit.
#
# 129MB of zeros compresses to a very small zlib stream but decompresses
# to well over 128MB.
oversize = 128 * 1024 * 1024 + 1
large_content = :binary.copy(<<0>>, oversize)
compressed = zlib_compress(large_content)
pack_data = build_pack_with_compressed_blob(compressed, oversize)
# The reader should reject this because decompressed size > 128MB
assert {:error, {:decompressed_too_large, _, _}} = Reader.parse(pack_data)
end
test "read_object rejects oversized decompressed data" do
oversize = 128 * 1024 * 1024 + 1
large_content = :binary.copy(<<0>>, oversize)
compressed = zlib_compress(large_content)
pack_data = build_pack_with_compressed_blob(compressed, oversize)
# read_object at offset 12 (after header) should also reject
assert {:error, {:decompressed_too_large, _, _}} = Reader.read_object(pack_data, 12)
end
end
# ============================================================================
# Fix 2: SHA cache passed through resolve_delta_entries
#
# During parse(), non-delta objects now have their SHAs computed and cached
# in a {:sha, sha} => offset map. This cache is passed to resolve_delta_entries
# which passes it to read_object calls, avoiding redundant build_sha_index
# calls for REF_DELTA resolution.
# ============================================================================
describe "Fix 2: SHA cache in resolve_delta_entries - delta resolution" do
test "Reader.parse resolves OFS_DELTA objects in real git packfiles" do
# Create a git repo with similar files to produce OFS_DELTA objects
tmp_dir = System.tmp_dir!()
dir = Path.join(tmp_dir, "cycle2_delta_test_#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(dir)
try do
git!(dir, ["init"])
git!(dir, ["config", "user.email", "test@test.com"])
git!(dir, ["config", "user.name", "Test"])
# Create initial file
base_content = String.duplicate("This is line number X of the base file.\n", 100)
File.write!(Path.join(dir, "file.txt"), base_content)
git!(dir, ["add", "."])
git!(dir, ["commit", "-m", "first"])
# Modify file slightly to create similar objects (delta candidates)
modified_content = base_content <> "Extra line appended.\n"
File.write!(Path.join(dir, "file.txt"), modified_content)
git!(dir, ["add", "."])
git!(dir, ["commit", "-m", "second"])
# gc to create a packfile with deltas
git!(dir, ["gc", "--aggressive"])
# Read pack file
pack_dir = Path.join([dir, ".git", "objects", "pack"])
{:ok, files} = File.ls(pack_dir)
pack_file = Enum.find(files, &String.ends_with?(&1, ".pack"))
pack_data = File.read!(Path.join(pack_dir, pack_file))
# Parse should succeed with all delta objects resolved
assert {:ok, entries} = Reader.parse(pack_data)
assert length(entries) > 0
# All entries should be fully resolved (no :ofs_delta or :ref_delta types)
for entry <- entries do
assert entry.type in [:blob, :tree, :commit],
"Expected resolved type, got #{entry.type}"
assert is_binary(entry.data)
assert byte_size(entry.data) > 0
end
after
File.rm_rf!(dir)
end
end
test "Reader.parse returns correct data for multi-object packs with SHA caching" do
# Create multiple distinct objects. The SHA cache built during parse_entries
# should contain all non-delta objects' SHAs mapped to their offsets.
objects =
for i <- 1..10 do
content = "Object content number #{i} - #{:crypto.strong_rand_bytes(16) |> Base.encode16()}\n"
blob = Blob.from_content(content)
sha = Object.hash(blob)
{content, sha}
end
writer_entries = Enum.map(objects, fn {content, sha} -> {:blob, content, sha} end)
{pack_data, _pack_sha} = Writer.generate(writer_entries)
assert {:ok, entries} = Reader.parse(pack_data)
assert length(entries) == 10
# Verify all objects are present with correct content
parsed_contents = Enum.map(entries, & &1.data) |> MapSet.new()
expected_contents = Enum.map(objects, &elem(&1, 0)) |> MapSet.new()
assert parsed_contents == expected_contents
end
test "build_sha_index matches SHA cache from parse for non-delta packs" do
# For a non-delta pack, the SHA cache built during parse_entries should
# contain the same SHA-to-offset mappings as build_sha_index.
content1 = "alpha content\n"
blob1 = Blob.from_content(content1)
sha1 = Object.hash(blob1)
content2 = "beta content\n"
blob2 = Blob.from_content(content2)
sha2 = Object.hash(blob2)
{pack_data, _} = Writer.generate([{:blob, content1, sha1}, {:blob, content2, sha2}])
# build_sha_index gives us SHA -> offset
assert {:ok, sha_index} = Reader.build_sha_index(pack_data)
# parse gives us fully resolved entries with correct offsets
assert {:ok, entries} = Reader.parse(pack_data)
# Verify each entry's computed SHA appears in the sha_index at the correct offset
for entry <- entries do
type_str = Atom.to_string(entry.type)
raw = "#{type_str} #{byte_size(entry.data)}\0" <> entry.data
computed_sha = :crypto.hash(:sha, raw) |> Base.encode16(case: :lower)
assert Map.has_key?(sha_index, computed_sha),
"SHA #{computed_sha} should be in the index"
assert sha_index[computed_sha] == entry.offset,
"Offset mismatch for SHA #{computed_sha}: index=#{sha_index[computed_sha]}, entry=#{entry.offset}"
end
end
test "Reader.parse with real git delta objects resolves correctly" do
# This test creates a repo with conditions that produce delta objects
# and verifies Reader.parse resolves them all correctly by comparing
# against git cat-file output.
tmp_dir = System.tmp_dir!()
dir = Path.join(tmp_dir, "cycle2_cache_test_#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(dir)
try do
git!(dir, ["init"])
git!(dir, ["config", "user.email", "test@test.com"])
git!(dir, ["config", "user.name", "Test"])
# Create a few files
for i <- 1..3 do
content = "File #{i}: " <> String.duplicate("content-", 50) <> "\n"
File.write!(Path.join(dir, "file#{i}.txt"), content)
end
git!(dir, ["add", "."])
git!(dir, ["commit", "-m", "initial"])
git!(dir, ["gc", "--aggressive"])
pack_dir = Path.join([dir, ".git", "objects", "pack"])
{:ok, files} = File.ls(pack_dir)
pack_file = Enum.find(files, &String.ends_with?(&1, ".pack"))
pack_data = File.read!(Path.join(pack_dir, pack_file))
assert {:ok, entries} = Reader.parse(pack_data)
# Verify blobs match git cat-file output
blob_entries = Enum.filter(entries, &(&1.type == :blob))
for blob_entry <- blob_entries do
# Compute SHA from the parsed data
raw = "blob #{byte_size(blob_entry.data)}\0" <> blob_entry.data
sha = :crypto.hash(:sha, raw) |> Base.encode16(case: :lower)
# Verify git recognizes this object
{git_content, 0} =
System.cmd("git", ["cat-file", "blob", sha],
cd: dir,
stderr_to_stdout: true
)
assert git_content == blob_entry.data,
"Content mismatch for blob #{sha}"
end
after
File.rm_rf!(dir)
end
end
end
# ============================================================================
# Fix 3: encode_raw_from_type validates type atoms
#
# encode_raw_from_type/2 now has a guard `when type in [:blob, :commit, :tree, :tag]`
# instead of just `is_atom(type)`.
# ============================================================================
describe "Fix 3: encode_raw_from_type type validation - valid types" do
test ":blob type produces correct raw encoding" do
content = "blob test content"
result = Object.encode_raw_from_type(:blob, content)
expected = "blob #{byte_size(content)}\0" <> content
assert result == expected
end
test ":commit type produces correct raw encoding" do
content = "tree 0000000000000000000000000000000000000000\nauthor Test <t@t> 0 +0000\ncommitter Test <t@t> 0 +0000\n\nmessage\n"
result = Object.encode_raw_from_type(:commit, content)
expected = "commit #{byte_size(content)}\0" <> content
assert result == expected
end
test ":tree type produces correct raw encoding" do
content = "100644 file.txt\0" <> :binary.copy(<<0>>, 20)
result = Object.encode_raw_from_type(:tree, content)
expected = "tree #{byte_size(content)}\0" <> content
assert result == expected
end
test ":tag type produces correct raw encoding" do
content = "object 0000000000000000000000000000000000000000\ntype commit\ntag v1\ntagger Test <t@t> 0 +0000\n\nmessage\n"
result = Object.encode_raw_from_type(:tag, content)
expected = "tag #{byte_size(content)}\0" <> content
assert result == expected
end
test "all valid types produce SHA-hashable raw format" do
for type <- [:blob, :commit, :tree, :tag] do
content = "test content for #{type}"
raw = Object.encode_raw_from_type(type, content)
# Verify the format is: "<type> <size>\0<content>"
type_str = Atom.to_string(type)
assert String.starts_with?(raw, "#{type_str} ")
assert :binary.match(raw, <<0>>) != :nomatch
# Verify SHA can be computed from the result
sha = :crypto.hash(:sha, raw) |> Base.encode16(case: :lower)
assert byte_size(sha) == 40
end
end
test "empty content is accepted for valid types" do
for type <- [:blob, :commit, :tree, :tag] do
result = Object.encode_raw_from_type(type, <<>>)
type_str = Atom.to_string(type)
assert result == "#{type_str} 0\0"
end
end
end
describe "Fix 3: encode_raw_from_type type validation - invalid types" do
test ":invalid atom raises FunctionClauseError" do
assert_raise FunctionClauseError, fn ->
Object.encode_raw_from_type(:invalid, "content")
end
end
test ":foo atom raises FunctionClauseError" do
assert_raise FunctionClauseError, fn ->
Object.encode_raw_from_type(:foo, "content")
end
end
test ":bar atom raises FunctionClauseError" do
assert_raise FunctionClauseError, fn ->
Object.encode_raw_from_type(:bar, "content")
end
end
test ":object atom raises FunctionClauseError" do
assert_raise FunctionClauseError, fn ->
Object.encode_raw_from_type(:object, "content")
end
end
test ":delta atom raises FunctionClauseError" do
assert_raise FunctionClauseError, fn ->
Object.encode_raw_from_type(:delta, "content")
end
end
test ":ofs_delta atom raises FunctionClauseError" do
# This is particularly important: delta types should not be accepted
assert_raise FunctionClauseError, fn ->
Object.encode_raw_from_type(:ofs_delta, "content")
end
end
test ":ref_delta atom raises FunctionClauseError" do
assert_raise FunctionClauseError, fn ->
Object.encode_raw_from_type(:ref_delta, "content")
end
end
test "non-atom type raises FunctionClauseError" do
assert_raise FunctionClauseError, fn ->
Object.encode_raw_from_type("blob", "content")
end
end
test "non-binary content raises FunctionClauseError" do
assert_raise FunctionClauseError, fn ->
Object.encode_raw_from_type(:blob, 12345)
end
end
end
# ============================================================================
# Helpers
# ============================================================================
defp zlib_compress(data) do
z = :zlib.open()
try do
:zlib.deflateInit(z)
compressed = :zlib.deflate(z, data, :finish)
:zlib.deflateEnd(z)
IO.iodata_to_binary(compressed)
after
:zlib.close(z)
end
end
defp build_pack_with_compressed_blob(compressed_blob, uncompressed_size) do
# Pack header: "PACK" + version 2 + count 1
header = <<"PACK", 2::unsigned-big-32, 1::unsigned-big-32>>
# Object header for blob (type 3) with the given uncompressed size
obj_header = encode_pack_object_header(3, uncompressed_size)
body = header <> obj_header <> compressed_blob
# Compute trailing SHA-1 checksum
checksum = :crypto.hash(:sha, body)
body <> checksum
end
defp encode_pack_object_header(type, size) when type in 1..7 do
low_nibble = Bitwise.band(size, 0x0F)
remaining = Bitwise.bsr(size, 4)
if remaining == 0 do
<<Bitwise.bor(Bitwise.bsl(type, 4), low_nibble)>>
else
first = 0x80 |> Bitwise.bor(Bitwise.bsl(type, 4)) |> Bitwise.bor(low_nibble)
<<first>> <> encode_size_continuation(remaining)
end
end
defp encode_size_continuation(0), do: <<>>
defp encode_size_continuation(size) do
low_7 = Bitwise.band(size, 0x7F)
remaining = Bitwise.bsr(size, 7)
if remaining == 0 do
<<low_7>>
else
<<Bitwise.bor(0x80, low_7)>> <> encode_size_continuation(remaining)
end
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