ref:1bd54cd6cefabac7d52ef7088fda9ed02bc2f1ec

fix: add buffering to UploadPackV2 for split SSH data

SSH can split protocol data across multiple messages. The v2 state machine was dispatching commands on each data message without waiting for a complete command (terminated by flush). This caused "expected packfile" errors when the fetch command arrived in multiple fragments. Added a buffer field to accumulate partial data and only dispatch when a flush-terminated command is complete. Mirrors the existing negotiate_buffer approach in UploadPack (v1). Also fixes alias ordering (credo --strict) and adds comprehensive unit tests for the buffering behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SHA: 1bd54cd6cefabac7d52ef7088fda9ed02bc2f1ec
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-14 00:39
Parents: 55327c2
2 files changed +341 -8
Type
lib/ex_git_objectstore/protocol/upload_pack_v2.ex +27 −8
@@ -30,8 +30,8 @@
This is a pure functional state machine — no processes.
"""
alias ExGitObjectstore.{ObjectResolver, Ref, Repo}
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Commit, Tag, Tree}
alias ExGitObjectstore.{ObjectResolver, Ref, Repo}
alias ExGitObjectstore.Pack.Writer
alias ExGitObjectstore.Protocol.PktLine
@@ -40,10 +40,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.
@@ -59,19 +60,30 @@
@doc """
Feed a v2 command from the client into the state machine.
Returns `{response_data, new_state}`.
SSH can split protocol data across multiple messages, so we buffer
incomplete data until a complete command (terminated by a flush packet)
arrives before dispatching.
"""
@spec feed(state(), binary()) :: {binary(), state()}
def feed(%__MODULE__{phase: :command} = state, data) do
# Prepend any buffered partial data from previous feed calls.
full_data = state.buffer <> data
case parse_command(data) do
case parse_command(full_data) do
{:ls_refs, args} ->
response = handle_ls_refs(state.repo, args)
{response, state}
{response, %{state | buffer: <<>>}}
{:fetch, args} ->
response = handle_fetch(state.repo, args)
{response, %{state | phase: :done, buffer: <<>>}}
{:incomplete, _rest} ->
# Not enough data yet — buffer and wait for more
{<<>>, %{state | buffer: full_data}}
{response, %{state | phase: :done}}
{:error, _} ->
{PktLine.flush(), %{state | phase: :done}}
{PktLine.flush(), %{state | phase: :done, buffer: <<>>}}
end
end
@@ -106,7 +118,14 @@
defp parse_command(data) do
case PktLine.decode(data) do
{:ok, packets, _rest} ->
# A complete v2 command is terminated by a flush packet.
dispatch_command(packets)
# If we decoded packets but there's no flush, the data is
# incomplete (split across SSH messages) — buffer it.
if :flush in packets do
dispatch_command(packets)
else
{:incomplete, data}
end
{:error, _} = err ->
err