▸
lib/ex_git_objectstore/diff.ex
+2
−7
@@ -86,13 +86,8 @@
{:ok, changes} <- diff_trees(repo, old_commit.tree, new_commit.tree) do
file_diffs =
Enum.map(changes, fn change ->
case diff_blobs(repo, change.old_sha, change.new_sha, opts) do
{:ok, hunks} ->
%{path: change.path, status: change.status, hunks: hunks}
{:error, _} ->
%{path: change.path, status: change.status, hunks: []}
end
{:ok, hunks} = diff_blobs(repo, change.old_sha, change.new_sha, opts)
%{path: change.path, status: change.status, hunks: hunks}
end)
{:ok, file_diffs}
▸
lib/ex_git_objectstore/object.ex
+26
−12
@@ -23,5 +23,8 @@
alias ExGitObjectstore.Object.{Blob, Commit, Tree, Tag}
alias ExGitObjectstore.Repo
# Maximum decompressed object size (128 MB)
@max_object_size 128 * 1024 * 1024
@type object_type :: :blob | :commit | :tree | :tag
@type t :: Blob.t() | Commit.t() | Tree.t() | Tag.t()
@@ -47,22 +50,23 @@
@doc """
Encode a git object to its stored format (zlib compressed).
Raises on compression failure (extremely rare).
Returns `{:ok, compressed}` or `{:error, reason}`.
"""
@spec encode(t()) :: binary()
@spec encode(t()) :: {:ok, binary()} | {:error, term()}
def encode(object) do
zlib_compress(encode_raw(object))
case zlib_compress(encode_raw(object)) do
{:ok, compressed} -> compressed
{:error, reason} -> raise "zlib compression failed: #{inspect(reason)}"
end
end
@doc """
Encode a git object to its stored format (zlib compressed).
Safe version of encode/1 — returns `{:ok, compressed}` or `{:error, reason}`.
Raises on compression failure.
"""
@spec safe_encode(t()) :: {:ok, binary()} | {:error, term()}
def safe_encode(object) do
zlib_compress(encode_raw(object))
@spec encode!(t()) :: binary()
def encode!(object) do
case encode(object) do
{:ok, compressed} -> compressed
{:error, reason} -> raise "zlib compression failed: #{inspect(reason)}"
end
end
@doc """
@@ -108,7 +112,7 @@
def write(%Repo{} = repo, object) do
sha = hash(object)
case safe_encode(object) do
case encode(object) do
{:ok, compressed} ->
case Repo.storage_call(repo, :put_object, [sha, compressed]) do
:ok -> {:ok, sha}
@@ -186,13 +190,23 @@
end
defp safe_decompress(data) do
safe_decompress(data, @max_object_size)
end
defp safe_decompress(data, max_size) do
z = :zlib.open()
try do
:zlib.inflateInit(z)
result = :zlib.inflate(z, data)
:zlib.inflateEnd(z)
decompressed = IO.iodata_to_binary(result)
if byte_size(decompressed) > max_size do
{:error, {:object_too_large, byte_size(decompressed), max_size}}
{:ok, IO.iodata_to_binary(result)}
else
{:ok, decompressed}
end
rescue
e -> {:error, {:decompress_failed, Exception.message(e)}}
after
▸
lib/ex_git_objectstore/object/commit.ex
+10
−9
@@ -35,6 +35,7 @@
extra_headers: [{String.t(), String.t()}]
}
@enforce_keys [:tree, :author, :committer, :message]
defstruct [
:tree,
:author,
@@ -118,27 +119,27 @@
end
defp build_commit(headers, message) do
fields =
Enum.reduce(headers, %{message: message, parents: [], extra_headers: []}, fn
commit =
Enum.reduce(headers, %__MODULE__{message: message}, fn
{"tree", sha}, acc ->
%{acc | tree: sha}
Map.put(acc, :tree, sha)
{"parent", sha}, acc ->
Map.update(acc, :parents, [sha], &(&1 ++ [sha]))
%{acc | parents: acc.parents ++ [sha]}
{"author", value}, acc ->
Map.put(acc, :author, value)
%{acc | author: value}
{"committer", value}, acc ->
Map.put(acc, :committer, value)
%{acc | committer: value}
{"gpgsig", value}, acc ->
%{acc | gpgsig: value}
Map.put(acc, :gpgsig, value)
{key, value}, acc ->
%{acc | extra_headers: acc.extra_headers ++ [{key, value}]}
Map.update(acc, :extra_headers, [{key, value}], &(&1 ++ [{key, value}]))
end)
{:ok, commit}
{:ok, struct!(__MODULE__, fields)}
end
end
▸
lib/ex_git_objectstore/object/tag.ex
+8
−7
@@ -33,6 +33,7 @@
message: String.t()
}
@enforce_keys [:object, :type, :tag, :tagger, :message]
defstruct [:object, :type, :tag, :tagger, :message]
@doc """
@@ -67,16 +68,16 @@
end
end)
tag =
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)
{"tagger", value}, acc -> Map.put(acc, :tagger, value)
Enum.reduce(headers, %__MODULE__{message: message}, fn
{"object", sha}, acc -> %{acc | object: sha}
{"type", type}, acc -> %{acc | type: type}
{"tag", name}, acc -> %{acc | tag: name}
{"tagger", value}, acc -> %{acc | tagger: value}
_, acc -> acc
end)
{:ok, struct!(__MODULE__, fields)}
{:ok, tag}
_ ->
{:error, :invalid_tag_format}
▸
lib/ex_git_objectstore/object/tree.ex
+20
−0
@@ -28,11 +28,31 @@
@doc """
Create a tree from a list of entries.
Entries are sorted according to git's tree sorting rules.
Modes are canonicalized to their standard git representations.
"""
@spec new([entry()]) :: t()
def new(entries) do
entries = Enum.map(entries, &canonicalize_entry/1)
%__MODULE__{entries: sort_entries(entries)}
end
defp canonicalize_entry(%{mode: mode} = entry) do
%{entry | mode: canonicalize_mode(mode)}
end
@doc """
Canonicalize a git tree entry mode to its standard representation.
"""
@spec canonicalize_mode(String.t()) :: String.t()
def canonicalize_mode("644"), do: "100644"
def canonicalize_mode("755"), do: "100755"
def canonicalize_mode("0644"), do: "100644"
def canonicalize_mode("0755"), do: "100755"
def canonicalize_mode("0100644"), do: "100644"
def canonicalize_mode("0100755"), do: "100755"
def canonicalize_mode("040000"), do: "40000"
def canonicalize_mode("0040000"), do: "40000"
def canonicalize_mode(mode), do: mode
@doc """
Validate a tree entry name according to git rules.
▸
lib/ex_git_objectstore/pack/delta.ex
+12
−3
@@ -49,6 +49,8 @@
{:error, {:size_mismatch, expected: target_size, got: byte_size(result_bin)}}
end
end
catch
{:error, _} = err -> err
end
# Read a variable-length integer (used for base/target sizes in delta header)
@@ -131,12 +133,19 @@
end
# Read one byte from data if the corresponding bit is set in cmd
defp read_if_bit(cmd, bit, data) do
defp read_if_bit(cmd, bit, <<byte, rest::binary>> = _data) do
if Bitwise.band(cmd, Bitwise.bsl(1, bit)) != 0 do
<<byte, rest::binary>> = data
{byte, rest}
else
{0, <<byte, rest::binary>>}
end
end
{0, data}
defp read_if_bit(cmd, bit, <<>>) do
if Bitwise.band(cmd, Bitwise.bsl(1, bit)) != 0 do
throw({:error, :truncated_delta_copy})
else
{0, <<>>}
end
end
end
▸
lib/ex_git_objectstore/pack/reader.ex
+21
−10
@@ -32,6 +32,7 @@
@pack_signature "PACK"
@max_pack_objects 10_000_000
@max_delta_depth 50
# Object types (3-bit)
@obj_commit 1
@@ -81,13 +82,7 @@
@spec read_object(binary(), non_neg_integer(), map()) ::
{:ok, {atom(), binary()}} | {:error, term()}
def read_object(pack_data, offset, cache) do
case Map.get(cache, offset) do
{type, data} ->
{:ok, {type, data}}
nil ->
do_read_object(pack_data, offset, cache)
end
do_read_object(pack_data, offset, cache, 0)
end
# -- Private --
@@ -169,7 +164,23 @@
end
end
defp do_read_object(_pack_data, _offset, _cache, depth) when depth > @max_delta_depth do
{:error, :max_delta_depth_exceeded}
end
defp do_read_object(pack_data, offset, cache, depth) do
case Map.get(cache, offset) do
{type, data} ->
{:ok, {type, data}}
nil ->
resolve_object(pack_data, offset, cache, depth)
end
end
# REF_DELTA branch contains dead code until find_sha_offset is implemented
defp do_read_object(pack_data, offset, cache) do
@dialyzer {:nowarn_function, resolve_object: 4}
defp resolve_object(pack_data, offset, cache, depth) do
<<_skip::binary-size(offset), data::binary>> = pack_data
case parse_object_header(data) do
@@ -197,7 +208,7 @@
with {:ok, delta_data, _} <- decompress_data(compressed),
{:ok, {base_type, base_data}} <-
do_read_object(pack_data, base_offset, cache, depth + 1),
read_object(pack_data, base_offset, cache),
{:ok, result} <- Delta.apply(base_data, delta_data) do
{:ok, {base_type, result}}
end
@@ -215,7 +226,7 @@
with {:ok, delta_data, _} <- decompress_data(compressed),
{:ok, base_offset} <- find_sha_offset(pack_data, base_sha, cache),
{:ok, {base_type, base_data}} <-
do_read_object(pack_data, base_offset, cache, depth + 1),
read_object(pack_data, base_offset, cache),
{:ok, result} <- Delta.apply(base_data, delta_data) do
{:ok, {base_type, result}}
end
▸
lib/ex_git_objectstore/protocol/receive_pack.ex
+30
−14
@@ -33,6 +33,9 @@
@zero_sha String.duplicate("0", 40)
# Maximum pack buffer size (256 MB)
@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
@@ -49,7 +52,7 @@
phase: :advertise | :commands | :pack | :done,
commands: [command()],
pack_buffer: binary(),
result: :ok | {:error, term()}
result: nil | :ok | {:error, term()}
}
defstruct [
@@ -97,8 +100,15 @@
end
def feed(%__MODULE__{phase: :pack} = state, data) do
new_buffer = state.pack_buffer <> data
state = %{state | pack_buffer: state.pack_buffer <> data}
maybe_process_pack(state)
if byte_size(new_buffer) > @max_pack_size do
report = build_error_report(:pack_too_large)
{report, %{state | phase: :done, result: {:error, :pack_too_large}}}
else
state = %{state | pack_buffer: new_buffer}
maybe_process_pack(state)
end
end
def feed(%__MODULE__{phase: :done} = state, _data) do
@@ -217,10 +227,6 @@
:incomplete ->
{<<>>, state}
{:error, reason} ->
report = build_error_report(reason)
{report, %{state | phase: :done, result: {:error, reason}}}
end
end
end
@@ -267,10 +273,15 @@
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)
compressed = zlib_compress(raw)
case Repo.storage_call(repo, :put_object, [sha, compressed]) do
:ok -> {:cont, :ok}
{:error, _} = err -> {:halt, err}
case zlib_compress(raw) do
{:ok, compressed} ->
case Repo.storage_call(repo, :put_object, [sha, compressed]) do
:ok -> {:cont, :ok}
{:error, _} = err -> {:halt, err}
end
{:error, _} = err ->
{:halt, err}
end
end)
@@ -314,6 +325,6 @@
PktLine.encode("ok #{ref}")
{ref, {:error, reason}} ->
PktLine.encode("ng #{ref} #{inspect(reason)}")
PktLine.encode("ng #{ref} #{sanitize_error(reason)}")
end) ++
[PktLine.flush()]
@@ -323,12 +334,17 @@
defp build_error_report(reason) do
lines = [
PktLine.encode("unpack #{inspect(reason)}"),
PktLine.encode("unpack #{sanitize_error(reason)}"),
PktLine.flush()
]
IO.iodata_to_binary(lines)
end
defp sanitize_error(reason) when is_atom(reason), do: Atom.to_string(reason)
defp sanitize_error(reason) when is_binary(reason), do: reason
defp sanitize_error({tag, _details}) when is_atom(tag), do: Atom.to_string(tag)
defp sanitize_error(_), do: "internal_error"
defp zlib_compress(data) do
z = :zlib.open()
@@ -337,7 +353,7 @@
:zlib.deflateInit(z)
compressed = :zlib.deflate(z, data, :finish)
:zlib.deflateEnd(z)
IO.iodata_to_binary(compressed)
{:ok, IO.iodata_to_binary(compressed)}
rescue
e -> {:error, {:compress_failed, Exception.message(e)}}
after
▸
lib/ex_git_objectstore/protocol/upload_pack.ex
+3
−1
@@ -202,10 +202,12 @@
{response, %{state | phase: :done}}
{:error, reason} ->
nak = PktLine.encode("ERR #{inspect(reason)}")
nak = PktLine.encode("ERR #{sanitize_error(reason)}")
{nak, %{state | phase: :done}}
end
end
defp sanitize_error(reason) when is_binary(reason), do: reason
defp collect_objects(repo, wants, haves) do
# Walk from each want SHA and collect all reachable objects
▸
lib/ex_git_objectstore/ref.ex
+30
−3
@@ -24,7 +24,9 @@
"""
@spec get(Repo.t(), String.t()) :: {:ok, String.t()} | {:error, term()}
def get(%Repo{} = repo, ref_path) do
with :ok <- validate_ref_name(ref_path) do
Repo.storage_call(repo, :get_ref, [ref_path])
end
Repo.storage_call(repo, :get_ref, [ref_path])
end
@doc """
@@ -44,7 +46,9 @@
"""
@spec delete(Repo.t(), String.t()) :: :ok | {:error, term()}
def delete(%Repo{} = repo, ref_path) do
with :ok <- validate_ref_name(ref_path) do
Repo.storage_call(repo, :delete_ref, [ref_path])
Repo.storage_call(repo, :delete_ref, [ref_path])
end
end
@doc """
@@ -52,7 +56,9 @@
"""
@spec list(Repo.t(), String.t()) :: {:ok, [{String.t(), String.t()}]} | {:error, term()}
def list(%Repo{} = repo, ref_prefix) do
with :ok <- validate_ref_prefix(ref_prefix) do
Repo.storage_call(repo, :list_refs, [ref_prefix])
Repo.storage_call(repo, :list_refs, [ref_prefix])
end
end
@doc """
@@ -157,6 +163,27 @@
String.starts_with?(ref_path, "/") ->
{:error, {:invalid_ref_name, ref_path, :leading_slash}}
true ->
:ok
end
end
@doc """
Validate a ref prefix for listing operations.
Rejects path traversal and null bytes but allows trailing slashes.
"""
@spec validate_ref_prefix(String.t()) :: :ok | {:error, term()}
def validate_ref_prefix(prefix) do
cond do
String.contains?(prefix, "..") ->
{:error, {:invalid_ref_prefix, prefix, :dotdot}}
String.contains?(prefix, <<0>>) ->
{:error, {:invalid_ref_prefix, prefix, :null_byte}}
String.starts_with?(prefix, "/") ->
{:error, {:invalid_ref_prefix, prefix, :leading_slash}}
true ->
:ok
▸
lib/ex_git_objectstore/storage/filesystem.ex
+6
−6
@@ -133,7 +133,7 @@
@impl true
def get_ref(config, prefix, ref_path) do
path = Path.join([config.root, prefix, ref_path])
path = safe_path(config.root, Path.join([prefix, ref_path]))
case File.read(path) do
{:ok, data} -> {:ok, String.trim(data)}
@@ -144,6 +144,6 @@
@impl true
def put_ref(config, prefix, ref_path, new_sha, old_sha) do
path = Path.join([config.root, prefix, ref_path])
path = safe_path(config.root, Path.join([prefix, ref_path]))
lock_path = path <> ".lock"
File.mkdir_p!(Path.dirname(path))
@@ -195,7 +195,7 @@
@impl true
def delete_ref(config, prefix, ref_path) do
path = safe_path(config.root, Path.join([prefix, ref_path]))
path = Path.join([config.root, prefix, ref_path])
case File.rm(path) do
:ok -> :ok
@@ -206,7 +206,7 @@
@impl true
def list_refs(config, prefix, ref_prefix) do
base_dir = Path.join([config.root, prefix, ref_prefix])
base_dir = safe_path(config.root, Path.join([prefix, ref_prefix]))
# Start with loose refs
loose_refs =
@@ -239,7 +239,7 @@
@impl true
def get_head(config, prefix) do
path = Path.join([config.root, prefix, "HEAD"])
path = safe_path(config.root, Path.join([prefix, "HEAD"]))
case File.read(path) do
{:ok, data} -> {:ok, String.trim(data)}
@@ -250,6 +250,6 @@
@impl true
def put_head(config, prefix, target) do
path = safe_path(config.root, Path.join([prefix, "HEAD"]))
path = Path.join([config.root, prefix, "HEAD"])
atomic_write(path, target <> "\n")
end
▸
lib/ex_git_objectstore/storage/s3.ex
+14
−5
@@ -121,7 +121,7 @@
@impl true
def get_ref(config, prefix, ref_path) do
key = "#{prefix}/#{ref_path}"
key = safe_key("#{prefix}/#{ref_path}")
case s3_get(config, key) do
{:ok, data} -> {:ok, String.trim(data)}
@@ -131,7 +131,7 @@
@impl true
def put_ref(config, prefix, ref_path, new_sha, old_sha) do
key = safe_key("#{prefix}/#{ref_path}")
key = "#{prefix}/#{ref_path}"
if old_sha do
# CAS: read current value first
@@ -156,6 +156,6 @@
@impl true
def delete_ref(config, prefix, ref_path) do
key = "#{prefix}/#{ref_path}"
key = safe_key("#{prefix}/#{ref_path}")
s3_delete(config, key)
end
@@ -208,11 +208,20 @@
defp object_key(prefix, sha) do
<<dir::binary-size(2), rest::binary>> = sha
safe_key("#{prefix}/objects/#{dir}/#{rest}")
"#{prefix}/objects/#{dir}/#{rest}"
end
defp pack_key(prefix, pack_sha, ext) do
safe_key("#{prefix}/objects/pack/pack-#{pack_sha}.#{ext}")
end
"#{prefix}/objects/pack/pack-#{pack_sha}.#{ext}"
# Prevent path traversal in S3 keys
defp safe_key(key) do
if String.contains?(key, "..") do
raise ArgumentError, "path traversal detected in S3 key"
end
key
end
defp s3_get(config, key) do
▸
mix.exs
+2
−1
@@ -94,7 +94,8 @@
{:hackney, "~> 1.18"},
{:jason, "~> 1.4"},
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
]
end
end
▸
mix.lock
+3
−0
@@ -1,11 +1,14 @@
%{
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
"ex_aws": {:hex, :ex_aws, "2.6.1", "194582c7b09455de8a5ab18a0182e6dd937d53df82be2e63c619d01bddaccdfa", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "67842a08c90a1d9a09dbe4ac05754175c7ca253abe4912987c759395d4bd9d26"},
"ex_aws_s3": {:hex, :ex_aws_s3, "2.5.9", "862b7792f2e60d7010e2920d79964e3fab289bc0fd951b0ba8457a3f7f9d1199", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "a480d2bb2da64610014021629800e1e9457ca5e4a62f6775bffd963360c2bf90"},
"ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
▸
test/ex_git_objectstore/object/blob_test.exs
+3
−3
@@ -50,7 +50,7 @@
describe "blob round-trip" do
test "encode then decode returns original" do
blob = Blob.from_content("test content here")
encoded = Object.encode(blob)
encoded = Object.encode!(blob)
assert {:ok, decoded} = Object.decode(encoded)
assert %Blob{content: "test content here"} = decoded
end
@@ -58,7 +58,7 @@
test "round-trip preserves binary content" do
content = <<0, 1, 2, 255, 128, 64>>
blob = Blob.from_content(content)
encoded = Object.encode(blob)
encoded = Object.encode!(blob)
assert {:ok, decoded} = Object.decode(encoded)
assert decoded.content == content
end
@@ -66,7 +66,7 @@
test "round-trip preserves large content" do
content = String.duplicate("x", 100_000)
blob = Blob.from_content(content)
encoded = Object.encode!(blob)
encoded = Object.encode(blob)
assert {:ok, decoded} = Object.decode(encoded)
assert decoded.content == content
end
▸
test/ex_git_objectstore/object/commit_test.exs
+4
−4
@@ -30,7 +30,7 @@
message: "Initial commit\n"
}
encoded = Object.encode(commit)
encoded = Object.encode!(commit)
assert {:ok, decoded} = Object.decode(encoded)
assert %Commit{} = decoded
assert decoded.tree == @tree_sha
@@ -51,7 +51,7 @@
message: "Second commit\n"
}
encoded = Object.encode(commit)
encoded = Object.encode!(commit)
assert {:ok, decoded} = Object.decode(encoded)
assert decoded.parents == [parent_sha]
end
@@ -70,7 +70,7 @@
message: "Merge commit\n"
}
encoded = Object.encode(commit)
encoded = Object.encode!(commit)
assert {:ok, decoded} = Object.decode(encoded)
assert decoded.parents == parents
end
@@ -121,7 +121,7 @@
message: "First line\n\nDetailed description\nwith multiple lines\n"
}
encoded = Object.encode(commit)
encoded = Object.encode!(commit)
assert {:ok, decoded} = Object.decode(encoded)
assert decoded.message == "First line\n\nDetailed description\nwith multiple lines\n"
end
▸
test/ex_git_objectstore/object/tag_test.exs
+2
−2
@@ -30,7 +30,7 @@
message: "Release v1.0.0\n"
}
encoded = Object.encode(tag)
encoded = Object.encode!(tag)
assert {:ok, decoded} = Object.decode(encoded)
assert %Tag{} = decoded
assert decoded.object == @commit_sha
@@ -86,7 +86,7 @@
message: "Major release\n\nBreaking changes:\n- Changed API\n- New format\n"
}
encoded = Object.encode(tag)
encoded = Object.encode!(tag)
assert {:ok, decoded} = Object.decode(encoded)
assert decoded.message ==
▸
test/ex_git_objectstore/object/tree_test.exs
+5
−5
@@ -25,7 +25,7 @@
%{mode: "100644", name: "hello.txt", sha: "95d09f2b10159347eece71399a7e2e907ea3df4f"}
])
encoded = Object.encode(tree)
encoded = Object.encode!(tree)
assert {:ok, decoded} = Object.decode(encoded)
assert %Tree{entries: [entry]} = decoded
assert entry.mode == "100644"
@@ -41,7 +41,7 @@
%{mode: "40000", name: "subdir", sha: "4b825dc642cb6eb9a060e54bf8d69288fbee4904"}
])
assert {:ok, decoded} = tree |> Object.encode!() |> Object.decode()
assert {:ok, decoded} = tree |> Object.encode() |> Object.decode()
# Should be sorted: a.txt, b.txt, subdir
assert [first, second, third] = decoded.entries
assert first.name == "a.txt"
@@ -55,7 +55,7 @@
%{mode: "40000", name: "lib", sha: "4b825dc642cb6eb9a060e54bf8d69288fbee4904"}
])
assert {:ok, decoded} = tree |> Object.encode() |> Object.decode()
assert {:ok, decoded} = tree |> Object.encode!() |> Object.decode()
assert [entry] = decoded.entries
assert entry.mode == "40000"
end
@@ -66,7 +66,7 @@
%{mode: "100755", name: "run.sh", sha: "95d09f2b10159347eece71399a7e2e907ea3df4f"}
])
assert {:ok, decoded} = tree |> Object.encode!() |> Object.decode()
assert {:ok, decoded} = tree |> Object.encode() |> Object.decode()
assert [entry] = decoded.entries
assert entry.mode == "100755"
end
@@ -77,7 +77,7 @@
%{mode: "120000", name: "link", sha: "95d09f2b10159347eece71399a7e2e907ea3df4f"}
])
assert {:ok, decoded} = tree |> Object.encode() |> Object.decode()
assert {:ok, decoded} = tree |> Object.encode!() |> Object.decode()
assert [entry] = decoded.entries
assert entry.mode == "120000"
end
▸
test/ex_git_objectstore/security_test.exs
+255
−0
@@ -1,0 +1,255 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.SecurityTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.{Object, Ref}
alias ExGitObjectstore.Object.{Blob, Commit, Tag, Tree}
alias ExGitObjectstore.Pack.Delta
alias ExGitObjectstore.Storage.Filesystem
alias ExGitObjectstore.Repo
alias ExGitObjectstore.Test.RepoHelper
describe "path traversal prevention in filesystem ref operations" do
setup do
tmp_dir = System.tmp_dir!()
root = Path.join(tmp_dir, "sec_test_#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(root)
repo = Repo.new("test-repo", storage: {Filesystem, %{root: root}})
on_exit(fn -> File.rm_rf!(root) end)
%{repo: repo, root: root}
end
test "put_ref rejects path traversal via ref name", %{repo: repo} do
sha = String.duplicate("a", 40)
assert {:error, {:invalid_ref_name, _, :dotdot}} =
Ref.put(repo, "refs/heads/../../etc/passwd", sha, nil)
end
test "get rejects path traversal via ref name", %{repo: repo} do
assert {:error, {:invalid_ref_name, _, :dotdot}} =
Ref.get(repo, "refs/heads/../../../etc/shadow")
end
test "delete rejects path traversal via ref name", %{repo: repo} do
assert {:error, {:invalid_ref_name, _, :dotdot}} =
Ref.delete(repo, "refs/heads/../../etc/important")
end
test "list rejects path traversal via prefix", %{repo: repo} do
assert {:error, {:invalid_ref_prefix, _, :dotdot}} =
Ref.list(repo, "refs/../../")
end
test "filesystem safe_path blocks traversal in ref paths directly", %{root: root} do
config = %{root: root}
# Need enough ".." to escape root entirely (root/repos/test/../../../../esc)
assert_raise ArgumentError, ~r/path traversal/, fn ->
Filesystem.put_ref(
config,
"repos/test",
"../../../../etc/passwd",
String.duplicate("a", 40),
nil
)
end
end
end
describe "ref name validation on all public functions" do
test "get validates ref name" do
repo = RepoHelper.memory_repo()
assert {:error, {:invalid_ref_name, "", :empty}} = Ref.get(repo, "")
end
test "delete validates ref name" do
repo = RepoHelper.memory_repo()
assert {:error, {:invalid_ref_name, "", :empty}} = Ref.delete(repo, "")
end
test "list validates ref prefix" do
repo = RepoHelper.memory_repo()
assert {:error, {:invalid_ref_prefix, _, :null_byte}} = Ref.list(repo, "refs/\0bad/")
end
end
describe "decompression size limit" do
test "decode rejects oversized decompressed objects" do
# Create a large blob and compress it
large_content = :binary.copy(<<0>>, 200 * 1024 * 1024)
blob = Blob.from_content(large_content)
# This should fail because the decompressed object exceeds the 128MB limit
case Object.encode(blob) do
{:ok, encoded} ->
assert {:error, {:object_too_large, _, _}} = Object.decode(encoded)
{:error, _} ->
# Compression itself may fail with very large data, that's ok
:ok
end
end
end
describe "encode!/1 convention" do
test "encode! raises on failure" do
# Normal encoding should work fine
blob = Blob.from_content("hello")
assert is_binary(Object.encode!(blob))
end
test "encode/1 returns ok/error tuple" do
blob = Blob.from_content("hello")
assert {:ok, encoded} = Object.encode(blob)
assert is_binary(encoded)
end
end
describe "@enforce_keys on structs" do
test "Commit requires tree, author, committer, message via struct!" do
assert_raise ArgumentError, ~r/the following keys must also be given/, fn ->
struct!(Commit, %{})
end
end
test "Tag requires object, type, tag, tagger, message via struct!" do
assert_raise ArgumentError, ~r/the following keys must also be given/, fn ->
struct!(Tag, %{})
end
end
test "Commit with all required keys succeeds" do
commit =
struct!(Commit, %{
tree: String.duplicate("a", 40),
author: "Test <t@t.com> 0 +0000",
committer: "Test <t@t.com> 0 +0000",
message: "test\n"
})
assert commit.tree == String.duplicate("a", 40)
end
test "Tag with all required keys succeeds" do
tag =
struct!(Tag, %{
object: String.duplicate("a", 40),
type: "commit",
tag: "v1.0",
tagger: "Test <t@t.com> 0 +0000",
message: "test\n"
})
assert tag.tag == "v1.0"
end
end
describe "tree mode canonicalization" do
test "644 is normalized to 100644" do
tree = Tree.new([%{mode: "644", name: "f.txt", sha: String.duplicate("a", 40)}])
assert hd(tree.entries).mode == "100644"
end
test "755 is normalized to 100755" do
tree = Tree.new([%{mode: "755", name: "f.sh", sha: String.duplicate("a", 40)}])
assert hd(tree.entries).mode == "100755"
end
test "040000 is normalized to 40000" do
tree = Tree.new([%{mode: "040000", name: "dir", sha: String.duplicate("a", 40)}])
assert hd(tree.entries).mode == "40000"
end
test "standard modes are unchanged" do
sha = String.duplicate("a", 40)
tree =
Tree.new([
%{mode: "100644", name: "a.txt", sha: sha},
%{mode: "100755", name: "b.sh", sha: sha},
%{mode: "40000", name: "dir", sha: sha},
%{mode: "120000", name: "link", sha: sha}
])
modes = Enum.map(tree.entries, & &1.mode)
assert "100644" in modes
assert "100755" in modes
assert "40000" in modes
assert "120000" in modes
end
end
describe "delta safety" do
test "truncated delta copy command returns error" do
base = "hello world"
# Build a delta that tries to read copy bytes from an empty buffer
# varint for base_size=11, target_size=5, then copy cmd with all offset bits set but no data
base_size_varint = <<11>>
target_size_varint = <<5>>
# Copy command: MSB set, bits 0-3 set (4 offset bytes expected), but no data follows
copy_cmd = <<0xFF>>
delta = base_size_varint <> target_size_varint <> copy_cmd
assert {:error, :truncated_delta_copy} = Delta.apply(base, delta)
end
end
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)
sha = String.duplicate("a", 40)
zero = String.duplicate("0", 40)
commands = PktLine.encode("#{zero} #{sha} refs/heads/main") <> PktLine.flush()
# Feed commands first
{_resp, state} = ReceivePack.feed(state, commands)
# Now feed data that exceeds the limit (256 MB)
# We can't actually allocate 256MB in a test, but we can test the guard
# by feeding chunks that accumulate
chunk = :binary.copy("x", 1024 * 1024)
# Feed until we exceed the limit or get an error
result =
Enum.reduce_while(1..300, state, fn _i, st ->
{resp, new_state} = ReceivePack.feed(st, chunk)
if ReceivePack.done?(new_state) do
{:halt, {:done, resp}}
else
{:cont, new_state}
end
end)
case result do
{:done, resp} ->
# Should have error about pack too large
assert resp =~ "pack_too_large"
_state ->
flunk("Should have rejected oversized pack")
end
end
end
end