@@ -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