ref:ee9b4d1ac02c9b5a3a0eaa3f1c22f6008b290310

fix: buffer partial data in UploadPackV2 feed

SSH delivers data in arbitrary-sized chunks. The v2 protocol handler assumed each feed() call contained a complete command, causing :unknown_command errors when the command= pkt-line was split across TCP segments. Now: accumulate data in a buffer until a flush/delim packet indicates a complete command is ready for parsing. Also distinguish between no_command (bare flush, ignore) and unknown_command (real error). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SHA: ee9b4d1ac02c9b5a3a0eaa3f1c22f6008b290310
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-21 03:51
Parents: cd0457c
1 files changed +39 -8
Type
lib/ex_git_objectstore/protocol/upload_pack_v2.ex +39 −8
@@ -42,10 +42,11 @@
@type state :: %__MODULE__{
repo: Repo.t(),
phase: :command | :done
phase: :command | :done,
buffer: binary()
}
defstruct [:repo, phase: :command]
defstruct [:repo, phase: :command, buffer: <<>>]
@doc """
Create a new v2 upload-pack state machine and generate the capability advertisement.
@@ -64,8 +65,36 @@
"""
@spec feed(state(), binary()) :: {binary(), state()}
def feed(%__MODULE__{phase: :command} = state, data) do
buffered = state.buffer <> data
# Only parse when we have a complete command (indicated by a flush packet).
# SSH delivers data in arbitrary chunks, so we may need to buffer across
# multiple feed calls.
if has_complete_command?(buffered) do
process_command(%{state | buffer: <<>>}, buffered)
else
{<<>>, %{state | buffer: buffered}}
end
end
def feed(%__MODULE__{phase: :done} = state, _data) do
{<<>>, state}
end
defp has_complete_command?(data) do
# A complete v2 command is terminated by a flush (0000) or delim (0001) packet.
# Also treat data that fails pkt-line parsing as "complete" so we surface the error
Logger.info("UploadPackV2.feed: received #{byte_size(data)} bytes")
# immediately rather than buffering forever.
case PktLine.decode(data) do
{:ok, packets, _rest} ->
Enum.any?(packets, &(&1 in [:flush, :delim]))
{:error, _} ->
true
end
end
defp process_command(state, data) do
case parse_command(data) do
{:ls_refs, args} ->
Logger.info("UploadPackV2: processing ls-refs command")
@@ -78,16 +107,17 @@
Logger.info("UploadPackV2: fetch response #{byte_size(response)} bytes")
{response, %{state | phase: :done}}
{:error, :no_command} ->
# No command= line found — could be a bare flush or partial data.
# Ignore and wait for next feed.
{<<>>, state}
{:error, err} ->
Logger.error("UploadPackV2: parse_command failed: #{inspect(err)}")
{PktLine.flush(), %{state | phase: :done}}
end
end
def feed(%__MODULE__{phase: :done} = state, _data) do
{<<>>, state}
end
@doc """
Check if the protocol exchange is complete.
"""
@@ -133,6 +163,7 @@
case command do
"ls-refs" -> {:ls_refs, args}
"fetch" -> {:fetch, args}
nil -> {:error, :no_command}
_ -> {:error, :unknown_command}
other -> {:error, {:unknown_command, other}}
end
end