@@ -1,0 +1,415 @@
# 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.Lfs.InteropSshAuthCapture do
@moduledoc false
# Test-only Plug that records the `Authorization` header of every LFS
# request before delegating to `LfsHttpAdapter`. Lets the SSH interop
# tests prove that `git lfs` actually *replays* the credentials handed
# back by `git-lfs-authenticate` on the follow-up HTTP API calls — the
# load-bearing claim of the whole SSH auth model.
@behaviour Plug
alias ExGitObjectstore.Test.LfsHttpAdapter
@impl true
def init(opts), do: opts
@impl true
def call(conn, opts) do
sink = Keyword.fetch!(opts, :auth_sink)
auth =
case Plug.Conn.get_req_header(conn, "authorization") do
[value | _] -> value
_ -> nil
end
Agent.update(sink, fn acc ->
[%{method: conn.method, path: conn.request_path, auth: auth} | acc]
end)
LfsHttpAdapter.call(conn, opts)
end
end
defmodule ExGitObjectstore.Lfs.InteropSshTest do
@moduledoc """
Interop for the SSH transport path against the real `git lfs` binary.
When a remote is an SSH URL, `git lfs` does not transfer over SSH. It
runs `git-lfs-authenticate <path> <op>` over the SSH connection and
uses the returned JSON to discover the **HTTP** LFS endpoint, then
drives Batch / transfer / locks over HTTP.
This test stands in for the consumer's SSH command dispatcher with a
fake `core.sshCommand` script that:
* declines `git-lfs-transfer` (exit 1) so the client falls back to
the authenticate handoff, and
* answers `git-lfs-authenticate` with the bytes produced by
`ExGitObjectstore.Lfs.Authenticate.handle/3`, pointed at the live
Bandit HTTP server.
Without the handoff (the bug in #66), `git lfs` reports
*"does not support the Git LFS locking API"* and the push hangs against
a guessed HTTPS URL. These tests prove the handoff fixes both, that the
credentials it returns are replayed on the discovered HTTP endpoint,
and that download as well as upload round-trips over it.
Skipped automatically when `git-lfs` is not on PATH.
"""
use ExUnit.Case, async: false
@moduletag requirements: ["REQ-LFS-007", "REQ-LFS-008"]
@moduletag :interop
alias ExGitObjectstore.Lfs.Authenticate
alias ExGitObjectstore.Lfs.InteropSshAuthCapture
alias ExGitObjectstore.Lfs.Store.Filesystem, as: LfsFilesystem
alias ExGitObjectstore.Repo
alias ExGitObjectstore.Storage.Filesystem, as: GitFilesystem
@git_lfs_available (case System.cmd("git", ["lfs", "version"], stderr_to_stdout: true) do
{_out, 0} -> true
_ -> false
end)
if !@git_lfs_available do
@moduletag :skip
end
setup do
root = tmp!("lfs-ssh-root")
port = random_port()
{:ok, auth_sink} = Agent.start_link(fn -> [] end)
factory = fn repo_id ->
Repo.new(repo_id,
storage: {GitFilesystem, %{root: root}},
lfs_storage: {LfsFilesystem, %{root: root}}
)
end
{:ok, server} =
Bandit.start_link(
plug: {InteropSshAuthCapture, repo_factory: factory, auth_sink: auth_sink},
scheme: :http,
port: port,
ip: {127, 0, 0, 1}
)
on_exit(fn ->
if Process.alive?(server) do
ref = Process.monitor(server)
try do
GenServer.stop(server, :normal, 5_000)
catch
:exit, _ -> :ok
end
receive do
{:DOWN, ^ref, _, _, _} -> :ok
after
5_000 -> :ok
end
end
end)
%{
root: root,
port: port,
factory: factory,
auth_sink: auth_sink,
lfs_url: fn repo_id -> "http://127.0.0.1:#{port}/#{repo_id}/info/lfs" end
}
end
describe "git lfs over an SSH remote (authenticate handoff)" do
@tag timeout: :timer.minutes(1)
test "push round-trips the object and replays the authenticate credentials", ctx do
repo_id = "interop_ssh_#{:erlang.unique_integer([:positive])}"
work = tmp!("lfs-ssh-work")
payload = :crypto.strong_rand_bytes(2 * 1024 * 1024)
init_ssh_repo(ctx, repo_id, work)
git!(work, ["lfs", "track", "*.bin"])
File.write!(Path.join(work, "big.bin"), payload)
git!(work, ["add", ".gitattributes", "big.bin"])
git!(work, ["commit", "-qm", "add lfs blob"])
# No explicit lfs.url: the endpoint is discovered purely through the
# SSH git-lfs-authenticate handoff.
out = git!(work, ["lfs", "push", "--all", "origin"])
oid = sha256_hex(payload)
assert server_object_exists?(ctx.root, repo_id, oid),
"object should have been uploaded via the SSH-discovered endpoint; push said: #{out}"
assert server_object_bytes(ctx.root, repo_id, oid) == payload
# The load-bearing claim: git-lfs must replay the `header` from the
# upload-operation authenticate response on the HTTP Batch API call.
assert "Bearer test-upload" in batch_auth_headers(ctx.auth_sink),
"git lfs must replay the authenticate Authorization header on the batch request; " <>
"captured: #{inspect(captured(ctx.auth_sink))}"
end
@tag timeout: :timer.minutes(1)
test "fetch (download op) round-trips the object back over the SSH endpoint", ctx do
repo_id = "interop_ssh_dl_#{:erlang.unique_integer([:positive])}"
work = tmp!("lfs-ssh-dl-work")
payload = :crypto.strong_rand_bytes(1 * 1024 * 1024)
init_ssh_repo(ctx, repo_id, work)
git!(work, ["lfs", "track", "*.bin"])
File.write!(Path.join(work, "data.bin"), payload)
git!(work, ["add", ".gitattributes", "data.bin"])
git!(work, ["commit", "-qm", "add"])
git!(work, ["lfs", "push", "--all", "origin"])
oid = sha256_hex(payload)
# Drop the local LFS cache so the next fetch MUST hit the server,
# exercising the download operation (not just a cache hit).
File.rm_rf!(Path.join([work, ".git", "lfs", "objects"]))
refute local_lfs_object_exists?(work, oid)
out = git!(work, ["lfs", "fetch", "--all", "origin"])
assert local_lfs_object_exists?(work, oid),
"object should have been downloaded via the SSH-discovered endpoint; fetch said: #{out}"
assert local_lfs_object_bytes(work, oid) == payload
# The download-operation credentials must be replayed too.
assert "Bearer test-download" in batch_auth_headers(ctx.auth_sink),
"git lfs must replay the download authenticate header on fetch; " <>
"captured: #{inspect(captured(ctx.auth_sink))}"
end
@tag timeout: :timer.minutes(1)
test "push succeeds when the handoff uses expires_at instead of expires_in", ctx do
repo_id = "interop_ssh_exp_#{:erlang.unique_integer([:positive])}"
work = tmp!("lfs-ssh-exp-work")
payload = :crypto.strong_rand_bytes(32 * 1024)
# Far-future RFC3339 expiry — proves git-lfs accepts the expires_at
# form the library emits, end-to-end.
init_ssh_repo(ctx, repo_id, work, auth_opts: [expires_at: "2099-01-01T00:00:00Z"])
git!(work, ["lfs", "track", "*.bin"])
File.write!(Path.join(work, "x.bin"), payload)
git!(work, ["add", ".gitattributes", "x.bin"])
git!(work, ["commit", "-qm", "add"])
out = git!(work, ["lfs", "push", "--all", "origin"])
assert server_object_exists?(ctx.root, repo_id, sha256_hex(payload)),
"push with expires_at should round-trip; push said: #{out}"
end
@tag timeout: :timer.minutes(1)
test "locking API is reachable (no 'does not support locking', no hang)", ctx do
repo_id = "interop_ssh_lock_#{:erlang.unique_integer([:positive])}"
work = tmp!("lfs-ssh-lock-work")
init_ssh_repo(ctx, repo_id, work)
git!(work, ["lfs", "track", "*.psd"])
File.write!(Path.join(work, "design.psd"), "psd")
git!(work, ["add", ".gitattributes", "design.psd"])
git!(work, ["commit", "-qm", "track"])
# Acquire a lock — resolves the lock endpoint via the SSH handoff.
lock_out = git!(work, ["lfs", "lock", "design.psd"])
assert lock_out =~ "design.psd"
# The bug symptom: this message must NOT appear once the endpoint is
# discoverable over SSH.
refute lock_out =~ "does not support",
"locking API should be reachable over the SSH-discovered endpoint"
# The lock is visible through the same discovered endpoint.
list_out = git!(work, ["lfs", "locks"])
assert list_out =~ "design.psd"
# And it can be released.
unlock_out = git!(work, ["lfs", "unlock", "design.psd"])
assert unlock_out =~ "design.psd"
refute git!(work, ["lfs", "locks"]) =~ "design.psd"
end
@tag timeout: :timer.minutes(1)
test "push-time lock verification does not report the locking API as unsupported", ctx do
repo_id = "interop_ssh_verify_#{:erlang.unique_integer([:positive])}"
work = tmp!("lfs-ssh-verify-work")
payload = :crypto.strong_rand_bytes(64 * 1024)
init_ssh_repo(ctx, repo_id, work)
# locksverify=true makes git-lfs treat a missing/!200 /locks/verify as a
# hard failure rather than a warning — the strictest form of the symptom.
git!(work, ["config", "lfs.#{ctx.lfs_url.(repo_id)}.locksverify", "true"])
git!(work, ["lfs", "track", "*.bin"])
File.write!(Path.join(work, "data.bin"), payload)
git!(work, ["add", ".gitattributes", "data.bin"])
git!(work, ["commit", "-qm", "add"])
out = git!(work, ["lfs", "push", "--all", "origin"])
refute out =~ "does not support",
"push-time /locks/verify must succeed over the SSH-discovered endpoint; got: #{out}"
assert server_object_exists?(ctx.root, repo_id, sha256_hex(payload))
end
end
# -- Helpers --
# Initialise a working repo whose `origin` is an SSH remote, with a fake
# core.sshCommand that performs the git-lfs-authenticate handoff using the
# library's Authenticate module.
defp init_ssh_repo(ctx, repo_id, work, opts \\ []) do
git!(work, ["init", "-q", "-b", "main"])
git!(work, ["config", "user.email", "test@example.com"])
git!(work, ["config", "user.name", "Test"])
git!(work, ["remote", "add", "origin", "ssh://git@127.0.0.1/#{repo_id}"])
git!(work, ["config", "core.sshCommand", build_ssh_command(ctx, repo_id, work, opts)])
:ok
end
# Generates the per-operation authenticate JSON via the library and writes a
# POSIX sh script that git invokes as core.sshCommand. `opts[:auth_opts]` is
# forwarded to `Authenticate.handle/3` (e.g. to select expires_at).
defp build_ssh_command(ctx, repo_id, work, opts) do
repo = ctx.factory.(repo_id)
href = ctx.lfs_url.(repo_id)
auth_opts = Keyword.get(opts, :auth_opts, [])
json_dir = Path.join(work, ".ssh-auth")
File.mkdir_p!(json_dir)
for op <- ["upload", "download"] do
{:ok, body} =
Authenticate.handle(
repo,
op,
[href: href, header: %{"Authorization" => "Bearer test-#{op}"}] ++ auth_opts
)
File.write!(Path.join(json_dir, "#{op}.json"), Jason.encode!(body))
end
script = Path.join(work, "fake_ssh.sh")
File.write!(script, """
#!/bin/sh
# core.sshCommand stand-in for a server's SSH git-lfs-authenticate handler.
cmd="$*"
case "$cmd" in
*git-lfs-transfer*)
# Decline the pure-SSH transfer so the client falls back to authenticate.
exit 1 ;;
*git-lfs-authenticate*)
rest="${cmd##*git-lfs-authenticate }" # "<path> <op>"
op="${rest##* }"
cat "#{json_dir}/${op}.json"
exit 0 ;;
*)
# Any other SSH command (git-upload-pack probes, etc.): not served.
exit 1 ;;
esac
""")
File.chmod!(script, 0o755)
script
end
defp captured(sink), do: Agent.get(sink, & &1)
# Authorization headers seen on the Batch API endpoint — where git-lfs is
# required to replay the credentials from the authenticate handoff.
defp batch_auth_headers(sink) do
sink
|> captured()
|> Enum.filter(&String.ends_with?(&1.path, "/objects/batch"))
|> Enum.map(& &1.auth)
|> Enum.reject(&is_nil/1)
end
defp sha256_hex(bin), do: :crypto.hash(:sha256, bin) |> Base.encode16(case: :lower)
defp server_object_path(root, repo_id, oid) do
Path.join([
root,
"repos",
repo_id,
"lfs",
String.slice(oid, 0, 2),
String.slice(oid, 2, 2),
oid
])
end
defp server_object_exists?(root, repo_id, oid),
do: File.exists?(server_object_path(root, repo_id, oid))
defp server_object_bytes(root, repo_id, oid),
do: File.read!(server_object_path(root, repo_id, oid))
defp local_lfs_object_path(work, oid) do
Path.join([
work,
".git",
"lfs",
"objects",
String.slice(oid, 0, 2),
String.slice(oid, 2, 2),
oid
])
end
defp local_lfs_object_exists?(work, oid), do: File.exists?(local_lfs_object_path(work, oid))
defp local_lfs_object_bytes(work, oid), do: File.read!(local_lfs_object_path(work, oid))
defp tmp!(label) do
dir = Path.join(System.tmp_dir!(), "#{label}-#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(dir)
on_exit(fn -> File.rm_rf!(dir) end)
dir
end
defp random_port do
{:ok, sock} = :gen_tcp.listen(0, [:binary, {:ip, {127, 0, 0, 1}}])
{:ok, port} = :inet.port(sock)
:gen_tcp.close(sock)
port
end
defp git!(dir, args) do
{output, status} =
System.cmd("git", args,
cd: dir,
stderr_to_stdout: true,
env: [{"GIT_LFS_SKIP_SMUDGE", "1"}]
)
if status != 0 do
raise "git #{Enum.join(args, " ")} failed (#{status}): #{output}"
end
String.trim(output)
end
end