fangorn/ex_git_objectstore
public
ref:main
# 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.Test.LfsHttpAdapter do
@moduledoc """
Plug router that wires the LFS library modules to HTTP endpoints
so real `git lfs` clients can drive the library end-to-end.
Routes (under `/:repo_id/info/lfs`):
* `POST /objects/batch`
* `GET /objects/:oid`
* `PUT /objects/:oid`
* `POST /verify`
* `POST /locks`
* `POST /locks/verify`
* `POST /locks/:id/unlock`
* `GET /locks`
Tests wire this via Bandit in `setup` and tear it down after. The
server relies on a `:repo_factory` plug_opt — a function that takes
`repo_id` and returns an `ExGitObjectstore.Repo.t()` — so each test
can inject its own storage/lfs_storage configuration.
"""
use Plug.Router
alias ExGitObjectstore.Lfs.{Batch, Locks, Transfer}
alias ExGitObjectstore.Protocol.{PktLine, ReceivePack, UploadPack}
plug(:stash_opts)
plug(:match)
plug(Plug.Parsers, parsers: [:json], pass: ["*/*"], json_decoder: Jason)
plug(:dispatch)
defp stash_opts(conn, _), do: Plug.Conn.put_private(conn, :lfs_opts, conn.assigns[:_opts] || [])
def init(opts), do: opts
def call(conn, opts) do
conn
|> Plug.Conn.assign(:_opts, opts)
|> super(opts)
end
# -- Git smart-http (protocol v0) --
#
# GET /:repo_id/info/refs?service=git-(upload|receive)-pack → service banner + advertisement
# POST /:repo_id/git-(upload|receive)-pack → single request/response cycle
get "/:repo_id/info/refs" do
repo = build_repo(conn, repo_id)
case conn.query_params["service"] do
"git-upload-pack" ->
{advert, _state} = UploadPack.init(repo)
send_smart_http_advert(conn, "git-upload-pack", advert)
"git-receive-pack" ->
{advert, _state} = ReceivePack.init(repo)
send_smart_http_advert(conn, "git-receive-pack", advert)
_ ->
send_resp(conn, 400, "unknown service")
end
end
post "/:repo_id/git-upload-pack" do
repo = build_repo(conn, repo_id)
{:ok, conn, body} = read_full_body(conn)
{_advert, state} = UploadPack.init(repo)
{response, _state} = UploadPack.feed(state, body)
conn
|> put_resp_content_type("application/x-git-upload-pack-result")
|> send_resp(200, response)
end
post "/:repo_id/git-receive-pack" do
repo = build_repo(conn, repo_id)
{:ok, conn, body} = read_full_body(conn)
{_advert, state} = ReceivePack.init(repo)
{feed_resp, state} = ReceivePack.feed(state, body)
{flush_resp, _state} = ReceivePack.flush(state)
response = feed_resp <> flush_resp
conn
|> put_resp_content_type("application/x-git-receive-pack-result")
|> send_resp(200, response)
end
# -- Git LFS --
post "/:repo_id/info/lfs/objects/batch" do
repo = build_repo(conn, repo_id)
body = conn.body_params
base_url = build_base_url(conn, repo_id)
response = Batch.handle(repo, body, base_url: base_url)
conn
|> put_resp_content_type("application/vnd.git-lfs+json")
|> send_resp(response.status, Jason.encode!(response.body))
end
get "/:repo_id/info/lfs/objects/:oid" do
repo = build_repo(conn, repo_id)
case Transfer.download(repo, oid) do
{:ok, %{size: size, stream: stream}} ->
conn =
conn
|> put_resp_header("content-length", Integer.to_string(size))
|> put_resp_content_type("application/octet-stream")
|> send_chunked(200)
Enum.reduce_while(stream, conn, fn chunk, c ->
case Plug.Conn.chunk(c, chunk) do
{:ok, c} -> {:cont, c}
{:error, _} -> {:halt, c}
end
end)
{:error, :not_found} ->
send_lfs_error(conn, 404, "object not found")
{:error, :bad_oid} ->
send_lfs_error(conn, 422, "bad oid")
{:error, :lfs_not_configured} ->
send_lfs_error(conn, 501, "LFS not configured")
end
end
put "/:repo_id/info/lfs/objects/:oid" do
repo = build_repo(conn, repo_id)
{:ok, conn, stream} = collect_body_stream(conn)
case Transfer.upload(repo, oid, stream) do
{:ok, _bytes} ->
send_resp(conn, 200, "")
{:error, :oid_mismatch} ->
send_lfs_error(conn, 422, "sha256 mismatch")
{:error, :bad_oid} ->
send_lfs_error(conn, 422, "bad oid")
{:error, reason} ->
send_lfs_error(conn, 500, "upload failed: #{inspect(reason)}")
end
end
post "/:repo_id/info/lfs/verify" do
repo = build_repo(conn, repo_id)
%{"oid" => oid, "size" => size} = conn.body_params
case Transfer.verify(repo, oid, size) do
:ok -> send_resp(conn, 200, "")
{:error, :not_found} -> send_lfs_error(conn, 404, "object not found")
{:error, :size_mismatch} -> send_lfs_error(conn, 422, "size mismatch")
{:error, reason} -> send_lfs_error(conn, 500, inspect(reason))
end
end
post "/:repo_id/info/lfs/locks" do
repo = build_repo(conn, repo_id)
%{"path" => path} = conn.body_params
owner = requester_name(conn)
case Locks.create(repo, path, owner) do
{:ok, lock} ->
send_json(conn, 201, %{"lock" => lock_to_json(lock)})
{:error, {:conflict, existing}} ->
send_json(conn, 409, %{
"lock" => lock_to_json(existing),
"message" => "already locked"
})
{:error, :bad_request} ->
send_lfs_error(conn, 400, "bad request")
end
end
post "/:repo_id/info/lfs/locks/verify" do
repo = build_repo(conn, repo_id)
owner = requester_name(conn)
case Locks.verify(repo, owner) do
{:ok, %{ours: ours, theirs: theirs}} ->
send_json(conn, 200, %{
"ours" => Enum.map(ours, &lock_to_json/1),
"theirs" => Enum.map(theirs, &lock_to_json/1)
})
{:error, reason} ->
send_lfs_error(conn, 500, inspect(reason))
end
end
post "/:repo_id/info/lfs/locks/:id/unlock" do
repo = build_repo(conn, repo_id)
force? = conn.body_params["force"] == true
owner = requester_name(conn)
case Locks.unlock(repo, id, owner, force: force?) do
{:ok, lock} -> send_json(conn, 200, %{"lock" => lock_to_json(lock)})
{:error, :not_found} -> send_lfs_error(conn, 404, "lock not found")
{:error, :forbidden} -> send_lfs_error(conn, 403, "not the lock owner")
{:error, reason} -> send_lfs_error(conn, 500, inspect(reason))
end
end
get "/:repo_id/info/lfs/locks" do
repo = build_repo(conn, repo_id)
opts =
[]
|> maybe_put(:path, conn.query_params["path"])
|> maybe_put(:id, conn.query_params["id"])
case Locks.list(repo, opts) do
{:ok, locks} ->
send_json(conn, 200, %{"locks" => Enum.map(locks, &lock_to_json/1)})
{:error, reason} ->
send_lfs_error(conn, 500, inspect(reason))
end
end
match _ do
send_resp(conn, 404, "")
end
# -- Helpers --
defp build_repo(conn, repo_id) do
factory = Keyword.fetch!(conn.private.lfs_opts, :repo_factory)
factory.(repo_id)
end
defp build_base_url(conn, repo_id) do
scheme = Atom.to_string(conn.scheme)
port_str = if conn.port in [80, 443], do: "", else: ":#{conn.port}"
"#{scheme}://#{conn.host}#{port_str}/#{repo_id}/info/lfs"
end
defp send_lfs_error(conn, status, message) do
conn
|> put_resp_content_type("application/vnd.git-lfs+json")
|> send_resp(status, Jason.encode!(%{"message" => message}))
end
defp send_json(conn, status, body) do
conn
|> put_resp_content_type("application/vnd.git-lfs+json")
|> send_resp(status, Jason.encode!(body))
end
defp collect_body_stream(conn, chunks \\ []) do
case Plug.Conn.read_body(conn, length: 16 * 1024 * 1024, read_length: 16 * 1024 * 1024) do
{:more, chunk, conn} -> collect_body_stream(conn, [chunk | chunks])
{:ok, chunk, conn} -> {:ok, conn, Enum.reverse([chunk | chunks])}
end
end
defp read_full_body(conn, chunks \\ []) do
case Plug.Conn.read_body(conn, length: 64 * 1024 * 1024, read_length: 16 * 1024 * 1024) do
{:more, chunk, conn} -> read_full_body(conn, [chunk | chunks])
{:ok, chunk, conn} -> {:ok, conn, IO.iodata_to_binary(Enum.reverse([chunk | chunks]))}
end
end
defp send_smart_http_advert(conn, service, advert) do
body =
IO.iodata_to_binary([
PktLine.encode("# service=#{service}\n"),
PktLine.flush(),
advert
])
conn
|> put_resp_content_type("application/x-#{service}-advertisement")
|> put_resp_header("cache-control", "no-cache")
|> send_resp(200, body)
end
defp requester_name(conn) do
case Plug.Conn.get_req_header(conn, "x-lfs-user") do
[name | _] -> name
_ -> "anonymous"
end
end
defp lock_to_json(%{id: id, path: path, locked_at: locked_at, owner: %{name: name}}) do
%{
"id" => id,
"path" => path,
"locked_at" => locked_at,
"owner" => %{"name" => name}
}
end
defp maybe_put(opts, _k, nil), do: opts
defp maybe_put(opts, k, v), do: Keyword.put(opts, k, v)
end