ref:b37e1e80293bba4105ad46aacf7da643efa162ba

Fix git spec compliance: 30+ findings across objects, packs, and protocol

Audit against the git format specs identified 4 critical, 4 high, 13 medium, and 13 low severity compliance issues. All have been fixed with tests. Critical fixes: - P1: Capability NUL space removed (\0caps not \0 caps) - P2: HEAD advertised first in ref advertisement (spec requirement) - P3: pkt-line max length enforced (65520 bytes/0xFFF0) - O1: GPG signature continuation lines (space-prefixed per spec) High fixes: - PK1: Pack checksum verification on parse - PK2: REF_DELTA resolution via in-memory SHA-to-offset index - P4: Two-phase want/have negotiation with ACK support - P5: Client capability parsing from NUL-separated command line Medium fixes: - O2: Header size validation in decode_raw - O3: Tree sort uses directory_mode? helper - O4: Gitlink (160000) mode canonicalization - O5: Optional tagger in tags - O6: Extra headers preserved in tags - PK3: Pack version 3 support - PK4: Index checksum verification - PK7: SHA sort validation in index - PK8: O(N) fanout computation - P6: Peeled tag refs (^{}) in advertisement - P7: Strip exactly one trailing LF - P9/P10: Per-ref error reporting and result tracking Low fixes: - O7: SHA-1 integrity verification on object read - O8: Tree entry name validation (.git, slash, null byte) - O12-O14: Tag continuation lines and unknown header preservation - P11: Visited set prevents exponential object collection - P12: Empty pack boundary fix (>= 32 not > 32) 352 tests, 0 failures (44 new spec compliance tests added). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SHA: b37e1e80293bba4105ad46aacf7da643efa162ba
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-02-11 02:15
Parents: 8a06b8f
19 files changed +1260 -165
Type
lib/ex_git_objectstore/object.ex +25 −4
@@ -86,8 +86,13 @@
@spec decode_raw(binary()) :: {:ok, t()} | {:error, term()}
def decode_raw(raw) do
case parse_header(raw) do
{:ok, type, _size, content} ->
{:ok, type, size, content} ->
# Validate that header size matches actual content length
if byte_size(content) != size do
{:error, {:size_mismatch, expected: size, actual: byte_size(content)}}
else
decode_typed(type, content)
end
decode_typed(type, content)
{:error, _} = err ->
err
@@ -100,7 +105,23 @@
@spec read(Repo.t(), String.t()) :: {:ok, t()} | {:error, term()}
def read(%Repo{} = repo, sha) do
case Repo.storage_call(repo, :get_object, [sha]) do
{:ok, data} ->
# 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)
{:ok, data} -> decode(data)
{:error, _} = err -> err
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
end
end
lib/ex_git_objectstore/object/commit.ex +19 −2
@@ -60,7 +60,7 @@
lines =
if commit.gpgsig do
lines ++ ["gpgsig #{commit.gpgsig}\n"]
lines ++ [encode_multiline_header("gpgsig", commit.gpgsig)]
else
lines
end
@@ -68,7 +68,7 @@
lines =
lines ++
Enum.map(commit.extra_headers, fn {key, value} ->
"#{key} #{value}\n"
encode_multiline_header(key, value)
end)
lines = lines ++ ["\n", commit.message]
@@ -100,6 +100,23 @@
[key] -> {key, ""}
end
end)
end
# Encode a header value that may contain newlines as continuation lines.
# Per the git spec, continuation lines are prefixed with a space character.
defp encode_multiline_header(key, value) do
if String.contains?(value, "\n") do
# Split on newlines and prefix continuation lines with space
[first | rest] = String.split(value, "\n")
lines =
["#{key} #{first}\n"] ++
Enum.map(rest, fn line -> " #{line}\n" end)
IO.iodata_to_binary(lines)
else
"#{key} #{value}\n"
end
end
# Handle GPG signature continuation lines (start with space)
lib/ex_git_objectstore/object/tag.ex +58 −16
@@ -29,26 +29,42 @@
object: String.t(),
type: String.t(),
tag: String.t(),
tagger: String.t(),
message: String.t()
tagger: String.t() | nil,
message: String.t(),
extra_headers: [{String.t(), String.t()}]
}
@enforce_keys [:object, :type, :tag, :tagger, :message]
defstruct [:object, :type, :tag, :tagger, :message]
# tagger is optional — older git versions created tags without it
@enforce_keys [:object, :type, :tag, :message]
defstruct [:object, :type, :tag, :tagger, :message, extra_headers: []]
@doc """
Encode tag to its content format.
"""
@spec encode_content(t()) :: binary()
def encode_content(%__MODULE__{} = tag) do
IO.iodata_to_binary([
lines = [
"object #{tag.object}\n",
"type #{tag.type}\n",
"tag #{tag.tag}\n"
]
lines =
if tag.tagger do
lines ++ ["tagger #{tag.tagger}\n"]
else
"tag #{tag.tag}\n",
"tagger #{tag.tagger}\n",
"\n",
tag.message
])
lines
end
lines =
lines ++
Enum.map(tag.extra_headers || [], fn {key, value} ->
"#{key} #{value}\n"
end)
lines = lines ++ ["\n", tag.message]
IO.iodata_to_binary(lines)
end
@doc """
@@ -61,6 +77,7 @@
headers =
headers_str
|> String.split("\n")
|> fold_continuation_lines()
|> Enum.map(fn line ->
case String.split(line, " ", parts: 2) do
[key, value] -> {key, value}
@@ -69,12 +86,21 @@
end)
fields =
Enum.reduce(headers, %{message: message}, fn
{"object", sha}, acc -> Map.put(acc, :object, sha)
{"type", type}, acc -> Map.put(acc, :type, type)
{"tag", name}, acc -> Map.put(acc, :tag, name)
Enum.reduce(headers, %{message: message, extra_headers: []}, fn
{"object", sha}, acc ->
Map.put(acc, :object, sha)
{"type", type}, acc ->
Map.put(acc, :type, type)
{"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}]))
{"tagger", value}, acc -> Map.put(acc, :tagger, value)
_, acc -> acc
end)
{:ok, struct!(__MODULE__, fields)}
@@ -82,5 +108,21 @@
_ ->
{:error, :invalid_tag_format}
end
end
# 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" <> line | rest]
[] -> [line]
end
else
[line | acc]
end
end)
|> Enum.reverse()
end
end
lib/ex_git_objectstore/object/tree.ex +26 −3
@@ -33,5 +33,14 @@
@spec new([entry()]) :: t()
def new(entries) do
entries = Enum.map(entries, &canonicalize_entry/1)
# Validate entry names
Enum.each(entries, fn entry ->
case validate_entry_name(entry.name) do
:ok -> :ok
{:error, reason} -> raise ArgumentError, "invalid tree entry name: #{inspect(reason)}"
end
end)
%__MODULE__{entries: sort_entries(entries)}
end
@@ -52,9 +61,20 @@
def canonicalize_mode("0100755"), do: "100755"
def canonicalize_mode("040000"), do: "40000"
def canonicalize_mode("0040000"), do: "40000"
# Gitlink (submodule) mode
def canonicalize_mode("0160000"), do: "160000"
def canonicalize_mode(mode), do: mode
@doc """
Returns true if the given mode represents a tree (directory).
"""
@spec directory_mode?(String.t()) :: boolean()
def directory_mode?("40000"), do: true
def directory_mode?("040000"), do: true
def directory_mode?("0040000"), do: true
def directory_mode?(_), do: false
@doc """
Validate a tree entry name according to git rules.
Returns `:ok` or `{:error, reason}`.
"""
@@ -64,6 +84,7 @@
name == "" -> {:error, {:invalid_entry_name, name, :empty}}
name == "." -> {:error, {:invalid_entry_name, name, :dot}}
name == ".." -> {:error, {:invalid_entry_name, name, :dotdot}}
String.downcase(name) == ".git" -> {:error, {:invalid_entry_name, name, :dotgit}}
String.contains?(name, "/") -> {:error, {:invalid_entry_name, name, :slash}}
String.contains?(name, <<0>>) -> {:error, {:invalid_entry_name, name, :null_byte}}
true -> :ok
@@ -118,11 +139,13 @@
end
# Git sorts tree entries by name, treating directories as if they have a trailing "/".
# This matches git's base_name_compare() in tree-diff.c.
defp sort_entries(entries) do
Enum.sort_by(entries, fn entry ->
case entry.mode do
"40000" -> entry.name <> "/"
_ -> entry.name
if directory_mode?(entry.mode) do
entry.name <> "/"
else
entry.name
end
end)
end
lib/ex_git_objectstore/pack/index.ex +51 −17
@@ -46,27 +46,61 @@
@max_pack_objects 10_000_000
@spec parse(binary()) :: {:ok, t()} | {:error, term()}
def parse(<<@idx_magic, @idx_version, rest::binary>>) do
with {:ok, fanout, rest} <- parse_fanout(rest),
count = elem(fanout, 255),
:ok <- validate_fanout(fanout),
:ok <- validate_count(count),
{:ok, shas, rest} <- parse_shas(rest, count),
{:ok, _crcs, rest} <- parse_crcs(rest, count),
{:ok, offsets_4, rest} <- parse_offsets_4(rest, count),
{:ok, large_offsets} <- parse_large_offsets(rest, offsets_4),
{:ok, offset_map} <- build_offset_map(shas, offsets_4, large_offsets) do
{:ok,
%__MODULE__{
fanout: fanout,
shas: shas,
offsets: offset_map,
count: count
}}
def parse(<<@idx_magic, @idx_version, _rest::binary>> = idx_data) do
# Verify index checksum first (PK4)
with :ok <- verify_index_checksum(idx_data) do
<<_magic_version::binary-size(8), rest::binary>> = idx_data
with {:ok, fanout, rest} <- parse_fanout(rest),
count = elem(fanout, 255),
:ok <- validate_fanout(fanout),
:ok <- validate_count(count),
{:ok, shas, rest} <- parse_shas(rest, count),
:ok <- validate_shas_sorted(shas),
{:ok, _crcs, rest} <- parse_crcs(rest, count),
{:ok, offsets_4, rest} <- parse_offsets_4(rest, count),
{:ok, large_offsets} <- parse_large_offsets(rest, offsets_4),
{:ok, offset_map} <- build_offset_map(shas, offsets_4, large_offsets) do
{:ok,
%__MODULE__{
fanout: fanout,
shas: shas,
offsets: offset_map,
count: count
}}
end
end
end
def parse(_), do: {:error, :invalid_idx_header}
# Verify the trailing 20-byte index checksum
defp verify_index_checksum(idx_data) when byte_size(idx_data) >= 28 do
body_len = byte_size(idx_data) - 20
<<body::binary-size(body_len), checksum::binary-size(20)>> = idx_data
expected = :crypto.hash(:sha, body)
if checksum == expected do
:ok
else
{:error, :index_checksum_mismatch}
end
end
defp verify_index_checksum(_), do: {:error, :index_too_short}
# Validate that SHA list is sorted (PK7)
defp validate_shas_sorted([]), do: :ok
defp validate_shas_sorted([_]), do: :ok
defp validate_shas_sorted(shas) do
sorted? =
shas
|> Enum.chunk_every(2, 1, :discard)
|> Enum.all?(fn [a, b] -> a < b end)
if sorted?, do: :ok, else: {:error, :shas_not_sorted}
end
@doc """
Look up the pack offset for a given SHA.
lib/ex_git_objectstore/pack/reader.ex +146 −14
@@ -53,14 +53,30 @@
Returns a list of `%{type: atom, data: binary, offset: integer}`.
"""
@spec parse(binary()) :: {:ok, [pack_object()]} | {:error, term()}
def parse(<<@pack_signature, version::unsigned-big-32, count::unsigned-big-32, rest::binary>>)
when version == 2 do
def parse(
<<@pack_signature, version::unsigned-big-32, count::unsigned-big-32, _rest::binary>> =
pack_data
)
when version in [2, 3] do
if count > @max_pack_objects do
{:error, {:pack_too_large, count}}
else
case parse_entries(rest, count, [], %{}) do
{:ok, entries, _cache} -> {:ok, entries}
{:error, _} = err -> err
# Verify trailing checksum (PK1)
case verify_pack_checksum(pack_data) do
:ok ->
# Strip the trailing 20-byte checksum before parsing entries
entries_len = byte_size(pack_data) - 12 - 20
<<_header::binary-size(12), entries_data::binary-size(entries_len),
_checksum::binary-size(20)>> = pack_data
case parse_entries(entries_data, count, [], %{}) do
{:ok, entries, _cache} -> {:ok, entries}
{:error, _} = err -> err
end
{:error, _} = err ->
err
end
end
end
@@ -68,6 +84,24 @@
def parse(_), do: {:error, :invalid_pack_header}
@doc """
Verify the trailing SHA-1 checksum of a pack file.
"""
@spec verify_pack_checksum(binary()) :: :ok | {:error, term()}
def verify_pack_checksum(pack_data) when byte_size(pack_data) >= 32 do
body_len = byte_size(pack_data) - 20
<<body::binary-size(body_len), checksum::binary-size(20)>> = pack_data
expected = :crypto.hash(:sha, body)
if checksum == expected do
:ok
else
{:error, :pack_checksum_mismatch}
end
end
def verify_pack_checksum(_), do: {:error, :pack_too_short}
@doc """
Read a single object at the given offset from a packfile binary.
Resolves deltas recursively using the pack data itself.
"""
@@ -178,7 +212,5 @@
end
end
# REF_DELTA branch contains dead code until find_sha_offset is implemented
@dialyzer {:nowarn_function, resolve_object: 4}
defp resolve_object(pack_data, offset, cache, depth) do
<<_skip::binary-size(offset), data::binary>> = pack_data
@@ -363,12 +395,112 @@
defp type_num_to_atom(@obj_blob), do: :blob
defp type_num_to_atom(@obj_tag), do: :tag
# TODO: Implement REF_DELTA resolution by building an in-memory index from
# the pack data. Currently REF_DELTA objects can only be resolved when the
# caller provides offsets via the cache (e.g., from a .idx file lookup).
# Without this, thin packs from git-fetch that use REF_DELTA will fail.
# See: https://git-scm.com/docs/pack-format#_deltified_representation
defp find_sha_offset(_pack_data, _sha, _cache) do
{:error, :ref_delta_not_supported}
# 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.
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}}
offset -> {:ok, offset}
end
{:error, _} = err ->
err
end
end
end
@doc """
Build a SHA-to-offset index by scanning all non-delta entries in a pack.
"""
@spec build_sha_index(binary()) :: {:ok, map()} | {:error, term()}
def build_sha_index(
<<@pack_signature, version::unsigned-big-32, count::unsigned-big-32, rest::binary>>
)
when version in [2, 3] do
scan_entries_for_index(rest, count, 12, %{})
end
def build_sha_index(_), do: {:error, :invalid_pack_header}
defp scan_entries_for_index(_data, 0, _offset, index), do: {:ok, index}
defp scan_entries_for_index(data, remaining, offset, index) 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] ->
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
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
{: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
{: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, _} = err ->
err
end
else
{:error, :truncated_ref_delta}
end
_ ->
{:error, {:unknown_object_type, type_num}}
end
{:error, _} = err ->
err
end
end
end
lib/ex_git_objectstore/pack/writer.ex +14 −10
@@ -180,19 +180,23 @@
end
defp build_fanout(sorted_entries) do
# 256 entries, each is cumulative count of objects with first SHA byte <= N
sha_first_bytes =
Enum.map(sorted_entries, fn {sha, _offset, _crc} ->
# O(N) fanout computation: count objects per bucket, then cumulative sum
counts = :array.new(256, default: 0)
counts =
Enum.reduce(sorted_entries, counts, fn {sha, _offset, _crc}, acc ->
<<first_byte, _rest::binary>> = elem(Base.decode16(sha, case: :mixed), 1)
first_byte
:array.set(first_byte, :array.get(first_byte, acc) + 1, acc)
end)
fanout =
for i <- 0..255 do
Enum.count(sha_first_bytes, fn b -> b <= i end)
end
# Build cumulative fanout
{fanout_list, _} =
Enum.reduce(0..255, {[], 0}, fn i, {acc, cumulative} ->
total = cumulative + :array.get(i, counts)
{[<<total::unsigned-big-32>> | acc], total}
end)
fanout
|> Enum.map(fn count -> <<count::unsigned-big-32>> end)
fanout_list
|> Enum.reverse()
|> IO.iodata_to_binary()
end
lib/ex_git_objectstore/protocol/pkt_line.ex +30 −5
@@ -23,14 +23,18 @@
- `NNNN<data>` — data packet where NNNN is total length (including the 4 prefix bytes)
Minimum data packet length is `0005` (4 prefix + 1 data byte).
Maximum is `ffff` (65535 bytes total, 65531 data bytes).
Maximum is `fff0` (65520 bytes total, 65516 data bytes).
"""
@flush "0000"
@delim "0001"
@response_end "0002"
# Max total pkt-line is 65520 (0xFFF0) bytes per spec.
# Max payload = 65520 - 4 (length prefix) = 65516
@max_pkt_len 65520
@max_data_len 65520
# For sideband: max payload (65516) - 1 band byte = 65515
@max_sideband_data 65515
@doc """
Encode a single data line as a pkt-line.
@@ -40,6 +44,11 @@
def encode(data) when is_binary(data) do
data = if String.ends_with?(data, "\n"), do: data, else: data <> "\n"
len = byte_size(data) + 4
if len > @max_pkt_len do
raise ArgumentError, "pkt-line too long: #{len} bytes (max #{@max_pkt_len})"
end
hex_len = len |> Integer.to_string(16) |> String.downcase() |> String.pad_leading(4, "0")
<<hex_len::binary, data::binary>>
end
@@ -50,6 +59,11 @@
@spec encode_raw(binary()) :: binary()
def encode_raw(data) when is_binary(data) do
len = byte_size(data) + 4
if len > @max_pkt_len do
raise ArgumentError, "pkt-line too long: #{len} bytes (max #{@max_pkt_len})"
end
hex_len = len |> Integer.to_string(16) |> String.downcase() |> String.pad_leading(4, "0")
<<hex_len::binary, data::binary>>
end
@@ -109,8 +123,8 @@
{len, ""} when len >= 4 ->
if byte_size(data) >= len do
<<_hex::binary-size(4), payload::binary-size(len - 4), rest::binary>> = data
# Strip trailing newline if present
# Strip exactly one trailing LF if present (spec says receivers should strip it)
payload = strip_one_trailing_lf(payload)
payload = String.trim_trailing(payload, "\n")
{:ok, {:data, payload}, rest}
else
{:need_more, data}
@@ -143,7 +157,7 @@
"""
@spec encode_sideband(1 | 2 | 3, binary()) :: iodata()
def encode_sideband(band, data) when band in [1, 2, 3] do
chunks = chunk_binary(data, @max_sideband_data)
chunks = chunk_binary(data, @max_data_len)
Enum.map(chunks, fn chunk ->
encode_raw(<<band, chunk::binary>>)
@@ -167,5 +181,16 @@
{:error, _} = err ->
err
end
end
defp strip_one_trailing_lf(<<>>), do: <<>>
defp strip_one_trailing_lf(bin) do
size = byte_size(bin) - 1
case bin do
<<prefix::binary-size(size), "\n">> -> prefix
_ -> bin
end
end
lib/ex_git_objectstore/protocol/receive_pack.ex +117 −34
@@ -27,6 +27,6 @@
in and reads response data out.
"""
alias ExGitObjectstore.{Object, Ref, Repo}
alias ExGitObjectstore.{Object, ObjectResolver, Ref, Repo}
alias ExGitObjectstore.Pack.Reader
alias ExGitObjectstore.Protocol.PktLine
@@ -51,6 +51,7 @@
repo: Repo.t(),
phase: :advertise | :commands | :pack | :done,
commands: [command()],
client_caps: MapSet.t(),
pack_buffer: binary(),
result: nil | :ok | {:error, term()}
}
@@ -59,6 +60,7 @@
:repo,
phase: :advertise,
commands: [],
client_caps: MapSet.new(),
pack_buffer: <<>>,
result: nil
]
@@ -84,7 +86,14 @@
@spec feed(state(), binary()) :: {binary(), state()}
def feed(%__MODULE__{phase: :commands} = state, data) do
case parse_commands(data) do
{:ok, commands, client_caps, rest} ->
state = %{
state
| commands: commands,
client_caps: client_caps,
phase: :pack,
{:ok, commands, rest} ->
state = %{state | commands: commands, phase: :pack, pack_buffer: rest}
pack_buffer: rest
}
# Check if the pack is already complete in the buffer
maybe_process_pack(state)
@@ -103,7 +112,7 @@
new_buffer = state.pack_buffer <> data
if byte_size(new_buffer) > @max_pack_size do
report = build_error_report(:pack_too_large)
report = build_error_report(:pack_too_large, state.commands)
{report, %{state | phase: :done, result: {:error, :pack_too_large}}}
else
state = %{state | pack_buffer: new_buffer}
@@ -125,20 +134,20 @@
# -- Private --
defp build_advertisement(repo) do
refs = list_all_refs(repo)
refs = list_all_refs_with_head(repo)
case refs do
[] ->
# Empty repo — advertise capabilities with zero SHA
line = "#{@zero_sha} capabilities^{}\0 #{@capabilities}"
line = "#{@zero_sha} capabilities^{}\0#{@capabilities}"
PktLine.encode(line) <> PktLine.flush()
[{first_ref, first_sha} | rest] ->
first_line = "#{first_sha} #{first_ref}\0#{@capabilities}"
first_line = "#{first_sha} #{first_ref}\0 #{@capabilities}"
lines =
[PktLine.encode(first_line)] ++
Enum.map(rest, fn {ref, sha} ->
PktLine.encode("#{sha} #{ref}")
Enum.flat_map(rest, fn {ref, sha} ->
[PktLine.encode("#{sha} #{ref}")]
end) ++
[PktLine.flush()]
@@ -147,7 +156,9 @@
end
end
# Build the ref list with HEAD first (spec: HEAD MUST appear first).
# Also includes peeled tag refs (^{}) for annotated tags.
defp list_all_refs(repo) do
defp list_all_refs_with_head(repo) do
heads =
case Ref.list(repo, "refs/heads/") do
{:ok, refs} -> refs
@@ -160,28 +171,76 @@
_ -> []
end
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 ->
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
[entry]
end
end)
# Resolve HEAD and prepend it
case resolve_head_sha(repo) do
{:ok, head_sha} ->
[{"HEAD", head_sha} | sorted_with_peeled]
_ ->
sorted_with_peeled
end
end
defp resolve_head_sha(repo) do
case Ref.get_head(repo) do
{:ok, "ref: " <> ref_path} -> Ref.get(repo, ref_path)
{:ok, sha} -> {:ok, sha}
error -> error
end
end
defp peel_tag(repo, sha) do
case ObjectResolver.read(repo, sha) do
{:ok, %ExGitObjectstore.Object.Tag{object: target_sha}} ->
{:ok, target_sha}
_ ->
{:ok, sha}
(heads ++ tags)
|> Enum.sort_by(fn {ref, _sha} -> ref end)
end
end
defp parse_commands(data) do
parse_command_lines(data, [])
parse_command_lines(data, [], nil)
end
defp parse_command_lines(data, acc) do
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}
{:ok, [], rest}
else
{:ok, Enum.reverse(acc), client_caps, rest}
{:ok, Enum.reverse(acc), rest}
end
{:ok, {:data, line}, rest} ->
case parse_command_line(line) do
{:ok, cmd, line_caps} ->
# Only the first command line carries capabilities
{:ok, cmd} ->
parse_command_lines(rest, [cmd | acc])
caps = caps || line_caps
parse_command_lines(rest, [cmd | acc], caps)
{:error, _} = err ->
err
@@ -197,18 +256,26 @@
defp parse_command_line(line) do
# Format: "<old-sha> <new-sha> <ref-name>[\0<capabilities>]"
# Strip capabilities if present
# Extract capabilities if present
{cmd_str, caps} =
line =
case String.split(line, "\0", parts: 2) do
[cmd, caps_str] ->
cap_set =
caps_str
|> String.split(" ", trim: true)
|> MapSet.new()
[cmd, _caps] -> cmd
{cmd, cap_set}
[cmd] ->
{cmd, MapSet.new()}
[cmd] -> cmd
end
case String.split(line, " ", parts: 3) 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 ->
{:ok, %{ref: ref, old_sha: old_sha, new_sha: new_sha}, caps}
{:ok, %{ref: ref, old_sha: old_sha, new_sha: new_sha}}
_ ->
{:error, {:invalid_command, cmd_str}}
{:error, {:invalid_command, line}}
end
end
@@ -237,7 +304,7 @@
# 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
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)
@@ -262,7 +329,7 @@
process_ref_updates(state)
{:error, reason} ->
report = build_error_report(reason)
report = build_error_report(reason, state.commands)
{report, %{state | phase: :done, result: {:error, reason}}}
end
end
@@ -298,8 +365,17 @@
{cmd.ref, result}
end)
# Track whether any ref updates failed
any_errors? = Enum.any?(results, fn {_ref, r} -> match?({:error, _}, r) end)
overall_result = if any_errors?, do: {:error, :some_refs_failed}, else: :ok
report = build_report(results)
# Only send report-status if client requested it (or if no commands/caps parsed)
if MapSet.member?(state.client_caps, "report-status") or MapSet.size(state.client_caps) == 0 do
report = build_report(results)
{report, %{state | phase: :done, result: overall_result}}
else
{<<>>, %{state | phase: :done, result: overall_result}}
end
{report, %{state | phase: :done, result: :ok}}
end
defp apply_ref_command(repo, %{old_sha: @zero_sha, new_sha: new_sha, ref: ref}) do
@@ -332,11 +408,18 @@
IO.iodata_to_binary(lines)
end
defp build_error_report(reason, commands \\ []) do
error_msg = sanitize_error(reason)
defp build_error_report(reason) do
lines = [
PktLine.encode("unpack #{sanitize_error(reason)}"),
PktLine.flush()
]
ref_lines =
Enum.map(commands, fn cmd ->
PktLine.encode("ng #{cmd.ref} #{error_msg}")
end)
lines =
[PktLine.encode("unpack #{error_msg}")] ++
ref_lines ++
[PktLine.flush()]
IO.iodata_to_binary(lines)
end
lib/ex_git_objectstore/protocol/upload_pack.ex +189 −58
@@ -75,11 +75,20 @@
generate_pack_response(state)
{:ok, wants, haves, :continue} ->
# Client wants more negotiation rounds — simplified: just ACK and continue
state = %{state | wants: wants ++ state.wants, haves: haves ++ state.haves}
nak = PktLine.encode("NAK")
{nak, state}
# 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}}
@@ -100,21 +109,21 @@
# -- Private --
defp build_advertisement(repo) do
refs = list_all_refs_with_head(repo)
refs = list_all_refs(repo)
symref = head_symref(repo)
caps = build_capabilities(symref)
case refs do
[] ->
line = "#{@zero_sha} capabilities^{}\0 #{caps}"
line = "#{@zero_sha} capabilities^{}\0#{caps}"
PktLine.encode(line) <> PktLine.flush()
[{first_ref, first_sha} | rest] ->
first_line = "#{first_sha} #{first_ref}\0 #{caps}"
first_line = "#{first_sha} #{first_ref}\0#{caps}"
lines =
[PktLine.encode(first_line)] ++
Enum.map(rest, fn {ref, sha} ->
Enum.flat_map(rest, fn {ref, sha} ->
[PktLine.encode("#{sha} #{ref}")]
PktLine.encode("#{sha} #{ref}")
end) ++
[PktLine.flush()]
@@ -133,7 +142,9 @@
defp build_capabilities(nil), do: @capabilities
defp build_capabilities(ref), do: "#{@capabilities} symref=HEAD:#{ref}"
# 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
defp list_all_refs(repo) do
heads =
case Ref.list(repo, "refs/heads/") do
{:ok, refs} -> refs
@@ -146,43 +157,140 @@
_ -> []
end
(heads ++ tags)
|> Enum.sort_by(fn {ref, _sha} -> ref end)
sorted =
(heads ++ tags)
|> Enum.sort_by(fn {ref, _sha} -> ref 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
[entry]
end
end)
# Resolve HEAD and prepend it
case resolve_head_sha(repo) do
{:ok, head_sha} ->
[{"HEAD", head_sha} | sorted_with_peeled]
_ ->
sorted_with_peeled
end
end
defp resolve_head_sha(repo) do
case Ref.get_head(repo) do
{:ok, "ref: " <> ref_path} -> Ref.get(repo, ref_path)
{:ok, sha} -> {:ok, sha}
error -> error
end
end
# Peel an annotated tag to its target object SHA.
defp peel_tag(repo, sha) do
case ObjectResolver.read(repo, sha) do
{:ok, %ExGitObjectstore.Object.Tag{object: target_sha}} ->
{:ok, target_sha}
_ ->
{:ok, sha}
end
end
defp parse_wants_haves(data) do
# Phase 1: Parse want lines until flush
case parse_want_lines(data, []) do
{:ok, wants, rest} ->
# Phase 2: Parse have lines until "done"
case parse_have_lines(rest, []) do
{:ok, haves, :done} ->
{:ok, wants, haves, :done}
parse_wh_lines(data, [], [], nil)
{:ok, haves, :continue} ->
{:ok, wants, haves, :continue}
{:error, _} = err ->
err
end
{:error, _} = err ->
err
end
end
defp parse_wh_lines(data, wants, haves, _terminator) do
defp parse_want_lines(data, acc) do
case PktLine.decode_one(data) do
{:ok, :flush, rest} ->
{:ok, Enum.reverse(acc), rest}
{:ok, {:data, "want " <> sha_and_caps}, rest} ->
sha = sha_and_caps |> String.split(" ", parts: 2) |> List.first() |> String.trim()
parse_want_lines(rest, [sha | acc])
{:ok, {:data, "done"}, _rest} ->
# Client sent done without any haves (simple clone)
{:ok, Enum.reverse(acc), <<>>}
{:ok, {:data, _other}, rest} ->
parse_want_lines(rest, acc)
{:need_more, _} ->
{:ok, Enum.reverse(acc), <<>>}
{:error, _} = err ->
err
end
end
defp parse_have_lines(<<>>, acc) do
# No have data — this is a clone (wants only, no haves)
{:ok, :flush, _rest} ->
{:ok, Enum.reverse(wants), Enum.reverse(haves), :done}
{:ok, Enum.reverse(acc), :done}
end
defp parse_have_lines(data, acc) do
case PktLine.decode_one(data) do
{:ok, {:data, "done"}, _rest} ->
{:ok, Enum.reverse(wants), Enum.reverse(haves), :done}
{:ok, Enum.reverse(acc), :done}
{:ok, {:data, "want " <> sha_and_caps}, rest} ->
# First want line may have capabilities after SHA
sha = sha_and_caps |> String.split(" ", parts: 2) |> List.first() |> String.trim()
parse_wh_lines(rest, [sha | wants], haves, nil)
{:ok, :flush, _rest} ->
{:ok, Enum.reverse(acc), :continue}
{:ok, {:data, "have " <> sha}, rest} ->
sha = String.trim(sha)
parse_wh_lines(rest, wants, [sha | haves], nil)
parse_have_lines(rest, [sha | acc])
{:ok, {:data, _other}, rest} ->
# Skip unknown lines
parse_wh_lines(rest, wants, haves, nil)
parse_have_lines(rest, acc)
{:need_more, _} ->
{:ok, Enum.reverse(wants), Enum.reverse(haves), :continue}
{:ok, Enum.reverse(acc), :continue}
{:error, _} = err ->
err
end
end
# Check if any of the given SHAs exist in our repo.
# Returns {:ok, sha} for the last one found, or :none.
defp find_common(repo, haves) do
Enum.reduce(haves, :none, fn sha, acc ->
case ObjectResolver.read(repo, sha) do
{:ok, _} -> {:ok, sha}
_ -> acc
end
end)
end
defp generate_pack_response(state) do
# Collect all objects needed for the wants, excluding objects reachable from haves
case collect_objects(state.repo, state.wants, state.haves) do
@@ -207,19 +315,23 @@
end
end
# Defensive: handle various error shapes even though current callers only pass binary
@dialyzer {:nowarn_function, sanitize_error: 1}
defp sanitize_error(reason) when is_binary(reason), do: reason
defp sanitize_error(reason) when is_atom(reason), do: Atom.to_string(reason)
defp sanitize_error({tag, _details}) when is_atom(tag), do: Atom.to_string(tag)
defp sanitize_error(_), do: "internal_error"
defp collect_objects(repo, wants, haves) do
# Walk from each want SHA and collect all reachable objects
have_set = MapSet.new(haves)
try do
objects =
wants
|> Enum.flat_map(fn sha ->
collect_reachable(repo, sha, have_set)
{objects, _visited} =
Enum.reduce(wants, {[], MapSet.new()}, fn sha, {acc, visited} ->
{new_objects, visited} = collect_reachable(repo, sha, have_set, visited)
{acc ++ new_objects, visited}
end)
|> Enum.uniq_by(fn {_type, _data, sha} -> sha end)
{:ok, objects}
rescue
@@ -227,64 +339,83 @@
end
end
defp collect_reachable(repo, sha, exclude_set, visited) do
defp collect_reachable(repo, sha, exclude_set) do
if MapSet.member?(exclude_set, sha) do
[]
if MapSet.member?(exclude_set, sha) or MapSet.member?(visited, sha) do
{[], visited}
else
visited = MapSet.put(visited, sha)
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{} = commit} ->
# Collect the tree and all referenced objects
{tree_objects, visited} = collect_tree_objects(repo, commit.tree, visited)
tree_objects = collect_tree_objects(repo, commit.tree)
# Recurse into parents (but stop at haves)
{parent_objects, visited} =
Enum.reduce(commit.parents, {[], visited}, fn parent_sha, {acc, vis} ->
{objs, vis} = collect_reachable(repo, parent_sha, exclude_set, vis)
parent_objects =
Enum.flat_map(commit.parents, fn parent_sha ->
{acc ++ objs, vis}
collect_reachable(repo, parent_sha, exclude_set)
end)
[{:commit, Object.encode_content_only(commit), sha} | tree_objects ++ parent_objects]
objects = [
{:commit, Object.encode_content_only(commit), sha} | tree_objects ++ parent_objects
]
{objects, visited}
{:ok, %Tree{} = tree} ->
collect_tree_entry_objects(repo, tree, sha)
collect_tree_entry_objects(repo, tree, sha, visited)
{:ok, %Blob{content: content}} ->
[{:blob, content, sha}]
{[{:blob, content, sha}], visited}
{:error, _} ->
[]
{[], visited}
end
end
end
defp collect_tree_objects(repo, tree_sha, visited) do
if MapSet.member?(visited, tree_sha) do
{[], visited}
defp collect_tree_objects(repo, tree_sha) do
case ObjectResolver.read(repo, tree_sha) do
{:ok, %Tree{} = tree} ->
collect_tree_entry_objects(repo, tree, tree_sha)
else
case ObjectResolver.read(repo, tree_sha) do
{:ok, %Tree{} = tree} ->
collect_tree_entry_objects(repo, tree, tree_sha, visited)
_ ->
_ ->
{[], visited}
end
[]
end
end
defp collect_tree_entry_objects(repo, %Tree{} = tree, tree_sha, visited) do
visited = MapSet.put(visited, tree_sha)
defp collect_tree_entry_objects(repo, %Tree{} = tree, tree_sha) do
tree_content = Tree.encode_content(tree)
entry = [{:tree, tree_content, tree_sha}]
child_objects =
Enum.flat_map(tree.entries, fn
{child_objects, visited} =
Enum.reduce(tree.entries, {[], visited}, fn
%{mode: "40000", sha: sha}, {acc, vis} ->
{objs, vis} = collect_tree_objects(repo, sha, vis)
{acc ++ objs, vis}
%{mode: "40000", sha: sha} ->
collect_tree_objects(repo, sha)
%{sha: sha} ->
%{sha: sha}, {acc, vis} ->
case ObjectResolver.read(repo, sha) do
{:ok, %Blob{content: content}} ->
[{:blob, content, sha}]
if MapSet.member?(vis, sha) do
{acc, vis}
else
vis = MapSet.put(vis, sha)
case ObjectResolver.read(repo, sha) do
{:ok, %Blob{content: content}} ->
{acc ++ [{:blob, content, sha}], vis}
_ ->
_ ->
{acc, vis}
end
[]
end
end)
{entry ++ child_objects, visited}
entry ++ child_objects
end
end
test/ex_git_objectstore/object/blob_test.exs +15 −0
@@ -47,6 +47,21 @@
end
end
describe "spec compliance - header size validation" do
test "decode_raw rejects size mismatch" do
# Construct a raw object with wrong size in header
# Header says 10 bytes but content is only 5
raw = "blob 10\0hello"
result = Object.decode_raw(raw)
assert {:error, {:size_mismatch, expected: 10, actual: 5}} = result
end
test "decode_raw accepts correct size" do
raw = "blob 5\0hello"
assert {:ok, %Blob{content: "hello"}} = Object.decode_raw(raw)
end
end
describe "blob round-trip" do
test "encode then decode returns original" do
blob = Blob.from_content("test content here")
test/ex_git_objectstore/object/commit_test.exs +56 −0
@@ -112,6 +112,62 @@
assert content == expected
end
test "encode and decode commit with GPG signature (continuation lines)" do
gpgsig =
"-----BEGIN PGP SIGNATURE-----\n" <>
"iQEzBAABCAAdFiEEYp1234567890\n" <>
"abcdef1234567890==\n" <>
"-----END PGP SIGNATURE-----"
commit = %Commit{
tree: @tree_sha,
parents: [],
author: "Test User <test@example.com> 1234567890 +0000",
committer: "Test User <test@example.com> 1234567890 +0000",
gpgsig: gpgsig,
message: "Signed commit\n"
}
# Encode and verify continuation lines format
content = Commit.encode_content(commit)
lines = String.split(content, "\n")
# The gpgsig header should use continuation lines (space prefix)
gpgsig_idx = Enum.find_index(lines, &String.starts_with?(&1, "gpgsig "))
assert gpgsig_idx != nil
# Lines after the first gpgsig line should start with space (continuation)
continuation_lines =
lines
|> Enum.drop(gpgsig_idx + 1)
|> Enum.take_while(&String.starts_with?(&1, " "))
assert length(continuation_lines) >= 2
# Roundtrip: encode then decode preserves the signature
encoded = Object.encode!(commit)
assert {:ok, decoded} = Object.decode(encoded)
assert decoded.gpgsig != nil
# The decoded gpgsig should contain the continuation line prefix
assert String.contains?(decoded.gpgsig, "BEGIN PGP SIGNATURE")
assert String.contains?(decoded.gpgsig, "END PGP SIGNATURE")
end
test "encode and decode commit with extra headers" do
commit = %Commit{
tree: @tree_sha,
parents: [],
author: "Test User <test@example.com> 1234567890 +0000",
committer: "Test User <test@example.com> 1234567890 +0000",
extra_headers: [{"mergetag", "some-tag-data"}],
message: "Merge commit\n"
}
encoded = Object.encode!(commit)
assert {:ok, decoded} = Object.decode(encoded)
assert [{"mergetag", "some-tag-data"}] = decoded.extra_headers
end
test "encode and decode preserves multi-line message" do
commit = %Commit{
tree: @tree_sha,
test/ex_git_objectstore/object/tag_test.exs +32 −0
@@ -77,6 +77,38 @@
assert content == expected
end
test "encode and decode tag without tagger (optional tagger)" do
tag = %Tag{
object: @commit_sha,
type: "commit",
tag: "v0.1.0",
tagger: nil,
message: "Lightweight-style annotated tag\n"
}
encoded = Object.encode!(tag)
assert {:ok, decoded} = Object.decode(encoded)
assert %Tag{} = decoded
assert decoded.tagger == nil
assert decoded.tag == "v0.1.0"
assert decoded.message == "Lightweight-style annotated tag\n"
end
test "encode and decode tag with extra headers" do
tag = %Tag{
object: @commit_sha,
type: "commit",
tag: "v1.1.0",
tagger: "Test User <test@example.com> 1234567890 +0000",
extra_headers: [{"some-header", "some-value"}],
message: "Tag with extra headers\n"
}
encoded = Object.encode!(tag)
assert {:ok, decoded} = Object.decode(encoded)
assert [{"some-header", "some-value"}] = decoded.extra_headers
end
test "encode and decode tag with multi-line message" do
tag = %Tag{
object: @commit_sha,
test/ex_git_objectstore/object/tree_test.exs +123 −0
@@ -116,6 +116,129 @@
end
end
describe "spec compliance - mode canonicalization" do
test "canonicalizes short modes" do
tree =
Tree.new([
%{mode: "644", name: "a.txt", sha: "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"}
])
assert [%{mode: "100644"}] = tree.entries
end
test "canonicalizes padded modes" do
tree =
Tree.new([
%{mode: "0100644", name: "a.txt", sha: "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"}
])
assert [%{mode: "100644"}] = tree.entries
end
test "canonicalizes executable mode" do
tree =
Tree.new([
%{mode: "755", name: "run.sh", sha: "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"}
])
assert [%{mode: "100755"}] = tree.entries
end
test "canonicalizes directory mode" do
tree =
Tree.new([
%{mode: "040000", name: "dir", sha: "4b825dc642cb6eb9a060e54bf8d69288fbee4904"}
])
assert [%{mode: "40000"}] = tree.entries
end
test "canonicalizes gitlink (submodule) mode" do
tree =
Tree.new([
%{mode: "0160000", name: "submod", sha: "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"}
])
assert [%{mode: "160000"}] = tree.entries
end
end
describe "spec compliance - entry name validation" do
test "rejects empty name" do
assert_raise ArgumentError, ~r/invalid tree entry name/, fn ->
Tree.new([
%{mode: "100644", name: "", sha: "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"}
])
end
end
test "rejects dot entry" do
assert_raise ArgumentError, ~r/invalid tree entry name/, fn ->
Tree.new([
%{mode: "100644", name: ".", sha: "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"}
])
end
end
test "rejects dotdot entry" do
assert_raise ArgumentError, ~r/invalid tree entry name/, fn ->
Tree.new([
%{mode: "100644", name: "..", sha: "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"}
])
end
end
test "rejects .git entry (case-insensitive)" do
assert_raise ArgumentError, ~r/invalid tree entry name/, fn ->
Tree.new([
%{mode: "100644", name: ".GIT", sha: "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"}
])
end
end
test "rejects slash in name" do
assert_raise ArgumentError, ~r/invalid tree entry name/, fn ->
Tree.new([
%{mode: "100644", name: "a/b", sha: "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"}
])
end
end
test "rejects null byte in name" do
assert_raise ArgumentError, ~r/invalid tree entry name/, fn ->
Tree.new([
%{mode: "100644", name: "a\0b", sha: "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"}
])
end
end
test "allows valid names" do
tree =
Tree.new([
%{
mode: "100644",
name: "valid-file.txt",
sha: "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
},
%{mode: "100644", name: ".gitignore", sha: "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"},
%{mode: "100644", name: ".gitmodules", sha: "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"}
])
assert length(tree.entries) == 3
end
end
describe "spec compliance - directory_mode?" do
test "identifies directory modes" do
assert Tree.directory_mode?("40000")
assert Tree.directory_mode?("040000")
assert Tree.directory_mode?("0040000")
refute Tree.directory_mode?("100644")
refute Tree.directory_mode?("100755")
refute Tree.directory_mode?("160000")
end
end
describe "tree content format" do
test "encode_content produces correct binary format" do
sha_hex = "95d09f2b10159347eece71399a7e2e907ea3df4f"
test/ex_git_objectstore/pack/index_test.exs +56 −0
@@ -47,6 +47,62 @@
end
end
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)
{_pack_data, idx_data, _pack_sha} =
ExGitObjectstore.Pack.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
<<body::binary-size(body_len), checksum::binary-size(20)>> = idx_data
assert checksum == :crypto.hash(:sha, body)
# Parse should succeed (validates checksum internally)
assert {:ok, _index} = Index.parse(idx_data)
end
test "rejects index with corrupted checksum" do
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}])
# Corrupt the trailing checksum
body_len = byte_size(idx_data) - 20
<<body::binary-size(body_len), _checksum::binary-size(20)>> = idx_data
bad_checksum = :binary.copy(<<0>>, 20)
corrupted = <<body::binary, bad_checksum::binary>>
assert {:error, :index_checksum_mismatch} = Index.parse(corrupted)
end
test "SHAs in generated index are sorted" do
blob1 = ExGitObjectstore.Object.Blob.from_content("one\n")
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},
{:blob, "two\n", sha2},
{:blob, "three\n", sha3}
]
{_pack_data, idx_data, _pack_sha} =
ExGitObjectstore.Pack.Writer.generate_with_index(objects)
assert {:ok, index} = Index.parse(idx_data)
assert index.shas == Enum.sort(index.shas)
end
end
# -- Helpers --
defp create_packed_repo do
test/ex_git_objectstore/pack/reader_test.exs +74 −0
@@ -67,6 +67,80 @@
end
end
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)
{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 = ExGitObjectstore.Object.Blob.from_content(content)
sha = ExGitObjectstore.Object.hash(blob)
{pack_data, _} = ExGitObjectstore.Pack.Writer.generate([{:blob, content, sha}])
# Corrupt one byte in the middle
mid = div(byte_size(pack_data), 2)
<<before::binary-size(mid), byte, after_bytes::binary>> = pack_data
corrupted = <<before::binary, Bitwise.bxor(byte, 0xFF), after_bytes::binary>>
assert {:error, :pack_checksum_mismatch} = Reader.verify_pack_checksum(corrupted)
end
test "verify_pack_checksum rejects too-short data" do
assert {:error, :pack_too_short} = Reader.verify_pack_checksum(<<1, 2, 3>>)
end
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}])
# Corrupt the trailing checksum
body_len = byte_size(pack_data) - 20
<<body::binary-size(body_len), _checksum::binary-size(20)>> = pack_data
bad_checksum = :binary.copy(<<0>>, 20)
corrupted = <<body::binary, bad_checksum::binary>>
assert {:error, :pack_checksum_mismatch} = Reader.parse(corrupted)
end
end
describe "spec compliance - version support" do
test "accepts version 2 packs" do
content = "test\n"
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
assert {:ok, _entries} = Reader.parse(pack_data)
end
end
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}])
assert {:ok, index} = Reader.build_sha_index(pack_data)
assert Map.has_key?(index, sha)
assert is_integer(Map.get(index, sha))
end
test "build_sha_index rejects invalid header" do
assert {:error, :invalid_pack_header} = Reader.build_sha_index("not a pack")
end
end
# -- Helpers --
defp create_packed_repo_with_objects do
test/ex_git_objectstore/protocol/pkt_line_test.exs +57 −0
@@ -141,6 +141,63 @@
end
end
describe "spec compliance - max length" do
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)
assert_raise ArgumentError, ~r/pkt-line too long/, fn ->
PktLine.encode(big_data)
end
end
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)
assert_raise ArgumentError, ~r/pkt-line too long/, fn ->
PktLine.encode_raw(big_data)
end
end
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", 65515) <> "\n"
result = PktLine.encode(data)
assert byte_size(result) == 65520
end
test "encode_raw allows data at exact max payload" do
# 65516 bytes + 4 prefix = 65520
data = :binary.copy(<<0>>, 65516)
result = PktLine.encode_raw(data)
assert byte_size(result) == 65520
end
end
describe "spec compliance - trailing LF stripping" do
test "strips exactly one trailing LF" do
# Data "hello\n\n" (2 newlines at end)
# Encoding: 4 prefix + 7 data = 11 = 000b
encoded = "000bhello\n\n"
assert {:ok, {:data, "hello\n"}, ""} = PktLine.decode_one(encoded)
end
test "does not strip non-LF trailing bytes" do
encoded = PktLine.encode_raw("hello")
assert {:ok, {:data, "hello"}, ""} = PktLine.decode_one(encoded)
end
test "handles empty payload" do
# Length 4 means 0 data bytes, but min is actually 5 for data
# encode_raw of empty string: "0004" (4 bytes prefix, 0 data = 4 total)
encoded = PktLine.encode_raw("")
assert {:ok, {:data, ""}, ""} = PktLine.decode_one(encoded)
end
end
describe "sideband" do
test "decode sideband band 1 (pack data)" do
assert {:pack, "data"} = PktLine.decode_sideband(<<1, "data">>)
test/ex_git_objectstore/protocol/receive_pack_test.exs +111 −2
@@ -70,9 +70,118 @@
_ -> false
end)
assert length(data_lines) >= 1
# HEAD first (spec: HEAD MUST appear first), then refs/heads/main
assert length(data_lines) >= 2
{:data, first_line} = hd(data_lines)
assert String.contains?(first_line, commit_sha)
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")
assert String.contains?(all_text, "refs/heads/main")
end
end
describe "spec compliance - advertisement format" do
test "HEAD appears first in advertisement with refs" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
blob = Blob.from_content("hello\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "file.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: [],
author: "Test <t@t.com> 1000000000 +0000",
committer: "Test <t@t.com> 1000000000 +0000",
message: "init\n"
}
{:ok, commit_sha} = Object.write(repo, commit)
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
{advert, _state} = ReceivePack.init(repo)
{:ok, packets, ""} = PktLine.decode(advert)
data_lines = for {:data, d} <- packets, do: d
# First line must be HEAD
first = hd(data_lines)
assert String.contains?(first, "HEAD")
assert String.contains?(first, commit_sha)
end
test "capabilities immediately follow NUL (no space)" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{advert, _state} = ReceivePack.init(repo)
{:ok, {:data, first_line}, _rest} = PktLine.decode_one(advert)
[_ref_part, caps_part] = String.split(first_line, "\0", parts: 2)
refute String.starts_with?(caps_part, " ")
assert String.contains?(caps_part, "report-status")
assert String.contains?(caps_part, "delete-refs")
end
test "empty repo advertises zero SHA with capabilities" do
repo = RepoHelper.memory_repo()
assert String.contains?(first_line, "refs/heads/main")
ExGitObjectstore.init(repo)
{advert, _state} = ReceivePack.init(repo)
{:ok, packets, ""} = PktLine.decode(advert)
data_lines = for {:data, d} <- packets, do: d
first = hd(data_lines)
zero_sha = String.duplicate("0", 40)
assert String.starts_with?(first, zero_sha)
assert String.contains?(first, "capabilities^{}")
end
end
describe "spec compliance - client capabilities" do
test "parses client capabilities from first command line" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
blob = Blob.from_content("hello\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "file.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: [],
author: "Test <t@t.com> 1000000000 +0000",
committer: "Test <t@t.com> 1000000000 +0000",
message: "init\n"
}
{:ok, commit_sha} = Object.write(repo, commit)
{_advert, state} = ReceivePack.init(repo)
# Send command with capabilities after NUL
zero = String.duplicate("0", 40)
cmd_with_caps = "#{zero} #{commit_sha} refs/heads/main\0report-status delete-refs"
commands = PktLine.encode_raw(cmd_with_caps <> "\n") <> PktLine.flush()
objects = [
{:blob, "hello\n", blob_sha},
{:tree, Tree.encode_content(tree), tree_sha},
{:commit, Commit.encode_content(commit), commit_sha}
]
{pack_data, _} = Writer.generate(objects)
{response, state} = ReceivePack.feed(state, commands <> pack_data)
assert ReceivePack.done?(state)
# Client sent report-status capability, so we should get a report
{:ok, packets, _} = PktLine.decode(response)
data_lines = for {:data, d} <- packets, do: d
assert "unpack ok" in data_lines
end
end
test/ex_git_objectstore/protocol/upload_pack_test.exs +61 −0
@@ -68,6 +68,67 @@
end
end
describe "spec compliance - advertisement format" do
test "HEAD appears first in advertisement" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{commit_sha, _, _} = create_commit(repo, "hello\n", "init\n")
:ok = Ref.put(repo, "refs/heads/main", commit_sha, nil)
{advert, _state} = UploadPack.init(repo)
{:ok, packets, ""} = PktLine.decode(advert)
data_lines = for {:data, d} <- packets, do: d
# First line must be HEAD (spec requirement)
first = hd(data_lines)
assert String.contains?(first, "HEAD")
assert String.contains?(first, commit_sha)
end
test "capabilities immediately follow NUL (no space)" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{advert, _state} = UploadPack.init(repo)
# Parse raw bytes: the first pkt-line should contain \0 followed immediately by caps
{:ok, {:data, first_line}, _rest} = PktLine.decode_one(advert)
# There should be a NUL byte separating ref info from capabilities
assert String.contains?(first_line, "\0")
# After NUL, the next character should NOT be a space
[_ref_part, caps_part] = String.split(first_line, "\0", parts: 2)
refute String.starts_with?(caps_part, " ")
end
test "includes symref capability" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{advert, _state} = UploadPack.init(repo)
{:ok, {:data, first_line}, _rest} = PktLine.decode_one(advert)
[_ref_part, caps_part] = String.split(first_line, "\0", parts: 2)
assert String.contains?(caps_part, "symref=HEAD:refs/heads/main")
end
test "empty repo advertises zero SHA with capabilities" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{advert, _state} = UploadPack.init(repo)
{:ok, packets, ""} = PktLine.decode(advert)
data_lines = for {:data, d} <- packets, do: d
first = hd(data_lines)
zero_sha = String.duplicate("0", 40)
assert String.starts_with?(first, zero_sha)
assert String.contains?(first, "capabilities^{}")
end
end
describe "feed - clone (wants only, no haves)" do
test "sends packfile with all objects for a clone" do
repo = RepoHelper.memory_repo()