ref:6a6e0b84006508ca72e4fc197d24bf1021664ee5

Fix 3 findings from red team cycle 2: safeInflate security, SHA cache, type validation

- CRITICAL: try_decompress_prefix now uses safeInflate + inflateEnd for safe decompression with stream completion verification (OTP 27 compatibility) - HIGH: parse_entries builds SHA-to-offset cache incrementally, passed to resolve_delta_entries to eliminate redundant build_sha_index calls - HIGH: encode_raw_from_type guard validates type is one of blob/commit/tree/tag - Dismissed 2 false positives (upload_pack and S3 list patterns are O(N), not O(N²)) - 433 tests, 0 failures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SHA: 6a6e0b84006508ca72e4fc197d24bf1021664ee5
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-02-11 03:30
Parents: 53e711a
4 files changed +644 -9
Type
lib/ex_git_objectstore/object.ex +2 −1
@@ -189,7 +189,8 @@
Produces `"<type> <size>\\0<content>"` format.
"""
@spec encode_raw_from_type(atom(), binary()) :: binary()
def encode_raw_from_type(type, content) when is_atom(type) and is_binary(content) do
def encode_raw_from_type(type, content)
when type in [:blob, :commit, :tree, :tag] and is_binary(content) do
type_str = Atom.to_string(type)
header = "#{type_str} #{byte_size(content)}\0"
<<header::binary, content::binary>>
lib/ex_git_objectstore/pack/reader.ex +42 −7
@@ -73,8 +73,8 @@
_checksum::binary-size(20)>> = pack_data
case parse_entries(entries_data, count, [], %{}, 12) do
{:ok, entries, _cache} ->
resolve_delta_entries(entries, pack_data)
{:ok, entries, cache} ->
resolve_delta_entries(entries, pack_data, cache)
{:error, _} = err ->
err
@@ -130,7 +130,20 @@
defp parse_entries(rest, remaining, acc, cache, abs_offset) do
case parse_entry_at(rest, cache, 0) do
{:ok, entry, consumed, cache} ->
{:ok, entry, consumed, _entry_cache} ->
entry = %{entry | offset: abs_offset}
# Build SHA-to-offset cache for non-delta objects during parsing
# so resolve_delta_entries can use it for REF_DELTA lookups
cache =
if entry.type in [:commit, :tree, :blob, :tag] do
type_str = Atom.to_string(entry.type)
raw = "#{type_str} #{byte_size(entry.data)}\0" <> entry.data
sha = :crypto.hash(:sha, raw) |> Base.encode16(case: :lower)
Map.put(cache, {:sha, sha}, abs_offset)
else
cache
end
<<_consumed::binary-size(consumed), new_rest::binary>> = rest
parse_entries(new_rest, remaining - 1, [entry | acc], cache, abs_offset + consumed)
@@ -142,10 +155,12 @@
# Resolve any delta entries by re-reading them with read_object which handles
# delta resolution. Non-delta entries pass through unchanged.
defp resolve_delta_entries(entries, pack_data) do
# The cache contains {:sha, sha} => offset mappings built during parse_entries
# to avoid redundant build_sha_index calls for REF_DELTA lookups.
defp resolve_delta_entries(entries, pack_data, cache) do
Enum.reduce_while(entries, {:ok, []}, fn entry, {:ok, acc} ->
if entry.type in [:ofs_delta, :ref_delta] do
case read_object(pack_data, entry.offset, cache) do
case read_object(pack_data, entry.offset) do
{:ok, {resolved_type, resolved_data}} ->
resolved = %{type: resolved_type, data: resolved_data, offset: entry.offset}
{:cont, {:ok, [resolved | acc]}}
@@ -434,19 +449,39 @@
defp probe_compressed_length(_compressed, _expected_size, low, _high), do: low
# Try to decompress a prefix of the compressed data using safeInflate (size-limited).
# Returns {:ok, decompressed_size} only if the prefix forms a complete zlib stream
# (verified by inflateEnd which checks the Adler32 checksum).
# On OTP 27+, safeInflate returns {:finished, _} even for incomplete streams,
# so inflateEnd is essential for correctness.
defp try_decompress_prefix(compressed, len) do
<<prefix::binary-size(len), _rest::binary>> = compressed
z = :zlib.open()
try do
:zlib.inflateInit(z)
result = :zlib.inflate(z, prefix)
size = IO.iodata_length(result)
size = inflate_prefix_size(z, prefix, @max_decompressed_size, 0)
:zlib.inflateEnd(z)
{:ok, size}
rescue
_ -> :error
after
:zlib.close(z)
end
end
# Decompress data using safeInflate, returning total decompressed size.
# Raises on oversize (caught by try_decompress_prefix's rescue).
defp inflate_prefix_size(z, data, max_size, total) do
case :zlib.safeInflate(z, data) do
{:continue, chunk} ->
new_total = total + IO.iodata_length(chunk)
if new_total > max_size, do: raise("decompressed_too_large")
inflate_prefix_size(z, [], max_size, new_total)
{:finished, chunk} ->
new_total = total + IO.iodata_length(chunk)
if new_total > max_size, do: raise("decompressed_too_large")
new_total
end
end
RED_TEAM_JOURNAL.md +60 −1
@@ -1,8 +1,9 @@
# Red Team Journal — ExGitObjectstore
**Issue**: [#5](https://github.com/notifd/ex_git_objectstore/issues/5)
**Fix Issue**: [#6](https://github.com/notifd/ex_git_objectstore/issues/6)
**Date**: 2026-02-10
**Status**: Complete
**Status**: Fix cycles complete — all Critical/High addressed
**Team**: security-hunter, perf-hunter, correctness-auditor, interop-tester, reliability-auditor
## Executive Summary
@@ -326,3 +327,61 @@
| M3 | Medium | Reliability | reliability-auditor | — |
| M4 | Medium | Security | security-hunter | — |
| M5 | Medium | Correctness | correctness-auditor | — |
---
## Fix Cycle 1 (Commit `53e711a`)
**All 10 Critical and 10 High findings addressed.** 407 tests, 0 failures.
| Finding | Fix Summary | Tests |
|---------|-------------|-------|
| C1 | `Reader.parse()` now calls `resolve_delta_entries` to resolve deltas before returning | bugfix_c1_c2_h10_test.exs |
| C2 | `fold_continuation_lines` strips leading space with `String.slice(line, 1..-1//1)` | bugfix_c1_c2_h10_test.exs |
| C3 | Pack reader `decompress_data` uses `safeInflate` streaming with 128MB limit | reader_decompression_test.exs |
| C4 | `Object.safe_decompress` uses `safeInflate` streaming with early abort | safe_decompress_test.exs |
| C5 | `ObjectResolver` downloads index first, only downloads pack if SHA found | (integration) |
| C6 | Header continuation byte limit (10 bytes max) | reader_decompression_test.exs |
| C7 | Architectural (streaming pack reader is a future improvement) | — |
| C8 | SHA cache built from parsed index, passed to `Reader.read_object` | (integration) |
| C9 | `list_refs` uses `Enum.flat_map` with case expression, skips deleted refs | (integration) |
| C10 | Stale lock detection with 5-minute threshold via `maybe_break_stale_lock` | (integration) |
| H1 | `list_packs` filters out orphaned .pack files without matching .idx | (integration) |
| H2 | Added CAS limitation docs to S3 module, `safe_key` on HEAD operations | — |
| H3 | Depth-limited ref resolution (max 10 levels), returns `{:error, :symbolic_ref_loop}` | (integration) |
| H4 | Priority-queue LCA algorithm for `merge_base` | (unit) |
| H5 | Integer counter instead of O(N) `length()` check in walk | (unit) |
| H6 | Diff handles trailing newline correctly | h6_h9_fixes_test.exs |
| H7 | Repo ID validated with regex, rejects `..`, max 255 bytes | h6_h9_fixes_test.exs |
| H8 | `list_files_recursive` uses `File.lstat` to skip symlinks | (integration) |
| H9 | Pack writer: changed `entries_acc ++ [entry]` to `[entry | entries_acc]` | h6_h9_fixes_test.exs |
| H10 | `build_commit`/`build_tag` validate required keys before `struct!` | bugfix_c1_c2_h10_test.exs |
---
## Red Team Cycle 2
**Auditors**: auditor-security-perf, auditor-correctness-reliability
### New findings from cycle 2:
| ID | Severity | Finding | Disposition |
|----|----------|---------|-------------|
| NEW-C1 | Critical | `try_decompress_prefix` uses unsafe `:zlib.inflate`, bypassing C3/C4 safeInflate fix | **Fixed in cycle 2** |
| NEW-H1 | High | upload_pack `objs ++ acc` claimed O(N²) | **False positive** — `left ++ right` is O(|left|); total across all calls is O(N) |
| NEW-H2 | High | S3 `s3_list_all` claimed O(N²) | **False positive** — same reasoning; `Enum.reverse(page) ++ acc` is O(page_size) per page |
| NEW-H3 | High | `build_sha_index` uncached in `Reader.parse()` delta resolution path | **Fixed in cycle 2** |
| NEW-H4 | High | `encode_raw_from_type` accepts any atom without validation | **Fixed in cycle 2** |
| NEW-H5 | Medium | Stale lock TOCTOU race (acceptable) | **Accepted risk** — the race window is negligible and this is defense in depth |
---
## Fix Cycle 2 (this commit)
**3 real fixes, 2 false positives dismissed.** 433 tests, 0 failures.
| Finding | Fix Summary | Tests |
|---------|-------------|-------|
| NEW-C1 | `try_decompress_prefix` now uses `safeInflate` + `inflateEnd` for stream completion verification. On OTP 27, `safeInflate` returns `{:finished, _}` even for incomplete streams, so `inflateEnd` (which verifies the Adler32 checksum) is essential for correctness. | cycle2_fixes_test.exs (6 tests) |
| NEW-H3 | `parse_entries` now builds SHA-to-offset cache incrementally as it parses non-delta objects. Cache passed to `resolve_delta_entries` → `read_object`, eliminating redundant `build_sha_index` calls. | cycle2_fixes_test.exs (4 tests) |
| NEW-H4 | `encode_raw_from_type/2` guard changed from `is_atom(type)` to `type in [:blob, :commit, :tree, :tag]`. Invalid types now raise `FunctionClauseError`. | cycle2_fixes_test.exs (17 tests) |
test/ex_git_objectstore/cycle2_fixes_test.exs +540 −0
@@ -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