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.GitDaemon do
@moduledoc """
Minimal `git://` daemon for integration tests.
Spins up a TCP listener on 127.0.0.1 that accepts `git://` service
handshake lines and routes each connection to either
`UploadPackV2` (for `git-upload-pack`, protocol v2) or `ReceivePack`
(for `git-receive-pack`).
Designed for test use only: each daemon serves a single repo (or a
caller-supplied `repo_fun/0` that can return different repos on each
connection, e.g. to simulate a repo being swapped behind a URL).
## Example
repo = RepoHelper.memory_repo()
{port, stop} = GitDaemon.start_upload_pack(repo)
on_exit(stop)
url = "git://127.0.0.1:" <> to_string(port) <> "/repo"
{out, code} = GitDaemon.git_clone(url, dest)
## Caveats
- Only protocol v2 is supported for upload-pack. A v0/v1 service line
causes the connection to be closed (no advertisement).
- The state machine terminates after a single command for upload-pack
(multi-round negotiation on the same TCP session will not work; the
client is expected to reconnect). This matches what HTTP-smart-style
clients do, and is the same behavior the production server exposes
in the failure modes we're targeting.
"""
alias ExGitObjectstore.Protocol.{PktLine, ReceivePack, UploadPackV2}
# --- public API ---
@doc """
Start an upload-pack (fetch/clone) daemon. `repo_or_fun` may be
an `%ExGitObjectstore.Repo{}` or a zero-arity function that returns one
(called per-connection, useful for stale-haves scenarios).
Returns `{port, stop_fn}`.
"""
def start_upload_pack(repo_or_fun) do
repo_fun = as_fun(repo_or_fun)
start_daemon(fn client -> serve_upload_pack(client, repo_fun) end)
end
@doc """
Start a receive-pack (push) daemon. `opts` is forwarded to
`ReceivePack.init/2` (so you can wire up pre_receive_hook / update_hook
/ post_receive_hook for tests that need to exercise hook behavior).
Returns `{port, stop_fn}`.
"""
def start_receive_pack(repo_or_fun, opts \\ []) do
repo_fun = as_fun(repo_or_fun)
start_daemon(fn client -> serve_receive_pack(client, repo_fun, opts) end)
end
@doc """
Start a smart-HTTP daemon that serves both upload-pack and
receive-pack for a single repo.
Routes handled (path prefix is ignored — any URL path is accepted):
* `GET */info/refs?service=git-upload-pack`
* `POST */git-upload-pack` — body fed to `UploadPackV2`
* `GET */info/refs?service=git-receive-pack`
* `POST */git-receive-pack` — body fed to `ReceivePack`
Protocol v2 is used whenever the client sends
`Git-Protocol: version=2` (git does this by default on recent versions).
Returns `{port, stop_fn}`.
"""
def start_http_smart(repo_or_fun, opts \\ []) do
repo_fun = as_fun(repo_or_fun)
start_daemon(fn client -> serve_http(client, repo_fun, opts) end)
end
@doc """
`git clone` wrapper. Returns `{output, exit_code}`.
"""
def git_clone(url, dest, extra_args \\ []) do
git_at(nil, ["-c", "protocol.version=2", "clone"] ++ extra_args ++ [url, dest])
end
@doc """
Run `git <args>` in `dir` (or no `cd` if `dir` is nil). Env defaults
suppress password prompts and disable the keyring for non-interactive
runs. Returns `{output, exit_code}`.
"""
def git_at(dir, args, extra_env \\ []) do
env = [{"GIT_TERMINAL_PROMPT", "0"} | extra_env]
opts = [stderr_to_stdout: true, env: env]
opts = if dir, do: [{:cd, dir} | opts], else: opts
System.cmd("git", args, opts)
end
@doc """
Run `git <args>` in `dir` and raise if it fails. Returns trimmed stdout.
"""
def git!(dir, args, extra_env \\ []) do
{out, code} = git_at(dir, args, extra_env)
if code != 0 do
raise """
git #{Enum.join(args, " ")} failed (exit #{code}):
#{out}
"""
end
String.trim(out)
end
@doc """
Initialize a client-side git working dir with a local identity set.
Returns the dir path.
"""
def init_client_dir(tmp_dir, name \\ "client") do
dir = Path.join(tmp_dir, name)
File.mkdir_p!(dir)
git!(dir, ["init", "--initial-branch=main"])
git!(dir, ["config", "user.email", "t@t.com"])
git!(dir, ["config", "user.name", "t"])
git!(dir, ["config", "commit.gpgsign", "false"])
dir
end
@doc """
Clone `url` into `dest_dir` and raise on failure. Returns `dest_dir`.
"""
def seed_client_clone(url, dest_dir) do
parent = Path.dirname(dest_dir)
File.mkdir_p!(parent)
{out, code} = git_clone(url, dest_dir)
if code != 0, do: raise("seed_client_clone failed (#{code}):\n#{out}")
dest_dir
end
# --- internal ---
defp as_fun(fun) when is_function(fun, 0), do: fun
defp as_fun(repo), do: fn -> repo end
defp start_daemon(handler) do
{:ok, listen} =
:gen_tcp.listen(0, [
:binary,
active: false,
reuseaddr: true,
packet: :raw,
ip: {127, 0, 0, 1}
])
{:ok, port} = :inet.port(listen)
# Unlinked: stopping the daemon (closing the listen socket) must not
# take the test process with it.
spawn(fn -> accept_loop(listen, handler) end)
stop = fn -> :gen_tcp.close(listen) end
{port, stop}
end
defp accept_loop(listen, handler) do
case :gen_tcp.accept(listen, 30_000) do
{:ok, client} ->
spawn(fn -> handler.(client) end)
accept_loop(listen, handler)
{:error, :closed} ->
:ok
{:error, _} = err ->
err
end
end
# --- upload-pack connection ---
defp serve_upload_pack(client, repo_fun) do
with {:ok, service_line} <- read_one_pkt(client),
true <- service_is?(service_line, "git-upload-pack"),
true <- v2_requested?(service_line) do
repo = repo_fun.()
{advert, state} = UploadPackV2.init(repo)
:ok = :gen_tcp.send(client, advert)
drive_upload(client, state)
else
_ -> :ok
end
after
:gen_tcp.close(client)
end
defp drive_upload(client, state) do
case :gen_tcp.recv(client, 0, 15_000) do
{:ok, data} ->
{response, new_state} = UploadPackV2.feed(state, data)
if byte_size(response) > 0, do: :ok = :gen_tcp.send(client, response)
if UploadPackV2.done?(new_state) do
:ok
else
drive_upload(client, new_state)
end
{:error, _} ->
:ok
end
end
# --- receive-pack connection ---
defp serve_receive_pack(client, repo_fun, opts) do
with {:ok, service_line} <- read_one_pkt(client),
true <- service_is?(service_line, "git-receive-pack") do
repo = repo_fun.()
{advert, state} = ReceivePack.init(repo, opts)
:ok = :gen_tcp.send(client, advert)
drive_receive(client, state)
else
_ -> :ok
end
after
:gen_tcp.close(client)
end
defp drive_receive(client, state) do
if ReceivePack.done?(state) do
:ok
else
drive_receive_recv(client, state)
end
end
defp drive_receive_recv(client, state) do
case :gen_tcp.recv(client, 0, 15_000) do
{:ok, data} ->
drive_receive_feed(client, state, data)
{:error, _} ->
# Client closed its side — that's end-of-stream. Flush so the
# state machine actually finalizes (the absorb-only feed/2
# contract leaves it in :pack until somebody calls flush).
_ = drive_receive_flush(client, state)
:ok
end
end
defp drive_receive_feed(client, state, data) do
{response, new_state} = ReceivePack.feed(state, data)
if byte_size(response) > 0, do: :ok = :gen_tcp.send(client, response)
drive_receive(client, new_state)
end
# When the TCP recv loop returns an error/timeout (i.e. the client
# closed its side), we treat that as end-of-stream and flush. Real
# SSH/HTTP layers do the same on their respective EOF signals; the
# test daemon just polls until socket close.
defp drive_receive_flush(client, state) do
{response, new_state} = ReceivePack.flush(state)
if byte_size(response) > 0, do: :ok = :gen_tcp.send(client, response)
new_state
end
# --- shared ---
defp read_one_pkt(client) do
with {:ok, <<hex::binary-size(4)>>} <- :gen_tcp.recv(client, 4, 5_000),
{len, ""} <- Integer.parse(hex, 16) do
read_pkt_payload(client, len)
else
:error -> {:error, :bad_pkt_len}
{_, _} -> {:error, :bad_pkt_len}
err -> err
end
end
defp read_pkt_payload(_client, 0), do: {:ok, ""}
defp read_pkt_payload(_client, len) when len < 4, do: {:error, :bad_pkt_len}
defp read_pkt_payload(client, len) do
:gen_tcp.recv(client, len - 4, 5_000)
end
defp service_is?(line, svc), do: String.contains?(line, svc)
defp v2_requested?(line), do: String.contains?(line, "version=2")
# --- HTTP smart-protocol handler ---
defp serve_http(client, repo_fun, opts) do
case read_http_request(client) do
{:ok, %{method: "GET", path: path, headers: headers}} ->
handle_http_get(client, path, headers, repo_fun)
{:ok, %{method: "POST", path: path, headers: headers, body: body}} ->
handle_http_post(client, path, headers, body, repo_fun, opts)
_ ->
send_http(client, 400, "text/plain", "bad request")
end
after
:gen_tcp.close(client)
end
defp handle_http_get(client, path, _headers, repo_fun) do
cond do
String.contains?(path, "service=git-upload-pack") ->
send_info_refs(client, "git-upload-pack", repo_fun.(), :upload)
String.contains?(path, "service=git-receive-pack") ->
send_info_refs(client, "git-receive-pack", repo_fun.(), :receive)
true ->
send_http(client, 404, "text/plain", "not found")
end
end
defp handle_http_post(client, path, headers, body, repo_fun, opts) do
cond do
String.ends_with?(path, "/git-upload-pack") ->
handle_upload_pack_post(client, headers, body, repo_fun)
String.ends_with?(path, "/git-receive-pack") ->
handle_receive_pack_post(client, body, repo_fun, opts)
true ->
send_http(client, 404, "text/plain", "not found")
end
end
defp send_info_refs(client, service, repo, :upload) do
{advert, _state} = UploadPackV2.init(repo)
body = service_header_line(service) <> advert
send_http(client, 200, "application/x-#{service}-advertisement", body)
end
defp send_info_refs(client, service, repo, :receive) do
{advert, _state} = ReceivePack.init(repo)
body = service_header_line(service) <> advert
send_http(client, 200, "application/x-#{service}-advertisement", body)
end
defp service_header_line(service) do
# Smart-HTTP "service=" header line per Documentation/http-protocol.txt.
PktLine.encode("# service=#{service}\n") <> PktLine.flush()
end
defp handle_upload_pack_post(client, headers, body, repo_fun) do
repo = repo_fun.()
# Upload-pack POST is always stateless per request: advertisement
# isn't expected (client already got it via info/refs), just the
# command body. Protocol v2 clients include `Git-Protocol: version=2`.
if headers["git-protocol"] && String.contains?(headers["git-protocol"], "version=2") do
{_advert, state} = UploadPackV2.init(repo)
{response, _state} = UploadPackV2.feed(state, body)
send_http(client, 200, "application/x-git-upload-pack-result", response)
else
# v0/v1 not supported by this test daemon.
send_http(client, 400, "text/plain", "only protocol v2 supported")
end
end
defp handle_receive_pack_post(client, body, repo_fun, opts) do
repo = repo_fun.()
{_advert, state} = ReceivePack.init(repo, opts)
{response, _state} = drive_receive_state(body, state)
send_http(client, 200, "application/x-git-receive-pack-result", response)
end
defp drive_receive_state(body, state) do
# HTTP delivers the whole request body in one shot — feed once,
# then flush since the body has a known length.
{first, state} = ReceivePack.feed(state, body)
{second, state} = ReceivePack.flush(state)
{first <> second, state}
end
# --- HTTP/1.1 mini-parser ---
defp read_http_request(client) do
with {:ok, request_line} <- read_line(client),
[method, path, _version] <- String.split(request_line, " ", parts: 3),
{:ok, headers} <- read_headers(client, %{}) do
build_http_request(client, method, path, headers)
else
err -> {:error, err}
end
end
defp build_http_request(_client, "GET", path, headers) do
{:ok, %{method: "GET", path: path, headers: headers}}
end
defp build_http_request(client, method, path, headers) do
case read_body(client, headers) do
{:ok, body} -> {:ok, %{method: method, path: path, headers: headers, body: body}}
err -> err
end
end
defp read_line(client, acc \\ <<>>) do
case :gen_tcp.recv(client, 1, 5_000) do
{:ok, <<?\r>>} ->
case :gen_tcp.recv(client, 1, 5_000) do
{:ok, <<?\n>>} -> {:ok, acc}
_ -> {:error, :bad_line_end}
end
{:ok, <<byte>>} ->
read_line(client, acc <> <<byte>>)
{:error, _} = err ->
err
end
end
defp read_headers(client, acc) do
case read_line(client) do
{:ok, ""} ->
{:ok, acc}
{:ok, line} ->
case String.split(line, ":", parts: 2) do
[k, v] ->
read_headers(client, Map.put(acc, String.downcase(String.trim(k)), String.trim(v)))
_ ->
read_headers(client, acc)
end
err ->
err
end
end
defp read_body(client, headers) do
cond do
len = headers["content-length"] ->
n = String.to_integer(len)
if n == 0, do: {:ok, <<>>}, else: :gen_tcp.recv(client, n, 15_000)
headers["transfer-encoding"] == "chunked" ->
read_chunked(client, <<>>)
true ->
{:ok, <<>>}
end
end
defp read_chunked(client, acc) do
with {:ok, size_line} <- read_line(client),
{size, _} <- Integer.parse(size_line, 16) do
read_chunked_body(client, acc, size)
else
_ -> {:error, :bad_chunk}
end
end
defp read_chunked_body(client, acc, 0) do
# Read the trailing CRLF after the last chunk.
_ = read_line(client)
{:ok, acc}
end
defp read_chunked_body(client, acc, size) do
with {:ok, chunk} <- :gen_tcp.recv(client, size, 15_000),
{:ok, _} <- read_line(client) do
read_chunked(client, acc <> chunk)
end
end
defp send_http(client, status, content_type, body) do
status_text =
case status do
200 -> "OK"
400 -> "Bad Request"
404 -> "Not Found"
_ -> "OK"
end
headers = [
"HTTP/1.1 #{status} #{status_text}\r\n",
"Content-Type: #{content_type}\r\n",
"Content-Length: #{byte_size(body)}\r\n",
"Cache-Control: no-cache\r\n",
"Connection: close\r\n",
"\r\n"
]
:gen_tcp.send(client, [headers, body])
end
end