ref:f3281b695d81c30af529d1b618151d454ca4bff4

Merge pull request #3 from feat/configurable-size-limits-and-docs into main

SHA: f3281b695d81c30af529d1b618151d454ca4bff4
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-04-09 13:02
8 files changed +177 -27
Type
CHANGELOG.md +15 −0
@@ -5,6 +5,21 @@
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Repository integrity verification (`Fsck.check/2`) with full and quick modes
- `list_objects/2` callback on Storage behaviour for enumerating loose objects
- Dialyzer enforced in CI pipeline
- `Pack.Reader.read_object/4` with external resolver for REF_DELTA bases
### Fixed
- `Diff.diff_blobs/4` spec now includes binary file return variant
- `Protocol.ReceivePack` state type includes all struct fields
- Resolved all Dialyzer warnings (4 type errors)
## [0.1.0] - 2026-02-10
### Added
lib/ex_git_objectstore/object.ex +20 −14
@@ -23,8 +23,8 @@
alias ExGitObjectstore.Object.{Blob, Commit, Tag, Tree}
alias ExGitObjectstore.Repo
# Maximum decompressed object size (128 MB)
# Default maximum decompressed object size (128 MB)
@default_max_object_size 128 * 1024 * 1024
@max_object_size 128 * 1024 * 1024
@type object_type :: :blob | :commit | :tree | :tag
@type t :: Blob.t() | Commit.t() | Tree.t() | Tag.t()
@@ -71,10 +71,16 @@
@doc """
Decode a stored git object (zlib compressed) into a struct.
## Options
* `:max_size` - maximum decompressed size in bytes (default: 128 MB)
"""
@spec decode(binary(), keyword()) :: {:ok, t()} | {:error, term()}
@spec decode(binary()) :: {:ok, t()} | {:error, term()}
def decode(compressed_data) do
case safe_decompress(compressed_data) do
def decode(compressed_data, opts \\ []) do
max_size = Keyword.get(opts, :max_size, @default_max_object_size)
case safe_decompress(compressed_data, max_size) do
{:ok, raw} -> decode_raw(raw)
{:error, _} = err -> err
end
@@ -106,7 +112,7 @@
def read(%Repo{} = repo, sha) do
case Repo.storage_call(repo, :get_object, [sha]) do
{:ok, data} ->
decompress_and_verify(data, sha)
decompress_and_verify(data, sha, repo.max_object_size)
{:error, _} = err ->
err
@@ -133,7 +139,7 @@
end
# Decompress stored data and verify SHA-1 integrity before decoding.
defp decompress_and_verify(data, sha, max_size) do
case safe_decompress(data, max_size) do
defp decompress_and_verify(data, sha) do
case safe_decompress(data) do
{:ok, raw} ->
verify_sha_and_decode(raw, sha)
@@ -225,10 +231,6 @@
after
:zlib.close(z)
end
end
defp safe_decompress(data) do
safe_decompress(data, @max_object_size)
end
defp safe_decompress(data, max_size) do
@@ -259,7 +261,9 @@
new_total = total + byte_size(chunk_bin)
if new_total > max_size do
{:error, {:object_too_large, new_total, max_size}}
{:error,
{:object_too_large,
"decompressed size #{new_total} exceeds limit of #{max_size} bytes"}}
else
safe_inflate_all(z, [], max_size, new_total, [chunk_bin | acc])
end
@@ -269,7 +273,9 @@
new_total = total + byte_size(chunk_bin)
if new_total > max_size do
{:error, {:object_too_large, new_total, max_size}}
{:error,
{:object_too_large,
"decompressed size #{new_total} exceeds limit of #{max_size} bytes"}}
else
{:ok, IO.iodata_to_binary(Enum.reverse([chunk_bin | acc]))}
end
lib/ex_git_objectstore/object_resolver.ex +11 −2
@@ -71,8 +71,17 @@
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
{:ok, {type, data}} ->
if byte_size(data) > repo.max_object_size do
{:error,
{:object_too_large,
"decompressed size #{byte_size(data)} exceeds limit of #{repo.max_object_size} bytes"}}
else
wrap_object(type, data)
end
{:error, _} = err ->
err
end
{:error, _} = err ->
lib/ex_git_objectstore/protocol/receive_pack.ex +13 −7
@@ -386,14 +386,20 @@
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)
if byte_size(entry.data) > repo.max_object_size do
{:error,
{:object_too_large,
"decompressed size #{byte_size(entry.data)} exceeds limit of #{repo.max_object_size} bytes"}}
else
raw = Object.encode_raw_from_type(entry.type, entry.data)
sha = :crypto.hash(:sha, raw) |> Base.encode16(case: :lower)
case zlib_compress(raw) do
{:ok, compressed} ->
Repo.storage_call(repo, :put_object, [sha, compressed])
case zlib_compress(raw) do
{:ok, compressed} ->
Repo.storage_call(repo, :put_object, [sha, compressed])
{:error, _} = err ->
err
{:error, _} = err ->
err
end
end
end
lib/ex_git_objectstore/repo.ex +9 −3
@@ -17,14 +17,18 @@
Repository handle. Encapsulates the repo identity and storage backend config.
"""
# Default maximum decompressed object size (128 MB)
@default_max_object_size 128 * 1024 * 1024
@type t :: %__MODULE__{
id: String.t(),
storage: {module(), map()},
cache: {module(), map()} | nil
cache: {module(), map()} | nil,
max_object_size: pos_integer()
}
@enforce_keys [:id, :storage]
defstruct [:id, :storage, :cache]
defstruct [:id, :storage, :cache, max_object_size: @default_max_object_size]
@doc """
Create a new repo handle.
@@ -39,10 +43,12 @@
validate_repo_id!(id)
storage = Keyword.fetch!(opts, :storage)
cache = Keyword.get(opts, :cache)
max_object_size = Keyword.get(opts, :max_object_size, @default_max_object_size)
%__MODULE__{
id: id,
storage: storage,
cache: cache
cache: cache,
max_object_size: max_object_size
}
end
README.md +1 −0
@@ -18,5 +18,6 @@
- **Git wire protocol** — pkt-line framing, upload-pack, receive-pack
- **Pluggable storage** — filesystem, S3 (any S3-compatible service), and in-memory backends
- **ETS caching** — LRU object cache with configurable size limits
- **Integrity verification** — fsck with full (SHA re-hash) and quick (refs-only) modes
## Installation
test/ex_git_objectstore/configurable_size_limit_test.exs +107 −0
@@ -1,0 +1,107 @@
# 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.ConfigurableSizeLimitTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.{Object, Repo}
alias ExGitObjectstore.Object.Blob
alias ExGitObjectstore.Storage.Memory
defp repo_with_limit(max_object_size) do
{:ok, pid} = Memory.start_link()
Repo.new("test-repo", storage: {Memory, Memory.config(pid)}, max_object_size: max_object_size)
end
defp default_repo do
{:ok, pid} = Memory.start_link()
Repo.new("test-repo", storage: {Memory, Memory.config(pid)})
end
# -- Default behavior unchanged --
test "default max_object_size is 128MB" do
repo = default_repo()
assert repo.max_object_size == 128 * 1024 * 1024
end
test "small objects work with default limits" do
repo = default_repo()
ExGitObjectstore.init(repo)
blob = Blob.from_content("hello\n")
assert {:ok, _sha} = Object.write(repo, blob)
end
# -- Custom limits --
test "custom max_object_size is stored in repo" do
repo = repo_with_limit(1024)
assert repo.max_object_size == 1024
end
test "object read rejects objects exceeding custom limit" do
# Write with a generous limit, then read with a tight one
write_repo = repo_with_limit(1_000_000)
ExGitObjectstore.init(write_repo)
# Create a blob larger than 100 bytes
content = String.duplicate("x", 200)
blob = Blob.from_content(content)
{:ok, sha} = Object.write(write_repo, blob)
# Create a repo with the same storage but tight limit
read_repo = %{write_repo | max_object_size: 100}
assert {:error, {:object_too_large, _}} = Object.read(read_repo, sha)
end
test "object decode respects custom limit" do
content = String.duplicate("a", 500)
blob = Blob.from_content(content)
{:ok, compressed} = Object.encode(blob)
# Decode with tight limit
assert {:error, {:object_too_large, _}} = Object.decode(compressed, max_size: 100)
end
test "object decode works with default limit" do
content = String.duplicate("a", 500)
blob = Blob.from_content(content)
{:ok, compressed} = Object.encode(blob)
# Decode without limit uses default
assert {:ok, %Blob{}} = Object.decode(compressed)
end
# -- Error messages include configured max --
test "error message includes the configured limit" do
write_repo = repo_with_limit(1_000_000)
ExGitObjectstore.init(write_repo)
content = String.duplicate("y", 300)
blob = Blob.from_content(content)
{:ok, sha} = Object.write(write_repo, blob)
read_repo = %{write_repo | max_object_size: 50}
case Object.read(read_repo, sha) do
{:error, {:object_too_large, detail}} ->
assert detail =~ "50"
other ->
flunk("Expected {:error, {:object_too_large, _}}, got: #{inspect(other)}")
end
end
end
test/ex_git_objectstore/security_test.exs +1 −1
@@ -99,7 +99,7 @@
# 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)
assert {:error, {:object_too_large, _}} = Object.decode(encoded)
{:error, _} ->
# Compression itself may fail with very large data, that's ok