ref:4c5251ddc23bbdcf15471d8fd260ad076ef8b11f

Implement fsck integrity verification (#2)

## Summary - Add `Fsck.check/2` with `:full` and `:quick` modes for repository integrity verification - Add `list_objects/2` callback to Storage behaviour, implemented in all three backends - Full mode verifies: loose object SHA integrity, object graph completeness, ref validity, pack checksums - Quick mode verifies: ref validity and graph reachability only Closes #14 ## Changes - **lib/ex_git_objectstore/fsck.ex** — New module with `check/2` entry point - **lib/ex_git_objectstore/storage.ex** — Added `list_objects` callback - **lib/ex_git_objectstore/storage/{memory,filesystem,s3}.ex** — `list_objects` implementations - **test/support/tracking_memory.ex** — Added `list_objects` delegation - **test/ex_git_objectstore/fsck_test.exs** — 11 tests covering all acceptance criteria ## Test plan - [x] Valid repo returns clean report - [x] Detects corrupted object data (SHA mismatch) - [x] Detects missing object referenced by tree - [x] Detects missing parent commit - [x] Detects dangling ref - [x] Detects corrupted pack checksum - [x] Detects missing tag target - [x] Quick mode detects dangling ref but skips SHA verification - [x] Report includes counts - [x] All 540 tests pass, dialyzer clean
SHA: 4c5251ddc23bbdcf15471d8fd260ad076ef8b11f
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-04-09 12:51
Parents: 552f599
7 files changed +724 -0
Type
lib/ex_git_objectstore/fsck.ex +352 −0
@@ -1,0 +1,352 @@
# 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.Fsck do
@moduledoc """
Repository integrity verification.
Checks that:
- All refs point to existing objects
- All object SHAs match their content
- The object graph is complete (all referenced objects exist)
- Pack checksums are valid
Two modes:
- `:full` (default) — verifies every object's SHA and the full reachability graph
- `:quick` — only verifies refs point to existing objects and walks the graph
"""
alias ExGitObjectstore.{Object, ObjectResolver, Ref, Repo}
alias ExGitObjectstore.Object.{Commit, Tag, Tree}
alias ExGitObjectstore.Pack.Reader
# Dialyzer contract_with_opaque: MapSet.t() is opaque and Dialyzer cannot
# reconcile the internal representation with usage in walk_objects/check_graph.
@dialyzer {:no_opaque, check_graph: 4, walk_objects: 6, check_children: 4}
@type error :: %{
type:
:sha_mismatch
| :missing_object
| :dangling_ref
| :pack_checksum_mismatch
| :decode_error,
sha: String.t() | nil,
ref: String.t() | nil,
referenced_by: String.t() | nil,
detail: String.t() | nil
}
@type report :: %{
errors: [error()],
objects_checked: non_neg_integer(),
refs_checked: non_neg_integer(),
packs_checked: non_neg_integer()
}
@doc """
Run integrity checks on a repository.
## Options
* `:mode` - `:full` (default) or `:quick`
- `:full` verifies object SHA integrity, graph completeness, ref validity, and pack checksums
- `:quick` verifies ref validity and graph completeness (object existence only, no SHA re-hash)
"""
@spec check(Repo.t(), keyword()) :: {:ok, report()}
def check(%Repo{} = repo, opts \\ []) do
mode = Keyword.get(opts, :mode, :full)
errors = []
stats = %{objects_checked: 0, refs_checked: 0, packs_checked: 0}
# Step 1: Verify refs
{ref_errors, ref_shas, stats} = check_refs(repo, stats)
errors = errors ++ ref_errors
# Step 2: Walk reachable graph and check references exist
{graph_errors, _objects_visited, stats} = check_graph(repo, ref_shas, mode, stats)
errors = errors ++ graph_errors
# Step 3 (full mode only): Verify all loose object SHAs
{sha_errors, loose_count} =
if mode == :full do
check_all_loose_objects(repo)
else
{[], 0}
end
errors = errors ++ sha_errors
stats = %{stats | objects_checked: stats.objects_checked + loose_count}
# Step 4: Verify pack checksums
{pack_errors, stats} = check_packs(repo, stats)
errors = errors ++ pack_errors
{:ok,
%{
errors: errors,
objects_checked: stats.objects_checked,
refs_checked: stats.refs_checked,
packs_checked: stats.packs_checked
}}
end
# -- Ref checking --
defp check_refs(repo, stats) do
refs = list_all_refs(repo)
errors = []
{errors, valid_shas} =
Enum.reduce(refs, {errors, []}, fn {ref_path, sha}, {errs, shas} ->
case ObjectResolver.read(repo, sha) do
{:ok, _} ->
{errs, [sha | shas]}
{:error, _} ->
err = %{
type: :dangling_ref,
sha: sha,
ref: ref_path,
referenced_by: nil,
detail: "ref #{ref_path} points to non-existent object #{sha}"
}
{[err | errs], shas}
end
end)
stats = %{stats | refs_checked: length(refs)}
{Enum.reverse(errors), valid_shas, stats}
end
defp list_all_refs(repo) do
refs =
case Ref.list(repo, "refs/") do
{:ok, refs} -> refs
{:error, _} -> []
end
# Also check HEAD
head_refs =
case Ref.get_head(repo) do
{:ok, "ref: " <> _} -> []
{:ok, sha} -> [{"HEAD", sha}]
{:error, _} -> []
end
head_refs ++ refs
end
# -- Graph walking --
defp check_graph(repo, ref_shas, mode, stats) do
# Walk from each ref SHA, collecting all reachable objects and checking references
visited = MapSet.new()
errors = []
queue = ref_shas
{errors, visited, stats} = walk_objects(repo, queue, visited, errors, mode, stats)
{errors, visited, stats}
end
defp walk_objects(_repo, [], visited, errors, _mode, stats) do
stats = %{stats | objects_checked: MapSet.size(visited)}
{Enum.reverse(errors), visited, stats}
end
defp walk_objects(repo, [sha | rest], visited, errors, mode, stats) do
if MapSet.member?(visited, sha) do
walk_objects(repo, rest, visited, errors, mode, stats)
else
visited = MapSet.put(visited, sha)
case ObjectResolver.read(repo, sha) do
{:ok, object} ->
child_shas = extract_references(object)
{new_errors, queue_additions} =
check_children(repo, sha, child_shas, visited)
walk_objects(
repo,
queue_additions ++ rest,
visited,
new_errors ++ errors,
mode,
stats
)
{:error, _} ->
# Object missing — already reported as dangling_ref if from a ref,
# or as missing_object if referenced by another object.
walk_objects(repo, rest, visited, errors, mode, stats)
end
end
end
defp check_children(repo, parent_sha, child_shas, visited) do
Enum.reduce(child_shas, {[], []}, fn child_sha, {errs, queue} ->
if MapSet.member?(visited, child_sha) do
{errs, queue}
else
case ObjectResolver.read(repo, child_sha) do
{:ok, _} ->
{errs, [child_sha | queue]}
{:error, _} ->
err = %{
type: :missing_object,
sha: child_sha,
ref: nil,
referenced_by: parent_sha,
detail: "object #{child_sha} referenced by #{parent_sha} does not exist"
}
{[err | errs], queue}
end
end
end)
end
defp extract_references(%Commit{tree: tree, parents: parents}) do
[tree | parents]
end
defp extract_references(%Tree{entries: entries}) do
Enum.map(entries, & &1.sha)
end
defp extract_references(%Tag{object: target}) do
[target]
end
defp extract_references(_blob) do
[]
end
# -- SHA verification (full mode) --
defp check_all_loose_objects(repo) do
case Repo.storage_call(repo, :list_objects, []) do
{:ok, shas} ->
errors =
Enum.reduce(shas, [], fn sha, errs ->
case verify_loose_object_sha(repo, sha) do
:ok -> errs
{:error, error} -> [error | errs]
end
end)
{Enum.reverse(errors), length(shas)}
{:error, _} ->
{[], 0}
end
end
defp verify_loose_object_sha(repo, sha) do
case Repo.storage_call(repo, :get_object, [sha]) do
{:ok, compressed} ->
case Object.decode(compressed) do
{:ok, object} ->
actual_sha = Object.hash(object)
if actual_sha == sha do
:ok
else
{:error,
%{
type: :sha_mismatch,
sha: sha,
ref: nil,
referenced_by: nil,
detail: "object #{sha} has actual SHA #{actual_sha}"
}}
end
{:error, reason} ->
{:error,
%{
type: :decode_error,
sha: sha,
ref: nil,
referenced_by: nil,
detail: "failed to decode object #{sha}: #{inspect(reason)}"
}}
end
{:error, reason} ->
{:error,
%{
type: :decode_error,
sha: sha,
ref: nil,
referenced_by: nil,
detail: "failed to read object #{sha}: #{inspect(reason)}"
}}
end
end
# -- Pack checksum verification --
defp check_packs(repo, stats) do
case Repo.storage_call(repo, :list_packs, []) do
{:ok, pack_shas} ->
{errors, count} =
Enum.reduce(pack_shas, {[], 0}, fn pack_sha, {errs, count} ->
case check_single_pack(repo, pack_sha) do
:ok -> {errs, count + 1}
{:error, err} -> {[err | errs], count + 1}
end
end)
{Enum.reverse(errors), %{stats | packs_checked: count}}
{:error, _} ->
{[], stats}
end
end
defp check_single_pack(repo, pack_sha) do
case Repo.storage_call(repo, :get_pack, [pack_sha]) do
{:ok, pack_data} ->
case Reader.verify_pack_checksum(pack_data) do
:ok ->
:ok
{:error, _} ->
{:error,
%{
type: :pack_checksum_mismatch,
sha: pack_sha,
ref: nil,
referenced_by: nil,
detail: "pack #{pack_sha} has invalid checksum"
}}
end
{:error, reason} ->
{:error,
%{
type: :decode_error,
sha: pack_sha,
ref: nil,
referenced_by: nil,
detail: "failed to read pack #{pack_sha}: #{inspect(reason)}"
}}
end
end
end
lib/ex_git_objectstore/storage.ex +1 −0
@@ -30,5 +30,6 @@
@callback get_object(config, prefix, sha) :: {:ok, binary()} | {:error, term()}
@callback put_object(config, prefix, sha, binary()) :: :ok | {:error, term()}
@callback object_exists?(config, prefix, sha) :: boolean()
@callback list_objects(config, prefix) :: {:ok, [String.t()]} | {:error, term()}
# -- Pack operations --
lib/ex_git_objectstore/storage/filesystem.ex +34 −0
@@ -58,6 +58,40 @@
File.exists?(object_path(config, prefix, sha))
end
@impl true
def list_objects(config, prefix) do
objects_dir = safe_path(config.root, Path.join(prefix, "objects"))
case File.ls(objects_dir) do
{:ok, entries} ->
shas =
entries
|> Enum.filter(fn dir ->
byte_size(dir) == 2 and Regex.match?(~r/\A[0-9a-f]{2}\z/, dir) and
File.dir?(Path.join(objects_dir, dir))
end)
|> Enum.flat_map(fn dir ->
subdir = Path.join(objects_dir, dir)
case File.ls(subdir) do
{:ok, files} ->
Enum.map(files, fn file -> dir <> file end)
{:error, _} ->
[]
end
end)
{:ok, shas}
{:error, :enoent} ->
{:ok, []}
{:error, reason} ->
{:error, reason}
end
end
# -- Pack operations --
@impl true
lib/ex_git_objectstore/storage/memory.ex +15 −0
@@ -61,6 +61,21 @@
end)
end
@impl true
def list_objects(%{pid: pid}, prefix) do
full_prefix = prefix <> "/"
shas =
Agent.get(pid, fn state ->
state.objects
|> Map.keys()
|> Enum.filter(&String.starts_with?(&1, full_prefix))
|> Enum.map(&String.replace_prefix(&1, full_prefix, ""))
end)
{:ok, shas}
end
# -- Pack operations --
@impl true
lib/ex_git_objectstore/storage/s3.ex +21 −0
@@ -74,6 +74,27 @@
end
end
@impl true
def list_objects(config, prefix) do
objects_prefix = "#{prefix}/objects/"
case s3_list(config, objects_prefix) do
{:ok, keys} ->
shas =
keys
|> Enum.filter(&String.match?(&1, ~r|/objects/[0-9a-f]{2}/[0-9a-f]{38}$|))
|> Enum.map(fn key ->
[dir, rest] = key |> String.split("/") |> Enum.take(-2)
dir <> rest
end)
{:ok, shas}
{:error, _} = err ->
err
end
end
# -- Pack operations --
@impl true
test/ex_git_objectstore/fsck_test.exs +295 −0
@@ -1,0 +1,295 @@
# 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.FsckTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.{Fsck, Object, Ref, Repo}
alias ExGitObjectstore.Object.{Blob, Commit, Tag, Tree}
alias ExGitObjectstore.Pack.Writer
alias ExGitObjectstore.Test.RepoHelper
# -- Helpers --
defp build_simple_repo do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
blob = Blob.from_content("hello world\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 <test@test.com> 1000000000 +0000",
committer: "Test <test@test.com> 1000000000 +0000",
message: "initial\n"
}
{:ok, commit_sha} = Object.write(repo, commit)
Ref.put(repo, "refs/heads/main", commit_sha, nil)
%{
repo: repo,
blob_sha: blob_sha,
tree_sha: tree_sha,
commit_sha: commit_sha
}
end
# -- Valid repository --
test "valid repo returns no errors" do
%{repo: repo} = build_simple_repo()
assert {:ok, report} = Fsck.check(repo)
assert report.errors == []
assert report.objects_checked > 0
end
test "valid repo quick mode returns no errors" do
%{repo: repo} = build_simple_repo()
assert {:ok, report} = Fsck.check(repo, mode: :quick)
assert report.errors == []
end
# -- Corrupted object (SHA mismatch) --
test "detects corrupted object data" do
%{repo: repo, blob_sha: blob_sha} = build_simple_repo()
# Corrupt the blob by writing garbage under its SHA
corrupted = :zlib.compress("blob 5\0wrong")
:ok = Repo.storage_call(repo, :put_object, [blob_sha, corrupted])
assert {:ok, report} = Fsck.check(repo)
assert length(report.errors) > 0
sha_mismatch = Enum.find(report.errors, fn e -> e.type == :sha_mismatch end)
assert sha_mismatch != nil
assert sha_mismatch.sha == blob_sha
end
# -- Missing object referenced by tree --
test "detects missing object referenced by tree" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
# Write a tree that references a non-existent blob
missing_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
tree = Tree.new([%{mode: "100644", name: "gone.txt", sha: missing_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: [],
author: "Test <test@test.com> 1000000000 +0000",
committer: "Test <test@test.com> 1000000000 +0000",
message: "with missing blob\n"
}
{:ok, commit_sha} = Object.write(repo, commit)
Ref.put(repo, "refs/heads/main", commit_sha, nil)
assert {:ok, report} = Fsck.check(repo)
missing = Enum.find(report.errors, fn e -> e.type == :missing_object end)
assert missing != nil
assert missing.sha == missing_sha
assert missing.referenced_by == tree_sha
end
# -- Missing object referenced by commit (parent) --
test "detects missing parent commit" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
blob = Blob.from_content("content\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "f.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
missing_parent = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
commit = %Commit{
tree: tree_sha,
parents: [missing_parent],
author: "Test <test@test.com> 1000000000 +0000",
committer: "Test <test@test.com> 1000000000 +0000",
message: "orphan\n"
}
{:ok, commit_sha} = Object.write(repo, commit)
Ref.put(repo, "refs/heads/main", commit_sha, nil)
assert {:ok, report} = Fsck.check(repo)
missing =
Enum.find(report.errors, fn e ->
e.type == :missing_object and e.sha == missing_parent
end)
assert missing != nil
assert missing.referenced_by == commit_sha
end
# -- Dangling ref --
test "detects ref pointing to non-existent object" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
# Create a ref pointing to nothing
Ref.put(repo, "refs/heads/main", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", nil)
assert {:ok, report} = Fsck.check(repo)
dangling = Enum.find(report.errors, fn e -> e.type == :dangling_ref end)
assert dangling != nil
assert dangling.ref =~ "refs/heads/main"
end
# -- Quick mode only checks refs --
test "quick mode detects dangling ref" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
Ref.put(repo, "refs/heads/main", "cccccccccccccccccccccccccccccccccccccccc", nil)
assert {:ok, report} = Fsck.check(repo, mode: :quick)
dangling = Enum.find(report.errors, fn e -> e.type == :dangling_ref end)
assert dangling != nil
end
test "quick mode does not verify object SHA integrity" do
%{repo: repo, blob_sha: blob_sha} = build_simple_repo()
# Corrupt blob data
corrupted = :zlib.compress("blob 5\0wrong")
:ok = Repo.storage_call(repo, :put_object, [blob_sha, corrupted])
# Quick mode should NOT catch SHA mismatch (only checks refs + graph)
assert {:ok, report} = Fsck.check(repo, mode: :quick)
sha_errors = Enum.filter(report.errors, fn e -> e.type == :sha_mismatch end)
assert sha_errors == []
end
# -- Pack checksum verification --
test "detects corrupted pack checksum" do
%{repo: repo, blob_sha: blob_sha, tree_sha: tree_sha, commit_sha: commit_sha} =
build_simple_repo()
# Build pack entries with {type, raw_content, sha} tuples
blob_obj = Blob.from_content("hello world\n")
tree_obj = Tree.new([%{mode: "100644", name: "file.txt", sha: blob_sha}])
commit_obj = %Commit{
tree: tree_sha,
parents: [],
author: "Test <test@test.com> 1000000000 +0000",
committer: "Test <test@test.com> 1000000000 +0000",
message: "initial\n"
}
objects = [
{:blob, blob_obj.content, blob_sha},
{:tree, Object.encode_content_only(tree_obj), tree_sha},
{:commit, Object.encode_content_only(commit_obj), commit_sha}
]
{pack_data, idx_data, _pack_sha} = Writer.generate_with_index(objects)
# Corrupt the pack checksum (last 20 bytes)
body_len = byte_size(pack_data) - 20
<<body::binary-size(body_len), _checksum::binary-size(20)>> = pack_data
corrupted_pack = body <> :crypto.strong_rand_bytes(20)
# Use the correct pack SHA from the body
correct_pack_sha = :crypto.hash(:sha, body) |> Base.encode16(case: :lower)
:ok = Repo.storage_call(repo, :put_pack, [correct_pack_sha, corrupted_pack, idx_data])
assert {:ok, report} = Fsck.check(repo)
pack_err = Enum.find(report.errors, fn e -> e.type == :pack_checksum_mismatch end)
assert pack_err != nil
end
# -- Tag target verification --
test "detects missing tag target" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
blob = Blob.from_content("content\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "f.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
commit = %Commit{
tree: tree_sha,
parents: [],
author: "Test <test@test.com> 1000000000 +0000",
committer: "Test <test@test.com> 1000000000 +0000",
message: "c\n"
}
{:ok, commit_sha} = Object.write(repo, commit)
Ref.put(repo, "refs/heads/main", commit_sha, nil)
# Write a tag pointing to a non-existent object
missing_target = "dddddddddddddddddddddddddddddddddddddddd"
tag = %Tag{
object: missing_target,
type: "commit",
tag: "v1.0",
tagger: "Test <test@test.com> 1000000000 +0000",
message: "release\n"
}
{:ok, tag_sha} = Object.write(repo, tag)
Ref.put(repo, "refs/tags/v1.0", tag_sha, nil)
assert {:ok, report} = Fsck.check(repo)
missing =
Enum.find(report.errors, fn e ->
e.type == :missing_object and e.sha == missing_target
end)
assert missing != nil
assert missing.referenced_by == tag_sha
end
# -- Report includes counts --
test "report includes object and ref counts" do
%{repo: repo} = build_simple_repo()
assert {:ok, report} = Fsck.check(repo)
assert report.objects_checked >= 3
assert report.refs_checked >= 1
assert report.packs_checked >= 0
end
end
test/support/tracking_memory.ex +6 −0
@@ -134,4 +134,10 @@
track(config, {:put_head, prefix})
Memory.put_head(mem_config(config), prefix, target)
end
@impl true
def list_objects(config, prefix) do
track(config, {:list_objects, prefix})
Memory.list_objects(mem_config(config), prefix)
end
end