ref:55327c296dfd2df4016eba4cba0eb4942f4c7e2a

fix: buffer partial protocol data across SSH transport splits

SSH can split git protocol data across multiple messages, especially on slow/loaded systems like Raspberry Pi. When ReceivePack.feed received partial command data, parse_commands returned {:need_more, _} and the data was silently dropped. The next feed with remaining bytes parsed zero commands, producing a vacuous report-status without ref updates. Add cmd_buffer to ReceivePack and negotiate_buffer to UploadPack to accumulate partial data between feed calls. Also fix dead code warning in Walk.log_continue where cursor_to_queue always returns {:ok, ...}. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SHA: 55327c296dfd2df4016eba4cba0eb4942f4c7e2a
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-13 19:47
Parents: df0a437
4 files changed +85 -16
Type
lib/ex_git_objectstore/protocol/receive_pack.ex +10 −3
@@ -62,6 +62,7 @@
phase: :advertise,
commands: [],
client_caps: MapSet.new(),
cmd_buffer: <<>>,
pack_buffer: <<>>,
result: nil
]
@@ -86,12 +87,18 @@
"""
@spec feed(state(), binary()) :: {binary(), state()}
def feed(%__MODULE__{phase: :commands} = state, data) do
# Prepend any buffered partial data from previous feed calls.
# SSH can split protocol data across multiple messages, especially
# on slow or loaded systems (e.g. Raspberry Pi).
full_data = state.cmd_buffer <> data
case parse_commands(full_data) do
case parse_commands(data) do
{:ok, commands, client_caps, rest} ->
state = %{
state
| commands: commands,
client_caps: client_caps,
phase: :pack,
cmd_buffer: <<>>,
pack_buffer: rest
}
@@ -100,8 +107,8 @@
maybe_process_pack(state)
{:need_more, _} ->
# Shouldn't happen in practice — commands come in one batch
{<<>>, state}
# Buffer partial data for the next feed call
{<<>>, %{state | cmd_buffer: full_data}}
{:error, reason} ->
report = build_error_report(reason)
lib/ex_git_objectstore/protocol/upload_pack.ex +10 −5
@@ -51,7 +51,8 @@
phase: :advertise,
wants: [],
haves: [],
common: []
common: [],
negotiate_buffer: <<>>
]
@doc """
@@ -71,16 +72,20 @@
"""
@spec feed(state(), binary()) :: {binary(), state()}
def feed(%__MODULE__{phase: :negotiation} = state, data) do
# Prepend any buffered partial data from previous feed calls.
# SSH can split protocol data across multiple messages.
full_data = state.negotiate_buffer <> data
case parse_wants_haves(full_data) do
case parse_wants_haves(data) do
{:ok, wants, haves, :done} ->
state = %{state | wants: wants, haves: haves, phase: :pack}
state = %{state | wants: wants, haves: haves, phase: :pack, negotiate_buffer: <<>>}
generate_pack_response(state)
{:ok, wants, haves, :continue} ->
handle_negotiation_continue(%{state | negotiate_buffer: <<>>}, wants, haves)
handle_negotiation_continue(state, wants, haves)
{:error, _reason} ->
nak = PktLine.encode("NAK")
{nak, %{state | phase: :done, negotiate_buffer: <<>>}}
{nak, %{state | phase: :done}}
end
end
lib/ex_git_objectstore/walk.ex +3 −8
@@ -111,14 +111,9 @@
def log_continue(%Repo{} = repo, cursor_shas, opts) when is_list(cursor_shas) do
max_count = Keyword.get(opts, :max_count, 20)
case cursor_to_queue(repo, cursor_shas) do
{:ok, queue, seen} ->
{commits, remaining} = walk_page(repo, queue, seen, [], 0, max_count)
{:ok, commits, queue_to_cursor(remaining)}
{:error, _} = err ->
err
end
{:ok, queue, seen} = cursor_to_queue(repo, cursor_shas)
{commits, remaining} = walk_page(repo, queue, seen, [], 0, max_count)
{:ok, commits, queue_to_cursor(remaining)}
end
defp apply_skip_and_limit(commits, 0, :infinity), do: commits
test/ex_git_objectstore/protocol/receive_pack_test.exs +62 −0
@@ -280,4 +280,66 @@
assert {:error, _} = Ref.get(repo, "refs/heads/feature")
end
end
describe "split data handling" do
test "handles split command data across multiple feed calls" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
# Create objects
blob = Blob.from_content("hello\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 <t@t.com> 1000000000 +0000",
committer: "Test <t@t.com> 1000000000 +0000",
message: "init\n"
}
{:ok, commit_sha} = Object.write(repo, commit)
{_advert, state} = ReceivePack.init(repo)
# Build command + flush
zero = String.duplicate("0", 40)
commands = PktLine.encode("#{zero} #{commit_sha} refs/heads/main") <> PktLine.flush()
# Build pack
objects = [
{:blob, "hello\n", blob_sha},
{:tree, Tree.encode_content(tree), tree_sha},
{:commit, Commit.encode_content(commit), commit_sha}
]
{pack_data, _} = Writer.generate(objects)
full_data = commands <> pack_data
# Split the data so the flush packet is in the second chunk
# (simulates SSH splitting the data across messages)
split_point = byte_size(commands) - 4
<<first_chunk::binary-size(split_point), second_chunk::binary>> = full_data
# First feed: partial command data, should buffer and return empty
{response1, state} = ReceivePack.feed(state, first_chunk)
assert response1 == <<>>
refute ReceivePack.done?(state)
# Second feed: remaining data including flush + pack
{response2, state} = ReceivePack.feed(state, second_chunk)
assert ReceivePack.done?(state)
# Should get proper report-status
{:ok, packets, _} = PktLine.decode(response2)
data_lines = for {:data, d} <- packets, do: d
assert "unpack ok" in data_lines
assert "ok refs/heads/main" in data_lines
# Verify ref was created
assert {:ok, ^commit_sha} = Ref.get(repo, "refs/heads/main")
end
end
end