ref:cd9c2e346cd9cca727903fc37ab9476548bfe0d6

feat(lfs): git-lfs-authenticate SSH handoff + fix locking over SSH remotes (#42)

Closes #65 Closes #66 ## Problem Reported: over an **SSH remote**, `git lfs` prints *"Remote does not support the Git LFS locking API"* and `git push` **hangs**. ## Root cause Git LFS does not transfer over SSH. For an SSH remote the client runs `git-lfs-authenticate <path> <upload|download>` over the SSH connection and uses the returned JSON to discover the **HTTP** LFS endpoint, then drives Batch / transfer / **locks** over HTTP. The library shipped no handler for this command (`rg git-lfs-authenticate` returned nothing). So an SSH client could not discover the endpoint — it fell back to a *guessed* HTTPS URL, `POST /locks/verify` failed (→ the "not supported" message), and the object transfer **hung** against the unreachable guess. This was invisible because every existing LFS interop test sets an explicit `lfs.url` and pushes over HTTP (issue #58 scoped LFS to HTTP + PAT auth only; SSH was never considered). Reproduced directly with the real `git lfs` 3.7 binary before fixing — a missing/guessed authenticate href produces exactly the reported symptom (`connection refused` on batch + "does not support locking API"). ## Changes - **#65** — `ExGitObjectstore.Lfs.Authenticate.handle/3`: a pure module (same style as `Batch`/`Transfer`/`Locks`) that builds the spec JSON (`href`, `header`, `expires_in`/`expires_at`) a server writes to stdout for the SSH command. The library does **not** invent an auth scheme: the consumer supplies `:href` (it alone knows its public HTTP base) and any `:header`; the module validates operation (`:bad_operation`), href (`:missing_href`) and lfs-configured (`:lfs_not_configured`). Telemetry at `[:ex_git_objectstore, :lfs, :authenticate]`. - **#66** — `InteropSshTest`: drives real `git lfs` over an `ssh://` remote with **no explicit lfs.url**, standing in for a server's SSH dispatcher with a fake `core.sshCommand` that declines `git-lfs-transfer` (forcing the authenticate fallback) and answers `git-lfs-authenticate` with bytes from `Authenticate.handle/3`. Proves: push round-trips the object, locking is reachable (no "does not support locking", no hang), and push-time `/locks/verify` succeeds even with `locksverify=true`. The locking defect (#66) had the same root cause as #65; the authenticate handoff makes the endpoint discoverable, so locking works and the push completes. ## Testing - 9 unit tests for `Authenticate` (ops, header passthrough, expiry, validation, telemetry). - 3 SSH interop tests against real `git lfs` 3.7 (push, lock/list/unlock, push-time verify with `locksverify=true`). - Full suite: **985 passed, 0 failures**. `mix format` clean, `credo --strict` clean on all new files, `anvil requirement status` passes (REQ-LFS-007, REQ-LFS-008). ## Consumer follow-up (not in this library PR) The Anvil git server must wire its SSH command dispatcher to recognise `git-lfs-authenticate` and call `Authenticate.handle/3` (docs/example in the module). Tracked separately from this library change. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
SHA: cd9c2e346cd9cca727903fc37ab9476548bfe0d6
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-06-17 15:15
Parents: 21dd13c
4 files changed +874 -0
Type
CHANGELOG.md +48 −0
@@ -9,6 +9,54 @@
### Added
- **Git LFS `git-lfs-authenticate` SSH handoff.** New pure module
`ExGitObjectstore.Lfs.Authenticate` (`handle/3`) produces the JSON
blob a server writes to stdout in response to the SSH
`git-lfs-authenticate <path> <upload|download>` command. Git LFS does
not transfer over SSH: for an SSH remote it runs this command to
discover the HTTP LFS endpoint (`href`), the credentials to replay
(`header`), and their lifetime (`expires_in`/`expires_at`), then
drives Batch / transfer / **locks** over HTTP. The library does not
invent an auth scheme — the consumer supplies the `:href` (it alone
knows its public HTTP base) and any `:header`; the module assembles
and validates every input, returning an error rather than a malformed
blob: unconfigured LFS (`:lfs_not_configured`), unknown operation
(`:bad_operation`), absent/blank `:href` (`:missing_href`), a non-`http(s)`
`:href` (`:bad_href`), a non-string-map `:header` (`:bad_header`), a
non-positive `:expires_in` (`:bad_expires_in`), and a non-RFC3339
`:expires_at` (`:bad_expires_at`). Every call emits a
`[:ex_git_objectstore, :lfs, :authenticate]` telemetry event carrying
`outcome:` (`:ok` or the failure reason) so failed handoffs are
observable, not silent. The `@moduledoc` documents that a server using
this module **must decline `git-lfs-transfer`** (which git-lfs probes
first) or push will hang even with the handoff wired. (Closes #65.)
- **Interop coverage for the SSH transport path.** New
`InteropSshTest` drives the real `git lfs` binary over an `ssh://`
remote with **no explicit `lfs.url`**, standing in for a server's SSH
command dispatcher with a fake `core.sshCommand` that declines
`git-lfs-transfer` (forcing the authenticate fallback) and answers
`git-lfs-authenticate` with bytes from `Authenticate.handle/3`. Proves
push round-trips the object, the locking API is reachable (no
*"Remote does not support the Git LFS locking API"*, no hang), and
push-time `/locks/verify` succeeds even with `locksverify=true`.
Previously every LFS interop test set an explicit `lfs.url` over HTTP,
so the SSH discovery path — the only path that hangs in production —
was never exercised. (Closes #66.)
### Fixed
- **Git LFS locking unreachable over SSH remotes.** Over an SSH remote,
`git lfs` reported *"Remote does not support the Git LFS locking API"*
and `git push` hung. Root cause: with no `git-lfs-authenticate`
handler, an SSH client could not discover the HTTP LFS endpoint — it
fell back to a guessed HTTPS URL, so `POST /locks/verify` failed
(the "not supported" message) and the object transfer hung against the
unreachable guess. Adding the authenticate handoff (above) makes the
endpoint discoverable, so locking works and the push completes.
(Fixes #66.)
### Added
- **Git LFS support (spec v1).** Full Large File Storage implementation
exposed as pure request/response modules, matching the existing
`UploadPack`/`ReceivePack` style (no HTTP server in-tree).
lib/ex_git_objectstore/lfs/authenticate.ex +213 −0
@@ -1,0 +1,213 @@
# 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.Authenticate do
@moduledoc """
`git-lfs-authenticate` SSH handoff for Git LFS.
Git LFS does not transfer objects over SSH. When a repository's remote
is an SSH URL (`git@host:org/repo.git`), the client runs
ssh git@host "git-lfs-authenticate <path> <operation>"
over the SSH connection and reads a JSON blob from stdout that tells it
where the **HTTP** LFS API lives and how to authenticate against it:
{"href": "https://host/org/repo.git/info/lfs",
"header": {"Authorization": "Bearer …"},
"expires_in": 3600}
It then drives the Batch API, basic transfer, and **locking** endpoints
over HTTP using that `href`. Without this handoff an SSH client cannot
discover the LFS endpoint at all: it falls back to guessing an HTTPS URL
from the SSH host, push hangs against the unreachable guess, and
`POST /locks/verify` fails so the client prints
*"Remote does not support the Git LFS locking API"*.
This module is the pure request/response half. The consumer's SSH
command dispatcher recognises the `git-lfs-authenticate` command, calls
`handle/3`, JSON-encodes the result, and writes it to stdout. The
library deliberately does **not** invent an authentication scheme — the
consumer supplies both the HTTP `:href` (it alone knows its public HTTP
base) and any `:header` (a token minted by its own auth system).
## You must also handle (or decline) `git-lfs-transfer`
Before falling back to this handoff, modern `git lfs` probes the
**pure-SSH transfer** by running `git-lfs-transfer <path> <op>` over the
same SSH connection. If your dispatcher leaves that command unhandled in
a way that looks like success (e.g. a generic 0-exit handler), the
client may try to speak the pure-SSH transfer protocol and never reach
`git-lfs-authenticate` — push then hangs even though this module is
wired correctly. A server that only implements the authenticate + HTTP
path **must explicitly decline** `git-lfs-transfer` (exit non-zero), or
else fully implement the SSH transfer protocol. (The interop test does
exactly this — see `InteropSshTest`.)
## Wiring example (consumer side)
# git invokes the dispatcher with the command as a single string,
# e.g. "git-lfs-authenticate /org/repo.git upload". `path` therefore
# arrives with a LEADING slash; do not add another.
def handle_ssh_command("git-lfs-transfer " <> _rest, _user) do
# Not implemented — force the client to fall back to authenticate.
{:error, :unsupported}
end
def handle_ssh_command("git-lfs-authenticate " <> rest, user) do
[path, operation] = String.split(rest, " ", parts: 2)
repo = resolve_repo(path)
href = "https://" <> http_host() <> path <> "/info/lfs"
token = mint_lfs_token(user, repo, operation)
case Authenticate.handle(repo, operation, href: href,
header: %{"Authorization" => token}) do
{:ok, body} -> {:ok, Jason.encode!(body)}
{:error, reason} -> {:error, reason}
end
end
"""
alias ExGitObjectstore.Repo
@default_expires_in 3_600
@operations ["upload", "download"]
@type response :: %{required(String.t()) => term()}
@doc """
Build the `git-lfs-authenticate` response for `operation`
(`"upload"` or `"download"`).
Options:
* `:href` — **required**. Absolute URL of the HTTP LFS API root for
this repo, e.g. `"https://host/org/repo.git/info/lfs"`. The Batch,
transfer, verify and lock endpoints are resolved relative to it, so
it must be the LFS root (the same value a consumer would pass as
`:base_url` to `ExGitObjectstore.Lfs.Batch.handle/3`).
* `:header` — map of HTTP headers the client must replay on every
LFS request, typically `%{"Authorization" => token}`. Defaults to
`%{}` (anonymous).
* `:expires_in` — seconds until the credentials in `:header` expire.
Defaults to `#{@default_expires_in}`.
* `:expires_at` — RFC3339 string. When given, it is emitted instead
of `:expires_in` (git-lfs accepts either form).
Returns `{:ok, response}` where `response` is JSON-encodable to exactly
the bytes git-lfs expects on stdout, or `{:error, reason}`. All inputs
are validated — a bad value yields an error rather than a malformed
response git-lfs would choke on:
* `:lfs_not_configured` — the repo has no `:lfs_storage`.
* `:bad_operation` — operation was not `"upload"`/`"download"`.
* `:missing_href` — `:href` was absent or blank.
* `:bad_href` — `:href` was not an absolute `http(s)` URL.
* `:bad_header` — `:header` was not a map of string keys to string values.
* `:bad_expires_in` — `:expires_in` was not a positive integer.
* `:bad_expires_at` — `:expires_at` was not an RFC3339 timestamp.
Every call emits a `[:ex_git_objectstore, :lfs, :authenticate]` telemetry
event whose metadata carries the `outcome` (`:ok` or the error reason),
so failed handoffs are observable, not silent.
"""
@spec handle(Repo.t(), String.t(), keyword()) :: {:ok, response()} | {:error, atom()}
def handle(%Repo{} = repo, operation, opts \\ []) do
result =
with :ok <- validate_lfs_configured(repo),
:ok <- validate_operation(operation),
{:ok, href} <- fetch_href(opts),
{:ok, header} <- fetch_header(opts),
{:ok, expiry} <- fetch_expiry(opts) do
{:ok, Map.merge(%{"href" => href, "header" => header}, expiry)}
end
emit_telemetry(repo, operation, outcome(result))
result
end
defp validate_lfs_configured(%Repo{lfs_storage: nil}), do: {:error, :lfs_not_configured}
defp validate_lfs_configured(%Repo{lfs_storage: {_, _}}), do: :ok
defp validate_operation(op) when op in @operations, do: :ok
defp validate_operation(_), do: {:error, :bad_operation}
defp fetch_href(opts) do
case Keyword.get(opts, :href) do
href when is_binary(href) and href != "" ->
if absolute_http_url?(href), do: {:ok, href}, else: {:error, :bad_href}
_ ->
{:error, :missing_href}
end
end
defp absolute_http_url?(href) do
case URI.parse(href) do
%URI{scheme: scheme, host: host} when scheme in ["http", "https"] and is_binary(host) ->
host != ""
_ ->
false
end
end
defp fetch_header(opts) do
header = Keyword.get(opts, :header, %{})
if is_map(header) and Enum.all?(header, fn {k, v} -> is_binary(k) and is_binary(v) end) do
{:ok, header}
else
{:error, :bad_header}
end
end
# A non-blank `:expires_at` is emitted instead of `:expires_in` (git-lfs
# accepts either); a blank/absent one falls through to `:expires_in`.
defp fetch_expiry(opts) do
case Keyword.get(opts, :expires_at) do
at when is_binary(at) and at != "" ->
if rfc3339?(at), do: {:ok, %{"expires_at" => at}}, else: {:error, :bad_expires_at}
nil ->
fetch_expires_in(opts)
"" ->
fetch_expires_in(opts)
_ ->
{:error, :bad_expires_at}
end
end
defp fetch_expires_in(opts) do
case Keyword.get(opts, :expires_in, @default_expires_in) do
n when is_integer(n) and n > 0 -> {:ok, %{"expires_in" => n}}
_ -> {:error, :bad_expires_in}
end
end
defp rfc3339?(value), do: match?({:ok, _, _}, DateTime.from_iso8601(value))
defp outcome({:ok, _}), do: :ok
defp outcome({:error, reason}), do: reason
defp emit_telemetry(repo, operation, outcome) do
:telemetry.execute(
[:ex_git_objectstore, :lfs, :authenticate],
%{count: 1},
%{operation: operation, repo: repo.id, outcome: outcome}
)
end
end
test/ex_git_objectstore/lfs/authenticate_test.exs +198 −0
@@ -1,0 +1,198 @@
# 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.AuthenticateTest do
use ExUnit.Case, async: true
@moduletag requirements: ["REQ-LFS-007"]
alias ExGitObjectstore.Lfs.Authenticate
alias ExGitObjectstore.Lfs.Store.Memory, as: LfsMemory
alias ExGitObjectstore.Repo
alias ExGitObjectstore.Storage.Memory
@href "https://git.example.com/org/repo.git/info/lfs"
setup do
{:ok, git_pid} = Memory.start_link()
{:ok, lfs_pid} = LfsMemory.start_link()
repo =
Repo.new("repo",
storage: {Memory, Memory.config(git_pid)},
lfs_storage: {LfsMemory, LfsMemory.config(lfs_pid)}
)
%{repo: repo}
end
describe "handle/3 — happy path" do
test "download op returns href, header and expires_in", %{repo: repo} do
assert {:ok, resp} =
Authenticate.handle(repo, "download",
href: @href,
header: %{"Authorization" => "Bearer tok"}
)
assert resp["href"] == @href
assert resp["header"] == %{"Authorization" => "Bearer tok"}
assert resp["expires_in"] == 3600
# Must be JSON-encodable to exactly what git-lfs reads off stdout.
assert {:ok, _json} = Jason.encode(resp)
end
test "upload op is accepted", %{repo: repo} do
assert {:ok, %{"href" => @href}} =
Authenticate.handle(repo, "upload", href: @href)
end
test "header defaults to empty map (anonymous access)", %{repo: repo} do
assert {:ok, resp} = Authenticate.handle(repo, "download", href: @href)
assert resp["header"] == %{}
end
test "expires_in is configurable", %{repo: repo} do
assert {:ok, resp} =
Authenticate.handle(repo, "download", href: @href, expires_in: 60)
assert resp["expires_in"] == 60
end
test "expires_at overrides expires_in when supplied", %{repo: repo} do
assert {:ok, resp} =
Authenticate.handle(repo, "download",
href: @href,
expires_at: "2026-06-15T12:00:00Z"
)
assert resp["expires_at"] == "2026-06-15T12:00:00Z"
refute Map.has_key?(resp, "expires_in")
end
end
describe "handle/3 — validation" do
test "rejects an unknown operation", %{repo: repo} do
assert {:error, :bad_operation} = Authenticate.handle(repo, "delete", href: @href)
end
test "rejects a missing/blank href", %{repo: repo} do
assert {:error, :missing_href} = Authenticate.handle(repo, "download", [])
assert {:error, :missing_href} = Authenticate.handle(repo, "download", href: "")
end
test "rejects when LFS is not configured" do
{:ok, git_pid} = Memory.start_link()
repo = Repo.new("nolfs", storage: {Memory, Memory.config(git_pid)})
assert {:error, :lfs_not_configured} =
Authenticate.handle(repo, "download", href: @href)
end
test "rejects a non-URL or non-http(s) href", %{repo: repo} do
assert {:error, :bad_href} = Authenticate.handle(repo, "download", href: "not-a-url")
assert {:error, :bad_href} =
Authenticate.handle(repo, "download", href: "/relative/info/lfs")
assert {:error, :bad_href} = Authenticate.handle(repo, "download", href: "ftp://h/x")
end
test "rejects a non-map header", %{repo: repo} do
assert {:error, :bad_header} =
Authenticate.handle(repo, "download", href: @href, header: "Bearer x")
end
test "rejects a header with non-string keys or values", %{repo: repo} do
assert {:error, :bad_header} =
Authenticate.handle(repo, "download",
href: @href,
header: %{"Authorization" => 123}
)
assert {:error, :bad_header} =
Authenticate.handle(repo, "download", href: @href, header: %{1 => "x"})
end
test "rejects a non-positive or non-integer expires_in", %{repo: repo} do
assert {:error, :bad_expires_in} =
Authenticate.handle(repo, "download", href: @href, expires_in: 0)
assert {:error, :bad_expires_in} =
Authenticate.handle(repo, "download", href: @href, expires_in: -5)
assert {:error, :bad_expires_in} =
Authenticate.handle(repo, "download", href: @href, expires_in: "3600")
end
test "rejects a non-RFC3339 expires_at", %{repo: repo} do
assert {:error, :bad_expires_at} =
Authenticate.handle(repo, "download", href: @href, expires_at: "not-a-date")
# No timezone offset → not RFC3339.
assert {:error, :bad_expires_at} =
Authenticate.handle(repo, "download",
href: @href,
expires_at: "2099-01-01T00:00:00"
)
end
test "accepts a valid RFC3339 expires_at", %{repo: repo} do
assert {:ok, %{"expires_at" => "2099-01-01T00:00:00Z"}} =
Authenticate.handle(repo, "download",
href: @href,
expires_at: "2099-01-01T00:00:00Z"
)
end
end
describe "telemetry" do
test "emits an authenticate event", %{repo: repo} do
ref = make_ref()
handler = "test-#{inspect(ref)}"
:telemetry.attach(
handler,
[:ex_git_objectstore, :lfs, :authenticate],
fn _event, measurements, metadata, _ ->
send(self(), {:telemetry, measurements, metadata})
end,
nil
)
Authenticate.handle(repo, "download", href: @href)
assert_received {:telemetry, _measurements, %{operation: "download", outcome: :ok}}
:telemetry.detach(handler)
end
test "emits the failure reason on a validation error", %{repo: repo} do
ref = make_ref()
handler = "test-fail-#{inspect(ref)}"
:telemetry.attach(
handler,
[:ex_git_objectstore, :lfs, :authenticate],
fn _event, measurements, metadata, _ ->
send(self(), {:telemetry, measurements, metadata})
end,
nil
)
Authenticate.handle(repo, "nope", href: @href)
assert_received {:telemetry, _measurements, %{operation: "nope", outcome: :bad_operation}}
:telemetry.detach(handler)
end
end
end
test/ex_git_objectstore/lfs/interop_ssh_test.exs +415 −0
@@ -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