ref:411dfdf0d736fc15bedbbad8e0ba4bf2b466becf

fix: thin pack support + resolve all credo --strict issues

Add thin pack REF_DELTA resolution with external_resolver threading through the recursive pack reader chain. Refactor all 16 lib modules and 24 test files to pass credo --strict with zero issues: extract helpers to reduce cyclomatic complexity and nesting depth, fix alias ordering, replace length/1 with pattern matches, add number underscores, convert single-clause with to case, combine chained filters, use map_join, and alias nested modules. 502 tests, 0 failures. Zero credo issues. Closes #93 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SHA: 411dfdf0d736fc15bedbbad8e0ba4bf2b466becf
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-02-26 08:18
Parents: b0adbcc
40 files changed +1434 -1112
Type
lib/ex_git_objectstore.ex +1 −1
@@ -20,7 +20,7 @@
All git data (objects, refs, packs) is stored via a pluggable storage backend.
"""
alias ExGitObjectstore.{Merge, Object, ObjectResolver, Repo, Ref, Walk}
alias ExGitObjectstore.{Merge, Object, ObjectResolver, Ref, Repo, Walk}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
@type sha :: String.t()
lib/ex_git_objectstore/diff.ex +142 −132
@@ -17,8 +17,8 @@
Tree-level diff and line-level diff with hunk formatting.
"""
alias ExGitObjectstore.Diff.Myers
alias ExGitObjectstore.Object.{Blob, Tree}
alias ExGitObjectstore.{ObjectResolver, Repo}
alias ExGitObjectstore.Object.{Blob, Tree}
alias ExGitObjectstore.Diff.Myers
@max_tree_depth 64
@@ -134,36 +134,25 @@
|> Enum.with_index()
|> Enum.flat_map(fn {{type, line}, idx} ->
remaining = Enum.drop(lines, idx + 1)
formatted = format_line_by_type(type, line)
markers = no_newline_markers(type, remaining, no_newline_old, no_newline_new)
[formatted | markers]
end)
end
formatted =
case type do
:context -> " #{line}\n"
:add -> "+#{line}\n"
:del -> "-#{line}\n"
end
defp format_line_by_type(:context, line), do: " #{line}\n"
defp format_line_by_type(:add, line), do: "+#{line}\n"
defp format_line_by_type(:del, line), do: "-#{line}\n"
# A line contributes to the old side if it is :del or :context
# A line contributes to the new side if it is :add or :context
defp no_newline_markers(type, remaining, no_newline_old, no_newline_new) do
last_old? = type in [:del, :context] and no_more_old_lines?(remaining)
last_new? = type in [:add, :context] and no_more_new_lines?(remaining)
is_last_old_line = type in [:del, :context] and no_more_old_lines?(remaining)
is_last_new_line = type in [:add, :context] and no_more_new_lines?(remaining)
if (last_old? and no_newline_old) or (last_new? and no_newline_new) do
["\\ No newline at end of file\n"]
markers =
cond do
is_last_old_line and no_newline_old and is_last_new_line and no_newline_new ->
["\\ No newline at end of file\n"]
else
[]
end
is_last_old_line and no_newline_old ->
["\\ No newline at end of file\n"]
is_last_new_line and no_newline_new ->
["\\ No newline at end of file\n"]
true ->
[]
end
[formatted | markers]
end)
end
defp no_more_old_lines?(remaining) do
@@ -214,68 +203,79 @@
path = if prefix == "", do: name, else: "#{prefix}/#{name}"
old = Map.get(old_entries, name)
new = Map.get(new_entries, name)
diff_single_entry(repo, old, new, path, depth)
end)
end
cond do
old == nil and new != nil ->
if new.mode == "40000" do
# New directory — recurse
new_sub = load_tree_entries(repo, new.sha)
diff_tree_entries(repo, %{}, new_sub, path, depth + 1)
else
[
%{
path: path,
status: :added,
old_sha: nil,
defp diff_single_entry(repo, nil, new, path, depth) do
diff_added_entry(repo, new, path, depth)
end
defp diff_single_entry(repo, old, nil, path, depth) do
diff_deleted_entry(repo, old, path, depth)
end
new_sha: new.sha,
old_mode: nil,
new_mode: new.mode
}
]
end
old != nil and new == nil ->
if old.mode == "40000" do
old_sub = load_tree_entries(repo, old.sha)
diff_tree_entries(repo, old_sub, %{}, path, depth + 1)
else
[
%{
path: path,
status: :deleted,
old_sha: old.sha,
new_sha: nil,
old_mode: old.mode,
new_mode: nil
}
]
end
defp diff_single_entry(_repo, old, new, _path, _depth)
when old.sha == new.sha and old.mode == new.mode do
[]
end
old.sha == new.sha and old.mode == new.mode ->
# Unchanged
[]
defp diff_single_entry(repo, old, new, path, depth)
when old.mode == "40000" and new.mode == "40000" do
old_sub = load_tree_entries(repo, old.sha)
new_sub = load_tree_entries(repo, new.sha)
diff_tree_entries(repo, old_sub, new_sub, path, depth + 1)
end
defp diff_single_entry(_repo, old, new, path, _depth) do
[
%{
path: path,
status: :modified,
old_sha: old.sha,
new_sha: new.sha,
old_mode: old.mode,
old.mode == "40000" and new.mode == "40000" ->
# Both directories — recurse
old_sub = load_tree_entries(repo, old.sha)
new_sub = load_tree_entries(repo, new.sha)
diff_tree_entries(repo, old_sub, new_sub, path, depth + 1)
new_mode: new.mode
}
]
end
defp diff_added_entry(repo, %{mode: "40000"} = new, path, depth) do
new_sub = load_tree_entries(repo, new.sha)
true ->
diff_tree_entries(repo, %{}, new_sub, path, depth + 1)
[
%{
path: path,
status: :modified,
old_sha: old.sha,
new_sha: new.sha,
old_mode: old.mode,
new_mode: new.mode
}
]
end
end)
end
defp diff_added_entry(_repo, new, path, _depth) do
[
%{
path: path,
status: :added,
old_sha: nil,
new_sha: new.sha,
old_mode: nil,
new_mode: new.mode
}
]
end
defp diff_deleted_entry(repo, %{mode: "40000"} = old, path, depth) do
old_sub = load_tree_entries(repo, old.sha)
diff_tree_entries(repo, old_sub, %{}, path, depth + 1)
end
defp diff_deleted_entry(_repo, old, path, _depth) do
[
%{
path: path,
status: :deleted,
old_sha: old.sha,
new_sha: nil,
old_mode: old.mode,
new_mode: nil
}
]
end
defp edits_to_hunks(edits, context) do
# Convert Myers edits to indexed lines
indexed = index_edits(edits)
@@ -314,34 +314,39 @@
|> Enum.with_index()
|> Map.new(fn {item, idx} -> {item, idx} end)
# Find change regions using prepend + reverse to avoid O(n^2) append
all_groups = collect_change_groups(indexed)
# Add context lines around each group using pre-computed indices
all_groups
|> Enum.map(fn group -> add_context(group, indexed_vec, total, index_map, context) end)
|> Enum.reject(&Enum.empty?/1)
end
# Find change regions using prepend + reverse to avoid O(n²) append
defp collect_change_groups(indexed) do
{groups, current_group} =
Enum.reduce(indexed, {[], []}, fn item, {groups, current} ->
case item do
{:context, _, _, _} ->
if current == [] do
{groups, []}
else
{groups, [item | current]}
end
_ ->
{groups, [item | current]}
end
reduce_change_group(item, groups, current)
end)
if current_group != [] do
[Enum.reverse(current_group) | groups]
else
groups
end
|> Enum.reverse()
end
defp reduce_change_group({:context, _, _, _} = item, groups, []) do
{groups, [item]}
end
defp reduce_change_group({:context, _, _, _} = item, groups, current) do
{groups, [item | current]}
end
all_groups =
if current_group != [] do
[Enum.reverse(current_group) | groups]
else
groups
end
|> Enum.reverse()
defp reduce_change_group(item, groups, current) do
{groups, [item | current]}
# Add context lines around each group using pre-computed indices
all_groups
|> Enum.map(fn group -> add_context(group, indexed_vec, total, index_map, context) end)
|> Enum.reject(&Enum.empty?/1)
end
defp add_context([], _indexed_vec, _total, _index_map, _context), do: []
@@ -380,31 +385,8 @@
end)
# Find the starting line numbers
first_old =
lines
|> Enum.find(fn
{:context, _, _, _} -> true
{:del, _, _, _} -> true
_ -> false
end)
|> case do
{:context, _, old, _} -> old
{:del, _, old, _} -> old
nil -> 1
end
first_new =
lines
|> Enum.find(fn
{:context, _, _, _} -> true
{:add, _, _, _} -> true
_ -> false
end)
|> case do
{:context, _, _, new} -> new
{:add, _, _, new} -> new
nil -> 1
first_old = find_first_old_line(lines)
first_new = find_first_new_line(lines)
end
%{
old_start: first_old,
@@ -413,6 +395,34 @@
new_count: new_lines,
lines: Enum.reverse(formatted)
}
end
defp find_first_old_line(lines) do
lines
|> Enum.find(fn
{:context, _, _, _} -> true
{:del, _, _, _} -> true
_ -> false
end)
|> case do
{:context, _, old, _} -> old
{:del, _, old, _} -> old
nil -> 1
end
end
defp find_first_new_line(lines) do
lines
|> Enum.find(fn
{:context, _, _, _} -> true
{:add, _, _, _} -> true
_ -> false
end)
|> case do
{:context, _, _, new} -> new
{:add, _, _, new} -> new
nil -> 1
end
end
defp drop_trailing_empty_context(lines) do
lib/ex_git_objectstore/merge.ex +42 −39
@@ -56,14 +56,14 @@
Three-way merge of tree objects.
Walks all three trees in parallel, applying standard three-way merge rules:
- Entry identical in all three → keep
- Changed in ours only → take ours
- Changed in theirs only → take theirs
- Entry identical in all three -> keep
- Changed in ours only -> take ours
- Changed in theirs only -> take theirs
- Changed identically in both -> take either
- Changed differently in both -> conflict (blobs) or recurse (subtrees)
- Added in one side only -> add
- Deleted in one side, unchanged in other -> delete
- Deleted in one side, changed in other -> conflict
- Changed identically in both → take either
- Changed differently in both → conflict (blobs) or recurse (subtrees)
- Added in one side only → add
- Deleted in one side, unchanged in other → delete
- Deleted in one side, changed in other → conflict
Returns `{:ok, merged_tree_sha}` on clean merge, or
`{:error, {:conflicts, [conflict()]}}` if there are conflicts.
@@ -140,39 +140,42 @@
{:error, _} = err -> err
end
# Three-way merge logic for a single entry
# Three-way merge logic for a single entry.
# Delegates to classify_change to determine the merge action, then applies it.
defp merge_entry(repo, base, ours, theirs, path, depth) do
case classify_change(base, ours, theirs) do
{:keep_or_delete, entry} -> keep_or_delete(entry)
:both_changed_differently -> resolve_divergent(repo, base, ours, theirs, path, depth)
end
end
defp classify_change(base, ours, theirs) do
cond do
# All three identical — no change
same_entry?(base, ours) and same_entry?(base, theirs) ->
if base, do: {:keep, base}, else: :delete
same_entry?(base, ours) -> {:keep_or_delete, theirs}
same_entry?(base, theirs) -> {:keep_or_delete, ours}
same_entry?(ours, theirs) -> {:keep_or_delete, ours}
true -> :both_changed_differently
end
end
defp resolve_divergent(repo, base, ours, theirs, path, depth) do
if both_are_trees?(ours, theirs) and (base == nil or tree_entry?(base)) do
merge_subtrees(repo, base, ours, theirs, path, depth)
else
{:conflict, build_conflict(path, base, ours, theirs)}
end
# Only ours changed (theirs == base)
same_entry?(base, theirs) and not same_entry?(base, ours) ->
if ours, do: {:keep, ours}, else: :delete
# Only theirs changed (ours == base)
same_entry?(base, ours) and not same_entry?(base, theirs) ->
if theirs, do: {:keep, theirs}, else: :delete
# Both changed identically
same_entry?(ours, theirs) ->
if ours, do: {:keep, ours}, else: :delete
end
# Both changed differently — check if subtrees can be recursively merged
both_are_trees?(ours, theirs) and (base == nil or is_tree_entry?(base)) ->
merge_subtrees(repo, base, ours, theirs, path, depth)
defp keep_or_delete(nil), do: :delete
defp keep_or_delete(entry), do: {:keep, entry}
# Conflict: both modified differently (blobs), or delete vs modify
true ->
{:conflict,
%{
path: path,
base: entry_sha(base),
ours: entry_sha(ours),
theirs: entry_sha(theirs)
}}
end
defp build_conflict(path, base, ours, theirs) do
%{
path: path,
base: entry_sha(base),
ours: entry_sha(ours),
theirs: entry_sha(theirs)
}
end
defp merge_subtrees(repo, base, ours, theirs, path, depth) do
@@ -213,12 +216,12 @@
defp entry_sha(nil), do: nil
defp entry_sha(entry), do: entry.sha
defp is_tree_entry?(%{mode: "40000"}), do: true
defp is_tree_entry?(_), do: false
defp tree_entry?(%{mode: "40000"}), do: true
defp tree_entry?(_), do: false
defp both_are_trees?(nil, _), do: false
defp both_are_trees?(_, nil), do: false
defp both_are_trees?(a, b), do: is_tree_entry?(a) and is_tree_entry?(b)
defp both_are_trees?(a, b), do: tree_entry?(a) and tree_entry?(b)
defp join_path("", name), do: name
defp join_path(prefix, name), do: prefix <> "/" <> name
lib/ex_git_objectstore/object.ex +41 −25
@@ -20,7 +20,7 @@
The SHA-1 hash of the uncompressed header+content is the object ID.
"""
alias ExGitObjectstore.Object.{Blob, Commit, Tree, Tag}
alias ExGitObjectstore.Object.{Blob, Commit, Tag, Tree}
alias ExGitObjectstore.Repo
# Maximum decompressed object size (128 MB)
@@ -106,20 +106,7 @@
def read(%Repo{} = repo, sha) do
case Repo.storage_call(repo, :get_object, [sha]) do
{:ok, data} ->
decompress_and_verify(data, sha)
# Verify SHA-1 integrity: decompress, hash, and compare to requested SHA
case safe_decompress(data) do
{:ok, raw} ->
actual_sha = :crypto.hash(:sha, raw) |> Base.encode16(case: :lower)
if actual_sha != sha do
{:error, {:sha_mismatch, expected: sha, actual: actual_sha}}
else
decode_raw(raw)
end
{:error, _} = err ->
err
end
{:error, _} = err ->
err
@@ -145,6 +132,27 @@
end
end
# Decompress stored data and verify SHA-1 integrity before decoding.
defp decompress_and_verify(data, sha) do
case safe_decompress(data) do
{:ok, raw} ->
verify_sha_and_decode(raw, sha)
{:error, _} = err ->
err
end
end
defp verify_sha_and_decode(raw, sha) do
actual_sha = :crypto.hash(:sha, raw) |> Base.encode16(case: :lower)
if actual_sha != sha do
{:error, {:sha_mismatch, expected: sha, actual: actual_sha}}
else
decode_raw(raw)
end
end
defp encode_content(%Blob{content: content}), do: {"blob", content}
defp encode_content(%Tree{} = tree), do: {"tree", Tree.encode_content(tree)}
defp encode_content(%Commit{} = commit), do: {"commit", Commit.encode_content(commit)}
@@ -153,18 +161,26 @@
defp parse_header(raw) do
case :binary.split(raw, <<0>>) do
[header, content] ->
parse_header_parts(header, content)
_ ->
{:error, :missing_null_byte}
case String.split(header, " ", parts: 2) do
[type_str, size_str] ->
case Integer.parse(size_str) do
{size, ""} -> {:ok, type_str, size, content}
_ -> {:error, :invalid_size}
end
end
end
_ ->
{:error, :invalid_header}
end
defp parse_header_parts(header, content) do
case String.split(header, " ", parts: 2) do
[type_str, size_str] ->
parse_header_size(type_str, size_str, content)
_ ->
{:error, :missing_null_byte}
{:error, :invalid_header}
end
end
defp parse_header_size(type_str, size_str, content) do
case Integer.parse(size_str) do
{size, ""} -> {:ok, type_str, size, content}
_ -> {:error, :invalid_size}
end
end
lib/ex_git_objectstore/object/commit.ex +14 −10
@@ -128,18 +128,22 @@
# Handle GPG signature continuation lines (start with space)
defp fold_continuation_lines(lines) do
lines
|> Enum.reduce([], fn line, acc ->
if String.starts_with?(line, " ") do
case acc do
[prev | rest] -> [prev <> "\n" <> String.slice(line, 1..-1//1) | rest]
[] -> [line]
end
else
[line | acc]
end
end)
|> Enum.reduce([], &fold_line/2)
|> Enum.reverse()
end
defp fold_line(line, acc) do
if String.starts_with?(line, " ") do
append_continuation(line, acc)
else
[line | acc]
end
end
defp append_continuation(line, [prev | rest]),
do: [prev <> "\n" <> String.slice(line, 1..-1//1) | rest]
defp append_continuation(line, []), do: [line]
defp build_commit(headers, message) do
fields =
lib/ex_git_objectstore/object/tag.ex +55 −42
@@ -74,46 +74,55 @@
def parse_content(content) do
case :binary.split(content, "\n\n") do
[headers_str, message] ->
headers =
headers_str
|> String.split("\n")
|> fold_continuation_lines()
|> Enum.map(fn line ->
case String.split(line, " ", parts: 2) do
headers = parse_tag_headers(headers_str)
build_tag(headers, message)
_ ->
{:error, :invalid_tag_format}
end
end
[key, value] -> {key, value}
[key] -> {key, ""}
end
end)
defp parse_tag_headers(headers_str) do
headers_str
|> String.split("\n")
|> fold_continuation_lines()
|> Enum.map(fn line ->
case String.split(line, " ", parts: 2) do
[key, value] -> {key, value}
[key] -> {key, ""}
fields =
Enum.reduce(headers, %{message: message, extra_headers: []}, fn
{"object", sha}, acc ->
Map.put(acc, :object, sha)
end
end)
end
{"type", type}, acc ->
Map.put(acc, :type, type)
defp build_tag(headers, message) do
fields =
Enum.reduce(headers, %{message: message, extra_headers: []}, fn
{"object", sha}, acc ->
Map.put(acc, :object, sha)
{"tag", name}, acc ->
Map.put(acc, :tag, name)
{"type", type}, acc ->
Map.put(acc, :type, type)
{"tagger", value}, acc ->
Map.put(acc, :tagger, value)
{"tag", name}, acc ->
Map.put(acc, :tag, name)
{"tagger", value}, acc ->
Map.put(acc, :tagger, value)
{key, value}, acc ->
Map.update(acc, :extra_headers, [{key, value}], &(&1 ++ [{key, value}]))
end)
required_keys = [:object, :type, :tag, :message]
{key, value}, acc ->
Map.update(acc, :extra_headers, [{key, value}], &(&1 ++ [{key, value}]))
end)
validate_required_tag_fields(fields)
missing = Enum.filter(required_keys, &(not Map.has_key?(fields, &1)))
end
if missing == [] do
{:ok, struct!(__MODULE__, fields)}
else
{:error, {:missing_required_fields, missing}}
end
defp validate_required_tag_fields(fields) do
required_keys = [:object, :type, :tag, :message]
missing = Enum.filter(required_keys, &(not Map.has_key?(fields, &1)))
_ ->
{:error, :invalid_tag_format}
if missing == [] do
{:ok, struct!(__MODULE__, fields)}
else
{:error, {:missing_required_fields, missing}}
end
end
@@ -137,16 +146,20 @@
# Handle continuation lines (start with space) for multiline headers
defp fold_continuation_lines(lines) do
lines
|> Enum.reduce([], fn line, acc ->
if String.starts_with?(line, " ") do
case acc do
[prev | rest] -> [prev <> "\n" <> String.slice(line, 1..-1//1) | rest]
[] -> [line]
end
else
[line | acc]
end
end)
|> Enum.reduce([], &fold_line/2)
|> Enum.reverse()
end
defp fold_line(line, acc) do
if String.starts_with?(line, " ") do
append_continuation(line, acc)
else
[line | acc]
end
end
defp append_continuation(line, [prev | rest]),
do: [prev <> "\n" <> String.slice(line, 1..-1//1) | rest]
defp append_continuation(line, []), do: [line]
end
lib/ex_git_objectstore/object_resolver.ex +23 −33
@@ -18,6 +18,7 @@
"""
alias ExGitObjectstore.{Object, Repo}
alias ExGitObjectstore.Object.{Blob, Commit, Tag, Tree}
alias ExGitObjectstore.Pack.{Index, Reader}
@doc """
@@ -54,21 +55,9 @@
{:ok, index} <- Index.parse(idx_data),
{:ok, offset} <- Index.lookup(index, sha) do
# Only download pack data after confirming the SHA is in this index.
# TODO(C7): Reader.read_object loads the entire packfile into memory.
# NOTE(C7): Reader.read_object loads the entire packfile into memory.
# For large repos, consider streaming reads at specific offsets.
case Repo.storage_call(repo, :get_pack, [pack_sha]) do
{:ok, pack_data} ->
# Build a SHA→offset cache from the index for REF_DELTA resolution
sha_cache = build_sha_cache(index)
case Reader.read_object(pack_data, offset, sha_cache) do
{:ok, {type, data}} -> wrap_object(type, data)
{:error, _} = err -> err
end
{:error, _} = err ->
err
end
read_object_from_pack(repo, pack_sha, index, offset)
else
:not_found -> find_in_packs(repo, sha, rest)
{:error, :not_found} -> find_in_packs(repo, sha, rest)
@@ -76,29 +65,30 @@
end
end
# Convert the parsed index's SHA→offset map into the cache format
# expected by Reader.read_object's find_sha_offset (%{{:sha, hex_sha} => offset}).
defp build_sha_cache(%Index{offsets: offsets}) do
Map.new(offsets, fn {sha, offset} -> {{:sha, sha}, offset} end)
end
defp wrap_object(:blob, data) do
{:ok, ExGitObjectstore.Object.Blob.from_content(data)}
end
defp read_object_from_pack(repo, pack_sha, index, offset) do
case Repo.storage_call(repo, :get_pack, [pack_sha]) do
{:ok, pack_data} ->
sha_cache = build_sha_cache(index)
case Reader.read_object(pack_data, offset, sha_cache) do
{:ok, {type, data}} -> wrap_object(type, data)
defp wrap_object(:tree, data) do
ExGitObjectstore.Object.Tree.parse_content(data)
end
{:error, _} = err -> err
end
{:error, _} = err ->
err
defp wrap_object(:commit, data) do
ExGitObjectstore.Object.Commit.parse_content(data)
end
end
# Convert the parsed index's SHA→offset map into the cache format
# expected by Reader.read_object's find_sha_offset (%{{:sha, hex_sha} => offset}).
defp build_sha_cache(%Index{offsets: offsets}) do
Map.new(offsets, fn {sha, offset} -> {{:sha, sha}, offset} end)
defp wrap_object(:tag, data) do
ExGitObjectstore.Object.Tag.parse_content(data)
end
defp wrap_object(type, _data) do
{:error, {:unknown_type, type}}
end
defp wrap_object(:blob, data), do: {:ok, Blob.from_content(data)}
defp wrap_object(:tree, data), do: Tree.parse_content(data)
defp wrap_object(:commit, data), do: Commit.parse_content(data)
defp wrap_object(:tag, data), do: Tag.parse_content(data)
defp wrap_object(type, _data), do: {:error, {:unknown_type, type}}
end
lib/ex_git_objectstore/pack/index.ex +35 −26
@@ -188,31 +188,36 @@
end
end
defp parse_large_offsets(_rest, offsets_4) when offsets_4 == [], do: {:ok, %{}}
defp parse_large_offsets(rest, offsets_4) do
# Count how many offsets have MSB set (need 8-byte entries)
large_count = Enum.count(offsets_4, fn o -> Bitwise.band(o, 0x80000000) != 0 end)
if large_count == 0 do
{:ok, %{}}
else
large_bytes = large_count * 8
decode_large_offsets(rest, large_count)
end
end
if byte_size(rest) >= large_bytes do
<<large_bin::binary-size(large_bytes), _rest::binary>> = rest
defp decode_large_offsets(rest, large_count) do
large_bytes = large_count * 8
if byte_size(rest) >= large_bytes do
<<large_bin::binary-size(large_bytes), _rest::binary>> = rest
large_offsets =
for <<offset::unsigned-big-64 <- large_bin>> do
large_offsets =
for <<offset::unsigned-big-64 <- large_bin>> do
offset
end
offset
end
large_map =
large_offsets
large_map =
large_offsets
|> Enum.with_index()
|> Map.new(fn {offset, idx} -> {idx, offset} end)
|> Enum.with_index()
|> Map.new(fn {offset, idx} -> {idx, offset} end)
{:ok, large_map}
{:ok, large_map}
else
{:error, :truncated_large_offsets}
end
else
{:error, :truncated_large_offsets}
end
end
@@ -239,22 +244,26 @@
shas
|> Enum.zip(offsets_4)
|> Map.new(fn {sha, offset_4} ->
if Bitwise.band(offset_4, 0x80000000) != 0 do
large_idx = Bitwise.band(offset_4, 0x7FFFFFFF)
case Map.get(large_offsets, large_idx) do
nil -> {sha, {:error, {:missing_large_offset, large_idx}}}
offset -> {sha, offset}
end
else
{sha, offset_4}
end
resolve_offset(sha, offset_4, large_offsets)
end)
# Check if any entries had errors
case Enum.find(result, fn {_sha, val} -> match?({:error, _}, val) end) do
nil -> {:ok, result}
{_sha, err} -> err
end
end
defp resolve_offset(sha, offset_4, large_offsets) do
if Bitwise.band(offset_4, 0x80000000) != 0 do
large_idx = Bitwise.band(offset_4, 0x7FFFFFFF)
case Map.get(large_offsets, large_idx) do
nil -> {sha, {:error, {:missing_large_offset, large_idx}}}
offset -> {sha, offset}
end
else
{sha, offset_4}
end
end
end
lib/ex_git_objectstore/pack/reader.ex +318 −334
@@ -44,6 +44,8 @@
@obj_ofs_delta 6
@obj_ref_delta 7
@base_types [@obj_commit, @obj_tree, @obj_blob, @obj_tag]
@type pack_object :: %{
type: atom(),
data: binary(),
@@ -71,33 +73,29 @@
if count > @max_pack_objects do
{:error, {:pack_too_large, count}}
else
external_resolver = Keyword.get(opts, :external_resolver)
parse_verified(pack_data, count, Keyword.get(opts, :external_resolver))
end
end
# Verify trailing checksum (PK1)
case verify_pack_checksum(pack_data) do
:ok ->
# Strip the trailing 20-byte checksum before parsing entries
def parse(_, _opts), do: {:error, :invalid_pack_header}
entries_len = byte_size(pack_data) - 12 - 20
defp parse_verified(pack_data, count, external_resolver) do
with :ok <- verify_pack_checksum(pack_data),
<<_header::binary-size(12), entries_data::binary-size(entries_len),
_checksum::binary-size(20)>> = pack_data
entries_data = strip_pack_envelope(pack_data),
{:ok, entries, cache} <- parse_entries(entries_data, count, [], %{}, 12) do
resolve_delta_entries(entries, pack_data, cache, external_resolver)
end
end
case parse_entries(entries_data, count, [], %{}, 12) do
{:ok, entries, cache} ->
resolve_delta_entries(entries, pack_data, cache, external_resolver)
defp strip_pack_envelope(pack_data) do
entries_len = byte_size(pack_data) - 12 - 20
{:error, _} = err ->
err
end
<<_header::binary-size(12), entries_data::binary-size(entries_len),
_checksum::binary-size(20)>> = pack_data
{:error, _} = err ->
err
end
end
entries_data
end
def parse(_, _opts), do: {:error, :invalid_pack_header}
@doc """
Verify the trailing SHA-1 checksum of a pack file.
"""
@@ -134,26 +132,14 @@
do_read_object(pack_data, offset, cache, 0, nil)
end
# -- Entry parsing (sequential scan) --
# -- Private --
defp parse_entries(_rest, 0, acc, cache, _abs_offset), do: {:ok, Enum.reverse(acc), cache}
defp parse_entries(rest, remaining, acc, cache, abs_offset) do
case parse_entry_at(rest, cache, 0) do
{:ok, entry, consumed, _entry_cache} ->
case parse_entry_at(rest) do
{:ok, entry, consumed} ->
entry = %{entry | offset: abs_offset}
# Build SHA-to-offset cache for non-delta objects during parsing
cache = maybe_cache_sha(cache, entry, abs_offset)
# 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)
@@ -163,7 +149,16 @@
end
end
defp maybe_cache_sha(cache, entry, abs_offset) do
if entry.type in [:commit, :tree, :blob, :tag] do
sha = compute_object_sha(Atom.to_string(entry.type), entry.data)
Map.put(cache, {:sha, sha}, abs_offset)
# Resolve delta entries in two phases:
else
cache
end
end
# -- Two-phase delta resolution --
#
# Phase 1: Resolve all non-REF_DELTA entries (full objects + OFS_DELTAs).
# OFS_DELTAs can be fully resolved using offset chains within the pack.
@@ -179,215 +174,203 @@
defp resolve_delta_entries(entries, pack_data, cache, external_resolver) do
{ref_deltas, others} = Enum.split_with(entries, fn e -> e.type == :ref_delta end)
# Phase 1: Resolve non-REF_DELTA entries
phase1_result =
Enum.reduce_while(others, {:ok, []}, fn entry, {:ok, acc} ->
if entry.type == :ofs_delta do
case do_read_object(pack_data, entry.offset, cache, 0, nil) do
{:ok, {resolved_type, resolved_data}} ->
resolved = %{type: resolved_type, data: resolved_data, offset: entry.offset}
{:cont, {:ok, [resolved | acc]}}
with {:ok, resolved_others} <- resolve_non_ref_deltas(others, pack_data, cache) do
if ref_deltas == [] do
{:ok, resolved_others}
else
sha_map = build_sha_data_map(resolved_others)
{:error, _} = err ->
{:halt, err}
end
else
{:cont, {:ok, [entry | acc]}}
resolve_ref_deltas(ref_deltas, pack_data, cache, sha_map, external_resolver,
resolved: resolved_others
)
end
end
end
end
end)
case phase1_result do
{:ok, resolved_others} ->
resolved_others = Enum.reverse(resolved_others)
defp resolve_non_ref_deltas(entries, pack_data, cache) do
entries
|> Enum.reduce_while({:ok, []}, &resolve_non_ref_entry(&1, &2, pack_data, cache))
|> finalize_entries()
end
if ref_deltas == [] do
{:ok, resolved_others}
else
# Build SHA→{type, data} map from all resolved objects
sha_map = build_sha_data_map(resolved_others)
defp resolve_non_ref_entry(entry, {:ok, acc}, pack_data, cache) do
if entry.type == :ofs_delta do
case do_read_object(pack_data, entry.offset, cache, 0, nil) do
{:ok, {resolved_type, resolved_data}} ->
{:cont,
{:ok, [%{type: resolved_type, data: resolved_data, offset: entry.offset} | acc]}}
# Phase 2: Resolve REF_DELTA entries using sha_map + external_resolver
resolve_ref_deltas(ref_deltas, pack_data, cache, sha_map, external_resolver,
resolved: resolved_others
)
{:error, _} = err ->
{:halt, err}
end
else
{:cont, {:ok, [entry | acc]}}
end
{:error, _} = err ->
err
end
end
# Build a SHA→{type, data} map from resolved pack entries
defp finalize_entries({:ok, reversed}), do: {:ok, Enum.reverse(reversed)}
defp finalize_entries({:error, _} = err), do: err
defp build_sha_data_map(entries) do
Enum.reduce(entries, %{}, fn entry, acc ->
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)
sha = compute_object_sha(Atom.to_string(entry.type), entry.data)
Map.put(acc, sha, {entry.type, entry.data})
end)
end
# Multi-pass REF_DELTA resolution: newly resolved REF_DELTAs may be
# Resolve REF_DELTA entries using the SHA data map. Falls back to external
# resolver for thin packs. Multi-pass: newly resolved REF_DELTAs may be
# bases for other REF_DELTAs.
defp resolve_ref_deltas([], _pack_data, _cache, _sha_map, _ext_resolver, resolved: resolved) do
defp resolve_ref_deltas([], _pack_data, _cache, _sha_map, _ext, resolved: resolved) do
{:ok, resolved}
end
defp resolve_ref_deltas(ref_deltas, pack_data, cache, sha_map, ext, resolved: resolved) do
defp resolve_ref_deltas(ref_deltas, pack_data, cache, sha_map, ext_resolver, resolved: resolved) do
{newly_resolved, still_pending} =
Enum.reduce(ref_deltas, {[], []}, fn entry, {done, pending} ->
case resolve_single_ref_delta(entry, pack_data, cache, sha_map, ext_resolver) do
case resolve_single_ref_delta(entry, pack_data, cache, sha_map, ext) do
{:ok, resolved_entry} -> {[resolved_entry | done], pending}
:pending -> {done, [entry | pending]}
{:error, _} = err -> throw(err)
end
end)
if newly_resolved == [] and still_pending != [] do
# No progress — remaining REF_DELTAs can't be resolved
first_pending = hd(still_pending)
# Extract the base SHA from the pack for error reporting
<<_::binary-size(first_pending.offset), _header_data::binary>> = pack_data
handle_ref_delta_pass(newly_resolved, still_pending, pack_data, cache, sha_map, ext, resolved)
catch
{:error, _} = err -> err
end
{:error,
{:ref_delta_base_not_found, "unresolvable REF_DELTA at offset #{first_pending.offset}"}}
else
# Add newly resolved to sha_map for next pass
new_sha_map =
Enum.reduce(newly_resolved, sha_map, fn entry, acc ->
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(acc, sha, {entry.type, entry.data})
defp handle_ref_delta_pass([], [first | _], _pack_data, _cache, _sha_map, _ext, _resolved) do
{:error, {:ref_delta_base_not_found, "unresolvable REF_DELTA at offset #{first.offset}"}}
end
defp handle_ref_delta_pass(
newly_resolved,
still_pending,
pack_data,
cache,
sha_map,
ext,
end)
resolved
) do
new_sha_map =
Enum.reduce(newly_resolved, sha_map, fn entry, acc ->
sha = compute_object_sha(Atom.to_string(entry.type), entry.data)
Map.put(acc, sha, {entry.type, entry.data})
end)
all_resolved = resolved ++ Enum.reverse(newly_resolved)
all_resolved = resolved ++ Enum.reverse(newly_resolved)
resolve_ref_deltas(still_pending, pack_data, cache, new_sha_map, ext_resolver,
resolved: all_resolved
)
end
catch
{:error, _} = err -> err
resolve_ref_deltas(still_pending, pack_data, cache, new_sha_map, ext, resolved: all_resolved)
end
# Try to resolve a single REF_DELTA entry
defp resolve_single_ref_delta(entry, pack_data, _cache, sha_map, ext_resolver) do
<<_::binary-size(entry.offset), data::binary>> = pack_data
case parse_object_header(data) do
{:ok, @obj_ref_delta, _size, header_len, _rest} ->
data_start = entry.offset + header_len
resolve_ref_delta_at(entry, pack_data, header_len, sha_map, ext_resolver)
<<_::binary-size(data_start), base_sha_bin::binary-size(20), compressed::binary>> =
{:ok, _, _, _, _} ->
{:error, {:unexpected_type_at_ref_delta_offset, entry.offset}}
pack_data
base_sha = Base.encode16(base_sha_bin, case: :lower)
{:error, _} = err ->
throw(err)
end
end
case decompress_data(compressed) do
{:ok, delta_data, _} ->
# Try sha_map first (objects resolved in earlier phases)
case Map.get(sha_map, base_sha) do
{base_type, base_data} ->
defp resolve_ref_delta_at(entry, pack_data, header_len, sha_map, ext_resolver) do
data_start = entry.offset + header_len
case Delta.apply(base_data, delta_data) do
{:ok, result} ->
{:ok, %{type: base_type, data: result, offset: entry.offset}}
{:error, _} = err ->
<<_::binary-size(data_start), base_sha_bin::binary-size(20), compressed::binary>> = pack_data
throw(err)
end
nil ->
# Try external resolver (thin pack support)
if is_function(ext_resolver) do
case ext_resolver.(base_sha) do
{:ok, {base_type, base_data}} ->
case Delta.apply(base_data, delta_data) do
{:ok, result} ->
base_sha = Base.encode16(base_sha_bin, case: :lower)
{:ok, %{type: base_type, data: result, offset: entry.offset}}
case decompress_data(compressed) do
{:error, _} = err ->
throw(err)
end
{:ok, delta_data, _} ->
apply_ref_delta(delta_data, base_sha, entry.offset, sha_map, ext_resolver)
{:error, _} ->
:pending
end
else
:pending
end
end
{:error, _} = err ->
throw(err)
end
end
defp apply_ref_delta(delta_data, base_sha, offset, sha_map, ext_resolver) do
case Map.get(sha_map, base_sha) do
{:error, _} = err ->
throw(err)
end
{base_type, base_data} ->
apply_delta_to_entry(base_type, base_data, delta_data, offset)
nil ->
_ ->
{:error, {:unexpected_type_at_ref_delta_offset, entry.offset}}
try_external_ref_delta(delta_data, base_sha, offset, ext_resolver)
end
end
defp try_external_ref_delta(delta_data, base_sha, offset, ext_resolver)
when is_function(ext_resolver) do
defp parse_entry_at(data, _cache, _base_offset) do
case parse_object_header(data) do
{:ok, type_num, _size, header_len, rest_after_header} ->
case type_num do
t when t in [@obj_commit, @obj_tree, @obj_blob, @obj_tag] ->
case ext_resolver.(base_sha) do
{:ok, {base_type, base_data}} ->
apply_delta_to_entry(base_type, base_data, delta_data, offset)
type = type_num_to_atom(t)
{:error, _} ->
case decompress_data(rest_after_header) do
{:ok, decompressed, compressed_len} ->
entry = %{type: type, data: decompressed, offset: 0}
{:ok, entry, header_len + compressed_len, %{}}
:pending
end
end
{:error, _} = err ->
err
end
defp try_external_ref_delta(_delta_data, _base_sha, _offset, _ext_resolver), do: :pending
defp apply_delta_to_entry(base_type, base_data, delta_data, offset) do
@obj_ofs_delta ->
case parse_ofs_delta_offset(rest_after_header) do
{:ok, _neg_offset, _ofs_len, rest_after_ofs} ->
case Delta.apply(base_data, delta_data) do
{:ok, result} -> {:ok, %{type: base_type, data: result, offset: offset}}
{:error, _} = err -> throw(err)
end
end
case decompress_data(rest_after_ofs) do
{:ok, delta_data, compressed_len} ->
# For sequential parsing, we can't resolve OFS_DELTA without
# knowing the absolute offset. Store as unresolved delta.
entry = %{type: :ofs_delta, data: delta_data, offset: 0}
ofs_len = byte_size(rest_after_header) - byte_size(rest_after_ofs)
{:ok, entry, header_len + ofs_len + compressed_len, %{}}
{:error, _} = err ->
err
end
# -- Single-entry parsing --
defp parse_entry_at(data) do
{:error, _} = err ->
err
end
with {:ok, type_num, _size, header_len, rest_after_header} <- parse_object_header(data) do
parse_entry_body(type_num, header_len, rest_after_header)
end
end
@obj_ref_delta ->
if byte_size(rest_after_header) >= 20 do
defp parse_entry_body(t, header_len, rest) when t in @base_types do
type = type_num_to_atom(t)
<<_base_sha::binary-size(20), rest_after_sha::binary>> = rest_after_header
case decompress_data(rest_after_sha) do
{:ok, delta_data, compressed_len} ->
entry = %{type: :ref_delta, data: delta_data, offset: 0}
{:ok, entry, header_len + 20 + compressed_len, %{}}
case decompress_data(rest) do
{:ok, decompressed, compressed_len} ->
{:ok, %{type: type, data: decompressed, offset: 0}, header_len + compressed_len}
{:error, _} = err ->
err
end
else
{:error, :truncated_ref_delta}
end
{:error, _} = err ->
err
end
end
defp parse_entry_body(@obj_ofs_delta, header_len, rest) do
with {:ok, _neg_offset, ofs_len, rest_after_ofs} <- parse_ofs_delta_offset(rest),
{:ok, delta_data, compressed_len} <- decompress_data(rest_after_ofs) do
{:ok, %{type: :ofs_delta, data: delta_data, offset: 0},
header_len + ofs_len + compressed_len}
end
end
defp parse_entry_body(@obj_ref_delta, header_len, rest) when byte_size(rest) >= 20 do
<<_base_sha::binary-size(20), rest_after_sha::binary>> = rest
_ ->
{:error, {:unknown_object_type, type_num}}
end
case decompress_data(rest_after_sha) do
{:ok, delta_data, compressed_len} ->
{:ok, %{type: :ref_delta, data: delta_data, offset: 0}, header_len + 20 + compressed_len}
{:error, _} = err ->
err
end
end
defp parse_entry_body(@obj_ref_delta, _header_len, _rest), do: {:error, :truncated_ref_delta}
defp parse_entry_body(type_num, _header_len, _rest),
do: {:error, {:unknown_object_type, type_num}}
# -- Recursive object resolution --
defp do_read_object(_pack_data, _offset, _cache, depth, _external_resolver)
when depth > @max_delta_depth do
{:error, :max_delta_depth_exceeded}
@@ -395,103 +378,117 @@
defp do_read_object(pack_data, offset, cache, depth, external_resolver) do
case Map.get(cache, offset) do
{type, data} ->
{:ok, {type, data}}
nil ->
resolve_object(pack_data, offset, cache, depth, external_resolver)
{type, data} -> {:ok, {type, data}}
nil -> resolve_object(pack_data, offset, cache, depth, external_resolver)
end
end
defp resolve_object(pack_data, offset, cache, depth, external_resolver) do
<<_skip::binary-size(offset), data::binary>> = pack_data
with {:ok, type_num, _size, header_len, _rest} <- parse_object_header(data) do
case parse_object_header(data) do
{:ok, type_num, _size, header_len, _rest} ->
data_start = offset + header_len
resolve_object_by_type(
type_num,
pack_data,
offset,
header_len,
cache,
depth,
external_resolver
)
end
end
defp resolve_object_by_type(t, pack_data, offset, header_len, _cache, _depth, _ext)
when t in @base_types do
type = type_num_to_atom(t)
data_start = offset + header_len
<<_::binary-size(data_start), compressed::binary>> = pack_data
case type_num do
t when t in [@obj_commit, @obj_tree, @obj_blob, @obj_tag] ->
type = type_num_to_atom(t)
<<_::binary-size(data_start), compressed::binary>> = pack_data
case decompress_data(compressed) do
case decompress_data(compressed) do
{:ok, decompressed, _} -> {:ok, {type, decompressed}}
{:error, _} = err -> err
end
end
{:ok, decompressed, _} -> {:ok, {type, decompressed}}
{:error, _} = err -> err
end
@obj_ofs_delta ->
<<_::binary-size(data_start), rest::binary>> = pack_data
defp resolve_object_by_type(@obj_ofs_delta, pack_data, offset, header_len, cache, depth, ext) do
data_start = offset + header_len
<<_::binary-size(data_start), rest::binary>> = pack_data
resolve_ofs_delta(rest, pack_data, offset, data_start, cache, depth, ext)
end
case parse_ofs_delta_offset(rest) do
{:ok, neg_offset, ofs_len, _rest_after_ofs} ->
base_offset = offset - neg_offset
defp resolve_object_by_type(@obj_ref_delta, pack_data, offset, header_len, cache, depth, ext) do
data_start = offset + header_len
if base_offset < 0 do
{:error, :invalid_ofs_delta_offset}
else
delta_start = data_start + ofs_len
<<_::binary-size(data_start), base_sha_bin::binary-size(20), compressed::binary>> = pack_data
<<_::binary-size(delta_start), compressed::binary>> = pack_data
with {:ok, delta_data, _} <- decompress_data(compressed),
{:ok, {base_type, base_data}} <-
base_sha = Base.encode16(base_sha_bin, case: :lower)
resolve_ref_delta(compressed, base_sha, pack_data, cache, depth, ext)
end
do_read_object(
pack_data,
base_offset,
cache,
depth + 1,
external_resolver
),
{:ok, result} <- Delta.apply(base_data, delta_data) do
{:ok, {base_type, result}}
end
end
defp resolve_object_by_type(type_num, _pack_data, _offset, _hl, _cache, _depth, _ext) do
{:error, {:unknown_object_type, type_num}}
{:error, _} = err ->
end
err
end
@obj_ref_delta ->
<<_::binary-size(data_start), base_sha_bin::binary-size(20), compressed::binary>> =
pack_data
defp resolve_ofs_delta(rest, pack_data, offset, data_start, cache, depth, ext) do
with {:ok, neg_offset, ofs_len, _} <- parse_ofs_delta_offset(rest) do
base_offset = offset - neg_offset
base_sha = Base.encode16(base_sha_bin, case: :lower)
if base_offset < 0 do
{:error, :invalid_ofs_delta_offset}
else
delta_start = data_start + ofs_len
<<_::binary-size(delta_start), compressed::binary>> = pack_data
apply_delta_from_pack(compressed, pack_data, base_offset, cache, depth, ext)
end
end
end
defp apply_delta_from_pack(compressed, pack_data, base_offset, cache, depth, ext) do
with {:ok, delta_data, _} <- decompress_data(compressed),
with {:ok, delta_data, _} <- decompress_data(compressed) do
case find_sha_offset(pack_data, base_sha, cache) do
{:ok, base_offset} ->
with {:ok, {base_type, base_data}} <-
do_read_object(
pack_data,
base_offset,
cache,
depth + 1,
external_resolver
),
{:ok, result} <- Delta.apply(base_data, delta_data) do
{:ok, {base_type, result}}
end
{:ok, {base_type, base_data}} <-
do_read_object(pack_data, base_offset, cache, depth + 1, ext),
{:ok, result} <- Delta.apply(base_data, delta_data) do
{:ok, {base_type, result}}
end
end
{:error, {:ref_delta_base_not_found, _}} when is_function(external_resolver) ->
with {:ok, {base_type, base_data}} <- external_resolver.(base_sha),
{:ok, result} <- Delta.apply(base_data, delta_data) do
{:ok, {base_type, result}}
end
defp resolve_ref_delta(compressed, base_sha, pack_data, cache, depth, ext) do
with {:ok, delta_data, _} <- decompress_data(compressed) do
resolve_ref_delta_base(delta_data, base_sha, pack_data, cache, depth, ext)
end
end
{:error, _} = err ->
err
end
end
defp resolve_ref_delta_base(delta_data, base_sha, pack_data, cache, depth, ext) do
case find_sha_offset(pack_data, base_sha, cache) do
{:ok, base_offset} ->
apply_delta_from_pack_with_data(delta_data, pack_data, base_offset, cache, depth, ext)
_ ->
{:error, {:unknown_object_type, type_num}}
end
{:error, {:ref_delta_base_not_found, _}} when is_function(ext) ->
apply_delta_from_external(delta_data, base_sha, ext)
{:error, _} = err ->
err
end
end
defp apply_delta_from_pack_with_data(delta_data, pack_data, base_offset, cache, depth, ext) do
with {:ok, {base_type, base_data}} <-
do_read_object(pack_data, base_offset, cache, depth + 1, ext),
{:ok, result} <- Delta.apply(base_data, delta_data) do
{:ok, {base_type, result}}
end
end
defp apply_delta_from_external(delta_data, base_sha, external_resolver) do
with {:ok, {base_type, base_data}} <- external_resolver.(base_sha),
{:ok, result} <- Delta.apply(base_data, delta_data) do
{:ok, {base_type, result}}
end
end
# -- Object header parsing --
@doc """
Parse the variable-length object header.
Returns `{:ok, type_num, uncompressed_size, header_bytes_consumed, rest}`.
@@ -563,8 +560,8 @@
defp parse_ofs_continuation(<<>>, _offset, _consumed), do: {:error, :truncated_ofs_offset}
# -- Decompression --
# Decompress data with size limit and find the exact compressed length.
# Uses safeInflate for streaming decompression with early abort on oversize.
defp decompress_data(compressed) do
decompress_data(compressed, @max_decompressed_size)
end
@@ -634,11 +631,6 @@
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()
@@ -655,8 +647,6 @@
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} ->
@@ -671,31 +661,30 @@
end
end
# -- Type helpers --
defp type_num_to_atom(@obj_commit), do: :commit
defp type_num_to_atom(@obj_tree), do: :tree
defp type_num_to_atom(@obj_blob), do: :blob
defp type_num_to_atom(@obj_tag), do: :tag
# Build an in-memory SHA-to-offset index by scanning pack entries.
# For non-delta objects, compute the SHA from type+content.
# This enables REF_DELTA resolution without a .idx file.
# -- SHA index --
defp find_sha_offset(pack_data, sha, cache) do
# Check cache first (caller may have provided from .idx)
case Map.get(cache, {:sha, sha}) do
offset when is_integer(offset) ->
{:ok, offset}
nil ->
# Build index by scanning the pack
case build_sha_index(pack_data) do
{:ok, index} ->
case Map.get(index, sha) do
nil -> {:error, {:ref_delta_base_not_found, sha}}
lookup_sha_in_pack(pack_data, sha)
end
end
offset -> {:ok, offset}
end
defp lookup_sha_in_pack(pack_data, sha) do
with {:ok, index} <- build_sha_index(pack_data) do
case Map.get(index, sha) do
{:error, _} = err ->
err
end
nil -> {:error, {:ref_delta_base_not_found, sha}}
offset -> {:ok, offset}
end
end
end
@@ -716,72 +705,67 @@
defp scan_entries_for_index(_data, 0, _offset, index), do: {:ok, index}
defp scan_entries_for_index(data, remaining, offset, index) do
with {:ok, type_num, _size, header_len, rest_after_header} <- parse_object_header(data) do
scan_entry_by_type(type_num, data, header_len, rest_after_header, remaining, offset, index)
end
end
case parse_object_header(data) do
{:ok, type_num, _size, header_len, rest_after_header} ->
case type_num do
t when t in [@obj_commit, @obj_tree, @obj_blob, @obj_tag] ->
type_str = Atom.to_string(type_num_to_atom(t))
case decompress_data(rest_after_header) do
{:ok, content, compressed_len} ->
# Compute SHA: hash("type size\0content")
raw = "#{type_str} #{byte_size(content)}\0" <> content
sha = :crypto.hash(:sha, raw) |> Base.encode16(case: :lower)
consumed = header_len + compressed_len
<<_::binary-size(consumed), next_data::binary>> = data
defp scan_entry_by_type(t, data, header_len, rest, remaining, offset, index)
when t in @base_types do
type_str = Atom.to_string(type_num_to_atom(t))
case decompress_data(rest) do
{:ok, content, compressed_len} ->
sha = compute_object_sha(type_str, content)
consumed = header_len + compressed_len
<<_::binary-size(consumed), next_data::binary>> = data
scan_entries_for_index(
next_data,
remaining - 1,
offset + consumed,
Map.put(index, sha, offset)
)
scan_entries_for_index(
next_data,
remaining - 1,
offset + consumed,
Map.put(index, sha, offset)
{:error, _} = err ->
err
)
end
@obj_ofs_delta ->
case parse_ofs_delta_offset(rest_after_header) do
{:error, _} = err ->
err
end
end
{:ok, _neg_offset, ofs_len, rest_after_ofs} ->
case decompress_data(rest_after_ofs) do
{:ok, _delta_data, compressed_len} ->
consumed = header_len + ofs_len + compressed_len
<<_::binary-size(consumed), next_data::binary>> = data
# We don't index delta objects (they need resolution first)
scan_entries_for_index(next_data, remaining - 1, offset + consumed, index)
{:error, _} = err ->
err
end
defp scan_entry_by_type(@obj_ofs_delta, data, header_len, rest, remaining, offset, index) do
with {:ok, _neg_offset, ofs_len, rest_after_ofs} <- parse_ofs_delta_offset(rest),
{:ok, _delta_data, compressed_len} <- decompress_data(rest_after_ofs) do
consumed = header_len + ofs_len + compressed_len
<<_::binary-size(consumed), next_data::binary>> = data
scan_entries_for_index(next_data, remaining - 1, offset + consumed, index)
end
end
defp scan_entry_by_type(@obj_ref_delta, _data, _hl, rest, _rem, _off, _idx)
when byte_size(rest) < 20 do
{:error, _} = err ->
err
end
@obj_ref_delta ->
if byte_size(rest_after_header) >= 20 do
<<_base_sha::binary-size(20), rest_after_sha::binary>> = rest_after_header
case decompress_data(rest_after_sha) do
{:ok, _delta_data, compressed_len} ->
consumed = header_len + 20 + compressed_len
<<_::binary-size(consumed), next_data::binary>> = data
scan_entries_for_index(next_data, remaining - 1, offset + consumed, index)
{:error, :truncated_ref_delta}
end
{:error, _} = err ->
err
end
else
{:error, :truncated_ref_delta}
end
defp scan_entry_by_type(@obj_ref_delta, data, header_len, rest, remaining, offset, index) do
<<_base_sha::binary-size(20), rest_after_sha::binary>> = rest
_ ->
{:error, {:unknown_object_type, type_num}}
end
case decompress_data(rest_after_sha) do
{:ok, _delta_data, compressed_len} ->
consumed = header_len + 20 + compressed_len
<<_::binary-size(consumed), next_data::binary>> = data
scan_entries_for_index(next_data, remaining - 1, offset + consumed, index)
{:error, _} = err ->
err
end
end
defp scan_entry_by_type(type_num, _data, _hl, _rest, _rem, _off, _idx) do
{:error, {:unknown_object_type, type_num}}
end
defp compute_object_sha(type_str, content) do
raw = "#{type_str} #{byte_size(content)}\0" <> content
:crypto.hash(:sha, raw) |> Base.encode16(case: :lower)
end
end
lib/ex_git_objectstore/protocol/pkt_line.ex +2 −2
@@ -32,9 +32,9 @@
# Max total pkt-line is 65520 (0xFFF0) bytes per spec.
# Max payload = 65520 - 4 (length prefix) = 65516
@max_pkt_len 65520
@max_pkt_len 65_520
# For sideband: max payload (65516) - 1 band byte = 65515
@max_sideband_data 65515
@max_sideband_data 65_515
@doc """
Encode a single data line as a pkt-line.
lib/ex_git_objectstore/protocol/receive_pack.ex +131 −107
@@ -38,8 +38,8 @@
@max_pack_size 256 * 1024 * 1024
# Capabilities we actually implement. Others are scaffolded but not yet functional:
# TODO: ofs-delta — Writer doesn't generate OFS_DELTA yet
# TODO: side-band-64k — not yet used in report responses
# Future work: ofs-delta — Writer doesn't generate OFS_DELTA yet
# Future work: side-band-64k — not yet used in report responses
@capabilities "report-status delete-refs"
@type command :: %{
@@ -160,44 +160,47 @@
# Build the ref list with HEAD first (spec: HEAD MUST appear first).
# Also includes peeled tag refs (^{}) for annotated tags.
defp list_all_refs_with_head(repo) do
heads =
case Ref.list(repo, "refs/heads/") do
{:ok, refs} -> refs
_ -> []
end
tags =
case Ref.list(repo, "refs/tags/") do
{:ok, refs} -> refs
_ -> []
end
heads = list_refs_safe(repo, "refs/heads/")
tags = list_refs_safe(repo, "refs/tags/")
sorted =
(heads ++ tags)
|> Enum.sort_by(fn {ref, _sha} -> ref end)
# Add peeled tag refs
sorted_with_peeled =
Enum.flat_map(sorted, fn {ref, sha} = entry ->
sorted_with_peeled = add_peeled_tags(repo, sorted)
prepend_head(repo, sorted_with_peeled)
end
if String.starts_with?(ref, "refs/tags/") do
case peel_tag(repo, sha) do
{:ok, peeled_sha} when peeled_sha != sha ->
[entry, {"#{ref}^{}", peeled_sha}]
_ ->
[entry]
end
else
defp list_refs_safe(repo, prefix) do
case Ref.list(repo, prefix) do
{:ok, refs} -> refs
_ -> []
end
end
defp add_peeled_tags(repo, sorted) do
Enum.flat_map(sorted, fn entry ->
maybe_peel_tag_entry(repo, entry)
end)
end
defp maybe_peel_tag_entry(repo, {ref, sha} = entry) do
if String.starts_with?(ref, "refs/tags/") do
case peel_tag(repo, sha) do
{:ok, peeled_sha} when peeled_sha != sha ->
[entry, {"#{ref}^{}", peeled_sha}]
_ ->
[entry]
end
else
end
[entry]
end
end
end)
defp prepend_head(repo, refs) do
# Resolve HEAD and prepend it
case resolve_head_sha(repo) do
{:ok, head_sha} ->
[{"HEAD", head_sha} | sorted_with_peeled]
_ ->
sorted_with_peeled
{:ok, head_sha} -> [{"HEAD", head_sha} | refs]
_ -> refs
end
end
@@ -227,25 +230,10 @@
defp parse_command_lines(data, acc, caps) do
case PktLine.decode_one(data) do
{:ok, :flush, rest} ->
client_caps = caps || MapSet.new()
if acc == [] do
# No commands — client is just probing
{:ok, [], client_caps, rest}
else
{:ok, Enum.reverse(acc), client_caps, rest}
end
finalize_commands(acc, caps, rest)
{:ok, {:data, line}, rest} ->
case parse_command_line(line) do
{:ok, cmd, line_caps} ->
# Only the first command line carries capabilities
caps = caps || line_caps
parse_command_lines(rest, [cmd | acc], caps)
handle_command_data(line, rest, acc, caps)
{:error, _} = err ->
err
end
{:need_more, _} ->
{:need_more, data}
@@ -255,23 +243,45 @@
end
end
defp finalize_commands(acc, caps, rest) do
client_caps = caps || MapSet.new()
commands = if acc == [], do: [], else: Enum.reverse(acc)
{:ok, commands, client_caps, rest}
end
defp handle_command_data(line, rest, acc, caps) do
case parse_command_line(line) do
{:ok, cmd, line_caps} ->
caps = caps || line_caps
parse_command_lines(rest, [cmd | acc], caps)
{:error, _} = err ->
err
end
end
defp parse_command_line(line) do
# Format: "<old-sha> <new-sha> <ref-name>[\0<capabilities>]"
{cmd_str, caps} = extract_capabilities(line)
parse_sha_ref(cmd_str, caps)
# Extract capabilities if present
{cmd_str, caps} =
case String.split(line, "\0", parts: 2) do
[cmd, caps_str] ->
cap_set =
caps_str
|> String.split(" ", trim: true)
|> MapSet.new()
end
defp extract_capabilities(line) do
case String.split(line, "\0", parts: 2) do
{cmd, cap_set}
[cmd, caps_str] ->
cap_set =
caps_str
|> String.split(" ", trim: true)
|> MapSet.new()
{cmd, cap_set}
[cmd] ->
{cmd, MapSet.new()}
end
[cmd] ->
{cmd, MapSet.new()}
end
end
defp parse_sha_ref(cmd_str, caps) do
case String.split(cmd_str, " ", parts: 3) do
[old_sha, new_sha, ref] when byte_size(old_sha) == 40 and byte_size(new_sha) == 40 ->
if Regex.match?(@sha_hex_pattern, old_sha) and Regex.match?(@sha_hex_pattern, new_sha) do
@@ -309,25 +319,22 @@
# A complete pack has: header (12 bytes) + entries + 20-byte checksum
# We can't easily know the total size without parsing all entries,
# so we verify the trailing SHA-1 checksum
if byte_size(data) >= 32 do
body_len = byte_size(data) - 20
<<body::binary-size(body_len), checksum::binary-size(20)>> = data
expected = :crypto.hash(:sha, body)
if checksum == expected do
:complete
else
# May be incomplete — more data coming
verify_pack_checksum(data)
:incomplete
end
else
:incomplete
end
end
defp check_pack_complete(<<>>), do: :incomplete
defp check_pack_complete(_), do: :incomplete
defp verify_pack_checksum(data) when byte_size(data) < 32, do: :incomplete
defp verify_pack_checksum(data) do
body_len = byte_size(data) - 20
<<body::binary-size(body_len), checksum::binary-size(20)>> = data
expected = :crypto.hash(:sha, body)
if checksum == expected, do: :complete, else: :incomplete
end
defp process_pack_and_refs(state) do
case store_pack_objects(state.repo, state.pack_buffer) do
:ok ->
@@ -347,21 +354,29 @@
case Reader.parse(pack_data, external_resolver: external_resolver) do
{:ok, entries} ->
Enum.reduce_while(entries, :ok, fn entry, :ok ->
raw = Object.encode_raw_from_type(entry.type, entry.data)
sha = :crypto.hash(:sha, raw) |> Base.encode16(case: :lower)
store_entries(repo, entries)
case zlib_compress(raw) do
{:error, _} = err ->
err
end
end
defp store_entries(repo, entries) do
Enum.reduce_while(entries, :ok, fn entry, :ok ->
case store_single_entry(repo, entry) do
:ok -> {:cont, :ok}
{:error, _} = err -> {:halt, err}
end
end)
end
defp store_single_entry(repo, entry) do
raw = Object.encode_raw_from_type(entry.type, entry.data)
sha = :crypto.hash(:sha, raw) |> Base.encode16(case: :lower)
{:ok, compressed} ->
case Repo.storage_call(repo, :put_object, [sha, compressed]) do
:ok -> {:cont, :ok}
{:error, _} = err -> {:halt, err}
end
case zlib_compress(raw) do
{:ok, compressed} ->
Repo.storage_call(repo, :put_object, [sha, compressed])
{:error, _} = err ->
{:halt, err}
end
end)
{:error, _} = err ->
err
@@ -374,28 +389,37 @@
fn sha ->
case Repo.storage_call(repo, :get_object, [sha]) do
{:ok, compressed} ->
try do
raw = :zlib.uncompress(compressed)
case :binary.split(raw, <<0>>) do
[header, content] ->
case String.split(header, " ", parts: 2) do
["commit", _] -> {:ok, {:commit, content}}
["tree", _] -> {:ok, {:tree, content}}
["blob", _] -> {:ok, {:blob, content}}
["tag", _] -> {:ok, {:tag, content}}
decompress_and_parse_object(compressed, sha)
[other, _] -> {:error, {:unknown_object_type, other}}
end
_ ->
{:error, {:invalid_object_format, sha}}
end
rescue
_ -> {:error, {:decompress_failed, sha}}
end
{:error, _} = err ->
err
end
end
end
defp decompress_and_parse_object(compressed, sha) do
raw = :zlib.uncompress(compressed)
parse_raw_object(raw, sha)
rescue
_ -> {:error, {:decompress_failed, sha}}
end
defp parse_raw_object(raw, sha) do
case :binary.split(raw, <<0>>) do
[header, content] ->
classify_object_type(header, content)
_ ->
{:error, {:invalid_object_format, sha}}
end
end
defp classify_object_type(header, content) do
case String.split(header, " ", parts: 2) do
["commit", _] -> {:ok, {:commit, content}}
["tree", _] -> {:ok, {:tree, content}}
["blob", _] -> {:ok, {:blob, content}}
["tag", _] -> {:ok, {:tag, content}}
[other, _] -> {:error, {:unknown_object_type, other}}
end
end
lib/ex_git_objectstore/protocol/upload_pack.ex +106 −87
@@ -34,8 +34,8 @@
@sha_hex_pattern ~r/\A[0-9a-f]{40}\z/
# Capabilities we actually implement. Others are scaffolded but not yet functional:
# TODO: multi_ack_detailed — requires returning ACK during negotiation, currently NAK-only
# TODO: ofs-delta — reader supports OFS_DELTA, but Writer doesn't generate them yet
# Future work: multi_ack_detailed — requires returning ACK during negotiation, currently NAK-only
# Future work: ofs-delta — reader supports OFS_DELTA, but Writer doesn't generate them yet
@capabilities "side-band-64k"
@type state :: %__MODULE__{
@@ -77,20 +77,8 @@
generate_pack_response(state)
{:ok, wants, haves, :continue} ->
state = %{state | wants: wants ++ state.wants, haves: haves ++ state.haves}
handle_negotiation_continue(state, wants, haves)
# Find common objects: ACK the last have that we have in our repo
case find_common(state.repo, haves) do
{:ok, common_sha} ->
ack = PktLine.encode("ACK #{common_sha}")
state = %{state | common: [common_sha | state.common]}
{ack, state}
:none ->
nak = PktLine.encode("NAK")
{nak, state}
end
{:error, _reason} ->
nak = PktLine.encode("NAK")
{nak, %{state | phase: :done}}
@@ -101,6 +89,21 @@
{<<>>, state}
end
defp handle_negotiation_continue(state, wants, haves) do
state = %{state | wants: wants ++ state.wants, haves: haves ++ state.haves}
case find_common(state.repo, haves) do
{:ok, common_sha} ->
ack = PktLine.encode("ACK #{common_sha}")
state = %{state | common: [common_sha | state.common]}
{ack, state}
:none ->
nak = PktLine.encode("NAK")
{nak, state}
end
end
@doc """
Check if the protocol exchange is complete.
"""
@@ -147,45 +150,47 @@
# Build the ref list with HEAD first (spec: HEAD MUST appear first).
# Also includes peeled tag refs (^{}) for annotated tags.
defp list_all_refs_with_head(repo) do
heads =
case Ref.list(repo, "refs/heads/") do
{:ok, refs} -> refs
_ -> []
end
tags =
case Ref.list(repo, "refs/tags/") do
{:ok, refs} -> refs
_ -> []
end
heads = list_refs_safe(repo, "refs/heads/")
tags = list_refs_safe(repo, "refs/tags/")
sorted =
(heads ++ tags)
|> Enum.sort_by(fn {ref, _sha} -> ref end)
sorted_with_peeled = add_peeled_tags(repo, sorted)
prepend_head(repo, sorted_with_peeled)
end
# Add peeled tag refs: for annotated tags, add a refname^{} entry
# showing the commit the tag points to
sorted_with_peeled =
Enum.flat_map(sorted, fn {ref, sha} = entry ->
if String.starts_with?(ref, "refs/tags/") do
case peel_tag(repo, sha) do
{:ok, peeled_sha} when peeled_sha != sha ->
[entry, {"#{ref}^{}", peeled_sha}]
_ ->
[entry]
end
else
defp list_refs_safe(repo, prefix) do
case Ref.list(repo, prefix) do
{:ok, refs} -> refs
_ -> []
end
end
defp add_peeled_tags(repo, sorted) do
Enum.flat_map(sorted, fn entry ->
maybe_peel_tag_entry(repo, entry)
end)
end
defp maybe_peel_tag_entry(repo, {ref, sha} = entry) do
if String.starts_with?(ref, "refs/tags/") do
case peel_tag(repo, sha) do
{:ok, peeled_sha} when peeled_sha != sha ->
[entry, {"#{ref}^{}", peeled_sha}]
_ ->
[entry]
end
end)
end
else
[entry]
end
end
# Resolve HEAD and prepend it
defp prepend_head(repo, refs) do
case resolve_head_sha(repo) do
{:ok, head_sha} ->
[{"HEAD", head_sha} | sorted_with_peeled]
_ ->
sorted_with_peeled
{:ok, head_sha} -> [{"HEAD", head_sha} | refs]
_ -> refs
end
end
@@ -359,36 +364,43 @@
{[], visited}
else
visited = MapSet.put(visited, sha)
collect_reachable_object(repo, sha, exclude_set, visited)
end
end
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{} = commit} ->
# Each commit's tree starts at depth 0 (tree depth != commit chain depth)
{tree_objects, visited} = collect_tree_objects(repo, commit.tree, visited, 0)
defp collect_reachable_object(repo, sha, exclude_set, visited) do
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{} = commit} ->
collect_commit_objects(repo, sha, commit, exclude_set, visited)
# Recurse into parents (visited prevents cycles, no depth limit needed)
{parent_objects, visited} =
Enum.reduce(commit.parents, {[], visited}, fn parent_sha, {acc, vis} ->
{objs, vis} = collect_reachable(repo, parent_sha, exclude_set, vis)
{objs ++ acc, vis}
end)
{:ok, %Tree{} = tree} ->
collect_tree_entry_objects(repo, tree, sha, visited, 0)
objects = [
{:commit, Object.encode_content_only(commit), sha}
| tree_objects ++ Enum.reverse(parent_objects)
]
{:ok, %Blob{content: content}} ->
{[{:blob, content, sha}], visited}
{:error, _} ->
{objects, visited}
{[], visited}
end
end
{:ok, %Tree{} = tree} ->
collect_tree_entry_objects(repo, tree, sha, visited, 0)
defp collect_commit_objects(repo, sha, commit, exclude_set, visited) do
# Each commit's tree starts at depth 0 (tree depth != commit chain depth)
{tree_objects, visited} = collect_tree_objects(repo, commit.tree, visited, 0)
{:ok, %Blob{content: content}} ->
{[{:blob, content, sha}], visited}
# Recurse into parents (visited prevents cycles, no depth limit needed)
{parent_objects, visited} =
Enum.reduce(commit.parents, {[], visited}, fn parent_sha, {acc, vis} ->
{objs, vis} = collect_reachable(repo, parent_sha, exclude_set, vis)
{objs ++ acc, vis}
end)
{:error, _} ->
{[], visited}
end
end
objects = [
{:commit, Object.encode_content_only(commit), sha}
| tree_objects ++ Enum.reverse(parent_objects)
]
{objects, visited}
end
defp collect_tree_objects(_repo, _tree_sha, _visited, depth)
@@ -415,27 +427,34 @@
tree_content = Tree.encode_content(tree)
{child_objects, visited} =
Enum.reduce(tree.entries, {[], visited}, fn
%{mode: "40000", sha: sha}, {acc, vis} ->
{objs, vis} = collect_tree_objects(repo, sha, vis, depth + 1)
{objs ++ acc, vis}
Enum.reduce(tree.entries, {[], visited}, fn entry, {acc, vis} ->
collect_single_tree_entry(repo, entry, acc, vis, depth)
end)
%{sha: sha}, {acc, vis} ->
if MapSet.member?(vis, sha) do
{acc, vis}
else
vis = MapSet.put(vis, sha)
{[{:tree, tree_content, tree_sha} | Enum.reverse(child_objects)], visited}
end
case ObjectResolver.read(repo, sha) do
{:ok, %Blob{content: content}} ->
{[{:blob, content, sha} | acc], vis}
defp collect_single_tree_entry(repo, %{mode: "40000", sha: sha}, acc, vis, depth) do
{objs, vis} = collect_tree_objects(repo, sha, vis, depth + 1)
{objs ++ acc, vis}
end
_ ->
{acc, vis}
end
end
end)
defp collect_single_tree_entry(repo, %{sha: sha}, acc, vis, _depth) do
if MapSet.member?(vis, sha) do
{acc, vis}
else
vis = MapSet.put(vis, sha)
collect_blob_entry(repo, sha, acc, vis)
end
end
{[{:tree, tree_content, tree_sha} | Enum.reverse(child_objects)], visited}
defp collect_blob_entry(repo, sha, acc, vis) do
case ObjectResolver.read(repo, sha) do
{:ok, %Blob{content: content}} ->
{[{:blob, content, sha} | acc], vis}
_ ->
{acc, vis}
end
end
end
lib/ex_git_objectstore/ref.ex +21 −0
@@ -151,6 +151,13 @@
"""
@spec validate_ref_name(String.t()) :: :ok | {:error, term()}
def validate_ref_name(ref_path) do
with :ok <- check_ref_structure(ref_path),
:ok <- check_ref_content(ref_path) do
check_ref_components(ref_path)
end
end
defp check_ref_structure(ref_path) do
cond do
ref_path == "" ->
{:error, {:invalid_ref_name, ref_path, :empty}}
@@ -164,5 +171,12 @@
String.contains?(ref_path, "//") ->
{:error, {:invalid_ref_name, ref_path, :double_slash}}
true ->
:ok
end
end
defp check_ref_content(ref_path) do
cond do
String.contains?(ref_path, <<0>>) ->
{:error, {:invalid_ref_name, ref_path, :null_byte}}
@@ -179,5 +193,12 @@
has_forbidden_chars?(ref_path) ->
{:error, {:invalid_ref_name, ref_path, :forbidden_char}}
true ->
:ok
end
end
defp check_ref_components(ref_path) do
cond do
String.contains?(ref_path, "@{") ->
{:error, {:invalid_ref_name, ref_path, :at_brace}}
lib/ex_git_objectstore/storage/filesystem.ex +114 −82
@@ -68,10 +68,9 @@
{:ok, files} ->
packs =
files
|> Enum.filter(&String.ends_with?(&1, ".pack"))
|> Enum.filter(fn pack_file ->
idx_file = String.replace_suffix(pack_file, ".pack", ".idx")
idx_file in files
|> Enum.filter(fn file ->
String.ends_with?(file, ".pack") and
pack_has_index?(file, files)
end)
|> Enum.map(fn file ->
file
@@ -89,6 +88,11 @@
end
end
defp pack_has_index?(pack_file, files) do
idx_file = String.replace_suffix(pack_file, ".pack", ".idx")
idx_file in files
end
@impl true
def get_pack(config, prefix, pack_sha) do
path = pack_path(config, prefix, pack_sha, "pack")
@@ -116,8 +120,7 @@
pack_p = pack_path(config, prefix, pack_sha, "pack")
idx_p = pack_path(config, prefix, pack_sha, "idx")
with :ok <- atomic_write(pack_p, pack_data) do
with :ok <- atomic_write(pack_p, pack_data),
:ok <- atomic_write(idx_p, idx_data) do
:ok
atomic_write(idx_p, idx_data)
end
end
@@ -165,44 +168,58 @@
# Use lock file for atomicity
case File.open(lock_path, [:write, :exclusive]) do
{:ok, lock_fd} ->
try do
IO.binwrite(lock_fd, new_sha <> "\n")
File.close(lock_fd)
write_ref_with_lock(config, prefix, ref_path, path, lock_path, lock_fd, new_sha, old_sha)
if old_sha do
# CAS: verify current value under lock
{:error, :eexist} ->
maybe_break_stale_lock(config, prefix, ref_path, lock_path, new_sha, old_sha)
case File.read(path) do
{:ok, data} ->
if String.trim(data) == old_sha do
File.rename!(lock_path, path)
:ok
else
File.rm(lock_path)
{:error, :cas_failed}
end
{:error, :enoent} ->
File.rm(lock_path)
{:error, :cas_failed}
{:error, reason} ->
{:error, reason}
end
end
{:error, reason} ->
File.rm(lock_path)
{:error, reason}
end
else
File.rename!(lock_path, path)
:ok
end
defp write_ref_with_lock(
_config,
_prefix,
_ref_path,
path,
lock_path,
lock_fd,
new_sha,
old_sha
) do
IO.binwrite(lock_fd, new_sha <> "\n")
File.close(lock_fd)
if old_sha do
apply_cas_update(path, lock_path, old_sha)
else
File.rename!(lock_path, path)
:ok
end
rescue
e ->
File.rm(lock_path)
{:error, {:lock_failed, Exception.message(e)}}
end
rescue
e ->
File.rm(lock_path)
{:error, {:lock_failed, Exception.message(e)}}
defp apply_cas_update(path, lock_path, old_sha) do
case File.read(path) do
{:ok, data} ->
if String.trim(data) == old_sha do
File.rename!(lock_path, path)
:ok
else
File.rm(lock_path)
{:error, :cas_failed}
end
{:error, :eexist} ->
maybe_break_stale_lock(config, prefix, ref_path, lock_path, new_sha, old_sha)
{:error, :enoent} ->
File.rm(lock_path)
{:error, :cas_failed}
{:error, reason} ->
File.rm(lock_path)
{:error, reason}
end
end
@@ -250,27 +267,8 @@
base_dir = safe_path(config.root, Path.join([prefix, ref_prefix]))
# Start with loose refs
loose_refs =
case list_files_recursive(base_dir) do
{:ok, files} ->
files
|> Enum.reject(fn file ->
basename = Path.basename(file)
String.ends_with?(basename, ".lock") or String.contains?(basename, ".tmp")
end)
loose_refs = collect_loose_refs(base_dir, ref_prefix)
|> Enum.flat_map(fn file ->
ref_name = ref_prefix <> Path.relative_to(file, base_dir)
case File.read(file) do
{:ok, sha} -> [{ref_name, String.trim(sha)}]
{:error, _} -> []
end
end)
{:error, _} ->
[]
end
# Merge with packed-refs (loose refs take precedence)
packed_refs = list_packed_refs(config, prefix, ref_prefix)
loose_names = MapSet.new(loose_refs, &elem(&1, 0))
@@ -284,6 +282,32 @@
{:ok, merged}
end
defp collect_loose_refs(base_dir, ref_prefix) do
case list_files_recursive(base_dir) do
{:ok, files} ->
files
|> Enum.reject(fn file ->
basename = Path.basename(file)
String.ends_with?(basename, ".lock") or String.contains?(basename, ".tmp")
end)
|> Enum.flat_map(fn file ->
read_loose_ref(file, base_dir, ref_prefix)
end)
{:error, _} ->
[]
end
end
defp read_loose_ref(file, base_dir, ref_prefix) do
ref_name = ref_prefix <> Path.relative_to(file, base_dir)
case File.read(file) do
{:ok, sha} -> [{ref_name, String.trim(sha)}]
{:error, _} -> []
end
end
# -- HEAD --
@impl true
@@ -332,16 +356,21 @@
File.mkdir_p!(dir)
tmp_path = path <> ".tmp.#{:erlang.unique_integer([:positive])}"
with :ok <- File.write(tmp_path, data) do
case File.rename(tmp_path, path) do
:ok ->
:ok
case File.write(tmp_path, data) do
:ok ->
rename_or_cleanup(tmp_path, path)
{:error, reason} ->
File.rm(tmp_path)
{:error, reason} ->
File.rm(tmp_path)
{:error, reason}
end
end
defp rename_or_cleanup(tmp_path, path) do
case File.rename(tmp_path, path) do
:ok ->
:ok
{:error, reason}
end
else
{:error, reason} ->
File.rm(tmp_path)
{:error, reason}
@@ -407,27 +436,30 @@
files =
Enum.flat_map(entries, fn entry ->
path = Path.join(dir, entry)
case File.lstat(path) do
{:ok, %{type: :directory}} ->
case list_files_recursive(path, depth + 1) do
{:ok, sub_files} -> sub_files
_ -> []
end
list_entry(path, depth)
{:ok, %{type: :regular}} ->
[path]
_ ->
# Skip symlinks and other special files
[]
end
end)
{:ok, files}
{:error, reason} ->
{:error, reason}
end
end
defp list_entry(path, depth) do
case File.lstat(path) do
{:ok, %{type: :directory}} ->
case list_files_recursive(path, depth + 1) do
{:ok, sub_files} -> sub_files
_ -> []
end
{:ok, %{type: :regular}} ->
[path]
_ ->
# Skip symlinks and other special files
[]
end
end
end
lib/ex_git_objectstore/storage/s3.ex +31 −24
@@ -112,8 +112,7 @@
@impl true
def put_pack(config, prefix, pack_sha, pack_data, idx_data) do
with :ok <- s3_put(config, pack_key(prefix, pack_sha, "pack"), pack_data),
:ok <- s3_put(config, pack_key(prefix, pack_sha, "idx"), idx_data) do
with :ok <- s3_put(config, pack_key(prefix, pack_sha, "pack"), pack_data) do
s3_put(config, pack_key(prefix, pack_sha, "idx"), idx_data)
:ok
end
end
@@ -145,23 +144,27 @@
key = safe_key("#{prefix}/#{ref_path}")
if old_sha do
# CAS: read-then-write — racy without an external lock service.
# A concurrent writer can update between the read and write below.
case s3_get(config, key) do
{:ok, data} ->
if String.trim(data) == old_sha do
s3_put(config, key, new_sha <> "\n")
else
{:error, :cas_failed}
end
cas_put_ref(config, key, new_sha, old_sha)
else
s3_put(config, key, new_sha <> "\n")
end
end
# CAS: read-then-write — racy without an external lock service.
{:error, :not_found} ->
# A concurrent writer can update between the read and write below.
defp cas_put_ref(config, key, new_sha, old_sha) do
case s3_get(config, key) do
{:ok, data} ->
if String.trim(data) == old_sha do
s3_put(config, key, new_sha <> "\n")
else
{:error, :cas_failed}
end
{:error, _} = err ->
err
{:error, :not_found} ->
end
else
s3_put(config, key, new_sha <> "\n")
{:error, :cas_failed}
{:error, _} = err ->
err
end
end
@@ -181,12 +184,7 @@
refs =
keys
|> Enum.map(fn key ->
ref_name = String.replace_prefix(key, "#{prefix}/", "")
fetch_ref_from_key(config, key, prefix)
case s3_get(config, key) do
{:ok, data} -> {ref_name, String.trim(data)}
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.sort()
@@ -195,5 +193,14 @@
{:error, _} = err ->
err
end
end
defp fetch_ref_from_key(config, key, prefix) do
ref_name = String.replace_prefix(key, "#{prefix}/", "")
case s3_get(config, key) do
{:ok, data} -> {ref_name, String.trim(data)}
_ -> nil
end
end
lib/ex_git_objectstore/walk.ex +70 −61
@@ -19,8 +19,8 @@
Supports topological + time ordering with configurable limits.
"""
alias ExGitObjectstore.{ObjectResolver, Repo}
alias ExGitObjectstore.Object.Commit
alias ExGitObjectstore.{ObjectResolver, Repo}
@type log_opts :: [
max_count: non_neg_integer(),
@@ -52,22 +52,23 @@
case walk(repo, [start_sha], MapSet.new(), [], 0, limit) do
{:ok, all_commits} ->
{:ok, apply_skip_and_limit(all_commits, skip, max_count)}
result =
all_commits
|> Enum.drop(skip)
|> then(fn commits ->
case max_count do
:infinity -> commits
n -> Enum.take(commits, n)
end
end)
{:ok, result}
{:error, _} = err ->
err
end
end
defp apply_skip_and_limit(commits, skip, max_count) do
commits
|> Enum.drop(skip)
|> then(fn dropped ->
case max_count do
:infinity -> dropped
n -> Enum.take(dropped, n)
end
end)
end
@doc """
Find the merge base (lowest common ancestor) of two commits.
@@ -81,26 +82,26 @@
if sha_a == sha_b do
{:ok, sha_a}
else
# Use a priority queue approach: walk both sides simultaneously,
# processing commits in timestamp-descending order.
# Mark each commit as reachable from :a, :b, or :both.
# The first commit we pop that is reachable from both is the merge base.
case {ObjectResolver.read(repo, sha_a), ObjectResolver.read(repo, sha_b)} do
{{:ok, %Commit{} = ca}, {:ok, %Commit{} = cb}} ->
ts_a = parse_timestamp(ca.committer)
ts_b = parse_timestamp(cb.committer)
init_merge_base_walk(repo, sha_a, sha_b)
end
end
defp init_merge_base_walk(repo, sha_a, sha_b) do
case {ObjectResolver.read(repo, sha_a), ObjectResolver.read(repo, sha_b)} do
{{:ok, %Commit{} = ca}, {:ok, %Commit{} = cb}} ->
ts_a = parse_timestamp(ca.committer)
ts_b = parse_timestamp(cb.committer)
queue = [{ts_a, sha_a}, {ts_b, sha_b}]
queue = Enum.sort_by(queue, &elem(&1, 0), :desc)
reachable = %{sha_a => :a, sha_b => :b}
queue = [{ts_a, sha_a}, {ts_b, sha_b}]
queue = Enum.sort_by(queue, &elem(&1, 0), :desc)
reachable = %{sha_a => :a, sha_b => :b}
merge_base_walk(repo, queue, reachable)
merge_base_walk(repo, queue, reachable)
{{:error, _} = err, _} ->
err
{{:error, _} = err, _} ->
err
{_, {:error, _} = err} ->
err
end
{_, {:error, _} = err} ->
err
end
end
@@ -118,44 +119,52 @@
{:ok, sha}
side ->
process_merge_base_commit(repo, sha, side, rest, reachable)
end
end
defp process_merge_base_commit(repo, sha, side, rest, reachable) do
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{} = commit} ->
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{} = commit} ->
{new_queue, new_reachable} =
Enum.reduce(commit.parents, {rest, reachable}, fn parent, {q, r} ->
existing = Map.get(r, parent)
{new_queue, new_reachable} =
Enum.reduce(commit.parents, {rest, reachable}, fn parent, {q, r} ->
enqueue_parent(repo, parent, side, q, r)
end)
new_side =
case {existing, side} do
{nil, s} -> s
{^side, _} -> side
{:both, _} -> :both
{_, :both} -> :both
_ -> :both
merge_base_walk(repo, new_queue, new_reachable)
end
r = Map.put(r, parent, new_side)
{:error, _} = err ->
err
end
end
needs_enqueue = existing == nil or (existing != new_side and new_side == :both)
defp enqueue_parent(repo, parent, side, queue, reachable) do
existing = Map.get(reachable, parent)
new_side = compute_new_side(existing, side)
reachable = Map.put(reachable, parent, new_side)
needs_enqueue = existing == nil or (existing != new_side and new_side == :both)
if needs_enqueue do
enqueue_commit(repo, parent, queue, reachable)
else
{queue, reachable}
if needs_enqueue do
case ObjectResolver.read(repo, parent) do
{:ok, %Commit{} = pc} ->
ts = parse_timestamp(pc.committer)
q = insert_sorted(q, {ts, parent})
{q, r}
end
end
_ ->
{q, r}
end
else
{q, r}
end
end)
defp compute_new_side(nil, side), do: side
defp compute_new_side(:both, _side), do: :both
defp compute_new_side(_existing, :both), do: :both
defp compute_new_side(same, same), do: same
defp compute_new_side(_existing, _side), do: :both
merge_base_walk(repo, new_queue, new_reachable)
defp enqueue_commit(repo, parent, queue, reachable) do
case ObjectResolver.read(repo, parent) do
{:ok, %Commit{} = pc} ->
ts = parse_timestamp(pc.committer)
queue = insert_sorted(queue, {ts, parent})
{queue, reachable}
{:error, _} = err ->
err
end
_ ->
{queue, reachable}
end
end
test/ex_git_objectstore/bugfix_c1_c2_h10_test.exs +1 −1
@@ -56,6 +56,6 @@
# At minimum, offsets should be tracked. The first entry starts at offset 12
# (after the 12-byte pack header). Subsequent entries should have larger offsets.
assert length(entries) > 0
assert entries != []
offsets = Enum.map(entries, & &1.offset)
test/ex_git_objectstore/cycle2_fixes_test.exs +4 −4
@@ -22,9 +22,9 @@
"""
use ExUnit.Case, async: true
alias ExGitObjectstore.Pack.{Writer, Reader}
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Tree, Commit, Tag}
alias ExGitObjectstore.Object.{Blob, Commit, Tag, Tree}
alias ExGitObjectstore.Pack.{Reader, Writer}
# ============================================================================
# Fix 1: try_decompress_prefix uses safeInflate
@@ -232,7 +232,7 @@
# Parse should succeed with all delta objects resolved
assert {:ok, entries} = Reader.parse(pack_data)
assert length(entries) > 0
assert entries != []
# All entries should be fully resolved (no :ofs_delta or :ref_delta types)
for entry <- entries do
@@ -477,7 +477,7 @@
test "non-binary content raises FunctionClauseError" do
assert_raise FunctionClauseError, fn ->
Object.encode_raw_from_type(:blob, 12345)
Object.encode_raw_from_type(:blob, 12_345)
end
end
end
test/ex_git_objectstore/cycle3_fixes_test.exs +1 −1
@@ -74,7 +74,7 @@
# Diff against empty tree — should recurse through all 5 levels
assert {:ok, changes} = Diff.diff_trees(repo, nil, tree_sha)
assert length(changes) > 0
assert changes != []
# The leaf file should be found
assert Enum.any?(changes, fn c -> String.contains?(c.path, "leaf.txt") end)
test/ex_git_objectstore/cycle4_fixes_test.exs +2 −2
@@ -24,9 +24,9 @@
"""
use ExUnit.Case, async: true
alias ExGitObjectstore.Diff.Myers
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
@@ -180,6 +180,6 @@
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()
lines = Enum.map_join(1..5_000, "", &"line #{&1}\n")
text_a = lines <> "old line\n"
text_b = lines <> "new line\n"
test/ex_git_objectstore/cycle5_fixes_test.exs +3 −3
@@ -116,7 +116,7 @@
# Craft a pkt-line header with length 0xFFFF (65535), exceeding max 0xFFF0 (65520)
oversized_header = "ffff"
# Pad with enough data to satisfy the length
data = oversized_header <> String.duplicate("x", 65535 - 4)
data = oversized_header <> String.duplicate("x", 65_535 - 4)
assert {:error, {:invalid_pkt_len, "ffff"}} = PktLine.decode_one(data)
end
@@ -124,13 +124,13 @@
test "decoder rejects pkt-line with length 0xFFF1" do
# Just above the limit
header = "fff1"
data = header <> String.duplicate("x", 65521 - 4)
data = header <> String.duplicate("x", 65_521 - 4)
assert {:error, {:invalid_pkt_len, "fff1"}} = PktLine.decode_one(data)
end
test "decoder accepts pkt-line with length exactly 0xFFF0" do
# Build valid pkt-line with exactly the max length
payload = String.duplicate("x", 65_520 - 4)
payload = String.duplicate("x", 65520 - 4)
hex_len = "fff0"
data = hex_len <> payload
test/ex_git_objectstore/diff/diff_test.exs +4 −4
@@ -118,7 +118,7 @@
{:ok, new_sha} = Object.write(repo, new)
{:ok, hunks} = Diff.diff_blobs(repo, old_sha, new_sha)
assert length(hunks) >= 1
assert hunks != []
hunk = List.first(hunks)
types = Enum.map(hunk.lines, &elem(&1, 0))
@@ -133,7 +133,7 @@
{:ok, new_sha} = Object.write(repo, new)
{:ok, hunks} = Diff.diff_blobs(repo, nil, new_sha)
assert length(hunks) >= 1
assert hunks != []
end
test "deleted file (new_sha nil)" do
@@ -143,6 +143,6 @@
{:ok, old_sha} = Object.write(repo, old)
{:ok, hunks} = Diff.diff_blobs(repo, old_sha, nil)
assert length(hunks) >= 1
assert hunks != []
end
end
@@ -214,7 +214,7 @@
{:ok, diffs} = Diff.diff_commits(repo, c1_sha, c2_sha)
assert [%{path: "file.txt", status: :modified, hunks: hunks}] = diffs
assert length(hunks) >= 1
assert hunks != []
end
end
end
test/ex_git_objectstore/diff/myers_test.exs +3 −3
@@ -91,8 +91,8 @@
# Should show line2 deleted and modified inserted
dels = Enum.filter(result, fn {op, _} -> op == :del end)
ins = Enum.filter(result, fn {op, _} -> op == :ins end)
assert length(dels) >= 1
assert length(ins) >= 1
assert dels != []
assert ins != []
end
test "adding lines" do
@@ -101,6 +101,6 @@
result = Myers.diff_lines(a, b)
ins = Enum.filter(result, fn {op, _} -> op == :ins end)
assert ins != []
assert length(ins) >= 1
end
end
test/ex_git_objectstore/h6_h9_fixes_test.exs +5 −5
@@ -18,7 +18,7 @@
alias ExGitObjectstore.Diff
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.Blob
alias ExGitObjectstore.Pack.Writer
alias ExGitObjectstore.Pack.{Index, Reader, Writer}
alias ExGitObjectstore.Repo
alias ExGitObjectstore.Storage.Filesystem
alias ExGitObjectstore.Test.RepoHelper
@@ -265,20 +265,20 @@
assert <<"PACK", 2::unsigned-big-32, 100::unsigned-big-32, _rest::binary>> = pack_data
# Verify index is valid
assert {:ok, index} = Index.parse(idx_data)
assert {:ok, index} = ExGitObjectstore.Pack.Index.parse(idx_data)
assert index.count == 100
# Verify all objects are in the index
for {:blob, _content, sha} <- objects do
assert ExGitObjectstore.Pack.Index.member?(index, sha)
assert Index.member?(index, sha)
end
# Verify objects can be read back at correct offsets
for {:blob, content, sha} <- objects do
assert {:ok, offset} = Index.lookup(index, sha)
assert {:ok, offset} = ExGitObjectstore.Pack.Index.lookup(index, sha)
assert {:ok, {:blob, ^content}} =
ExGitObjectstore.Pack.Reader.read_object(pack_data, offset)
Reader.read_object(pack_data, offset)
end
end
end
test/ex_git_objectstore/integration/git_repo_test.exs +1 −1
@@ -20,7 +20,7 @@
use ExUnit.Case, async: true
alias ExGitObjectstore.{Object, Repo}
alias ExGitObjectstore.Object.{Blob, Tree, Commit}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Storage.Filesystem
@moduletag :integration
test/ex_git_objectstore/integration/packed_repo_test.exs +1 −1
@@ -19,8 +19,8 @@
"""
use ExUnit.Case, async: true
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Repo
alias ExGitObjectstore.Object.{Blob, Tree, Commit}
alias ExGitObjectstore.Storage.Filesystem
@moduletag :integration
test/ex_git_objectstore/interop_edge_cases_test.exs +4 −4
@@ -23,8 +23,8 @@
"""
use ExUnit.Case, async: false
alias ExGitObjectstore.{Repo, Ref}
alias ExGitObjectstore.Object.{Commit, Tag}
alias ExGitObjectstore.{Ref, Repo}
alias ExGitObjectstore.Storage.Filesystem
@moduletag :interop_edge_cases
@@ -168,9 +168,9 @@
re_encoded = Commit.encode_content(commit)
assert re_encoded == raw_content,
"Empty header value round-trip failed.\n" <>
"The encoder adds a trailing space to value-less headers.\n" <>
"\"myheader\\n\" becomes \"myheader \\n\"\n\n" <>
~s(Empty header value round-trip failed.\n) <>
~s(The encoder adds a trailing space to value-less headers.\n) <>
~s("myheader\\n" becomes "myheader \\n"\n\n) <>
"Original:\n#{inspect(raw_content)}\n\n" <>
"Re-encoded:\n#{inspect(re_encoded)}"
end
test/ex_git_objectstore/object/git_interop_test.exs +1 −1
@@ -20,6 +20,6 @@
use ExUnit.Case, async: true
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Tree, Commit}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
@moduletag :git_interop
test/ex_git_objectstore/object/safe_decompress_test.exs +3 −2
@@ -16,10 +16,11 @@
use ExUnit.Case, async: true
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.Blob
describe "C4: safe_decompress uses streaming decompression with early abort" do
test "decode succeeds for normal-sized objects" do
blob = Blob.from_content("hello world")
blob = ExGitObjectstore.Object.Blob.from_content("hello world")
{:ok, compressed} = Object.encode(blob)
assert {:ok, decoded} = Object.decode(compressed)
assert decoded.content == "hello world"
@@ -33,7 +34,7 @@
# public API, we test against the 128MB default by verifying the
# mechanism works (streaming decompression produces correct output).
content = String.duplicate("X", 10_000)
blob = ExGitObjectstore.Object.Blob.from_content(content)
blob = Blob.from_content(content)
{:ok, compressed} = Object.encode(blob)
# Should decompress and decode successfully
test/ex_git_objectstore/object_resolver_test.exs +4 −4
@@ -288,7 +288,7 @@
calls = TrackingMemory.get_calls(config)
# Should have called get_pack_index (to check the index)
assert length(calls_for(calls, :get_pack_index)) > 0
assert calls_for(calls, :get_pack_index) != []
# Should NOT have called get_pack (since SHA wasn't in the index)
assert calls_for(calls, :get_pack) == [],
@@ -309,9 +309,9 @@
calls = TrackingMemory.get_calls(config)
# Should have called both get_pack_index and get_pack
assert length(calls_for(calls, :get_pack_index)) > 0,
assert calls_for(calls, :get_pack_index) != [],
"get_pack_index should be called"
assert length(calls_for(calls, :get_pack)) > 0,
assert calls_for(calls, :get_pack) != [],
"get_pack should be called when SHA is in the index"
end
@@ -332,7 +332,7 @@
calls = TrackingMemory.get_calls(config)
# Should have called get_pack_index for at least one pack
assert length(calls_for(calls, :get_pack_index)) >= 1
assert calls_for(calls, :get_pack_index) != []
# get_pack should only be called once (for the pack that has the SHA)
assert length(calls_for(calls, :get_pack)) == 1,
test/ex_git_objectstore/pack/index_test.exs +16 −14
@@ -15,7 +15,9 @@
defmodule ExGitObjectstore.Pack.IndexTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Pack.Index
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.Blob
alias ExGitObjectstore.Pack.{Index, Writer}
describe "parse" do
test "parse real idx file from git gc'd repo" do
@@ -50,11 +52,11 @@
describe "spec compliance - checksum and sorting" do
test "generated index has valid checksum" do
# Use Writer to generate a pack+index, then verify index parses
blob = ExGitObjectstore.Object.Blob.from_content("test\n")
sha = ExGitObjectstore.Object.hash(blob)
blob = Blob.from_content("test\n")
sha = Object.hash(blob)
{_pack_data, idx_data, _pack_sha} =
ExGitObjectstore.Pack.Writer.generate_with_index([{:blob, "test\n", sha}])
Writer.generate_with_index([{:blob, "test\n", sha}])
# Verify the index checksum by checking the last 20 bytes
body_len = byte_size(idx_data) - 20
@@ -66,11 +68,11 @@
end
test "rejects index with corrupted checksum" do
blob = Blob.from_content("test\n")
sha = Object.hash(blob)
blob = ExGitObjectstore.Object.Blob.from_content("test\n")
sha = ExGitObjectstore.Object.hash(blob)
{_pack_data, idx_data, _pack_sha} =
ExGitObjectstore.Pack.Writer.generate_with_index([{:blob, "test\n", sha}])
Writer.generate_with_index([{:blob, "test\n", sha}])
# Corrupt the trailing checksum
body_len = byte_size(idx_data) - 20
@@ -82,12 +84,12 @@
end
test "SHAs in generated index are sorted" do
blob1 = ExGitObjectstore.Object.Blob.from_content("one\n")
blob1 = Blob.from_content("one\n")
sha1 = Object.hash(blob1)
blob2 = Blob.from_content("two\n")
sha2 = Object.hash(blob2)
blob3 = Blob.from_content("three\n")
sha3 = Object.hash(blob3)
sha1 = ExGitObjectstore.Object.hash(blob1)
blob2 = ExGitObjectstore.Object.Blob.from_content("two\n")
sha2 = ExGitObjectstore.Object.hash(blob2)
blob3 = ExGitObjectstore.Object.Blob.from_content("three\n")
sha3 = ExGitObjectstore.Object.hash(blob3)
objects = [
{:blob, "one\n", sha1},
@@ -96,7 +98,7 @@
]
{_pack_data, idx_data, _pack_sha} =
Writer.generate_with_index(objects)
ExGitObjectstore.Pack.Writer.generate_with_index(objects)
assert {:ok, index} = Index.parse(idx_data)
assert index.shas == Enum.sort(index.shas)
test/ex_git_objectstore/pack/reader_decompression_test.exs +9 −7
@@ -15,14 +15,16 @@
defmodule ExGitObjectstore.Pack.ReaderDecompressionTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Pack.Reader
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.Blob
alias ExGitObjectstore.Pack.{Reader, Writer}
describe "C3: decompress_data size limit" do
test "normal decompression works within default limit" do
content = "hello world\n"
blob = ExGitObjectstore.Object.Blob.from_content(content)
sha = ExGitObjectstore.Object.hash(blob)
{pack_data, _} = ExGitObjectstore.Pack.Writer.generate([{:blob, content, sha}])
blob = Blob.from_content(content)
sha = Object.hash(blob)
{pack_data, _} = Writer.generate([{:blob, content, sha}])
# Parsing should succeed for normal-sized data
assert {:ok, entries} = Reader.parse(pack_data)
@@ -32,9 +34,9 @@
test "read_object works within default limit" do
content = "hello world\n"
blob = ExGitObjectstore.Object.Blob.from_content(content)
sha = ExGitObjectstore.Object.hash(blob)
{pack_data, _} = ExGitObjectstore.Pack.Writer.generate([{:blob, content, sha}])
blob = Blob.from_content(content)
sha = Object.hash(blob)
{pack_data, _} = Writer.generate([{:blob, content, sha}])
# Read the object at offset 12 (after 12-byte header)
assert {:ok, {:blob, ^content}} = Reader.read_object(pack_data, 12)
test/ex_git_objectstore/pack/reader_test.exs +193 −16
@@ -12,10 +12,73 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# Generates valid git delta binary data for testing thin pack resolution.
# Uses prefix-copy + suffix-insert strategy to create minimal deltas.
defmodule DeltaGenerator do
@moduledoc false
import Bitwise
@doc "Generate delta instructions that transform `base` into `target`."
def generate(base, target) do
prefix_len = common_prefix_length(base, target, 0)
suffix = binary_part(target, prefix_len, byte_size(target) - prefix_len)
delta =
encode_size_varint(byte_size(base)) <>
encode_size_varint(byte_size(target)) <>
encode_copy(prefix_len) <>
encode_inserts(suffix)
{:ok, delta}
end
defp common_prefix_length(<<c, a::binary>>, <<c, b::binary>>, n),
do: common_prefix_length(a, b, n + 1)
defp common_prefix_length(_, _, n), do: n
defp encode_size_varint(n) when n < 128, do: <<n>>
defp encode_size_varint(n),
do: <<bor(band(n, 0x7F), 0x80)>> <> encode_size_varint(bsr(n, 7))
# Copy from offset 0 with given size
defp encode_copy(0), do: <<>>
defp encode_copy(size) do
{sz_bytes, sz_mask} = encode_le_bytes(size, [{0, 0x10}, {1, 0x20}, {2, 0x40}])
cmd = bor(0x80, sz_mask)
<<cmd>> <> IO.iodata_to_binary(sz_bytes)
end
defp encode_le_bytes(value, positions) do
Enum.reduce(positions, {[], 0}, fn {byte_idx, mask_bit}, {bytes, mask} ->
byte_val = band(bsr(value, byte_idx * 8), 0xFF)
if byte_val != 0 do
{bytes ++ [<<byte_val>>], bor(mask, mask_bit)}
else
{bytes, mask}
end
end)
end
defp encode_inserts(<<>>), do: <<>>
defp encode_inserts(data) when byte_size(data) <= 127,
do: <<byte_size(data)>> <> data
defp encode_inserts(<<chunk::binary-size(127), rest::binary>>),
do: <<127>> <> chunk <> encode_inserts(rest)
end
defmodule ExGitObjectstore.Pack.ReaderTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Object
alias ExGitObjectstore.Pack.{Index, Reader}
alias ExGitObjectstore.Object.Blob
alias ExGitObjectstore.Pack.{Index, Reader, Writer}
describe "read_object from packfile" do
test "reads all objects from a packed repo" do
@@ -70,18 +133,18 @@
describe "spec compliance - pack checksum verification" do
test "verify_pack_checksum succeeds on valid pack" do
content = "hello world\n"
blob = ExGitObjectstore.Object.Blob.from_content(content)
sha = ExGitObjectstore.Object.hash(blob)
blob = Blob.from_content(content)
sha = Object.hash(blob)
{pack_data, _} = Writer.generate([{:blob, content, sha}])
{pack_data, _} = ExGitObjectstore.Pack.Writer.generate([{:blob, content, sha}])
assert :ok = Reader.verify_pack_checksum(pack_data)
end
test "verify_pack_checksum detects corruption" do
content = "hello world\n"
blob = Blob.from_content(content)
sha = Object.hash(blob)
blob = ExGitObjectstore.Object.Blob.from_content(content)
sha = ExGitObjectstore.Object.hash(blob)
{pack_data, _} = ExGitObjectstore.Pack.Writer.generate([{:blob, content, sha}])
{pack_data, _} = Writer.generate([{:blob, content, sha}])
# Corrupt one byte in the middle
mid = div(byte_size(pack_data), 2)
@@ -97,9 +160,9 @@
test "parse rejects pack with corrupted checksum" do
content = "hello world\n"
blob = ExGitObjectstore.Object.Blob.from_content(content)
sha = ExGitObjectstore.Object.hash(blob)
{pack_data, _} = ExGitObjectstore.Pack.Writer.generate([{:blob, content, sha}])
blob = Blob.from_content(content)
sha = Object.hash(blob)
{pack_data, _} = Writer.generate([{:blob, content, sha}])
# Corrupt the trailing checksum
body_len = byte_size(pack_data) - 20
@@ -114,9 +177,9 @@
describe "spec compliance - version support" do
test "accepts version 2 packs" do
content = "test\n"
blob = Blob.from_content(content)
sha = Object.hash(blob)
{pack_data, _} = Writer.generate([{:blob, content, sha}])
blob = ExGitObjectstore.Object.Blob.from_content(content)
sha = ExGitObjectstore.Object.hash(blob)
{pack_data, _} = ExGitObjectstore.Pack.Writer.generate([{:blob, content, sha}])
# Verify it's version 2
<<"PACK", 2::unsigned-big-32, _rest::binary>> = pack_data
@@ -127,9 +190,9 @@
describe "spec compliance - SHA index building" do
test "build_sha_index returns SHA-to-offset map" do
content = "hello\n"
blob = ExGitObjectstore.Object.Blob.from_content(content)
sha = ExGitObjectstore.Object.hash(blob)
{pack_data, _} = ExGitObjectstore.Pack.Writer.generate([{:blob, content, sha}])
blob = Blob.from_content(content)
sha = Object.hash(blob)
{pack_data, _} = Writer.generate([{:blob, content, sha}])
assert {:ok, index} = Reader.build_sha_index(pack_data)
assert Map.has_key?(index, sha)
@@ -141,7 +204,121 @@
end
end
describe "thin pack support - REF_DELTA with external resolver" do
test "resolves REF_DELTA base via external_resolver" do
base_content = String.duplicate("base content line\n", 20)
base_sha = blob_sha(base_content)
target_content = String.duplicate("base content line\n", 18) <> "modified line\nnew line\n"
{:ok, delta_data} = DeltaGenerator.generate(base_content, target_content)
thin_pack = build_thin_pack_with_ref_delta(base_sha, delta_data)
# Without external_resolver: should fail
assert {:error, {:ref_delta_base_not_found, _}} = Reader.parse(thin_pack)
# With external_resolver: should succeed
resolver = fn sha ->
if sha == base_sha, do: {:ok, {:blob, base_content}}, else: {:error, :not_found}
end
assert {:ok, [entry]} = Reader.parse(thin_pack, external_resolver: resolver)
assert entry.type == :blob
assert entry.data == target_content
end
test "resolves REF_DELTA in mixed pack with full blob + REF_DELTA" do
base_content = String.duplicate("shared content\n", 30)
base_sha = blob_sha(base_content)
target_content = String.duplicate("shared content\n", 28) <> "new stuff here\nmore new\n"
{:ok, delta_data} = DeltaGenerator.generate(base_content, target_content)
unrelated_content = "some other file\n"
thin_pack = build_thin_pack_mixed(unrelated_content, base_sha, delta_data)
resolver = fn sha ->
if sha == base_sha, do: {:ok, {:blob, base_content}}, else: {:error, :not_found}
end
assert {:ok, entries} = Reader.parse(thin_pack, external_resolver: resolver)
assert length(entries) == 2
assert Enum.any?(entries, &(&1.data == target_content && &1.type == :blob))
end
end
# -- Helpers --
defp blob_sha(content) do
raw = "blob #{byte_size(content)}\0" <> content
:crypto.hash(:sha, raw) |> Base.encode16(case: :lower)
end
defp build_thin_pack_with_ref_delta(base_sha, delta_data) do
entry = encode_ref_delta_entry(base_sha, delta_data)
build_pack(1, entry)
end
defp build_thin_pack_mixed(blob_content, base_sha, delta_data) do
blob_entry = encode_blob_entry(blob_content)
ref_delta_entry = encode_ref_delta_entry(base_sha, delta_data)
build_pack(2, [blob_entry, ref_delta_entry])
end
defp build_pack(count, entries) do
header = <<"PACK", 2::unsigned-big-32, count::unsigned-big-32>>
body = IO.iodata_to_binary([header, entries])
checksum = :crypto.hash(:sha, body)
<<body::binary, checksum::binary>>
end
defp encode_blob_entry(content) do
header = encode_pack_object_header(3, byte_size(content))
[header, zlib_compress(content)]
end
defp encode_ref_delta_entry(base_sha, delta_data) do
header = encode_pack_object_header(7, byte_size(delta_data))
{:ok, base_sha_bin} = Base.decode16(base_sha, case: :lower)
[header, base_sha_bin, zlib_compress(delta_data)]
end
defp encode_pack_object_header(type_num, size) do
import Bitwise
first_byte = bor(bsl(type_num, 4), band(size, 0x0F))
remaining = bsr(size, 4)
if remaining == 0 do
<<first_byte>>
else
<<bor(first_byte, 0x80), encode_varint(remaining)::binary>>
end
end
defp encode_varint(0), do: <<>>
defp encode_varint(value) do
import Bitwise
byte = band(value, 0x7F)
remaining = bsr(value, 7)
if remaining == 0, do: <<byte>>, else: <<bor(byte, 0x80), encode_varint(remaining)::binary>>
end
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 create_packed_repo_with_objects do
tmp_dir = System.tmp_dir!()
test/ex_git_objectstore/pack/writer_test.exs +2 −2
@@ -15,9 +15,9 @@
defmodule ExGitObjectstore.Pack.WriterTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Pack.{Writer, Reader, Index}
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Tree, Commit}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Pack.{Index, Reader, Writer}
describe "generate" do
test "generates valid packfile with a single blob" do
test/ex_git_objectstore/protocol/pkt_line_test.exs +6 −6
@@ -145,7 +145,7 @@
test "encode raises on oversized data" do
# Max total pkt-line is 65520 bytes (0xFFF0)
# encode adds 4-byte prefix + 1-byte newline, so max data is 65515 chars
big_data = String.duplicate("x", 65516)
big_data = String.duplicate("x", 65_516)
assert_raise ArgumentError, ~r/pkt-line too long/, fn ->
PktLine.encode(big_data)
@@ -154,7 +154,7 @@
test "encode_raw raises on oversized data" do
# encode_raw adds 4-byte prefix, so max payload is 65516 bytes
big_data = :binary.copy(<<0>>, 65517)
big_data = :binary.copy(<<0>>, 65_517)
assert_raise ArgumentError, ~r/pkt-line too long/, fn ->
PktLine.encode_raw(big_data)
@@ -164,15 +164,15 @@
test "encode allows data at exact max length" do
# Max data + newline + 4 prefix = 65520
# If data already has newline: 65516 bytes data = 65520 total
data = String.duplicate("x", 65_515) <> "\n"
data = String.duplicate("x", 65515) <> "\n"
result = PktLine.encode(data)
assert byte_size(result) == 65520
assert byte_size(result) == 65_520
end
test "encode_raw allows data at exact max payload" do
# 65516 bytes + 4 prefix = 65520
data = :binary.copy(<<0>>, 65516)
data = :binary.copy(<<0>>, 65_516)
result = PktLine.encode_raw(data)
assert byte_size(result) == 65_520
assert byte_size(result) == 65520
end
end
test/ex_git_objectstore/protocol/receive_pack_test.exs +2 −2
@@ -15,10 +15,10 @@
defmodule ExGitObjectstore.Protocol.ReceivePackTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Protocol.{ReceivePack, PktLine}
alias ExGitObjectstore.{Object, Ref}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Pack.Writer
alias ExGitObjectstore.Protocol.{PktLine, ReceivePack}
alias ExGitObjectstore.Test.RepoHelper
describe "init" do
@@ -77,7 +77,7 @@
assert String.contains?(first_line, "HEAD")
# refs/heads/main should appear in subsequent lines
all_text = Enum.map(data_lines, fn {:data, d} -> d end) |> Enum.join("\n")
all_text = Enum.map_join(data_lines, "\n", fn {:data, d} -> d end)
assert String.contains?(all_text, "refs/heads/main")
end
end
test/ex_git_objectstore/protocol/upload_pack_test.exs +19 −18
@@ -15,9 +15,10 @@
defmodule ExGitObjectstore.Protocol.UploadPackTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Protocol.{UploadPack, PktLine}
alias ExGitObjectstore.{Object, Ref}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Pack.Reader
alias ExGitObjectstore.Protocol.{PktLine, UploadPack}
alias ExGitObjectstore.Test.RepoHelper
defp create_commit(repo, content, message, parents \\ []) do
@@ -157,7 +158,7 @@
assert byte_size(pack_data) > 0
# Verify the pack is parseable
assert {:ok, entries} = ExGitObjectstore.Pack.Reader.parse(pack_data)
assert {:ok, entries} = Reader.parse(pack_data)
assert length(entries) >= 3
types = Enum.map(entries, & &1.type) |> MapSet.new()
@@ -213,28 +214,28 @@
defp extract_sideband_loop(<<hex::binary-size(4), rest::binary>>, acc) do
case Integer.parse(hex, 16) do
{len, ""} when len >= 5 ->
extract_pkt_payload(rest, len - 4, acc)
_ ->
if acc == <<>>, do: nil, else: acc
end
payload_len = len - 4
end
defp extract_sideband_loop(_, acc), do: if(acc == <<>>, do: nil, else: acc)
if byte_size(rest) >= payload_len do
<<payload::binary-size(payload_len), after_pkt::binary>> = rest
case payload do
<<1, pack_chunk::binary>> ->
defp extract_pkt_payload(rest, payload_len, acc) when byte_size(rest) >= payload_len do
<<payload::binary-size(payload_len), after_pkt::binary>> = rest
# Sideband band 1 — pack data
extract_sideband_loop(after_pkt, acc <> pack_chunk)
_ ->
# NAK or other text line, skip
extract_sideband_loop(after_pkt, acc)
end
else
if acc == <<>>, do: nil, else: acc
end
case payload do
<<1, pack_chunk::binary>> ->
extract_sideband_loop(after_pkt, acc <> pack_chunk)
_ ->
if acc == <<>>, do: nil, else: acc
extract_sideband_loop(after_pkt, acc)
end
end
defp extract_sideband_loop(_, acc), do: if(acc == <<>>, do: nil, else: acc)
defp extract_pkt_payload(_rest, _payload_len, acc) do
if acc == <<>>, do: nil, else: acc
end
end
test/ex_git_objectstore/security_test.exs +2 −4
@@ -18,8 +18,9 @@
alias ExGitObjectstore.{Object, Ref}
alias ExGitObjectstore.Object.{Blob, Commit, Tag, Tree}
alias ExGitObjectstore.Pack.Delta
alias ExGitObjectstore.Protocol.{PktLine, ReceivePack}
alias ExGitObjectstore.Repo
alias ExGitObjectstore.Storage.Filesystem
alias ExGitObjectstore.Repo
alias ExGitObjectstore.Test.RepoHelper
describe "path traversal prevention in filesystem ref operations" do
@@ -212,8 +213,5 @@
describe "receive-pack buffer limit" do
test "rejects oversized pack data" do
alias ExGitObjectstore.Protocol.ReceivePack
alias ExGitObjectstore.Protocol.PktLine
repo = RepoHelper.memory_repo()
{_advert, state} = ReceivePack.init(repo)
test/ex_git_objectstore/storage/filesystem_test.exs +1 −1
@@ -15,9 +15,9 @@
defmodule ExGitObjectstore.Storage.FilesystemTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Storage.Filesystem
alias ExGitObjectstore.{Object, Repo}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Storage.Filesystem
setup do
tmp_dir = System.tmp_dir!()
test/ex_git_objectstore/storage/s3_test.exs +1 −1
@@ -17,9 +17,9 @@
@moduletag :s3
alias ExGitObjectstore.Storage.S3
alias ExGitObjectstore.{Object, Repo}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Storage.S3
@minio_config %{
bucket: "test-bucket",