From 52d71d15199031a148c05010f0ef01675dce6787 Mon Sep 17 00:00:00 2001 From: Ethan Brooks Date: Tue, 9 Jun 2026 23:38:57 +1000 Subject: [PATCH 1/3] feat: add upload-pack v2 server plumbing in gix-protocol Co-Authored-By: Oz --- Cargo.lock | 81 + crate-status.md | 5 + gix-protocol/Cargo.toml | 37 + .../examples/receive-pack-async-per-client.rs | 163 ++ gix-protocol/src/lib.rs | 4 + gix-protocol/src/receive_pack.rs | 1129 +++++++ gix-protocol/src/receive_pack/async_io.rs | 419 +++ gix-protocol/src/upload_pack.rs | 2608 +++++++++++++++++ gix-protocol/src/upload_pack/async_io.rs | 184 ++ gix-protocol/tests/async-server.rs | 2 + .../tests/async_server/upload_pack.rs | 862 ++++++ .../receive-pack/v1/delete-main.request | Bin 0 -> 189 bytes .../receive-pack/v1/push-basic.request | Bin 0 -> 401 bytes .../receive-pack/v1/push-with-option.request | Bin 0 -> 461 bytes gix-protocol/tests/protocol/mod.rs | 4 + gix-protocol/tests/protocol/receive_pack.rs | 192 ++ gix-protocol/tests/protocol/upload_pack.rs | 679 +++++ gix-transport/src/client/async_io/request.rs | 1 + 18 files changed, 6370 insertions(+) create mode 100644 gix-protocol/examples/receive-pack-async-per-client.rs create mode 100644 gix-protocol/src/receive_pack.rs create mode 100644 gix-protocol/src/receive_pack/async_io.rs create mode 100644 gix-protocol/src/upload_pack.rs create mode 100644 gix-protocol/src/upload_pack/async_io.rs create mode 100644 gix-protocol/tests/async-server.rs create mode 100644 gix-protocol/tests/async_server/upload_pack.rs create mode 100644 gix-protocol/tests/fixtures/receive-pack/v1/delete-main.request create mode 100644 gix-protocol/tests/fixtures/receive-pack/v1/push-basic.request create mode 100644 gix-protocol/tests/fixtures/receive-pack/v1/push-with-option.request create mode 100644 gix-protocol/tests/protocol/receive_pack.rs create mode 100644 gix-protocol/tests/protocol/upload_pack.rs diff --git a/Cargo.lock b/Cargo.lock index aeb589dff5b..282c4215f63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -335,6 +335,21 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d21f40d350a700f6aa107e45fb26448cf489d34794b2ba4522181dc9f1173af6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -2319,6 +2334,8 @@ dependencies = [ "gix-lock", "gix-negotiate", "gix-object", + "gix-odb", + "gix-pack", "gix-packetline", "gix-protocol", "gix-ref", @@ -2327,8 +2344,11 @@ dependencies = [ "gix-shallow", "gix-trace", "gix-transport", + "gix-traverse", "gix-utils", + "gix-zlib", "nonempty", + "proptest", "serde", "thiserror 2.0.18", ] @@ -4095,6 +4115,25 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.13.0", + "num-traits", + "rand 0.9.4", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "ptyprocess" version = "0.4.1" @@ -4104,6 +4143,12 @@ dependencies = [ "nix", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quinn" version = "0.11.11" @@ -4227,6 +4272,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "ratatui" version = "0.30.2" @@ -4590,6 +4644,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -5456,6 +5522,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-bom" version = "2.0.3" @@ -5574,6 +5646,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/crate-status.md b/crate-status.md index 2e35e1b4340..4a4afcb12da 100644 --- a/crate-status.md +++ b/crate-status.md @@ -559,6 +559,11 @@ Provide a native SSH transport and authentication backend so `gix` users can shi * [ ] report-status, sideband, delete-refs, push-options and atomic pushes * [ ] object-format negotiation * [ ] upload-pack / receive-pack server plumbing for in-process transports + * [x] upload-pack V2 request/response plumbing for blocking in-process servers (command parsing, `ls-refs` response encoding, `fetch` section encoding, sideband pack streaming) + * [x] async upload-pack V2 bridge for async transport streams (`futures_lite::io::BlockOn` adapter wrapping blocking plumbing) + * [x] repository-backed upload-pack fetch negotiation wiring (`want`/`have` resolution, `ACK`/`NAK` generation, `want-ref` ref-store resolution) + * [x] upload-pack pack construction wiring + * [x] receive-pack V1/V2 server plumbing for blocking in-process servers (V1 command/capability/push-options parsing, V2 command/section parsing, report-status response encoding, async per-client bridge) * [ ] bundle-uri protocol integration * [ ] remote helper protocol and integration * [x] API documentation diff --git a/gix-protocol/Cargo.toml b/gix-protocol/Cargo.toml index 36a9cbbc956..9b21d28381e 100644 --- a/gix-protocol/Cargo.toml +++ b/gix-protocol/Cargo.toml @@ -39,6 +39,27 @@ async-client = [ "handshake", "fetch" ] +## If set, blocking server-side upload-pack plumbing is available for in-process transports. +blocking-server = [ + "gix-transport/blocking-client", + "dep:gix-object", + "dep:gix-pack", + "dep:gix-traverse", + "dep:gix-zlib", +] +## If set, async server-side upload-pack plumbing is available for in-process transports. +# no `dep:` for futures-lite (https://github.com/rust-secure-code/cargo-auditable/issues/124) +async-server = [ + "gix-transport/async-client", + "gix-transport/blocking-client", + "dep:async-trait", + "dep:futures-io", + "futures-lite", + "dep:gix-object", + "dep:gix-pack", + "dep:gix-traverse", + "dep:gix-zlib", +] ## Add implementations for performing a `handshake` along with the dependencies needed for it. handshake = ["dep:gix-credentials"] @@ -71,6 +92,16 @@ name = "async" path = "tests/async-protocol.rs" required-features = ["async-client"] +[[test]] +name = "async-server" +path = "tests/async-server.rs" +required-features = ["async-server"] + +[[example]] +name = "receive-pack-async-per-client" +path = "examples/receive-pack-async-per-client.rs" +required-features = ["blocking-server", "async-client", "sha1"] + [dependencies] gix-features = { version = "^0.49.0", path = "../gix-features", features = [ "progress", @@ -86,6 +117,9 @@ gix-trace = { version = "^0.1.21", path = "../gix-trace", optional = true } gix-negotiate = { version = "^0.34.0", path = "../gix-negotiate", optional = true } gix-object = { version = "^0.63.0", path = "../gix-object", optional = true } gix-revwalk = { version = "^0.34.0", path = "../gix-revwalk", optional = true } +gix-pack = { version = "^0.73.0", path = "../gix-pack", optional = true } +gix-traverse = { version = "^0.60.0", path = "../gix-traverse", optional = true } +gix-zlib = { version = "^0.1.0", path = "../gix-zlib", optional = true } gix-credentials = { version = "^0.39.1", path = "../gix-credentials", optional = true } gix-refspec = { version = "^0.44.0", path = "../gix-refspec", optional = true } gix-lock = { version = "^24.0.0", path = "../gix-lock", optional = true } @@ -112,6 +146,9 @@ document-features = { version = "0.2.0", optional = true } async-std = { version = "1.9.0", features = ["attributes"] } gix-packetline = { path = "../gix-packetline", version = "^0.22.0" } gix-protocol = { path = "../gix-protocol", features = ["sha1"] } +gix-hash = { path = "../gix-hash", features = ["sha1"] } +gix-odb = { path = "../gix-odb", features = ["sha1"] } +proptest = "1.6.0" [package.metadata.docs.rs] features = ["sha1", "blocking-client", "document-features", "serde"] diff --git a/gix-protocol/examples/receive-pack-async-per-client.rs b/gix-protocol/examples/receive-pack-async-per-client.rs new file mode 100644 index 00000000000..74d4e2d0b77 --- /dev/null +++ b/gix-protocol/examples/receive-pack-async-per-client.rs @@ -0,0 +1,163 @@ +use std::{collections::VecDeque, io::Write as _}; + +use bstr::BString; +use gix_protocol::{ + futures_io::{AsyncRead, AsyncWrite}, + futures_lite, + receive_pack::{self, RefStatus, Response, UnpackStatus}, + transport::packetline::{ + PacketLineRef, + blocking_io::{StreamingPeekableIter, Writer, encode}, + }, +}; + +#[derive(Default)] +struct DemoDelegate { + sessions_processed: usize, +} + +impl receive_pack::Delegate for DemoDelegate { + fn receive( + &mut self, + request: &receive_pack::Request, + pack_data: &mut dyn std::io::Read, + ) -> Result> { + let mut header = [0u8; 4]; + pack_data.read_exact(&mut header)?; + if header != *b"PACK" { + return Err(std::io::Error::other(format!("expected pack header \"PACK\", got {header:?}")).into()); + } + + let first_update = request + .updates + .first() + .ok_or_else(|| std::io::Error::other("expected at least one reference update"))?; + + self.sessions_processed += 1; + + Ok(Response { + unpack_status: UnpackStatus::Ok, + ref_statuses: vec![RefStatus::Ok { + ref_name: first_update.ref_name.clone(), + }], + sideband_messages: Vec::new(), + }) + } +} + +async fn handle_client_session( + delegate: &mut impl receive_pack::Delegate, + input: &mut R, + output: &mut W, +) -> Result +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + receive_pack::async_io::serve_v2(input, output, delegate).await +} + +fn main() -> Result<(), Box> { + futures_lite::future::block_on(async { + let mut sessions = build_demo_sessions()?; + let mut delegate = DemoDelegate::default(); + + let mut session_id = 0usize; + while let Some((mut input, mut output)) = sessions.pop_front() { + session_id += 1; + let outcome = handle_client_session(&mut delegate, &mut input, &mut output).await?; + let report_status_lines = decode_report_status_lines(output.get_ref().as_slice())?; + println!( + "session #{session_id}: updates={}, push-options={}, report-status={report_status_lines:?}", + outcome.updates_received, outcome.push_options_received + ); + } + + println!("processed {} per-client sessions", delegate.sessions_processed); + Ok::<(), Box>(()) + }) +} + +fn build_demo_sessions() +-> Result>, futures_lite::io::Cursor>)>, Box> +{ + let session_one = request_bytes_v2( + &["report-status-v2", "agent=gitoxide-example"], + &["0000000000000000000000000000000000000000 808e50d724f604f69ab93c6da2919c014667bedb refs/heads/main"], + &[], + b"PACK\0\0\0\x02", + )?; + let session_two = request_bytes_v2( + &["report-status-v2", "push-options", "agent=gitoxide-example"], + &["808e50d724f604f69ab93c6da2919c014667bedb 0000000000000000000000000000000000000000 refs/heads/main"], + &["trace=1"], + b"PACK\0\0\0\x02", + )?; + + Ok(VecDeque::from(vec![ + ( + futures_lite::io::Cursor::new(session_one), + futures_lite::io::Cursor::new(Vec::new()), + ), + ( + futures_lite::io::Cursor::new(session_two), + futures_lite::io::Cursor::new(Vec::new()), + ), + ])) +} + +fn request_bytes_v2( + features: &[&str], + updates: &[&str], + push_options: &[&str], + pack_data: &[u8], +) -> Result, Box> { + if updates.is_empty() { + return Err(std::io::Error::other("at least one update command is required").into()); + } + + let mut out = Vec::new(); + { + let mut writer = Writer::new(&mut out); + writer.enable_text_mode(); + writer.write_all(b"command=push")?; + for feature in features { + writer.write_all(feature.as_bytes())?; + } + encode::delim_to_write(writer.inner_mut())?; + + writer.write_all(b"section=ref-updates")?; + for update in updates { + writer.write_all(update.as_bytes())?; + } + + if !push_options.is_empty() { + encode::delim_to_write(writer.inner_mut())?; + writer.write_all(b"section=push-options")?; + for option in push_options { + writer.write_all(option.as_bytes())?; + } + } + encode::flush_to_write(writer.inner_mut())?; + } + + out.extend_from_slice(pack_data); + Ok(out) +} + +fn decode_report_status_lines(output: &[u8]) -> Result, Box> { + let mut reader = StreamingPeekableIter::new(output, &[PacketLineRef::Flush], false); + let mut lines = Vec::new(); + while let Some(line) = reader.read_line() { + let line = line??; + let text = line + .as_text() + .ok_or_else(|| std::io::Error::other("expected text packetline in report-status response"))?; + lines.push(text.as_bstr().to_owned()); + } + + if reader.stopped_at() != Some(PacketLineRef::Flush) { + return Err(std::io::Error::other("expected report-status response to terminate with flush").into()); + } + Ok(lines) +} diff --git a/gix-protocol/src/lib.rs b/gix-protocol/src/lib.rs index 79d0ce70eb0..c349a32f78c 100644 --- a/gix-protocol/src/lib.rs +++ b/gix-protocol/src/lib.rs @@ -52,6 +52,10 @@ pub use gix_transport as transport; pub mod fetch; #[cfg(any(feature = "blocking-client", feature = "async-client"))] pub use fetch::function::fetch; +#[cfg(feature = "blocking-server")] +pub mod receive_pack; +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +pub mod upload_pack; mod remote_progress; pub use remote_progress::RemoteProgress; diff --git a/gix-protocol/src/receive_pack.rs b/gix-protocol/src/receive_pack.rs new file mode 100644 index 00000000000..8c7c2f855c2 --- /dev/null +++ b/gix-protocol/src/receive_pack.rs @@ -0,0 +1,1129 @@ +//! Blocking server-side plumbing for `receive-pack` protocol interactions. +//! +//! This module parses incoming push command sections (including negotiated capabilities and +//! optional push-options), exposes the remaining input as pack data to a delegate, and writes +//! report-status responses in plain packet-line or sideband mode. + +use std::io::{self, Write as _}; + +use bstr::{BStr, BString, ByteSlice, ByteVec}; +use gix_transport::packetline::{ + Channel, PacketLineRef, + blocking_io::{Writer, encode}, + decode, +}; + +type BoxError = Box; + +const MAX_SIDEBAND_DATA_BYTES: usize = 65_515; +const V2_SECTION_REF_UPDATES: &str = "section=ref-updates"; +const V2_SECTION_PUSH_OPTIONS: &str = "section=push-options"; +const V2_SECTION_REPORT_STATUS: &str = "report-status"; +const V2_SECTION_MESSAGES: &str = "messages"; + +/// Async transport integration for receive-pack server plumbing. +#[cfg(feature = "async-client")] +pub mod async_io; + +/// A parsed receive-pack capability from the first update command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Capability { + /// Capability name, like `report-status-v2` or `side-band-64k`. + pub name: BString, + /// Optional capability value for key-value capabilities. + pub value: Option, +} + +/// A parsed feature line from the V2 command header. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Feature { + /// The feature name, like `agent` or `report-status-v2`. + pub name: BString, + /// Optional feature value for key-value features. + pub value: Option, +} + +/// A capability line to advertise in protocol V2. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2Capability { + /// Capability name, like `push` or `server-option`. + pub name: BString, + /// Optional capability values associated with `name`. + pub values: Vec, +} + +/// A single requested ref update in a push command list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Update { + /// Expected old object id currently at `ref_name`. + pub old_id: gix_hash::ObjectId, + /// New object id to update `ref_name` to. + pub new_id: gix_hash::ObjectId, + /// Fully qualified reference name to update. + pub ref_name: BString, +} + +/// Parsed `receive-pack` request metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Request { + /// Parsed capabilities advertised by the client in the first update command. + pub capabilities: Vec, + /// Parsed update commands from the command section. + pub updates: Vec, + /// Optional push-options section entries (if negotiated and provided). + pub push_options: Vec, +} + +/// Parsed receive-pack protocol V2 request metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2Request { + /// Header features associated with the command request. + pub features: Vec, + /// Parsed receive-pack request payload. + pub request: Request, + /// If true, additional bytes are present after the argument section and represent pack data. + pub has_pack: bool, +} + +impl Request { + /// Returns true if the request contains a capability with `name`. + pub fn has_capability(&self, name: &str) -> bool { + let name = name.as_bytes().as_bstr(); + self.capabilities + .iter() + .any(|capability| capability.name.as_bstr() == name) + } + + fn uses_sideband(&self) -> bool { + self.has_capability("side-band") || self.has_capability("side-band-64k") + } + + fn wants_report_status(&self) -> bool { + self.has_capability("report-status") || self.has_capability("report-status-v2") + } +} + +/// Status of unpacking the received pack data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UnpackStatus { + /// Pack unpacking succeeded. + Ok, + /// Pack unpacking failed with a message. + Error(BString), +} + +/// Per-reference report-status entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefStatus { + /// Reference update succeeded. + Ok { + /// Updated reference name. + ref_name: BString, + }, + /// Reference update failed with a message. + Rejected { + /// Rejected reference name. + ref_name: BString, + /// Rejection reason. + message: BString, + }, +} + +/// Kind of sideband message to send before/after report-status data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SidebandMessageKind { + /// Sideband progress channel (`2`). + Progress, + /// Sideband error channel (`3`). + Error, +} + +/// A sideband message emitted during push processing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SidebandMessage { + /// Target sideband channel. + pub kind: SidebandMessageKind, + /// Message payload bytes. + pub text: BString, +} + +/// Delegate-provided receive-pack response data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + /// Overall unpack result. + pub unpack_status: UnpackStatus, + /// Per-reference results. + pub ref_statuses: Vec, + /// Optional sideband progress/error messages. + pub sideband_messages: Vec, +} + +impl Default for Response { + fn default() -> Self { + Response { + unpack_status: UnpackStatus::Ok, + ref_statuses: Vec::new(), + sideband_messages: Vec::new(), + } + } +} + +/// Outcome of serving one receive-pack push request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Outcome { + /// Number of update commands parsed from the request. + pub updates_received: usize, + /// Number of push-options parsed from the optional push-options section. + pub push_options_received: usize, + /// Number of per-ref statuses sent. + pub ref_statuses_sent: usize, + /// Whether a report-status payload was written. + pub report_status_sent: bool, + /// Number of bytes written onto sideband channels. + pub sideband_bytes_sent: u64, +} + +/// Delegate implementation used by [`serve_v1()`] to process received pushes. +pub trait Delegate { + /// Process a parsed `receive-pack` request and consume pack data from `pack_data`. + fn receive(&mut self, request: &Request, pack_data: &mut dyn io::Read) -> Result; +} + +/// Errors returned while parsing receive-pack requests and writing responses. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Decode(#[from] decode::Error), + #[error("Expected at least one update command before the command-section flush")] + MissingUpdateCommands, + #[error("Expected `command=` in receive-pack V2 request header")] + MissingV2Command, + #[error("Unsupported receive-pack V2 command {command:?}")] + UnsupportedV2Command { command: BString }, + #[error("Malformed receive-pack V2 header line {line:?}")] + MalformedV2HeaderLine { line: BString }, + #[error("Expected a V2 argument section after the header delimiter")] + MissingV2ArgumentSection, + #[error("Expected at least one section in receive-pack V2 request arguments")] + MissingV2SectionHeader, + #[error("Expected a `section=ref-updates` section in receive-pack V2 request arguments")] + MissingV2RefUpdatesSection, + #[error("Unknown receive-pack V2 section header {section:?}")] + UnknownV2Section { section: BString }, + #[error("Duplicate receive-pack V2 section header {section:?}")] + DuplicateV2Section { section: BString }, + #[error("Unexpected packet line type {line_type} in receive-pack request section")] + UnexpectedPacketLineType { line_type: &'static str }, + #[error("Malformed receive-pack update command line {line:?}")] + MalformedCommandLine { line: BString }, + #[error("Could not parse object id in command line {line:?}")] + InvalidObjectId { + line: BString, + #[source] + source: gix_hash::decode::Error, + }, + #[error("Delegate failed")] + Delegate(#[source] BoxError), +} + +/// Parse a protocol V1 receive-pack request from `input`, leaving `input` positioned at pack data. +/// +/// The parser consumes: +/// - command section (`old new ref` lines) until flush +/// - optional push-options section if negotiated and present +/// +/// Remaining bytes in `input` can be interpreted as pack data by the caller. +pub fn parse_v1_request(input: &mut impl io::BufRead) -> Result { + let mut command_lines = read_text_packet_lines_until_flush(input)?; + if command_lines.is_empty() { + return Err(Error::MissingUpdateCommands); + } + + let first_line = command_lines.remove(0); + let (first_command, capabilities) = split_first_command_and_capabilities(first_line.as_bstr()); + let mut updates = Vec::with_capacity(command_lines.len() + 1); + updates.push(parse_update_command(first_command.as_bstr())?); + for line in command_lines { + updates.push(parse_update_command(line.as_bstr())?); + } + + let push_options = if capabilities + .iter() + .any(|capability| capability.name.as_bstr() == "push-options".as_bytes().as_bstr()) + { + read_optional_push_options(input)? + } else { + Vec::new() + }; + + Ok(Request { + capabilities, + updates, + push_options, + }) +} + +/// Parse a protocol V2 receive-pack request from `input`, leaving `input` positioned at optional pack data. +/// +/// The parser consumes: +/// - command request header lines through delimiter (expects `command=push`) +/// - argument sections encoded as text packet lines: +/// - `section=ref-updates` (required) +/// - `section=push-options` (optional) +/// - each section terminated by delimiter or final flush +/// +/// Remaining bytes in `input` can be interpreted as pack data by the caller. +pub fn parse_v2_request(input: &mut impl io::BufRead) -> Result { + let (header_lines, header_terminator) = read_text_packet_lines_until_delimiter_or_flush(input)?; + if header_terminator != SectionTerminator::Delimiter { + return Err(Error::MissingV2ArgumentSection); + } + + let (command, features) = parse_v2_header_lines(header_lines)?; + if command.as_bstr() != "push".as_bytes().as_bstr() { + return Err(Error::UnsupportedV2Command { command }); + } + + let mut updates = None::>; + let mut push_options = None::>; + loop { + let (section_lines, section_terminator) = read_text_packet_lines_until_delimiter_or_flush(input)?; + if section_lines.is_empty() { + return Err(Error::MissingV2SectionHeader); + } + let section = section_lines[0].clone(); + let mut payload = section_lines; + payload.remove(0); + + match section.as_bstr() { + section_name if section_name == V2_SECTION_REF_UPDATES.as_bytes().as_bstr() => { + if updates.is_some() { + return Err(Error::DuplicateV2Section { section }); + } + if payload.is_empty() { + return Err(Error::MissingUpdateCommands); + } + let mut parsed = Vec::with_capacity(payload.len()); + for line in payload { + parsed.push(parse_update_command(line.as_bstr())?); + } + updates = Some(parsed); + } + section_name if section_name == V2_SECTION_PUSH_OPTIONS.as_bytes().as_bstr() => { + if push_options.is_some() { + return Err(Error::DuplicateV2Section { section }); + } + push_options = Some(payload); + } + _ => return Err(Error::UnknownV2Section { section }), + } + + if section_terminator == SectionTerminator::Flush { + break; + } + } + + let updates = updates.ok_or(Error::MissingV2RefUpdatesSection)?; + let capabilities = features + .iter() + .map(|feature| Capability { + name: feature.name.clone(), + value: feature.value.clone(), + }) + .collect::>(); + let has_pack = !input.fill_buf()?.is_empty(); + + Ok(V2Request { + features, + request: Request { + capabilities, + updates, + push_options: push_options.unwrap_or_default(), + }, + has_pack, + }) +} + +/// Serve one protocol V1 receive-pack push request end-to-end. +pub fn serve_v1( + input: impl io::Read, + mut output: impl io::Write, + delegate: &mut impl Delegate, +) -> Result { + let mut input = io::BufReader::new(input); + let request = parse_v1_request(&mut input)?; + let response = delegate.receive(&request, &mut input).map_err(Error::Delegate)?; + let report_status_sent = request.wants_report_status(); + let sideband_bytes_sent = write_v1_response(&mut output, &request, &response)?; + + Ok(Outcome { + updates_received: request.updates.len(), + push_options_received: request.push_options.len(), + ref_statuses_sent: response.ref_statuses.len(), + report_status_sent, + sideband_bytes_sent, + }) +} + +/// Write a receive-pack response matching `request` capabilities. +/// +/// Returns the number of payload bytes written through sideband channels. +pub fn write_v1_response(mut output: impl io::Write, request: &Request, response: &Response) -> Result { + let mut sideband_bytes_sent = 0u64; + let report_status_payload = request + .wants_report_status() + .then(|| encode_report_status_payload(response)) + .transpose()?; + + if request.uses_sideband() { + for message in &response.sideband_messages { + let channel = match message.kind { + SidebandMessageKind::Progress => Channel::Progress, + SidebandMessageKind::Error => Channel::Error, + }; + let payload: &[u8] = message.text.as_ref(); + sideband_bytes_sent += payload.len() as u64; + encode::band_to_write(channel, payload, &mut output)?; + } + if let Some(payload) = report_status_payload.as_ref() { + for chunk in payload.chunks(MAX_SIDEBAND_DATA_BYTES) { + sideband_bytes_sent += chunk.len() as u64; + encode::band_to_write(Channel::Data, chunk, &mut output)?; + } + } + encode::flush_to_write(&mut output)?; + return Ok(sideband_bytes_sent); + } + + if let Some(payload) = report_status_payload { + output.write_all(&payload)?; + } else { + encode::flush_to_write(&mut output)?; + } + Ok(0) +} + +/// Serve one protocol V2 receive-pack push request end-to-end. +pub fn serve_v2( + input: impl io::Read, + mut output: impl io::Write, + delegate: &mut impl Delegate, +) -> Result { + let mut input = io::BufReader::new(input); + let request = parse_v2_request(&mut input)?; + let response = if request.has_pack { + delegate + .receive(&request.request, &mut input) + .map_err(Error::Delegate)? + } else { + let mut empty = io::empty(); + delegate + .receive(&request.request, &mut empty) + .map_err(Error::Delegate)? + }; + + let report_status_sent = request.request.wants_report_status(); + let sideband_bytes_sent = write_v2_response(&mut output, &request.request, &response)?; + + Ok(Outcome { + updates_received: request.request.updates.len(), + push_options_received: request.request.push_options.len(), + ref_statuses_sent: response.ref_statuses.len(), + report_status_sent, + sideband_bytes_sent, + }) +} + +/// Write a protocol V2 capability advertisement, including the `version 2` line. +pub fn write_v2_capability_advertisement( + mut output: impl io::Write, + capabilities: &[V2Capability], +) -> Result<(), Error> { + let mut writer = Writer::new(&mut output); + writer.enable_text_mode(); + writer.write_all(b"version 2")?; + for capability in capabilities { + let mut line = capability.name.clone(); + if !capability.values.is_empty() { + line.push_byte(b'='); + for (idx, value) in capability.values.iter().enumerate() { + if idx != 0 { + line.push_byte(b' '); + } + line.push_str(value); + } + } + writer.write_all(line.as_ref())?; + } + encode::flush_to_write(writer.inner_mut())?; + Ok(()) +} + +/// Write a receive-pack V2 response with sectioned report-status and optional message sections. +pub fn write_v2_response(mut output: impl io::Write, request: &Request, response: &Response) -> Result { + let mut writer = Writer::new(&mut output); + writer.enable_text_mode(); + let mut wrote_section = false; + + if request.wants_report_status() { + writer.write_all(V2_SECTION_REPORT_STATUS.as_bytes())?; + writer.write_all(format_unpack_status_line(&response.unpack_status).as_ref())?; + for status in &response.ref_statuses { + writer.write_all(format_ref_status_line(status).as_ref())?; + } + wrote_section = true; + } + + if !response.sideband_messages.is_empty() { + if wrote_section { + encode::delim_to_write(writer.inner_mut())?; + } + writer.write_all(V2_SECTION_MESSAGES.as_bytes())?; + for message in &response.sideband_messages { + writer.write_all(format_v2_message_line(message).as_ref())?; + } + } + + encode::flush_to_write(writer.inner_mut())?; + Ok(0) +} + +fn read_optional_push_options(input: &mut impl io::BufRead) -> Result, Error> { + let Some(first_byte) = input.fill_buf()?.first().copied() else { + return Ok(Vec::new()); + }; + if !first_byte.is_ascii_hexdigit() { + return Ok(Vec::new()); + } + read_text_packet_lines_until_flush(input) +} + +fn read_text_packet_lines_until_flush(input: &mut impl io::BufRead) -> Result, Error> { + let mut lines = Vec::new(); + loop { + let mut hex_bytes = [0u8; 4]; + input.read_exact(&mut hex_bytes)?; + match decode::hex_prefix(&hex_bytes)? { + decode::PacketLineOrWantedSize::Line(PacketLineRef::Flush) => break, + decode::PacketLineOrWantedSize::Line(other) => { + return Err(Error::UnexpectedPacketLineType { + line_type: packet_line_kind(&other), + }); + } + decode::PacketLineOrWantedSize::Wanted(data_len) => { + let mut data = vec![0u8; data_len as usize]; + input.read_exact(&mut data)?; + if data.last() == Some(&b'\n') { + data.pop(); + if data.last() == Some(&b'\r') { + data.pop(); + } + } + lines.push(data.into()); + } + } + } + Ok(lines) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SectionTerminator { + Delimiter, + Flush, +} + +fn read_text_packet_lines_until_delimiter_or_flush( + input: &mut impl io::BufRead, +) -> Result<(Vec, SectionTerminator), Error> { + let mut lines = Vec::new(); + loop { + let mut hex_bytes = [0u8; 4]; + input.read_exact(&mut hex_bytes)?; + match decode::hex_prefix(&hex_bytes)? { + decode::PacketLineOrWantedSize::Line(PacketLineRef::Delimiter) => { + return Ok((lines, SectionTerminator::Delimiter)); + } + decode::PacketLineOrWantedSize::Line(PacketLineRef::Flush) => return Ok((lines, SectionTerminator::Flush)), + decode::PacketLineOrWantedSize::Line(other) => { + return Err(Error::UnexpectedPacketLineType { + line_type: packet_line_kind(&other), + }); + } + decode::PacketLineOrWantedSize::Wanted(data_len) => { + let mut data = vec![0u8; data_len as usize]; + input.read_exact(&mut data)?; + if data.last() == Some(&b'\n') { + data.pop(); + if data.last() == Some(&b'\r') { + data.pop(); + } + } + lines.push(data.into()); + } + } + } +} + +fn parse_v2_header_lines(lines: Vec) -> Result<(BString, Vec), Error> { + let mut command = None::; + let mut features = Vec::new(); + for line in lines { + let bytes: &[u8] = line.as_ref(); + if let Some(command_name) = bytes.strip_prefix(b"command=") { + if command.is_some() || command_name.is_empty() { + return Err(Error::MalformedV2HeaderLine { line }); + } + command = Some(command_name.into()); + continue; + } + features.push(parse_v2_feature_line(line.as_bstr())?); + } + let command = command.ok_or(Error::MissingV2Command)?; + Ok((command, features)) +} + +fn parse_v2_feature_line(line: &BStr) -> Result { + if let Some((name, value)) = split_once(line, b'=') { + if name.is_empty() { + return Err(Error::MalformedV2HeaderLine { line: line.to_owned() }); + } + return Ok(Feature { + name: name.to_owned(), + value: Some(value.to_owned()), + }); + } + if line.is_empty() { + return Err(Error::MalformedV2HeaderLine { line: line.to_owned() }); + } + Ok(Feature { + name: line.to_owned(), + value: None, + }) +} + +fn split_first_command_and_capabilities(line: &BStr) -> (BString, Vec) { + match line.find_byte(0) { + Some(nul_pos) => { + let command = line[..nul_pos].as_bstr().to_owned(); + let capabilities = parse_capabilities(line[nul_pos + 1..].as_bstr()); + (command, capabilities) + } + None => (line.to_owned(), Vec::new()), + } +} + +fn parse_capabilities(raw: &BStr) -> Vec { + raw.split(|byte| *byte == b' ') + .filter(|token| !token.is_empty()) + .map(|token| { + let token = token.as_bstr(); + if let Some((name, value)) = split_once(token, b'=') { + Capability { + name: name.to_owned(), + value: Some(value.to_owned()), + } + } else { + Capability { + name: token.to_owned(), + value: None, + } + } + }) + .collect() +} + +fn parse_update_command(line: &BStr) -> Result { + if line.find_byte(0).is_some() { + return Err(Error::MalformedCommandLine { line: line.to_owned() }); + } + + let mut tokens = line.splitn(3, |byte| *byte == b' '); + let old_hex = tokens + .next() + .ok_or_else(|| Error::MalformedCommandLine { line: line.to_owned() })?; + let new_hex = tokens + .next() + .ok_or_else(|| Error::MalformedCommandLine { line: line.to_owned() })?; + let ref_name = tokens + .next() + .ok_or_else(|| Error::MalformedCommandLine { line: line.to_owned() })?; + if old_hex.is_empty() || new_hex.is_empty() || ref_name.is_empty() { + return Err(Error::MalformedCommandLine { line: line.to_owned() }); + } + + let old_id = gix_hash::ObjectId::from_hex(old_hex).map_err(|source| Error::InvalidObjectId { + line: line.to_owned(), + source, + })?; + let new_id = gix_hash::ObjectId::from_hex(new_hex).map_err(|source| Error::InvalidObjectId { + line: line.to_owned(), + source, + })?; + + Ok(Update { + old_id, + new_id, + ref_name: ref_name.as_bstr().to_owned(), + }) +} + +fn encode_report_status_payload(response: &Response) -> Result, Error> { + let mut payload = Vec::new(); + let mut writer = Writer::new(&mut payload); + writer.enable_text_mode(); + writer.write_all(format_unpack_status_line(&response.unpack_status).as_ref())?; + for status in &response.ref_statuses { + writer.write_all(format_ref_status_line(status).as_ref())?; + } + encode::flush_to_write(writer.inner_mut())?; + Ok(payload) +} + +fn format_unpack_status_line(status: &UnpackStatus) -> BString { + match status { + UnpackStatus::Ok => "unpack ok".into(), + UnpackStatus::Error(message) => { + let mut line = BString::from("unpack "); + line.push_str(message); + line + } + } +} + +fn format_ref_status_line(status: &RefStatus) -> BString { + match status { + RefStatus::Ok { ref_name } => { + let mut line = BString::from("ok "); + line.push_str(ref_name); + line + } + RefStatus::Rejected { ref_name, message } => { + let mut line = BString::from("ng "); + line.push_str(ref_name); + line.push_byte(b' '); + line.push_str(message); + line + } + } +} + +fn format_v2_message_line(message: &SidebandMessage) -> BString { + match message.kind { + SidebandMessageKind::Progress => { + let mut line = BString::from("progress "); + line.push_str(&message.text); + line + } + SidebandMessageKind::Error => { + let mut line = BString::from("error "); + line.push_str(&message.text); + line + } + } +} + +fn split_once(line: &BStr, separator: u8) -> Option<(&BStr, &BStr)> { + let idx = line.find_byte(separator)?; + Some((line[..idx].as_bstr(), line[idx + 1..].as_bstr())) +} + +fn packet_line_kind(line: &PacketLineRef<'_>) -> &'static str { + match line { + PacketLineRef::Data(_) => "data", + PacketLineRef::Flush => "flush", + PacketLineRef::Delimiter => "delimiter", + PacketLineRef::ResponseEnd => "response-end", + } +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use gix_transport::packetline::{BandRef, PacketLineRef, blocking_io::StreamingPeekableIter}; + + use super::*; + + #[derive(Default)] + struct MockDelegate { + response: Response, + seen_request: Option, + seen_pack_prefix: Option<[u8; 4]>, + } + + impl Delegate for MockDelegate { + fn receive(&mut self, request: &Request, pack_data: &mut dyn io::Read) -> Result { + self.seen_request = Some(request.clone()); + let mut prefix = [0u8; 4]; + pack_data.read_exact(&mut prefix)?; + self.seen_pack_prefix = Some(prefix); + Ok(self.response.clone()) + } + } + + #[test] + fn serve_v1_parses_commands_capabilities_and_writes_sideband_report_status() + -> Result<(), Box> { + let request = request_bytes( + &["0000000000000000000000000000000000000000 808e50d724f604f69ab93c6da2919c014667bedb refs/heads/main"], + &[ + "report-status-v2", + "side-band-64k", + "object-format=sha1", + "agent=git/gitplane", + ], + &[], + b"PACK\0\0\0\x02", + )?; + let mut output = Vec::new(); + let mut delegate = MockDelegate { + response: Response { + unpack_status: UnpackStatus::Ok, + ref_statuses: vec![RefStatus::Ok { + ref_name: "refs/heads/main".into(), + }], + sideband_messages: Vec::new(), + }, + ..Default::default() + }; + + let outcome = serve_v1(request.as_slice(), &mut output, &mut delegate)?; + assert_eq!( + outcome, + Outcome { + updates_received: 1, + push_options_received: 0, + ref_statuses_sent: 1, + report_status_sent: true, + sideband_bytes_sent: 41, + } + ); + + let seen = delegate + .seen_request + .as_ref() + .expect("request should be visible to delegate"); + assert_eq!(seen.updates.len(), 1); + assert_eq!( + seen.updates[0].ref_name.as_bstr(), + "refs/heads/main".as_bytes().as_bstr() + ); + assert!(seen.has_capability("report-status-v2")); + assert!(seen.has_capability("side-band-64k")); + assert_eq!( + delegate.seen_pack_prefix, + Some(*b"PACK"), + "delegate should receive pack data at the current read position" + ); + + let mut outer_reader = StreamingPeekableIter::new(output.as_slice(), &[PacketLineRef::Flush], false); + let mut report_payload = Vec::::new(); + while let Some(line) = outer_reader.read_line() { + let line = line??; + match line.decode_band()? { + BandRef::Data(data) => report_payload.extend_from_slice(data), + BandRef::Progress(_) | BandRef::Error(_) => {} + } + } + assert_eq!(outer_reader.stopped_at(), Some(PacketLineRef::Flush)); + + let mut inner_reader = StreamingPeekableIter::new(report_payload.as_slice(), &[PacketLineRef::Flush], false); + assert_eq!( + next_text_line(&mut inner_reader)?.as_bstr(), + "unpack ok".as_bytes().as_bstr() + ); + assert_eq!( + next_text_line(&mut inner_reader)?.as_bstr(), + "ok refs/heads/main".as_bytes().as_bstr() + ); + assert!(inner_reader.read_line().is_none()); + assert_eq!(inner_reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) + } + + #[test] + fn serve_v1_parses_push_options_section_when_negotiated() -> Result<(), Box> { + let request = request_bytes( + &["0000000000000000000000000000000000000000 808e50d724f604f69ab93c6da2919c014667bedb refs/heads/main"], + &["report-status", "push-options"], + &["ci.skip", "trace=1"], + b"PACK\0\0\0\x02", + )?; + let mut output = Vec::new(); + let mut delegate = MockDelegate { + response: Response { + unpack_status: UnpackStatus::Ok, + ref_statuses: vec![RefStatus::Ok { + ref_name: "refs/heads/main".into(), + }], + sideband_messages: Vec::new(), + }, + ..Default::default() + }; + + let outcome = serve_v1(request.as_slice(), &mut output, &mut delegate)?; + assert_eq!(outcome.push_options_received, 2); + + let seen = delegate + .seen_request + .as_ref() + .expect("request should be visible to delegate"); + assert_eq!( + seen.push_options, + vec![BString::from("ci.skip"), BString::from("trace=1")] + ); + assert_eq!(delegate.seen_pack_prefix, Some(*b"PACK")); + + let mut reader = StreamingPeekableIter::new(output.as_slice(), &[PacketLineRef::Flush], false); + assert_eq!(next_text_line(&mut reader)?.as_bstr(), "unpack ok".as_bytes().as_bstr()); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "ok refs/heads/main".as_bytes().as_bstr() + ); + assert!(reader.read_line().is_none()); + assert_eq!(reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) + } + + #[test] + fn parse_v1_request_rejects_malformed_update_line() -> Result<(), Box> { + let request = request_bytes(&["not-an-update-line"], &[], &[], b"PACK\0\0\0\x02")?; + let mut input = std::io::BufReader::new(Cursor::new(request)); + let err = parse_v1_request(&mut input).expect_err("malformed command line should fail"); + assert!(matches!(err, Error::MalformedCommandLine { .. })); + Ok(()) + } + + #[test] + fn parse_v2_request_parses_sections_and_leaves_pack_data() -> Result<(), Box> { + let request = request_bytes_v2( + &["report-status-v2", "push-options", "agent=git/gitplane"], + &["0000000000000000000000000000000000000000 808e50d724f604f69ab93c6da2919c014667bedb refs/heads/main"], + &["ci.skip", "trace=1"], + b"PACK\0\0\0\x02", + )?; + let mut input = std::io::BufReader::new(Cursor::new(request)); + let parsed = parse_v2_request(&mut input)?; + + assert_eq!( + parsed.features, + vec![ + Feature { + name: "report-status-v2".into(), + value: None, + }, + Feature { + name: "push-options".into(), + value: None, + }, + Feature { + name: "agent".into(), + value: Some("git/gitplane".into()), + }, + ] + ); + assert!(parsed.has_pack); + assert_eq!(parsed.request.updates.len(), 1); + assert_eq!( + parsed.request.push_options, + vec![BString::from("ci.skip"), BString::from("trace=1")] + ); + assert!(parsed.request.has_capability("report-status-v2")); + assert!(parsed.request.has_capability("push-options")); + assert_eq!( + parsed.request.updates[0].ref_name.as_bstr(), + "refs/heads/main".as_bytes().as_bstr() + ); + + let mut prefix = [0u8; 4]; + std::io::Read::read_exact(&mut input, &mut prefix)?; + assert_eq!(prefix, *b"PACK"); + Ok(()) + } + + #[test] + fn parse_v2_request_rejects_unknown_section() -> Result<(), Box> { + let mut out = Vec::new(); + { + let mut writer = Writer::new(&mut out); + writer.enable_text_mode(); + writer.write_all(b"command=push")?; + encode::delim_to_write(writer.inner_mut())?; + writer.write_all(b"section=unknown")?; + writer.write_all( + b"0000000000000000000000000000000000000000 808e50d724f604f69ab93c6da2919c014667bedb refs/heads/main", + )?; + encode::flush_to_write(writer.inner_mut())?; + } + let mut input = std::io::BufReader::new(Cursor::new(out)); + let err = parse_v2_request(&mut input).expect_err("unknown V2 section should fail"); + assert!(matches!(err, Error::UnknownV2Section { .. })); + Ok(()) + } + + #[test] + fn serve_v2_parses_sections_and_writes_report_status() -> Result<(), Box> { + let request = request_bytes_v2( + &["report-status-v2", "push-options"], + &["0000000000000000000000000000000000000000 808e50d724f604f69ab93c6da2919c014667bedb refs/heads/main"], + &["trace=1"], + b"PACK\0\0\0\x02", + )?; + let mut output = Vec::new(); + let mut delegate = MockDelegate { + response: Response { + unpack_status: UnpackStatus::Ok, + ref_statuses: vec![RefStatus::Ok { + ref_name: "refs/heads/main".into(), + }], + sideband_messages: Vec::new(), + }, + ..Default::default() + }; + + let outcome = serve_v2(request.as_slice(), &mut output, &mut delegate)?; + assert_eq!( + outcome, + Outcome { + updates_received: 1, + push_options_received: 1, + ref_statuses_sent: 1, + report_status_sent: true, + sideband_bytes_sent: 0, + } + ); + assert_eq!(delegate.seen_pack_prefix, Some(*b"PACK")); + let seen = delegate + .seen_request + .as_ref() + .expect("request should be visible to delegate"); + assert_eq!(seen.push_options, vec![BString::from("trace=1")]); + assert!(seen.has_capability("report-status-v2")); + + let mut reader = StreamingPeekableIter::new(output.as_slice(), &[PacketLineRef::Flush], false); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + V2_SECTION_REPORT_STATUS.as_bytes().as_bstr() + ); + assert_eq!(next_text_line(&mut reader)?.as_bstr(), "unpack ok".as_bytes().as_bstr()); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "ok refs/heads/main".as_bytes().as_bstr() + ); + assert!(reader.read_line().is_none()); + assert_eq!(reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) + } + + #[test] + fn write_v2_capability_advertisement_includes_version_and_values() -> Result<(), Box> { + let mut output = Vec::new(); + write_v2_capability_advertisement( + &mut output, + &[ + V2Capability { + name: "push".into(), + values: vec!["report-status-v2".into(), "push-options".into()], + }, + V2Capability { + name: "object-format".into(), + values: vec!["sha1".into()], + }, + ], + )?; + + let mut reader = StreamingPeekableIter::new(output.as_slice(), &[PacketLineRef::Flush], false); + assert_eq!(next_text_line(&mut reader)?.as_bstr(), "version 2".as_bytes().as_bstr()); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "push=report-status-v2 push-options".as_bytes().as_bstr() + ); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "object-format=sha1".as_bytes().as_bstr() + ); + assert!(reader.read_line().is_none()); + assert_eq!(reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) + } + + fn request_bytes( + updates: &[&str], + capabilities: &[&str], + push_options: &[&str], + pack_data: &[u8], + ) -> Result, Box> { + assert!(!updates.is_empty(), "at least one update command is required"); + let mut out = Vec::new(); + { + let mut writer = Writer::new(&mut out); + writer.enable_text_mode(); + let first = if capabilities.is_empty() { + updates[0].to_owned() + } else { + format!("{}\0 {}", updates[0], capabilities.join(" ")) + }; + writer.write_all(first.as_bytes())?; + for update in &updates[1..] { + writer.write_all(update.as_bytes())?; + } + encode::flush_to_write(writer.inner_mut())?; + + if !push_options.is_empty() { + for option in push_options { + writer.write_all(option.as_bytes())?; + } + encode::flush_to_write(writer.inner_mut())?; + } + } + out.extend_from_slice(pack_data); + Ok(out) + } + + fn request_bytes_v2( + features: &[&str], + updates: &[&str], + push_options: &[&str], + pack_data: &[u8], + ) -> Result, Box> { + assert!(!updates.is_empty(), "at least one update command is required"); + let mut out = Vec::new(); + { + let mut writer = Writer::new(&mut out); + writer.enable_text_mode(); + writer.write_all(b"command=push")?; + for feature in features { + writer.write_all(feature.as_bytes())?; + } + encode::delim_to_write(writer.inner_mut())?; + writer.write_all(V2_SECTION_REF_UPDATES.as_bytes())?; + for update in updates { + writer.write_all(update.as_bytes())?; + } + if push_options.is_empty() { + encode::flush_to_write(writer.inner_mut())?; + } else { + encode::delim_to_write(writer.inner_mut())?; + writer.write_all(V2_SECTION_PUSH_OPTIONS.as_bytes())?; + for option in push_options { + writer.write_all(option.as_bytes())?; + } + encode::flush_to_write(writer.inner_mut())?; + } + } + out.extend_from_slice(pack_data); + Ok(out) + } + + fn next_text_line(reader: &mut StreamingPeekableIter<&[u8]>) -> Result> { + let line = reader + .read_line() + .expect("expected packetline") + .expect("read should succeed") + .expect("decode should succeed"); + Ok(line.as_text().expect("expected text packetline").as_bstr().to_owned()) + } +} diff --git a/gix-protocol/src/receive_pack/async_io.rs b/gix-protocol/src/receive_pack/async_io.rs new file mode 100644 index 00000000000..4d45193c515 --- /dev/null +++ b/gix-protocol/src/receive_pack/async_io.rs @@ -0,0 +1,419 @@ +//! Async transport integration for blocking `receive-pack` server plumbing. +//! +//! This module bridges async byte streams into [`super::serve_v1()`] and +//! [`super::serve_v2()`] for one client/session at a time. + +use futures_io::{AsyncRead, AsyncWrite}; +use futures_lite::AsyncWriteExt as _; + +/// Serve one protocol V1 receive-pack request over async transport streams. +/// +/// This adapts async readers/writers to the existing blocking receive-pack plumbing. +pub async fn serve_v1(input: &mut R, output: &mut W, delegate: &mut D) -> Result +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, + D: super::Delegate, +{ + let outcome = { + let mut blocking_input = futures_lite::io::BlockOn::new(input); + let mut blocking_output = futures_lite::io::BlockOn::new(&mut *output); + super::serve_v1(&mut blocking_input, &mut blocking_output, delegate)? + }; + output.flush().await?; + Ok(outcome) +} + +/// Serve one protocol V2 receive-pack request over async transport streams. +/// +/// This adapts async readers/writers to the existing blocking receive-pack plumbing. +pub async fn serve_v2(input: &mut R, output: &mut W, delegate: &mut D) -> Result +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, + D: super::Delegate, +{ + let outcome = { + let mut blocking_input = futures_lite::io::BlockOn::new(input); + let mut blocking_output = futures_lite::io::BlockOn::new(&mut *output); + super::serve_v2(&mut blocking_input, &mut blocking_output, delegate)? + }; + output.flush().await?; + Ok(outcome) +} + +#[cfg(test)] +mod tests { + use std::{ + collections::VecDeque, + future::Future, + io::Write as _, + sync::atomic::{AtomicBool, Ordering}, + }; + + use bstr::{BString, ByteSlice}; + use futures_lite::io::Cursor; + use gix_transport::packetline::{ + PacketLineRef, + blocking_io::{StreamingPeekableIter, Writer, encode}, + }; + + use super::*; + + #[derive(Debug, Default, Clone, PartialEq, Eq)] + struct ListenerOutcome { + sessions_served: usize, + updates_received: usize, + push_options_received: usize, + ref_statuses_sent: usize, + } + + #[derive(Default)] + struct RecordingDelegate { + response: super::super::Response, + seen_requests: Vec, + seen_pack_prefixes: Vec<[u8; 4]>, + } + + impl super::super::Delegate for RecordingDelegate { + fn receive( + &mut self, + request: &super::super::Request, + pack_data: &mut dyn std::io::Read, + ) -> Result> { + self.seen_requests.push(request.clone()); + let mut prefix = [0u8; 4]; + pack_data.read_exact(&mut prefix)?; + self.seen_pack_prefixes.push(prefix); + Ok(self.response.clone()) + } + } + + #[async_std::test] + async fn serve_v1_bridges_async_transport_io() -> Result<(), Box> { + let request = request_bytes( + &["0000000000000000000000000000000000000000 808e50d724f604f69ab93c6da2919c014667bedb refs/heads/main"], + &["report-status"], + &[], + b"PACK\0\0\0\x02", + )?; + let mut input = Cursor::new(request); + let mut output = Cursor::new(Vec::::new()); + let mut delegate = RecordingDelegate { + response: super::super::Response { + unpack_status: super::super::UnpackStatus::Ok, + ref_statuses: vec![super::super::RefStatus::Ok { + ref_name: "refs/heads/main".into(), + }], + sideband_messages: Vec::new(), + }, + ..Default::default() + }; + + let outcome = serve_v1(&mut input, &mut output, &mut delegate).await?; + assert_eq!(outcome.updates_received, 1); + assert_eq!(outcome.push_options_received, 0); + assert_eq!(outcome.ref_statuses_sent, 1); + assert_eq!(delegate.seen_pack_prefixes, vec![*b"PACK"]); + + let mut reader = StreamingPeekableIter::new(output.get_ref().as_slice(), &[PacketLineRef::Flush], false); + assert_eq!(next_text_line(&mut reader)?.as_bstr(), "unpack ok".as_bytes().as_bstr()); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "ok refs/heads/main".as_bytes().as_bstr() + ); + assert!(reader.read_line().is_none()); + assert_eq!(reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) + } + + #[async_std::test] + async fn serve_v2_bridges_async_transport_io() -> Result<(), Box> { + let request = request_bytes_v2( + &["report-status-v2", "push-options"], + &["0000000000000000000000000000000000000000 808e50d724f604f69ab93c6da2919c014667bedb refs/heads/main"], + &["trace=1"], + b"PACK\0\0\0\x02", + )?; + let mut input = Cursor::new(request); + let mut output = Cursor::new(Vec::::new()); + let mut delegate = RecordingDelegate { + response: super::super::Response { + unpack_status: super::super::UnpackStatus::Ok, + ref_statuses: vec![super::super::RefStatus::Ok { + ref_name: "refs/heads/main".into(), + }], + sideband_messages: Vec::new(), + }, + ..Default::default() + }; + + let outcome = serve_v2(&mut input, &mut output, &mut delegate).await?; + assert_eq!(outcome.updates_received, 1); + assert_eq!(outcome.push_options_received, 1); + assert_eq!(outcome.ref_statuses_sent, 1); + assert_eq!(delegate.seen_pack_prefixes, vec![*b"PACK"]); + assert!( + delegate.seen_requests[0].has_capability("report-status-v2"), + "V2 features should be lowered to capabilities" + ); + assert_eq!(delegate.seen_requests[0].push_options, vec![BString::from("trace=1")]); + + let mut reader = StreamingPeekableIter::new(output.get_ref().as_slice(), &[PacketLineRef::Flush], false); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "report-status".as_bytes().as_bstr() + ); + assert_eq!(next_text_line(&mut reader)?.as_bstr(), "unpack ok".as_bytes().as_bstr()); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "ok refs/heads/main".as_bytes().as_bstr() + ); + assert!(reader.read_line().is_none()); + assert_eq!(reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) + } + + #[async_std::test] + async fn listen_v1_serves_until_connection_source_is_exhausted() -> Result<(), Box> { + let request_one = request_bytes( + &["0000000000000000000000000000000000000000 808e50d724f604f69ab93c6da2919c014667bedb refs/heads/main"], + &["report-status"], + &[], + b"PACK\0\0\0\x02", + )?; + let request_two = request_bytes( + &["808e50d724f604f69ab93c6da2919c014667bedb 0000000000000000000000000000000000000000 refs/heads/main"], + &["report-status", "push-options"], + &["trace=1"], + b"PACK\0\0\0\x02", + )?; + + let mut incoming = VecDeque::from(vec![ + Ok((Cursor::new(request_one), Cursor::new(Vec::::new()))), + Ok((Cursor::new(request_two), Cursor::new(Vec::::new()))), + ]); + let mut delegate = RecordingDelegate { + response: super::super::Response { + unpack_status: super::super::UnpackStatus::Ok, + ref_statuses: vec![super::super::RefStatus::Ok { + ref_name: "refs/heads/main".into(), + }], + sideband_messages: Vec::new(), + }, + ..Default::default() + }; + let should_stop = AtomicBool::new(false); + + let outcome = listen_v1_for_test( + || { + std::future::ready(match incoming.pop_front() { + Some(connection) => connection.map(Some), + None => Ok(None), + }) + }, + &mut delegate, + &should_stop, + ) + .await?; + + assert_eq!( + outcome, + ListenerOutcome { + sessions_served: 2, + updates_received: 2, + push_options_received: 1, + ref_statuses_sent: 2, + } + ); + assert_eq!(delegate.seen_requests.len(), 2); + assert_eq!(delegate.seen_pack_prefixes, vec![*b"PACK", *b"PACK"]); + Ok(()) + } + + #[async_std::test] + async fn listen_v2_serves_until_connection_source_is_exhausted() -> Result<(), Box> { + let request_one = request_bytes_v2( + &["report-status-v2"], + &["0000000000000000000000000000000000000000 808e50d724f604f69ab93c6da2919c014667bedb refs/heads/main"], + &[], + b"PACK\0\0\0\x02", + )?; + let request_two = request_bytes_v2( + &["report-status-v2", "push-options"], + &["808e50d724f604f69ab93c6da2919c014667bedb 0000000000000000000000000000000000000000 refs/heads/main"], + &["trace=1"], + b"PACK\0\0\0\x02", + )?; + + let mut incoming = VecDeque::from(vec![ + Ok((Cursor::new(request_one), Cursor::new(Vec::::new()))), + Ok((Cursor::new(request_two), Cursor::new(Vec::::new()))), + ]); + let mut delegate = RecordingDelegate { + response: super::super::Response { + unpack_status: super::super::UnpackStatus::Ok, + ref_statuses: vec![super::super::RefStatus::Ok { + ref_name: "refs/heads/main".into(), + }], + sideband_messages: Vec::new(), + }, + ..Default::default() + }; + let should_stop = AtomicBool::new(false); + + let outcome = listen_v2_for_test( + || { + std::future::ready(match incoming.pop_front() { + Some(connection) => connection.map(Some), + None => Ok(None), + }) + }, + &mut delegate, + &should_stop, + ) + .await?; + + assert_eq!( + outcome, + ListenerOutcome { + sessions_served: 2, + updates_received: 2, + push_options_received: 1, + ref_statuses_sent: 2, + } + ); + assert_eq!(delegate.seen_requests.len(), 2); + assert_eq!(delegate.seen_pack_prefixes, vec![*b"PACK", *b"PACK"]); + Ok(()) + } + + async fn listen_v1_for_test( + mut next_connection: Next, + delegate: &mut D, + should_stop: &AtomicBool, + ) -> Result> + where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, + D: super::super::Delegate, + Next: FnMut() -> NextFuture, + NextFuture: Future>>, + { + let mut aggregated = ListenerOutcome::default(); + while !should_stop.load(Ordering::Relaxed) { + let Some((mut input, mut output)) = next_connection().await? else { + break; + }; + let outcome = serve_v1(&mut input, &mut output, delegate).await?; + aggregated.sessions_served += 1; + aggregated.updates_received += outcome.updates_received; + aggregated.push_options_received += outcome.push_options_received; + aggregated.ref_statuses_sent += outcome.ref_statuses_sent; + } + Ok(aggregated) + } + + async fn listen_v2_for_test( + mut next_connection: Next, + delegate: &mut D, + should_stop: &AtomicBool, + ) -> Result> + where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, + D: super::super::Delegate, + Next: FnMut() -> NextFuture, + NextFuture: Future>>, + { + let mut aggregated = ListenerOutcome::default(); + while !should_stop.load(Ordering::Relaxed) { + let Some((mut input, mut output)) = next_connection().await? else { + break; + }; + let outcome = serve_v2(&mut input, &mut output, delegate).await?; + aggregated.sessions_served += 1; + aggregated.updates_received += outcome.updates_received; + aggregated.push_options_received += outcome.push_options_received; + aggregated.ref_statuses_sent += outcome.ref_statuses_sent; + } + Ok(aggregated) + } + + fn request_bytes( + updates: &[&str], + capabilities: &[&str], + push_options: &[&str], + pack_data: &[u8], + ) -> Result, Box> { + assert!(!updates.is_empty(), "at least one update command is required"); + let mut out = Vec::new(); + { + let mut writer = Writer::new(&mut out); + writer.enable_text_mode(); + let first = if capabilities.is_empty() { + updates[0].to_owned() + } else { + format!("{}\0 {}", updates[0], capabilities.join(" ")) + }; + writer.write_all(first.as_bytes())?; + for update in &updates[1..] { + writer.write_all(update.as_bytes())?; + } + encode::flush_to_write(writer.inner_mut())?; + + if !push_options.is_empty() { + for option in push_options { + writer.write_all(option.as_bytes())?; + } + encode::flush_to_write(writer.inner_mut())?; + } + } + out.extend_from_slice(pack_data); + Ok(out) + } + + fn request_bytes_v2( + features: &[&str], + updates: &[&str], + push_options: &[&str], + pack_data: &[u8], + ) -> Result, Box> { + assert!(!updates.is_empty(), "at least one update command is required"); + let mut out = Vec::new(); + { + let mut writer = Writer::new(&mut out); + writer.enable_text_mode(); + writer.write_all(b"command=push")?; + for feature in features { + writer.write_all(feature.as_bytes())?; + } + encode::delim_to_write(writer.inner_mut())?; + writer.write_all(b"section=ref-updates")?; + for update in updates { + writer.write_all(update.as_bytes())?; + } + if push_options.is_empty() { + encode::flush_to_write(writer.inner_mut())?; + } else { + encode::delim_to_write(writer.inner_mut())?; + writer.write_all(b"section=push-options")?; + for option in push_options { + writer.write_all(option.as_bytes())?; + } + encode::flush_to_write(writer.inner_mut())?; + } + } + out.extend_from_slice(pack_data); + Ok(out) + } + + fn next_text_line(reader: &mut StreamingPeekableIter<&[u8]>) -> Result> { + let line = reader + .read_line() + .expect("expected packetline") + .expect("read should succeed") + .expect("decode should succeed"); + Ok(line.as_text().expect("expected text packetline").as_bstr().to_owned()) + } +} diff --git a/gix-protocol/src/upload_pack.rs b/gix-protocol/src/upload_pack.rs new file mode 100644 index 00000000000..2f463bad66c --- /dev/null +++ b/gix-protocol/src/upload_pack.rs @@ -0,0 +1,2608 @@ +//! Blocking server-side plumbing for `upload-pack` protocol V2 interactions. +//! +//! This module provides in-process request/response handling primitives intended for +//! server integrations that own connection handling and authentication. +//! It focuses on protocol framing and command parsing/writing while delegating repository +//! access and pack generation to caller-provided implementations. + +/// Async transport integration for upload-pack server plumbing. +#[cfg(feature = "async-server")] +pub mod async_io; + +use std::{ + collections::BTreeSet, + io, + sync::atomic::AtomicBool, +}; +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +use std::io::Write as _; + +use bstr::{BString, ByteSlice}; +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +use bstr::{BStr, ByteVec}; +use gix_ref::file::ReferenceExt as _; +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +use gix_transport::packetline::blocking_io::{StreamingPeekableIter, Writer, encode}; +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +use gix_transport::packetline::Channel; +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +use gix_transport::packetline::PacketLineRef; + +use crate::{ + fetch::response::{Acknowledgement, ShallowUpdate, WantedRef}, + handshake::Ref, +}; + +type BoxError = Box; + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +const HEADER_DELIMITERS: &[PacketLineRef<'static>] = &[PacketLineRef::Delimiter, PacketLineRef::Flush]; +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +const ARGUMENT_DELIMITERS: &[PacketLineRef<'static>] = &[PacketLineRef::Flush]; +#[allow(dead_code)] // Used by async_io submodule in later tasks. +pub(crate) const MAX_SIDEBAND_DATA_BYTES: usize = 65_515; + +/// A parsed feature line from a protocol V2 request header. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Feature { + /// The feature name, e.g. `agent`. + pub name: BString, + /// An optional feature value, e.g. `git/2.48.0`. + pub value: Option, +} + +/// A capability line to advertise in protocol V2. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Capability { + /// The capability name, like `ls-refs` or `fetch`. + pub name: BString, + /// Optional values associated with `name`, separated by spaces when rendered. + pub values: Vec, +} + +/// Server-side configuration for upload-pack capability validation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ServerConfig { + /// The object hash algorithm this server supports. + pub object_hash: gix_hash::Kind, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + object_hash: gix_hash::Kind::Sha1, + } + } +} + +/// A parsed protocol V2 request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Request { + /// Request header features that accompany the command. + pub features: Vec, + /// The upload-pack command payload. + pub command: Command, +} + +/// Parsed upload-pack command variants. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + /// A `ls-refs` command. + LsRefs(LsRefs), + /// A `fetch` command. + Fetch(Fetch), +} + +/// Parsed `ls-refs` command arguments. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct LsRefs { + /// If true, include symbolic reference targets in output. + pub symrefs: bool, + /// If true, include peeled object IDs where available. + pub peel: bool, + /// If true, include unborn refs in output. + pub unborn: bool, + /// Prefix filters to apply to advertised refs. + pub ref_prefixes: Vec, + /// Unknown arguments preserved for higher-level handling. + pub extra_arguments: Vec, +} + +/// Parsed `fetch` command arguments. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Fetch { + /// Requested object IDs. + pub wants: Vec, + /// Object IDs already present on the client. + pub haves: Vec, + /// Requested refs through `want-ref`. + pub want_refs: Vec, + /// Shallow boundary commits sent by the client. + pub shallow: Vec, + /// Optional depth requested by the client through `deepen `. + pub deepen: Option, + /// Optional depth timestamp requested by the client through `deepen-since `. + pub deepen_since: Option, + /// Ref exclusions requested by the client through `deepen-not `. + pub deepen_not: Vec, + /// If true, client requests `deepen-relative`. + pub deepen_relative: bool, + /// Filter specifications requested by the client through `filter `. + pub filters: Vec, + /// Protocols requested by the client through `packfile-uris `. + pub packfile_uris: Vec, + /// If true, client requests thin-pack behavior. + pub thin_pack: bool, + /// If true, client requests `no-progress`. + pub no_progress: bool, + /// If true, client requests `ofs-delta`. + pub ofs_delta: bool, + /// If true, client requests `include-tag`. + pub include_tag: bool, + /// If true, client requests `sideband-all`. + pub sideband_all: bool, + /// If true, client requests `wait-for-done`. + pub wait_for_done: bool, + /// If true, client completed negotiation with `done`. + pub done: bool, + /// Unknown arguments preserved for higher-level handling. + pub extra_arguments: Vec, +} +/// The result of negotiating an upload-pack `fetch` request against repository data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FetchNegotiation { + /// Acknowledgements to send in the `acknowledgments` section. + pub acknowledgements: Vec, + /// Requested refs that could be resolved to object IDs and returned in `wanted-refs`. + pub wanted_refs: Vec, + /// `want` object IDs that are present in the repository. + pub known_wants: Vec, + /// `want` object IDs that are absent in the repository. + pub missing_wants: Vec, + /// `have` object IDs that are present in the repository. + pub common_haves: Vec, + /// `want-ref` names that could not be resolved to a reference. + pub unresolved_want_refs: Vec, +} + +impl FetchNegotiation { + /// Convert this negotiation result into a [`FetchOutput`] without pack data. + pub fn into_output(self) -> FetchOutput { + let mut output = FetchOutput::without_pack(); + output.acknowledgements = self.acknowledgements; + output.wanted_refs = self.wanted_refs; + output + } + + /// Convert this negotiation result into a [`FetchOutput`] and populate repository-backed pack data. + /// + /// Pack generation traverses all commits reachable from negotiated wants and from peeled `want-ref` + /// targets while excluding commits reachable from acknowledged `have` lines. + pub fn into_output_with_repository_pack( + self, + request: &Fetch, + object_database: Find, + object_hash: gix_hash::Kind, + ) -> Result + where + Find: gix_object::Find + gix_pack::Find + Clone, + { + let pack_data = generate_fetch_pack_data_with_repository(request, &self, object_database, object_hash)?; + let mut output = self.into_output(); + output.pack_data = pack_data.map(|pack| Box::new(io::Cursor::new(pack)) as Box); + Ok(output) + } +} + +/// Errors returned while negotiating `fetch` requests against repository state. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum FetchNegotiationError { + #[error(transparent)] + OpenPackedRefs(#[from] gix_ref::packed::buffer::open::Error), + #[error("Could not lookup wanted ref {ref_name:?}")] + FindWantedRef { + ref_name: BString, + #[source] + source: gix_ref::file::find::existing::Error, + }, + #[error("Could not resolve wanted ref {ref_name:?} to an object id")] + ResolveWantedRef { + ref_name: BString, + #[source] + source: gix_ref::peel::to_object::Error, + }, +} + +/// Errors returned while building repository-backed pack data for negotiated fetches. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum FetchPackGenerationError { + #[error(transparent)] + FindObject(#[from] gix_object::find::Error), + #[error("Object {id} disappeared while preparing pack data")] + MissingObject { id: gix_hash::ObjectId }, + #[error(transparent)] + DecodeTag(#[from] gix_object::decode::Error), + #[error("Tag cycle detected at {id}")] + TagCycle { id: gix_hash::ObjectId }, + #[error(transparent)] + TraverseCommits(#[from] gix_traverse::commit::simple::Error), + #[error(transparent)] + CountObjects(#[from] gix_pack::data::output::count::objects::Error), + #[error(transparent)] + BuildPackEntry(#[from] gix_pack::data::output::entry::Error), + #[error(transparent)] + EncodePack(#[from] gix_pack::data::output::bytes::Error), + #[error("Cannot encode more than u32::MAX objects in a single pack, got {object_count}")] + TooManyObjects { object_count: usize }, +} + +fn generate_fetch_pack_data_with_repository( + _request: &Fetch, + negotiation: &FetchNegotiation, + object_database: Find, + object_hash: gix_hash::Kind, +) -> Result>, FetchPackGenerationError> +where + Find: gix_object::Find + gix_pack::Find + Clone, +{ + let mut requested_ids = Vec::new(); + let mut seen_requested_ids = BTreeSet::new(); + for object_id in negotiation + .known_wants + .iter() + .chain(negotiation.wanted_refs.iter().map(|wanted| &wanted.id)) + { + if seen_requested_ids.insert(*object_id) { + requested_ids.push(*object_id); + } + } + if requested_ids.is_empty() { + return Ok(None); + } + + let mut object_buf = Vec::new(); + let wanted_commit_tips = collect_commit_tips(&requested_ids, &object_database, &mut object_buf)?; + let hidden_commit_tips = collect_commit_tips(&negotiation.common_haves, &object_database, &mut object_buf)?; + + let mut objects_to_pack = Vec::new(); + if !wanted_commit_tips.is_empty() { + let mut walk = gix_traverse::commit::Simple::new(wanted_commit_tips, object_database.clone()); + if !hidden_commit_tips.is_empty() { + walk = walk.hide(hidden_commit_tips)?; + } + for commit in walk { + objects_to_pack.push(commit?.id); + } + } + objects_to_pack.extend(requested_ids); + if objects_to_pack.is_empty() { + return Ok(None); + } + + let mut object_ids = objects_to_pack.into_iter().map(Ok::<_, BoxError>); + let should_interrupt = AtomicBool::new(false); + let (counts, _) = gix_pack::data::output::count::objects_unthreaded( + &object_database, + &mut object_ids, + &gix_features::progress::Discard, + &should_interrupt, + gix_pack::data::output::count::objects::ObjectExpansion::TreeContents, + )?; + if counts.is_empty() { + return Ok(None); + } + + let object_count = counts.len(); + let num_entries = + u32::try_from(object_count).map_err(|_| FetchPackGenerationError::TooManyObjects { object_count })?; + let mut object_buf = Vec::new(); + let mut entries = Vec::with_capacity(object_count); + for count in &counts { + let object = gix_pack::Find::try_find(&object_database, count.id.as_ref(), &mut object_buf)? + .ok_or_else(|| FetchPackGenerationError::MissingObject { id: count.id })? + .0; + entries.push(gix_pack::data::output::Entry::from_data( + count, + &object, + gix_zlib::Compression::default(), + )?); + } + let mut writer = gix_pack::data::output::bytes::FromEntriesIter::new( + std::iter::once(Ok::<_, gix_pack::data::output::entry::Error>(entries)), + Vec::new(), + num_entries, + gix_pack::data::Version::V2, + object_hash, + ); + for written in &mut writer { + written?; + } + Ok(Some(writer.into_write())) +} + +fn collect_commit_tips( + object_ids: &[gix_hash::ObjectId], + object_database: &Find, + object_buf: &mut Vec, +) -> Result, FetchPackGenerationError> +where + Find: gix_object::Find, +{ + let mut tips = Vec::new(); + let mut seen_tips = BTreeSet::new(); + for object_id in object_ids { + if let Some(commit_id) = peel_to_commit_tip(object_id, object_database, object_buf)? { + if seen_tips.insert(commit_id) { + tips.push(commit_id); + } + } + } + Ok(tips) +} + +fn peel_to_commit_tip( + object_id: &gix_hash::ObjectId, + object_database: &Find, + object_buf: &mut Vec, +) -> Result, FetchPackGenerationError> +where + Find: gix_object::Find, +{ + let mut id = *object_id; + let mut seen_tags = BTreeSet::new(); + loop { + let object = object_database + .try_find(id.as_ref(), object_buf)? + .ok_or_else(|| FetchPackGenerationError::MissingObject { id })?; + match object.kind { + gix_object::Kind::Commit => return Ok(Some(id)), + gix_object::Kind::Tag => { + if !seen_tags.insert(id) { + return Err(FetchPackGenerationError::TagCycle { id }); + } + id = gix_object::TagRefIter::from_bytes(object.data, object.object_hash).target_id()?; + } + gix_object::Kind::Tree | gix_object::Kind::Blob => return Ok(None), + } + } +} + +/// Negotiate a `fetch` request using repository refs and object existence checks. +/// +/// This resolves: +/// - `have` lines into `ACK` responses for object IDs known by the repository +/// - `want` lines into known/missing object sets +/// - `want-ref` lines into `wanted-refs` response entries when refs can be resolved +/// +/// When `request.done` is true (client signals negotiation is complete), the acknowledgements +/// list ends with [`Acknowledgement::Ready`] if common haves exist, or is left empty for +/// fresh clones so that `write_fetch_response` omits the `acknowledgments` section entirely. +/// +/// Pack construction is intentionally out of scope of this helper. +pub fn negotiate_fetch_with_repository( + request: &Fetch, + refs: &gix_ref::file::Store, + mut object_exists: impl FnMut(&gix_hash::oid) -> bool, +) -> Result { + let mut common_haves = Vec::new(); + let mut seen_haves = BTreeSet::new(); + for have in &request.haves { + if object_exists(have) && seen_haves.insert(*have) { + common_haves.push(*have); + } + } + + let mut known_wants = Vec::new(); + let mut missing_wants = Vec::new(); + let mut seen_known_wants = BTreeSet::new(); + let mut seen_missing_wants = BTreeSet::new(); + for want in &request.wants { + if object_exists(want) { + if seen_known_wants.insert(*want) { + known_wants.push(*want); + } + } else if seen_missing_wants.insert(*want) { + missing_wants.push(*want); + } + } + + let packed = refs.cached_packed_buffer()?; + let packed = packed.as_ref().map(|buffer| &***buffer); + + let mut wanted_refs = Vec::new(); + let mut unresolved_want_refs = Vec::new(); + let mut seen_resolved_wants = BTreeSet::new(); + let mut seen_unresolved_wants = BTreeSet::new(); + for requested_ref in &request.want_refs { + if seen_resolved_wants.contains(requested_ref) || seen_unresolved_wants.contains(requested_ref) { + continue; + } + + let partial_name: &gix_ref::PartialNameRef = match requested_ref.as_bstr().try_into() { + Ok(name) => name, + Err(_) => { + if seen_unresolved_wants.insert(requested_ref.clone()) { + unresolved_want_refs.push(requested_ref.clone()); + } + continue; + } + }; + + match refs.find_packed(partial_name, packed) { + Ok(mut reference) => { + let id = reference.follow_to_object_packed(refs, packed).map_err(|source| { + FetchNegotiationError::ResolveWantedRef { + ref_name: requested_ref.clone(), + source, + } + })?; + if seen_resolved_wants.insert(requested_ref.clone()) { + wanted_refs.push(WantedRef { + id, + path: requested_ref.clone(), + }); + } + } + Err(gix_ref::file::find::existing::Error::NotFound { .. }) => { + if seen_unresolved_wants.insert(requested_ref.clone()) { + unresolved_want_refs.push(requested_ref.clone()); + } + } + Err(source) => { + return Err(FetchNegotiationError::FindWantedRef { + ref_name: requested_ref.clone(), + source, + }); + } + } + } + + let acknowledgements = if request.done { + if common_haves.is_empty() { + Vec::new() + } else { + let mut acks: Vec = common_haves + .iter() + .copied() + .map(Acknowledgement::Common) + .collect(); + acks.push(Acknowledgement::Ready); + acks + } + } else if common_haves.is_empty() { + vec![Acknowledgement::Nak] + } else { + common_haves.iter().copied().map(Acknowledgement::Common).collect() + }; + + Ok(FetchNegotiation { + acknowledgements, + wanted_refs, + known_wants, + missing_wants, + common_haves, + unresolved_want_refs, + }) +} + +/// Output payload for a `fetch` response. +pub struct FetchOutput { + /// Negotiation acknowledgements to return in the `acknowledgments` section. + pub acknowledgements: Vec, + /// Optional shallow boundary updates to return in the `shallow-info` section. + pub shallow_updates: Vec, + /// Optional `wanted-refs` section entries. + pub wanted_refs: Vec, + /// If present, pack data streamed as sideband channel 1 in the `packfile` section. + pub pack_data: Option>, +} + +impl FetchOutput { + /// Create a response output with `pack_data` and no additional sections. + pub fn new(pack_data: impl io::Read + Send + 'static) -> Self { + Self { + acknowledgements: Vec::new(), + shallow_updates: Vec::new(), + wanted_refs: Vec::new(), + pack_data: Some(Box::new(pack_data)), + } + } + + /// Create a response output without pack data. + pub fn without_pack() -> Self { + Self { + acknowledgements: Vec::new(), + shallow_updates: Vec::new(), + wanted_refs: Vec::new(), + pack_data: None, + } + } +} + +/// The outcome of serving a single upload-pack protocol V2 command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Outcome { + /// `ls-refs` output was produced. + LsRefs { + /// Number of refs sent to the client after applying filters. + refs_sent: usize, + }, + /// `fetch` output was produced. + Fetch { + /// Number of acknowledgement lines sent. + acknowledgements_sent: usize, + /// Number of shallow updates sent. + shallow_updates_sent: usize, + /// Number of wanted refs sent. + wanted_refs_sent: usize, + /// Number of raw pack bytes sent on sideband channel 1. + pack_bytes_sent: u64, + }, +} + +/// Delegate implementation used by [`serve_v2()`] to obtain repository data. +pub trait Delegate { + /// Return refs to advertise for the incoming `ls-refs` request. + fn ls_refs(&mut self, request: &LsRefs) -> Result, BoxError>; + /// Produce a fetch response for the incoming `fetch` request. + /// + /// [`negotiate_fetch_with_repository()`] can be used to obtain repository-backed + /// acknowledgement and `wanted-refs` data before pack generation is applied. + fn fetch(&mut self, request: &Fetch) -> Result; +} + +/// Errors returned by upload-pack request parsing and response writing. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Decode(#[from] gix_transport::packetline::decode::Error), + #[error("Expected text packetline, got {line_type}")] + NonTextPacketLine { line_type: &'static str }, + #[error("Expected `command=` in request header")] + MissingCommand, + #[error("Unsupported upload-pack V2 command {command:?}")] + UnsupportedCommand { command: BString }, + #[error("Malformed request header line {line:?}")] + MalformedHeaderLine { line: BString }, + #[error("Malformed {command} argument line {line:?}")] + MalformedArgument { command: &'static str, line: BString }, + #[error("Could not parse object id in line {line:?}")] + InvalidObjectId { + line: BString, + #[source] + source: gix_hash::decode::Error, + }, + #[error("Delegate failed")] + Delegate(#[source] BoxError), + #[error("Client requested object-format \"{requested}\" but server supports \"{supported}\"")] + UnsupportedObjectFormat { requested: BString, supported: BString }, + #[error("Invalid object-format value \"{value}\" (expected \"sha1\" or \"sha256\")")] + InvalidObjectFormat { value: BString }, + #[error("Object ID hex length {actual} does not match expected {expected} for {hash_kind}")] + ObjectIdLengthMismatch { + actual: usize, + expected: usize, + hash_kind: gix_hash::Kind, + }, +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +/// Parse a single protocol V2 upload-pack request from `input`. +/// +/// The `config` parameter controls capability validation — the client's `object-format` +/// feature (if present) is checked against `config.object_hash`, and OID hex lengths in +/// fetch arguments are enforced to match the configured hash kind. +pub fn parse_v2_request(input: impl io::Read, config: &ServerConfig) -> Result { + let mut reader = StreamingPeekableIter::new(input, HEADER_DELIMITERS, false); + let header_lines = read_text_lines(&mut reader)?; + let has_argument_section = reader.stopped_at() == Some(PacketLineRef::Delimiter); + let argument_lines = if has_argument_section { + reader.reset_with(ARGUMENT_DELIMITERS); + read_text_lines(&mut reader)? + } else { + Vec::new() + }; + + let (command, features) = parse_header_lines(header_lines)?; + validate_object_format(&features, config)?; + let command: &[u8] = command.as_ref(); + let command = match command { + b"ls-refs" => Command::LsRefs(parse_ls_refs_arguments(argument_lines)), + b"fetch" => Command::Fetch(parse_fetch_arguments(argument_lines, config.object_hash)?), + other => { + return Err(Error::UnsupportedCommand { command: other.into() }); + } + }; + Ok(Request { features, command }) +} + +/// Serve one protocol V2 upload-pack request end-to-end. +/// +/// The caller owns transport setup/teardown and invokes this function with one complete request payload. +/// The `config` parameter controls capability validation — the client's `object-format` feature +/// (if present) is checked against `config.object_hash`, and OID hex lengths in fetch arguments +/// are enforced to match the configured hash kind. +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +pub fn serve_v2( + input: impl io::Read, + mut output: impl io::Write, + delegate: &mut impl Delegate, + config: &ServerConfig, +) -> Result { + match parse_v2_request(input, config)? { + Request { + command: Command::LsRefs(request), + .. + } => { + let refs = delegate.ls_refs(&request).map_err(Error::Delegate)?; + let refs_sent = write_ls_refs_response(&mut output, &request, &refs)?; + Ok(Outcome::LsRefs { refs_sent }) + } + Request { + command: Command::Fetch(request), + .. + } => { + let mut response = delegate.fetch(&request).map_err(Error::Delegate)?; + let pack_bytes_sent = write_fetch_response(&mut output, &mut response)?; + Ok(Outcome::Fetch { + acknowledgements_sent: response.acknowledgements.len(), + shallow_updates_sent: response.shallow_updates.len(), + wanted_refs_sent: response.wanted_refs.len(), + pack_bytes_sent, + }) + } + } +} + +/// Write a protocol V2 capability advertisement, including the `version 2` line. +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +pub fn write_v2_capability_advertisement(mut output: impl io::Write, capabilities: &[Capability]) -> Result<(), Error> { + let mut writer = Writer::new(&mut output); + writer.enable_text_mode(); + writer.write_all(b"version 2")?; + for capability in capabilities { + let mut line = capability.name.clone(); + if !capability.values.is_empty() { + line.push_byte(b'='); + for (idx, value) in capability.values.iter().enumerate() { + if idx != 0 { + line.push_byte(b' '); + } + line.push_str(value); + } + } + writer.write_all(line.as_ref())?; + } + encode::flush_to_write(writer.inner_mut())?; + Ok(()) +} + +/// Write a `ls-refs` response body according to `request`. +/// +/// Returns the number of refs written. +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +pub fn write_ls_refs_response(mut output: impl io::Write, request: &LsRefs, refs: &[Ref]) -> Result { + let mut writer = Writer::new(&mut output); + writer.enable_text_mode(); + let mut refs_sent = 0usize; + + for line in refs + .iter() + .filter(|reference| matches_ref_prefixes(reference, &request.ref_prefixes)) + .filter_map(|reference| format_ls_ref_line(reference, request)) + { + writer.write_all(line.as_ref())?; + refs_sent += 1; + } + encode::flush_to_write(writer.inner_mut())?; + Ok(refs_sent) +} + +/// Write the non-pack metadata sections (acknowledgments, shallow-info, wanted-refs) of a fetch response. +/// +/// This is shared between the blocking [`write_fetch_response`] and the async variant +/// in [`async_io`] to avoid duplicating the section-framing logic. +/// +/// When `has_pack_data` is true, each section is terminated with a delimiter packet (`0001`) +/// to signal that another section follows. When false, the last section omits the trailing +/// delimiter — the caller's final flush packet (`0000`) terminates the response instead. +/// This matches the V2 protocol framing expected by clients. +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +pub(crate) fn write_fetch_metadata_sections( + mut output: impl io::Write, + acknowledgements: &[Acknowledgement], + shallow_updates: &[ShallowUpdate], + wanted_refs: &[WantedRef], + has_pack_data: bool, +) -> Result<(), Error> { + let mut writer = Writer::new(&mut output); + writer.enable_text_mode(); + + let sections: [(&[u8], bool); 3] = [ + (b"acknowledgments" as &[u8], !acknowledgements.is_empty()), + (b"shallow-info", !shallow_updates.is_empty()), + (b"wanted-refs", !wanted_refs.is_empty()), + ]; + let last_active_idx = sections.iter().rposition(|(_, active)| *active); + + if !acknowledgements.is_empty() { + writer.write_all(b"acknowledgments")?; + for ack in acknowledgements { + writer.write_all(format_acknowledgement_line(*ack).as_ref())?; + } + let is_last = last_active_idx == Some(0); + if has_pack_data || !is_last { + encode::delim_to_write(writer.inner_mut())?; + } + } + + if !shallow_updates.is_empty() { + writer.write_all(b"shallow-info")?; + for update in shallow_updates { + writer.write_all(format_shallow_update_line(update).as_ref())?; + } + let is_last = last_active_idx == Some(1); + if has_pack_data || !is_last { + encode::delim_to_write(writer.inner_mut())?; + } + } + + if !wanted_refs.is_empty() { + writer.write_all(b"wanted-refs")?; + for wanted in wanted_refs { + writer.write_all(format_wanted_ref_line(wanted).as_ref())?; + } + let is_last = last_active_idx == Some(2); + if has_pack_data || !is_last { + encode::delim_to_write(writer.inner_mut())?; + } + } + + Ok(()) +} + +/// Write a V2 `fetch` response, including optional sections and optional pack stream. +/// +/// Returns the number of raw pack bytes sent on sideband channel `1`. +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +pub fn write_fetch_response(mut output: impl io::Write, response: &mut FetchOutput) -> Result { + write_fetch_metadata_sections( + &mut output, + &response.acknowledgements, + &response.shallow_updates, + &response.wanted_refs, + response.pack_data.is_some(), + )?; + + let mut writer = Writer::new(&mut output); + writer.enable_text_mode(); + + let mut pack_bytes_sent = 0u64; + if let Some(pack_data) = response.pack_data.as_mut() { + writer.write_all(b"packfile")?; + let mut buffer = [0u8; MAX_SIDEBAND_DATA_BYTES]; + loop { + let bytes_read = pack_data.read(&mut buffer)?; + if bytes_read == 0 { + break; + } + pack_bytes_sent += bytes_read as u64; + encode::band_to_write(Channel::Data, &buffer[..bytes_read], writer.inner_mut())?; + } + } + encode::flush_to_write(writer.inner_mut())?; + Ok(pack_bytes_sent) +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn parse_header_lines(lines: Vec) -> Result<(BString, Vec), Error> { + let mut command = None::; + let mut features = Vec::new(); + + for line in lines { + let bytes: &[u8] = line.as_ref(); + if let Some(command_name) = bytes.strip_prefix(b"command=") { + if command.is_some() || command_name.is_empty() { + return Err(Error::MalformedHeaderLine { line }); + } + command = Some(command_name.into()); + continue; + } + features.push(parse_feature_line(line.as_bstr())?); + } + + let command = command.ok_or(Error::MissingCommand)?; + Ok((command, features)) +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn parse_feature_line(line: &BStr) -> Result { + if let Some((name, value)) = split_once(line, b'=') { + if name.is_empty() { + return Err(Error::MalformedHeaderLine { line: line.to_owned() }); + } + return Ok(Feature { + name: name.to_owned(), + value: Some(value.to_owned()), + }); + } + if line.is_empty() { + return Err(Error::MalformedHeaderLine { line: line.to_owned() }); + } + Ok(Feature { + name: line.to_owned(), + value: None, + }) +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn parse_ls_refs_arguments(arguments: Vec) -> LsRefs { + let mut parsed = LsRefs::default(); + for line in arguments { + let bytes: &[u8] = line.as_ref(); + match bytes { + b"symrefs" => parsed.symrefs = true, + b"peel" => parsed.peel = true, + b"unborn" => parsed.unborn = true, + _ => { + if let Some(prefix) = bytes.strip_prefix(b"ref-prefix ") { + if !prefix.is_empty() { + parsed.ref_prefixes.push(prefix.into()); + } else { + parsed.extra_arguments.push(line); + } + } else { + parsed.extra_arguments.push(line); + } + } + } + } + parsed +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn parse_fetch_arguments(arguments: Vec, object_hash: gix_hash::Kind) -> Result { + let mut parsed = Fetch::default(); + for line in arguments { + let bytes: &[u8] = line.as_ref(); + match bytes { + b"thin-pack" => parsed.thin_pack = true, + b"no-progress" => parsed.no_progress = true, + b"ofs-delta" => parsed.ofs_delta = true, + b"include-tag" => parsed.include_tag = true, + b"sideband-all" => parsed.sideband_all = true, + b"deepen-relative" => parsed.deepen_relative = true, + b"wait-for-done" => parsed.wait_for_done = true, + b"done" => parsed.done = true, + _ => { + if bytes.starts_with(b"want ") { + parsed + .wants + .push(parse_object_id(line.as_bstr(), b"want ", "fetch", object_hash)?); + } else if bytes.starts_with(b"have ") { + parsed + .haves + .push(parse_object_id(line.as_bstr(), b"have ", "fetch", object_hash)?); + } else if bytes.starts_with(b"shallow ") { + parsed + .shallow + .push(parse_object_id(line.as_bstr(), b"shallow ", "fetch", object_hash)?); + } else if let Some(value) = bytes.strip_prefix(b"deepen ") { + parsed.deepen = Some(parse_u32_argument(line.as_bstr(), value, "fetch", false)?); + } else if let Some(value) = bytes.strip_prefix(b"deepen-since ") { + parsed.deepen_since = Some(parse_i64_argument(line.as_bstr(), value, "fetch")?); + } else if let Some(value) = bytes.strip_prefix(b"deepen-not ") { + if value.is_empty() { + return Err(Error::MalformedArgument { command: "fetch", line }); + } + parsed.deepen_not.push(value.into()); + } else if let Some(value) = bytes.strip_prefix(b"filter ") { + if value.is_empty() { + return Err(Error::MalformedArgument { command: "fetch", line }); + } + parsed.filters.push(value.into()); + } else if let Some(value) = bytes.strip_prefix(b"want-ref ") { + if value.is_empty() { + return Err(Error::MalformedArgument { command: "fetch", line }); + } + parsed.want_refs.push(value.into()); + } else if let Some(value) = bytes.strip_prefix(b"packfile-uris ") { + parsed + .packfile_uris + .extend(parse_comma_separated_values(line.as_bstr(), value, "fetch")?); + } else { + parsed.extra_arguments.push(line); + } + } + } + } + Ok(parsed) +} + +/// Known `object-format` values — recognized regardless of compile-time hash features. +/// This ensures a sha256 request against a sha1-only build reports "unsupported" not "invalid". +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +const KNOWN_OBJECT_FORMATS: &[&str] = &["sha1", "sha256"]; + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn validate_object_format(features: &[Feature], config: &ServerConfig) -> Result<(), Error> { + for feature in features { + if feature.name == "object-format" { + let value: &BStr = feature + .value + .as_ref() + .map_or(b"".as_bstr(), |v| v.as_bstr()); + let value_str = match value.to_str() { + Ok(s) => s, + Err(_) => return Err(Error::InvalidObjectFormat { value: value.to_owned() }), + }; + // Check if the value is a recognized hash name (independent of compile-time features) + if !KNOWN_OBJECT_FORMATS.contains(&value_str) { + return Err(Error::InvalidObjectFormat { value: value.to_owned() }); + } + // Check if it matches the server's configured hash + if value_str == config.object_hash.to_string().as_str() { + return Ok(()); + } + return Err(Error::UnsupportedObjectFormat { + requested: value.to_owned(), + supported: config.object_hash.to_string().into(), + }); + } + } + // No object-format feature: assume server's hash — OK + Ok(()) +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn parse_object_id( + line: &BStr, + prefix: &[u8], + command: &'static str, + object_hash: gix_hash::Kind, +) -> Result { + let hex = line + .as_bytes() + .strip_prefix(prefix) + .ok_or_else(|| Error::MalformedArgument { + command, + line: line.to_owned(), + })?; + + let expected_len = object_hash.len_in_hex(); + if hex.len() != expected_len { + return Err(Error::ObjectIdLengthMismatch { + actual: hex.len(), + expected: expected_len, + hash_kind: object_hash, + }); + } + + gix_hash::ObjectId::from_hex(hex).map_err(|source| Error::InvalidObjectId { + line: line.to_owned(), + source, + }) +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn parse_u32_argument(line: &BStr, value: &[u8], command: &'static str, allow_zero: bool) -> Result { + std::str::from_utf8(value) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|parsed| allow_zero || *parsed != 0) + .ok_or_else(|| Error::MalformedArgument { + command, + line: line.to_owned(), + }) +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn parse_i64_argument(line: &BStr, value: &[u8], command: &'static str) -> Result { + std::str::from_utf8(value) + .ok() + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| Error::MalformedArgument { + command, + line: line.to_owned(), + }) +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn parse_comma_separated_values(line: &BStr, value: &[u8], command: &'static str) -> Result, Error> { + if value.is_empty() { + return Err(Error::MalformedArgument { + command, + line: line.to_owned(), + }); + } + + let values = value + .split(|byte| *byte == b',') + .map(|value| value.as_bstr().to_owned()) + .collect::>(); + if values.iter().any(|value| value.is_empty()) { + return Err(Error::MalformedArgument { + command, + line: line.to_owned(), + }); + } + Ok(values) +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn read_text_lines(reader: &mut StreamingPeekableIter) -> Result, Error> { + let mut out = Vec::new(); + while let Some(line) = reader.read_line() { + let line = line?; + let line = line?; + let text = line.as_text().ok_or_else(|| Error::NonTextPacketLine { + line_type: packet_line_kind(&line), + })?; + out.push(text.as_bstr().to_owned()); + } + Ok(out) +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn packet_line_kind(line: &PacketLineRef<'_>) -> &'static str { + match line { + PacketLineRef::Data(_) => "data", + PacketLineRef::Flush => "flush", + PacketLineRef::Delimiter => "delimiter", + PacketLineRef::ResponseEnd => "response-end", + } +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn split_once(line: &BStr, separator: u8) -> Option<(&BStr, &BStr)> { + let idx = line.find_byte(separator)?; + Some((line[..idx].as_bstr(), line[idx + 1..].as_bstr())) +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn matches_ref_prefixes(reference: &Ref, prefixes: &[BString]) -> bool { + if prefixes.is_empty() { + return true; + } + let full_ref_name = match reference { + Ref::Peeled { full_ref_name, .. } + | Ref::Direct { full_ref_name, .. } + | Ref::Symbolic { full_ref_name, .. } + | Ref::Unborn { full_ref_name, .. } => full_ref_name, + }; + prefixes.iter().any(|prefix| { + let full_ref_name: &[u8] = full_ref_name.as_ref(); + let prefix: &[u8] = prefix.as_ref(); + full_ref_name.starts_with(prefix) + }) +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn format_ls_ref_line(reference: &Ref, request: &LsRefs) -> Option { + let mut line = BString::default(); + match reference { + Ref::Direct { full_ref_name, object } => { + line.push_str(object.to_string()); + line.push_byte(b' '); + line.push_str(full_ref_name); + } + Ref::Peeled { + full_ref_name, + tag, + object, + } => { + line.push_str(tag.to_string()); + line.push_byte(b' '); + line.push_str(full_ref_name); + if request.peel { + line.push_str(" peeled:"); + line.push_str(object.to_string()); + } + } + Ref::Symbolic { + full_ref_name, + target, + tag, + object, + } => { + let advertised_id = tag.as_ref().unwrap_or(object); + line.push_str(advertised_id.to_string()); + line.push_byte(b' '); + line.push_str(full_ref_name); + + if request.symrefs { + line.push_str(" symref-target:"); + line.push_str(target); + } + if request.peel { + if let Some(tag) = tag { + line.push_str(" peeled:"); + line.push_str(object.to_string()); + if tag == object { + return Some(line); + } + } + } + } + Ref::Unborn { full_ref_name, target } => { + if !request.unborn { + return None; + } + line.push_str("unborn "); + line.push_str(full_ref_name); + line.push_str(" symref-target:"); + line.push_str(target); + } + } + Some(line) +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn format_acknowledgement_line(ack: Acknowledgement) -> BString { + match ack { + Acknowledgement::Common(id) => format!("ACK {id} common").into(), + Acknowledgement::Ready => "ready".into(), + Acknowledgement::Nak => "NAK".into(), + } +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn format_shallow_update_line(update: &ShallowUpdate) -> BString { + match update { + ShallowUpdate::Shallow(id) => format!("shallow {id}").into(), + ShallowUpdate::Unshallow(id) => format!("unshallow {id}").into(), + } +} + +#[cfg(any(feature = "blocking-server", feature = "async-server"))] +fn format_wanted_ref_line(wanted: &WantedRef) -> BString { + let mut line = BString::default(); + line.push_str(wanted.id.to_string()); + line.push_byte(b' '); + let path: &[u8] = wanted.path.as_ref(); + line.push_str(path); + line +} + +#[cfg(test)] +mod tests { + use gix_object::Write as _; + use std::{ + collections::BTreeSet, + fs, + io::Write as _, + io::{BufReader, Cursor, Read as _}, + path::PathBuf, + sync::atomic::{AtomicBool, AtomicU64, Ordering}, + }; + + use gix_transport::packetline::{BandRef, PacketLineRef, blocking_io::StreamingPeekableIter}; + + use super::*; + + #[derive(Default)] + struct MockDelegate { + refs: Vec, + fetch_output: Option, + seen_ls_refs: Option, + seen_fetch: Option, + } + + impl Delegate for MockDelegate { + fn ls_refs(&mut self, request: &LsRefs) -> Result, BoxError> { + self.seen_ls_refs = Some(request.clone()); + Ok(self.refs.clone()) + } + + fn fetch(&mut self, request: &Fetch) -> Result { + self.seen_fetch = Some(request.clone()); + self.fetch_output + .take() + .ok_or_else(|| std::io::Error::other("fetch output should be configured").into()) + } + } + + #[test] + fn parse_ls_refs_request() -> Result<(), Box> { + let input = request_bytes( + "ls-refs", + &["agent=git/gitplane", "object-format=sha1"], + &["symrefs", "peel", "ref-prefix refs/heads/"], + )?; + + let request = parse_v2_request(input.as_slice(), &ServerConfig::default())?; + assert_eq!( + request.features, + vec![ + Feature { + name: "agent".into(), + value: Some("git/gitplane".into()), + }, + Feature { + name: "object-format".into(), + value: Some("sha1".into()), + }, + ] + ); + match request.command { + Command::LsRefs(arguments) => { + assert!(arguments.symrefs); + assert!(arguments.peel); + assert_eq!(arguments.ref_prefixes, vec![BString::from("refs/heads/")]); + } + Command::Fetch(_) => panic!("expected ls-refs command"), + } + Ok(()) + } + + #[test] + fn parse_fetch_request() -> Result<(), Box> { + let id_one = "808e50d724f604f69ab93c6da2919c014667bedb"; + let id_two = "9e320b9180e0b5580af68fa3255b7f3d9ecd5af0"; + let input = request_bytes( + "fetch", + &["agent=git/gitplane"], + &[ + "thin-pack", + "ofs-delta", + &format!("want {id_one}"), + &format!("have {id_two}"), + "want-ref refs/heads/main", + "done", + ], + )?; + + let request = parse_v2_request(input.as_slice(), &ServerConfig::default())?; + match request.command { + Command::Fetch(arguments) => { + assert!(arguments.thin_pack); + assert!(arguments.ofs_delta); + assert!(arguments.done); + assert_eq!(arguments.wants, vec![gix_hash::ObjectId::from_hex(id_one.as_bytes())?]); + assert_eq!(arguments.haves, vec![gix_hash::ObjectId::from_hex(id_two.as_bytes())?]); + assert_eq!(arguments.want_refs, vec![BString::from("refs/heads/main")]); + } + Command::LsRefs(_) => panic!("expected fetch command"), + } + Ok(()) + } + + #[test] + fn parse_fetch_request_with_negotiation_arguments() -> Result<(), Box> { + let id = "808e50d724f604f69ab93c6da2919c014667bedb"; + let input = request_bytes( + "fetch", + &[], + &[ + "no-progress", + "deepen 16", + "deepen-since 12345", + "deepen-not refs/tags/v1.0.0", + "deepen-relative", + "filter blob:none", + "packfile-uris https,ssh", + "wait-for-done", + &format!("want {id}"), + "done", + ], + )?; + + let request = parse_v2_request(input.as_slice(), &ServerConfig::default())?; + match request.command { + Command::Fetch(arguments) => { + assert!(arguments.no_progress); + assert_eq!(arguments.deepen, Some(16)); + assert_eq!(arguments.deepen_since, Some(12_345)); + assert_eq!(arguments.deepen_not, vec![BString::from("refs/tags/v1.0.0")]); + assert!(arguments.deepen_relative); + assert_eq!(arguments.filters, vec![BString::from("blob:none")]); + assert_eq!( + arguments.packfile_uris, + vec![BString::from("https"), BString::from("ssh")] + ); + assert!(arguments.wait_for_done); + assert!(arguments.done); + } + Command::LsRefs(_) => panic!("expected fetch command"), + } + Ok(()) + } + + #[test] + fn parse_fetch_request_with_invalid_deepen_value() -> Result<(), Box> { + let input = request_bytes("fetch", &[], &["deepen nope"])?; + let err = parse_v2_request(input.as_slice(), &ServerConfig::default()) + .expect_err("invalid deepen value should fail parsing"); + assert!( + matches!(err, Error::MalformedArgument { command: "fetch", line } if line.as_bstr() == "deepen nope".as_bytes().as_bstr()) + ); + Ok(()) + } + + /// Informational features like `agent` pass through without validation, + /// and even `object-format` is preserved in the parsed features list. + #[test] + fn feature_pass_through() -> Result<(), Box> { + let input = request_bytes( + "ls-refs", + &["agent=git/test", "object-format=sha1"], + &[], + )?; + + let request = parse_v2_request(input.as_slice(), &ServerConfig::default())?; + assert_eq!( + request.features, + vec![ + Feature { + name: "agent".into(), + value: Some("git/test".into()), + }, + Feature { + name: "object-format".into(), + value: Some("sha1".into()), + }, + ], + "both agent and object-format features should be present in parsed result" + ); + Ok(()) + } + + #[test] + fn negotiate_fetch_with_repository_tracks_wants_and_common_haves() -> Result<(), Box> { + let (_tmp, refs) = temporary_ref_store(&Vec::<(&str, String)>::new())?; + let known_want = object_id("808e50d724f604f69ab93c6da2919c014667bedb"); + let missing_want = object_id("9e320b9180e0b5580af68fa3255b7f3d9ecd5af0"); + let common_have = object_id("f99771fe6a1b535783af3163eba95a927aae21d5"); + let unknown_have = object_id("2d9d136fb0765f2e24c44a0f91984318d580d03b"); + + let request = Fetch { + wants: vec![known_want.clone(), missing_want.clone(), known_want.clone()], + haves: vec![common_have.clone(), unknown_have, common_have.clone()], + ..Default::default() + }; + let known_objects = [known_want.clone(), common_have.clone()] + .into_iter() + .collect::>(); + + let negotiation = negotiate_fetch_with_repository(&request, &refs, |id| known_objects.contains(id))?; + + assert_eq!(negotiation.known_wants, vec![known_want]); + assert_eq!(negotiation.missing_wants, vec![missing_want]); + assert_eq!(negotiation.common_haves, vec![common_have]); + assert_eq!(negotiation.acknowledgements, vec![Acknowledgement::Common(common_have)]); + assert!(negotiation.wanted_refs.is_empty()); + assert!(negotiation.unresolved_want_refs.is_empty()); + + let output = negotiation.into_output(); + assert_eq!(output.acknowledgements, vec![Acknowledgement::Common(common_have)]); + assert!(output.wanted_refs.is_empty()); + assert!(output.pack_data.is_none()); + Ok(()) + } + + #[test] + fn negotiate_fetch_with_repository_sends_nak_without_common_haves() -> Result<(), Box> { + let (_tmp, refs) = temporary_ref_store(&Vec::<(&str, String)>::new())?; + let request = Fetch { + haves: vec![object_id("f99771fe6a1b535783af3163eba95a927aae21d5")], + ..Default::default() + }; + + let negotiation = negotiate_fetch_with_repository(&request, &refs, |_| false)?; + assert_eq!(negotiation.acknowledgements, vec![Acknowledgement::Nak]); + Ok(()) + } + + #[test] + fn negotiate_fetch_with_repository_resolves_want_refs() -> Result<(), Box> { + let main = object_id("808e50d724f604f69ab93c6da2919c014667bedb"); + let (_tmp, refs) = temporary_ref_store(&[ + ("HEAD", "ref: refs/heads/main\n".to_string()), + ("refs/heads/main", format!("{main}\n")), + ])?; + + let request = Fetch { + want_refs: vec![ + "HEAD".into(), + "refs/heads/main".into(), + "HEAD".into(), + "refs/heads/missing".into(), + "not a ref".into(), + ], + ..Default::default() + }; + + let negotiation = negotiate_fetch_with_repository(&request, &refs, |_| false)?; + assert_eq!( + negotiation.wanted_refs, + vec![ + WantedRef { + id: main.clone(), + path: "HEAD".into(), + }, + WantedRef { + id: main, + path: "refs/heads/main".into(), + }, + ] + ); + assert_eq!( + negotiation.unresolved_want_refs, + vec![BString::from("refs/heads/missing"), BString::from("not a ref")] + ); + Ok(()) + } + + #[test] + fn into_output_with_repository_pack_omits_pack_without_wants() -> Result<(), Box> { + let fixture = temporary_object_store_with_linear_history()?; + let (_tmp, refs) = temporary_ref_store(&Vec::<(&str, String)>::new())?; + let request = Fetch::default(); + + let negotiation = + negotiate_fetch_with_repository(&request, &refs, |id| gix_pack::Find::contains(&fixture.odb, id))?; + let output = + negotiation.into_output_with_repository_pack(&request, fixture.odb.clone(), gix_hash::Kind::Sha1)?; + + assert_eq!(output.acknowledgements, vec![Acknowledgement::Nak]); + assert!(output.pack_data.is_none(), "no wants should not produce a pack"); + Ok(()) + } + + #[test] + fn into_output_with_repository_pack_excludes_common_have_history() -> Result<(), Box> { + let fixture = temporary_object_store_with_linear_history()?; + let (_tmp, refs) = temporary_ref_store(&Vec::<(&str, String)>::new())?; + let request = Fetch { + wants: vec![fixture.commit_three.clone()], + haves: vec![fixture.commit_one.clone()], + ..Default::default() + }; + + let negotiation = + negotiate_fetch_with_repository(&request, &refs, |id| gix_pack::Find::contains(&fixture.odb, id))?; + let mut output = + negotiation.into_output_with_repository_pack(&request, fixture.odb.clone(), gix_hash::Kind::Sha1)?; + let mut pack_bytes = Vec::new(); + output + .pack_data + .as_mut() + .expect("known wants should produce pack data") + .read_to_end(&mut pack_bytes)?; + + let packed_ids = pack_object_ids(pack_bytes, gix_hash::Kind::Sha1)?; + assert!(packed_ids.contains(&fixture.commit_three)); + assert!(packed_ids.contains(&fixture.commit_two)); + assert!( + !packed_ids.contains(&fixture.commit_one), + "commits acknowledged as common should not be resent" + ); + Ok(()) + } + + #[test] + fn into_output_with_repository_pack_peels_tag_wants_to_commits() -> Result<(), Box> { + let fixture = temporary_object_store_with_linear_history()?; + let (_tmp, refs) = temporary_ref_store(&Vec::<(&str, String)>::new())?; + let request = Fetch { + wants: vec![fixture.tag_three.clone()], + haves: vec![fixture.commit_one.clone()], + ..Default::default() + }; + + let negotiation = + negotiate_fetch_with_repository(&request, &refs, |id| gix_pack::Find::contains(&fixture.odb, id))?; + let mut output = + negotiation.into_output_with_repository_pack(&request, fixture.odb.clone(), gix_hash::Kind::Sha1)?; + let mut pack_bytes = Vec::new(); + output + .pack_data + .as_mut() + .expect("tag wants should produce pack data") + .read_to_end(&mut pack_bytes)?; + + let packed_ids = pack_object_ids(pack_bytes, gix_hash::Kind::Sha1)?; + assert!(packed_ids.contains(&fixture.tag_three)); + assert!(packed_ids.contains(&fixture.commit_three)); + assert!(packed_ids.contains(&fixture.commit_two)); + assert!( + !packed_ids.contains(&fixture.commit_one), + "common history should stay excluded even for tag wants" + ); + Ok(()) + } + + #[test] + fn serve_ls_refs_with_prefix_filter() -> Result<(), Box> { + let request = request_bytes( + "ls-refs", + &["agent=git/gitplane"], + &["symrefs", "peel", "ref-prefix refs/heads/"], + )?; + let mut output = Vec::new(); + let mut delegate = MockDelegate { + refs: vec![ + Ref::Symbolic { + full_ref_name: "HEAD".into(), + target: "refs/heads/main".into(), + tag: None, + object: gix_hash::ObjectId::from_hex(b"808e50d724f604f69ab93c6da2919c014667bedb")?, + }, + Ref::Direct { + full_ref_name: "refs/heads/main".into(), + object: gix_hash::ObjectId::from_hex(b"808e50d724f604f69ab93c6da2919c014667bedb")?, + }, + Ref::Direct { + full_ref_name: "refs/tags/v1.0.0".into(), + object: gix_hash::ObjectId::from_hex(b"9e320b9180e0b5580af68fa3255b7f3d9ecd5af0")?, + }, + ], + ..Default::default() + }; + + let outcome = serve_v2(request.as_slice(), &mut output, &mut delegate, &ServerConfig::default())?; + assert_eq!(outcome, Outcome::LsRefs { refs_sent: 1 }); + assert!( + delegate + .seen_ls_refs + .as_ref() + .expect("request should be captured") + .symrefs + ); + + let mut reader = StreamingPeekableIter::new(output.as_slice(), &[PacketLineRef::Flush], false); + let advertised = next_text_line(&mut reader)?; + assert_eq!( + advertised.as_bstr(), + "808e50d724f604f69ab93c6da2919c014667bedb refs/heads/main" + .as_bytes() + .as_bstr() + ); + assert!(reader.read_line().is_none(), "flush should terminate response"); + assert_eq!(reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) + } + + #[test] + fn serve_fetch_with_pack_sideband() -> Result<(), Box> { + let common_id = gix_hash::ObjectId::from_hex(b"808e50d724f604f69ab93c6da2919c014667bedb")?; + let wanted_id = gix_hash::ObjectId::from_hex(b"9e320b9180e0b5580af68fa3255b7f3d9ecd5af0")?; + let request = request_bytes( + "fetch", + &["agent=git/gitplane"], + &[&format!("want {common_id}"), "done"], + )?; + let mut output = Vec::new(); + let mut fetch_output = FetchOutput::new(Cursor::new(b"PACK\0\0\0\0".to_vec())); + fetch_output.acknowledgements.push(Acknowledgement::Common(common_id)); + fetch_output.wanted_refs.push(WantedRef { + id: wanted_id, + path: "refs/heads/main".into(), + }); + let mut delegate = MockDelegate { + fetch_output: Some(fetch_output), + ..Default::default() + }; + + let outcome = serve_v2(request.as_slice(), &mut output, &mut delegate, &ServerConfig::default())?; + assert_eq!( + outcome, + Outcome::Fetch { + acknowledgements_sent: 1, + shallow_updates_sent: 0, + wanted_refs_sent: 1, + pack_bytes_sent: 8, + } + ); + assert!(delegate.seen_fetch.as_ref().expect("request should be captured").done); + + let mut reader = StreamingPeekableIter::new(output.as_slice(), &[PacketLineRef::Flush], false); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "acknowledgments".as_bytes().as_bstr() + ); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + format!("ACK {common_id} common").as_bytes().as_bstr() + ); + expect_delimiter(&mut reader)?; + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "wanted-refs".as_bytes().as_bstr() + ); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + format!("{wanted_id} refs/heads/main").as_bytes().as_bstr() + ); + expect_delimiter(&mut reader)?; + assert_eq!(next_text_line(&mut reader)?.as_bstr(), "packfile".as_bytes().as_bstr()); + assert_eq!(next_band_data(&mut reader)?, b"PACK\0\0\0\0"); + assert!(reader.read_line().is_none(), "flush should terminate response"); + assert_eq!(reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) + } + + fn next_text_line(reader: &mut StreamingPeekableIter<&[u8]>) -> Result> { + let line = reader + .read_line() + .expect("expected packetline") + .expect("read should succeed") + .expect("decode should succeed"); + Ok(line.as_text().expect("expected text packetline").as_bstr().to_owned()) + } + + fn expect_delimiter(reader: &mut StreamingPeekableIter<&[u8]>) -> Result<(), Box> { + let line = reader + .read_line() + .expect("expected packetline") + .expect("read should succeed") + .expect("decode should succeed"); + match line { + PacketLineRef::Delimiter => Ok(()), + other => Err(format!("expected delimiter, got {other:?}").into()), + } + } + + fn next_band_data(reader: &mut StreamingPeekableIter<&[u8]>) -> Result, Box> { + let line = reader + .read_line() + .expect("expected packetline") + .expect("read should succeed") + .expect("decode should succeed"); + match line.decode_band()? { + BandRef::Data(data) => Ok(data.to_vec()), + other => Err(format!("expected data band, got {other:?}").into()), + } + } + + fn request_bytes( + command: &str, + features: &[&str], + arguments: &[&str], + ) -> Result, Box> { + let mut out = Vec::new(); + let mut writer = Writer::new(&mut out); + writer.enable_text_mode(); + writer.write_all(format!("command={command}").as_bytes())?; + for feature in features { + writer.write_all(feature.as_bytes())?; + } + if arguments.is_empty() { + encode::flush_to_write(writer.inner_mut())?; + return Ok(out); + } + + encode::delim_to_write(writer.inner_mut())?; + for argument in arguments { + writer.write_all(argument.as_bytes())?; + } + encode::flush_to_write(writer.inner_mut())?; + Ok(out) + } + + struct ObjectStoreFixture { + _temp: TempDir, + odb: gix_odb::Handle, + commit_one: gix_hash::ObjectId, + commit_two: gix_hash::ObjectId, + commit_three: gix_hash::ObjectId, + tag_three: gix_hash::ObjectId, + } + + fn temporary_object_store_with_linear_history() -> Result> { + let temp = TempDir::new()?; + let objects_path = temp.path.join("objects"); + fs::create_dir_all(&objects_path)?; + let odb = gix_odb::at(objects_path)?; + + let blob_one = odb + .write_buf(gix_object::Kind::Blob, b"one\n") + .map_err(std::io::Error::other)?; + let tree_one = write_single_file_tree(&odb, "file.txt", &blob_one)?; + let commit_one = write_commit_object(&odb, &tree_one, None, "commit one")?; + let blob_two = odb + .write_buf(gix_object::Kind::Blob, b"two\n") + .map_err(std::io::Error::other)?; + let tree_two = write_single_file_tree(&odb, "file.txt", &blob_two)?; + let commit_two = write_commit_object(&odb, &tree_two, Some(&commit_one), "commit two")?; + let blob_three = odb + .write_buf(gix_object::Kind::Blob, b"three\n") + .map_err(std::io::Error::other)?; + let tree_three = write_single_file_tree(&odb, "file.txt", &blob_three)?; + let commit_three = write_commit_object(&odb, &tree_three, Some(&commit_two), "commit three")?; + let tag_three = write_tag_object(&odb, &commit_three, "v1.0.0", "release tag")?; + + Ok(ObjectStoreFixture { + _temp: temp, + odb, + commit_one, + commit_two, + commit_three, + tag_three, + }) + } + + fn write_single_file_tree( + odb: &gix_odb::Handle, + filename: &str, + blob_id: &gix_hash::ObjectId, + ) -> Result { + let tree = gix_object::Tree { + entries: vec![gix_object::tree::Entry { + mode: gix_object::tree::EntryKind::Blob.into(), + filename: BString::from(filename), + oid: blob_id.clone(), + }], + }; + odb.write(&tree).map_err(std::io::Error::other) + } + + fn write_commit_object( + odb: &gix_odb::Handle, + tree_id: &gix_hash::ObjectId, + parent: Option<&gix_hash::ObjectId>, + message: &str, + ) -> Result { + let mut bytes = format!("tree {tree_id}\n").into_bytes(); + if let Some(parent) = parent { + bytes.extend_from_slice(format!("parent {parent}\n").as_bytes()); + } + bytes.extend_from_slice(b"author Example 0 +0000\n"); + bytes.extend_from_slice(b"committer Example 0 +0000\n\n"); + bytes.extend_from_slice(message.as_bytes()); + bytes.push(b'\n'); + odb.write_buf(gix_object::Kind::Commit, &bytes) + .map_err(std::io::Error::other) + } + + fn write_tag_object( + odb: &gix_odb::Handle, + target: &gix_hash::ObjectId, + name: &str, + message: &str, + ) -> Result { + let mut bytes = format!("object {target}\n").into_bytes(); + bytes.extend_from_slice(b"type commit\n"); + bytes.extend_from_slice(format!("tag {name}\n").as_bytes()); + bytes.extend_from_slice(b"tagger Example 0 +0000\n\n"); + bytes.extend_from_slice(message.as_bytes()); + bytes.push(b'\n'); + odb.write_buf(gix_object::Kind::Tag, &bytes) + .map_err(std::io::Error::other) + } + + fn pack_object_ids( + pack_data: Vec, + object_hash: gix_hash::Kind, + ) -> Result, Box> { + let temp = TempDir::new()?; + let mut reader = BufReader::new(Cursor::new(pack_data)); + let outcome = gix_pack::Bundle::write_to_directory( + &mut reader, + Some(temp.path.as_path()), + &mut gix_features::progress::Discard, + &AtomicBool::new(false), + None::, + gix_pack::bundle::write::Options { + object_hash, + ..Default::default() + }, + )?; + let bundle = outcome + .to_bundle() + .ok_or_else(|| std::io::Error::other("a bundle path should be available"))??; + Ok(bundle.index.iter().map(|entry| entry.oid).collect()) + } + + fn object_id(hex: &str) -> gix_hash::ObjectId { + gix_hash::ObjectId::from_hex(hex.as_bytes()).expect("valid object id in test") + } + + // Bug condition exploration tests: these encode the EXPECTED behavior per protocol v2. + // They are expected to FAIL on unfixed code, confirming the bug exists (done flag is ignored). + // **Validates: Requirements 1.1, 1.2, 2.1, 2.2** + + #[test] + fn negotiate_fetch_done_with_common_haves_should_end_with_ready() -> Result<(), Box> { + let (_tmp, refs) = temporary_ref_store(&Vec::<(&str, String)>::new())?; + let known_have = object_id("808e50d724f604f69ab93c6da2919c014667bedb"); + + let request = Fetch { + haves: vec![known_have.clone()], + done: true, + ..Default::default() + }; + let known_objects = [known_have.clone()].into_iter().collect::>(); + + let negotiation = negotiate_fetch_with_repository(&request, &refs, |id| known_objects.contains(id))?; + + assert_eq!( + negotiation.acknowledgements, + vec![Acknowledgement::Common(known_have), Acknowledgement::Ready], + "when done=true and common haves exist, acknowledgements must end with Ready to signal packfile follows" + ); + Ok(()) + } + + #[test] + fn negotiate_fetch_done_with_no_haves_should_produce_empty_acknowledgements() -> Result<(), Box> { + let (_tmp, refs) = temporary_ref_store(&Vec::<(&str, String)>::new())?; + + let request = Fetch { + done: true, + ..Default::default() + }; + + let negotiation = negotiate_fetch_with_repository(&request, &refs, |_| false)?; + + assert!( + negotiation.acknowledgements.is_empty(), + "when done=true and no haves exist (fresh clone), acknowledgements must be empty so the section is omitted" + ); + Ok(()) + } + + #[test] + fn negotiate_fetch_done_with_all_unknown_haves_should_produce_empty_acknowledgements() -> Result<(), Box> { + let (_tmp, refs) = temporary_ref_store(&Vec::<(&str, String)>::new())?; + let unknown_have = object_id("f99771fe6a1b535783af3163eba95a927aae21d5"); + + let request = Fetch { + haves: vec![unknown_have], + done: true, + ..Default::default() + }; + + let negotiation = negotiate_fetch_with_repository(&request, &refs, |_| false)?; + + assert!( + negotiation.acknowledgements.is_empty(), + "when done=true and no haves are known (all unknown), acknowledgements must be empty so the section is omitted" + ); + Ok(()) + } + + #[test] + fn negotiate_fetch_done_with_mixed_known_unknown_haves_should_have_common_then_ready() -> Result<(), Box> { + let (_tmp, refs) = temporary_ref_store(&Vec::<(&str, String)>::new())?; + let known_have = object_id("808e50d724f604f69ab93c6da2919c014667bedb"); + let unknown_have = object_id("9e320b9180e0b5580af68fa3255b7f3d9ecd5af0"); + + let request = Fetch { + haves: vec![known_have.clone(), unknown_have], + done: true, + ..Default::default() + }; + let known_objects = [known_have.clone()].into_iter().collect::>(); + + let negotiation = negotiate_fetch_with_repository(&request, &refs, |id| known_objects.contains(id))?; + + assert_eq!( + negotiation.acknowledgements, + vec![Acknowledgement::Common(known_have), Acknowledgement::Ready], + "when done=true with mix of known/unknown haves, acknowledgements must be [Common(known), Ready]" + ); + Ok(()) + } + + // Preservation property tests: verify that `done == false` behavior is unchanged. + // These tests must PASS on unfixed code, confirming baseline behavior to preserve. + // **Validates: Requirements 3.1, 3.2** + + #[test] + fn preservation_done_false_single_known_have_produces_common_only() -> Result<(), Box> { + let (_tmp, refs) = temporary_ref_store(&Vec::<(&str, String)>::new())?; + let known_have = object_id("808e50d724f604f69ab93c6da2919c014667bedb"); + + let request = Fetch { + haves: vec![known_have.clone()], + done: false, + ..Default::default() + }; + let known_objects = [known_have.clone()].into_iter().collect::>(); + + let negotiation = negotiate_fetch_with_repository(&request, &refs, |id| known_objects.contains(id))?; + + assert_eq!( + negotiation.acknowledgements, + vec![Acknowledgement::Common(known_have)], + "when done=false with a known have, acknowledgements must be [Common(id)] without Ready" + ); + assert!( + !negotiation.acknowledgements.contains(&Acknowledgement::Ready), + "done=false must never produce Ready" + ); + assert!( + !negotiation.acknowledgements.is_empty(), + "done=false with known haves must never produce empty acknowledgements" + ); + Ok(()) + } + + #[test] + fn preservation_done_false_multiple_known_haves_produces_common_for_each() -> Result<(), Box> { + let (_tmp, refs) = temporary_ref_store(&Vec::<(&str, String)>::new())?; + let have_a = object_id("808e50d724f604f69ab93c6da2919c014667bedb"); + let have_b = object_id("9e320b9180e0b5580af68fa3255b7f3d9ecd5af0"); + let have_c = object_id("f99771fe6a1b535783af3163eba95a927aae21d5"); + + let request = Fetch { + haves: vec![have_a.clone(), have_b.clone(), have_c.clone()], + done: false, + ..Default::default() + }; + let known_objects = [have_a.clone(), have_b.clone(), have_c.clone()] + .into_iter() + .collect::>(); + + let negotiation = negotiate_fetch_with_repository(&request, &refs, |id| known_objects.contains(id))?; + + assert_eq!( + negotiation.acknowledgements, + vec![ + Acknowledgement::Common(have_a), + Acknowledgement::Common(have_b), + Acknowledgement::Common(have_c), + ], + "when done=false with multiple known haves, acknowledgements must be [Common(a), Common(b), Common(c)]" + ); + assert!( + !negotiation.acknowledgements.contains(&Acknowledgement::Ready), + "done=false must never produce Ready even with multiple known haves" + ); + Ok(()) + } + + #[test] + fn preservation_done_false_mixed_known_unknown_haves_produces_common_for_known_only() -> Result<(), Box> { + let (_tmp, refs) = temporary_ref_store(&Vec::<(&str, String)>::new())?; + let known_have = object_id("808e50d724f604f69ab93c6da2919c014667bedb"); + let unknown_have = object_id("9e320b9180e0b5580af68fa3255b7f3d9ecd5af0"); + + let request = Fetch { + haves: vec![known_have.clone(), unknown_have], + done: false, + ..Default::default() + }; + let known_objects = [known_have.clone()].into_iter().collect::>(); + + let negotiation = negotiate_fetch_with_repository(&request, &refs, |id| known_objects.contains(id))?; + + assert_eq!( + negotiation.acknowledgements, + vec![Acknowledgement::Common(known_have)], + "when done=false with mixed haves, only known haves appear as Common entries" + ); + assert!( + !negotiation.acknowledgements.contains(&Acknowledgement::Ready), + "done=false must never produce Ready" + ); + Ok(()) + } + + #[test] + fn preservation_done_false_no_haves_produces_nak() -> Result<(), Box> { + let (_tmp, refs) = temporary_ref_store(&Vec::<(&str, String)>::new())?; + + let request = Fetch { + done: false, + ..Default::default() + }; + + let negotiation = negotiate_fetch_with_repository(&request, &refs, |_| false)?; + + assert_eq!( + negotiation.acknowledgements, + vec![Acknowledgement::Nak], + "when done=false and no haves exist, acknowledgements must be [Nak]" + ); + Ok(()) + } + + #[test] + fn preservation_done_false_all_unknown_haves_produces_nak() -> Result<(), Box> { + let (_tmp, refs) = temporary_ref_store(&Vec::<(&str, String)>::new())?; + let unknown_a = object_id("808e50d724f604f69ab93c6da2919c014667bedb"); + let unknown_b = object_id("9e320b9180e0b5580af68fa3255b7f3d9ecd5af0"); + + let request = Fetch { + haves: vec![unknown_a, unknown_b], + done: false, + ..Default::default() + }; + + let negotiation = negotiate_fetch_with_repository(&request, &refs, |_| false)?; + + assert_eq!( + negotiation.acknowledgements, + vec![Acknowledgement::Nak], + "when done=false and all haves are unknown, acknowledgements must be [Nak]" + ); + Ok(()) + } + + #[test] + fn preservation_done_false_duplicate_known_haves_are_deduplicated() -> Result<(), Box> { + let (_tmp, refs) = temporary_ref_store(&Vec::<(&str, String)>::new())?; + let known_have = object_id("808e50d724f604f69ab93c6da2919c014667bedb"); + + let request = Fetch { + haves: vec![known_have.clone(), known_have.clone(), known_have.clone()], + done: false, + ..Default::default() + }; + let known_objects = [known_have.clone()].into_iter().collect::>(); + + let negotiation = negotiate_fetch_with_repository(&request, &refs, |id| known_objects.contains(id))?; + + assert_eq!( + negotiation.acknowledgements, + vec![Acknowledgement::Common(known_have)], + "duplicate haves must be deduplicated in acknowledgements" + ); + Ok(()) + } + + #[test] + fn preservation_non_acknowledgement_fields_unaffected_by_done_flag() -> Result<(), Box> { + let main_id = object_id("808e50d724f604f69ab93c6da2919c014667bedb"); + let (_tmp, refs) = temporary_ref_store(&[ + ("HEAD", "ref: refs/heads/main\n".to_string()), + ("refs/heads/main", format!("{main_id}\n")), + ])?; + let known_want = object_id("808e50d724f604f69ab93c6da2919c014667bedb"); + let missing_want = object_id("2d9d136fb0765f2e24c44a0f91984318d580d03b"); + let common_have = object_id("f99771fe6a1b535783af3163eba95a927aae21d5"); + let unknown_have = object_id("9e320b9180e0b5580af68fa3255b7f3d9ecd5af0"); + + let known_objects = [known_want.clone(), common_have.clone()] + .into_iter() + .collect::>(); + + // Request with done=false + let request_not_done = Fetch { + wants: vec![known_want.clone(), missing_want.clone()], + haves: vec![common_have.clone(), unknown_have.clone()], + want_refs: vec!["HEAD".into(), "refs/heads/missing".into()], + done: false, + ..Default::default() + }; + + // Request with done=true (same inputs except done flag) + let request_done = Fetch { + wants: vec![known_want.clone(), missing_want.clone()], + haves: vec![common_have.clone(), unknown_have.clone()], + want_refs: vec!["HEAD".into(), "refs/heads/missing".into()], + done: true, + ..Default::default() + }; + + let negotiation_not_done = + negotiate_fetch_with_repository(&request_not_done, &refs, |id| known_objects.contains(id))?; + let negotiation_done = + negotiate_fetch_with_repository(&request_done, &refs, |id| known_objects.contains(id))?; + + assert_eq!( + negotiation_not_done.known_wants, negotiation_done.known_wants, + "known_wants must be unaffected by done flag" + ); + assert_eq!( + negotiation_not_done.missing_wants, negotiation_done.missing_wants, + "missing_wants must be unaffected by done flag" + ); + assert_eq!( + negotiation_not_done.common_haves, negotiation_done.common_haves, + "common_haves must be unaffected by done flag" + ); + assert_eq!( + negotiation_not_done.wanted_refs, negotiation_done.wanted_refs, + "wanted_refs must be unaffected by done flag" + ); + assert_eq!( + negotiation_not_done.unresolved_want_refs, negotiation_done.unresolved_want_refs, + "unresolved_want_refs must be unaffected by done flag" + ); + Ok(()) + } + + // ServerConfig and validate_object_format tests + // Requirements: 1.2, 2.1, 2.2, 2.3, 2.4, 3.1 + + #[test] + fn server_config_default_returns_sha1() { + let config = ServerConfig::default(); + assert_eq!( + config.object_hash, + gix_hash::Kind::Sha1, + "ServerConfig::default() should configure SHA-1 as the object hash" + ); + } + + #[test] + fn validate_object_format_matching_sha1_accepted() -> Result<(), Box> { + let input = request_bytes("ls-refs", &["object-format=sha1"], &[])?; + let config = ServerConfig { + object_hash: gix_hash::Kind::Sha1, + }; + let request = parse_v2_request(input.as_slice(), &config)?; + assert_eq!( + request.features[0].name.as_bytes(), + b"object-format", + "object-format feature should be parsed" + ); + Ok(()) + } + + #[test] + fn validate_object_format_mismatched_sha256_rejected() -> Result<(), Box> { + let input = request_bytes("ls-refs", &["object-format=sha256"], &[])?; + let config = ServerConfig { + object_hash: gix_hash::Kind::Sha1, + }; + let err = parse_v2_request(input.as_slice(), &config) + .expect_err("sha256 against sha1 config should be rejected"); + match err { + Error::UnsupportedObjectFormat { requested, supported } => { + assert_eq!(requested.as_bytes(), b"sha256", "requested format should be sha256"); + assert_eq!(supported.as_bytes(), b"sha1", "supported format should be sha1"); + } + other => panic!("expected UnsupportedObjectFormat, got: {other:?}"), + } + Ok(()) + } + + #[test] + fn validate_object_format_invalid_blake3_rejected() -> Result<(), Box> { + let input = request_bytes("ls-refs", &["object-format=blake3"], &[])?; + let config = ServerConfig { + object_hash: gix_hash::Kind::Sha1, + }; + let err = parse_v2_request(input.as_slice(), &config) + .expect_err("unrecognized hash name should be rejected"); + match err { + Error::InvalidObjectFormat { value } => { + assert_eq!(value.as_bytes(), b"blake3", "invalid value should be blake3"); + } + other => panic!("expected InvalidObjectFormat, got: {other:?}"), + } + Ok(()) + } + + #[test] + fn validate_object_format_absent_feature_accepted() -> Result<(), Box> { + let input = request_bytes("ls-refs", &["agent=git/test"], &[])?; + let config = ServerConfig { + object_hash: gix_hash::Kind::Sha1, + }; + let request = parse_v2_request(input.as_slice(), &config)?; + assert_eq!( + request.features.len(), + 1, + "only agent feature should be present" + ); + assert_eq!( + request.features[0].name.as_bytes(), + b"agent", + "absent object-format should not cause rejection" + ); + Ok(()) + } + + #[test] + fn validate_object_format_empty_value_rejected() -> Result<(), Box> { + let input = request_bytes("ls-refs", &["object-format="], &[])?; + let config = ServerConfig::default(); + let err = parse_v2_request(input.as_slice(), &config) + .expect_err("empty object-format value should be rejected"); + match err { + Error::InvalidObjectFormat { value } => { + assert_eq!(value.as_bytes(), b"", "invalid value should be empty"); + } + other => panic!("expected InvalidObjectFormat, got: {other:?}"), + } + Ok(()) + } + + #[test] + fn validate_object_format_non_utf8_value_rejected() -> Result<(), Box> { + // Construct a request with a non-UTF-8 object-format value by manually + // building the packet-line bytes. The value \xff\xfe is invalid UTF-8. + let mut out = Vec::new(); + let mut writer = Writer::new(&mut out); + writer.enable_text_mode(); + writer.write_all(b"command=ls-refs")?; + // Write a feature line with non-UTF-8 value + writer.write_all(b"object-format=\xff\xfe")?; + encode::flush_to_write(writer.inner_mut())?; + + let config = ServerConfig::default(); + let err = parse_v2_request(out.as_slice(), &config) + .expect_err("non-UTF-8 object-format value should be rejected"); + match err { + Error::InvalidObjectFormat { value } => { + assert_eq!(value.as_bytes(), b"\xff\xfe", "invalid value should preserve the raw bytes"); + } + other => panic!("expected InvalidObjectFormat, got: {other:?}"), + } + Ok(()) + } + + // OID length enforcement tests + // Requirements: 4.1, 4.2 + + #[test] + fn oid_length_sha1_config_rejects_64_char_hex() -> Result<(), Box> { + let sha256_oid = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let input = request_bytes("fetch", &[], &[&format!("want {sha256_oid}"), "done"])?; + let config = ServerConfig { + object_hash: gix_hash::Kind::Sha1, + }; + + let err = parse_v2_request(input.as_slice(), &config) + .expect_err("SHA-1 config should reject 64-char hex OID"); + assert!( + matches!( + err, + Error::ObjectIdLengthMismatch { + actual: 64, + expected: 40, + hash_kind: gix_hash::Kind::Sha1, + } + ), + "expected ObjectIdLengthMismatch with actual=64, expected=40, got: {err:?}" + ); + Ok(()) + } + + #[cfg(feature = "sha256")] + #[test] + fn oid_length_sha256_config_rejects_40_char_hex() -> Result<(), Box> { + let sha1_oid = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let input = request_bytes("fetch", &[], &[&format!("want {sha1_oid}"), "done"])?; + let config = ServerConfig { + object_hash: gix_hash::Kind::Sha256, + }; + + let err = parse_v2_request(input.as_slice(), &config) + .expect_err("SHA-256 config should reject 40-char hex OID"); + assert!( + matches!( + err, + Error::ObjectIdLengthMismatch { + actual: 40, + expected: 64, + hash_kind: gix_hash::Kind::Sha256, + } + ), + "expected ObjectIdLengthMismatch with actual=40, expected=64, got: {err:?}" + ); + Ok(()) + } + + #[test] + fn oid_length_sha1_config_accepts_40_char_valid_hex() -> Result<(), Box> { + let sha1_oid = "808e50d724f604f69ab93c6da2919c014667bedb"; + let input = request_bytes("fetch", &[], &[&format!("want {sha1_oid}"), "done"])?; + let config = ServerConfig { + object_hash: gix_hash::Kind::Sha1, + }; + + let request = parse_v2_request(input.as_slice(), &config) + .expect("SHA-1 config should accept 40-char hex OID"); + match request.command { + Command::Fetch(fetch) => { + assert_eq!( + fetch.wants, + vec![gix_hash::ObjectId::from_hex(sha1_oid.as_bytes())?], + "parsed want should match the provided SHA-1 OID" + ); + } + Command::LsRefs(_) => panic!("expected fetch command"), + } + Ok(()) + } + + #[cfg(feature = "sha256")] + #[test] + fn oid_length_sha256_config_accepts_64_char_valid_hex() -> Result<(), Box> { + let sha256_oid = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let input = request_bytes("fetch", &[], &[&format!("want {sha256_oid}"), "done"])?; + let config = ServerConfig { + object_hash: gix_hash::Kind::Sha256, + }; + + let request = parse_v2_request(input.as_slice(), &config) + .expect("SHA-256 config should accept 64-char hex OID"); + match request.command { + Command::Fetch(fetch) => { + assert_eq!( + fetch.wants, + vec![gix_hash::ObjectId::from_hex(sha256_oid.as_bytes())?], + "parsed want should match the provided SHA-256 OID" + ); + } + Command::LsRefs(_) => panic!("expected fetch command"), + } + Ok(()) + } + + // Property-based tests for object-format validation + // Feature: upload-pack-capability-validation, Property 1: Object-format validation accepts matching, rejects mismatched or invalid + // **Validates: Requirements 2.1, 2.3, 3.1** + #[cfg(feature = "blocking-server")] + mod property_tests { + use super::*; + use proptest::prelude::*; + + proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + #[test] + fn property_object_format_validation_sha1( + value in ".*", + ) { + let kind = gix_hash::Kind::Sha1; + let config = ServerConfig { object_hash: kind }; + let features = vec![Feature { + name: "object-format".into(), + value: Some(value.clone().into()), + }]; + let result = validate_object_format(&features, &config); + + let known_formats = ["sha1", "sha256"]; + if !known_formats.contains(&value.as_str()) { + // Unrecognized → InvalidObjectFormat + prop_assert!( + matches!(result, Err(Error::InvalidObjectFormat { .. })), + "unrecognized value {:?} should produce InvalidObjectFormat, got: {:?}", + value, + result + ); + } else if value == kind.to_string() { + // Matching → Ok + prop_assert!( + result.is_ok(), + "matching value {:?} for kind {:?} should succeed, got: {:?}", + value, + kind, + result + ); + } else { + // Recognized but mismatched → UnsupportedObjectFormat + prop_assert!( + matches!(result, Err(Error::UnsupportedObjectFormat { .. })), + "mismatched value {:?} for kind {:?} should produce UnsupportedObjectFormat, got: {:?}", + value, + kind, + result + ); + } + } + } + + #[cfg(feature = "sha256")] + proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + #[test] + fn property_object_format_validation_sha256( + value in ".*", + ) { + let kind = gix_hash::Kind::Sha256; + let config = ServerConfig { object_hash: kind }; + let features = vec![Feature { + name: "object-format".into(), + value: Some(value.clone().into()), + }]; + let result = validate_object_format(&features, &config); + + let known_formats = ["sha1", "sha256"]; + if !known_formats.contains(&value.as_str()) { + // Unrecognized → InvalidObjectFormat + prop_assert!( + matches!(result, Err(Error::InvalidObjectFormat { .. })), + "unrecognized value {:?} should produce InvalidObjectFormat, got: {:?}", + value, + result + ); + } else if value == kind.to_string() { + // Matching → Ok + prop_assert!( + result.is_ok(), + "matching value {:?} for kind {:?} should succeed, got: {:?}", + value, + kind, + result + ); + } else { + // Recognized but mismatched → UnsupportedObjectFormat + prop_assert!( + matches!(result, Err(Error::UnsupportedObjectFormat { .. })), + "mismatched value {:?} for kind {:?} should produce UnsupportedObjectFormat, got: {:?}", + value, + kind, + result + ); + } + } + } + } + + // Property-based tests for upload-pack capability validation + // Feature: upload-pack-capability-validation + + /// **Validates: Requirements 5.1, 5.2, 5.3** + /// Property 3: Non-object-format features pass through without rejection + #[cfg(feature = "blocking-server")] + mod property_non_object_format_tests { + use super::*; + use proptest::prelude::*; + + fn arbitrary_server_config() -> gix_hash::Kind { + // Use SHA-1 as the default; SHA-256 tested when feature is available + gix_hash::Kind::Sha1 + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + #[test] + fn property_non_object_format_feature_pass_through( + name in "[a-z][a-z0-9-]{0,30}".prop_filter("must not be object-format", |s| s != "object-format"), + has_value in any::(), + value in ".*", + ) { + let config = ServerConfig { object_hash: arbitrary_server_config() }; + let feature_value = if has_value { Some(BString::from(value.as_str())) } else { None }; + let features = vec![Feature { name: name.clone().into(), value: feature_value }]; + + // Non-object-format features should never cause validation to fail + let result = validate_object_format(&features, &config); + prop_assert!(result.is_ok(), "non-object-format feature '{}' should not cause validation error, got: {:?}", name, result); + } + } + + #[cfg(feature = "sha256")] + proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + #[test] + fn property_non_object_format_feature_pass_through_sha256( + name in "[a-z][a-z0-9-]{0,30}".prop_filter("must not be object-format", |s| s != "object-format"), + has_value in any::(), + value in ".*", + ) { + let config = ServerConfig { object_hash: gix_hash::Kind::Sha256 }; + let feature_value = if has_value { Some(BString::from(value.as_str())) } else { None }; + let features = vec![Feature { name: name.clone().into(), value: feature_value }]; + + // Non-object-format features should never cause validation to fail with SHA-256 config either + let result = validate_object_format(&features, &config); + prop_assert!(result.is_ok(), "non-object-format feature '{}' with sha256 config should not cause validation error, got: {:?}", name, result); + } + } + } + + fn temporary_ref_store( + files: &[(&str, String)], + ) -> Result<(TempDir, gix_ref::file::Store), Box> { + let temp = TempDir::new()?; + for (relative_path, content) in files { + let path = temp.path.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, content)?; + } + let store = gix_ref::file::Store::at( + temp.path.clone(), + gix_ref::store::init::Options { + write_reflog: gix_ref::store::WriteReflog::Disable, + object_hash: gix_hash::Kind::Sha1, + ..Default::default() + }, + ); + Ok((temp, store)) + } + + struct TempDir { + path: PathBuf, + } + static TEMP_DIR_ID: AtomicU64 = AtomicU64::new(0); + + impl TempDir { + fn new() -> Result { + let base = std::env::temp_dir(); + for _ in 0..16 { + let unique = TEMP_DIR_ID.fetch_add(1, Ordering::Relaxed); + let path = base.join(format!("gitoxide-upload-pack-test-{}-{unique}", std::process::id())); + match fs::create_dir(&path) { + Ok(()) => return Ok(Self { path }), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "could not allocate unique temporary upload-pack test directory", + )) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + if let Err(_err) = fs::remove_dir_all(&self.path) { + // Best-effort cleanup for tests. + } + } + } + + /// Feature: upload-pack-capability-validation, Property 2: OID length enforcement + /// + /// For any `gix_hash::Kind` configured on the server, and for any hex string in a `want` + /// argument line, `parse_object_id` SHALL succeed if and only if the hex string length equals + /// `kind.len_in_hex()` AND the string contains only valid hex characters. If the length does + /// not match, the parser SHALL return `Error::ObjectIdLengthMismatch`. + /// + /// **Validates: Requirements 4.1, 4.2, 7.4** + #[cfg(feature = "blocking-server")] + mod property_tests_oid_length { + use super::*; + use proptest::prelude::*; + + fn arb_hash_kind() -> impl Strategy { + #[cfg(feature = "sha256")] + { + prop_oneof![ + Just(gix_hash::Kind::Sha1), + Just(gix_hash::Kind::Sha256), + ] + .boxed() + } + #[cfg(not(feature = "sha256"))] + { + Just(gix_hash::Kind::Sha1).boxed() + } + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + #[test] + fn property_oid_length_enforcement( + kind in arb_hash_kind(), + hex_chars in prop::collection::vec(prop::char::range('\0', '\x7f'), 0..128usize), + ) { + let hex_string: String = hex_chars.into_iter().collect(); + let line = format!("want {hex_string}"); + let result = parse_object_id(line.as_bytes().as_bstr(), b"want ", "fetch", kind); + + let expected_len = kind.len_in_hex(); + if hex_string.len() != expected_len { + prop_assert!( + matches!(result, Err(Error::ObjectIdLengthMismatch { .. })), + "expected ObjectIdLengthMismatch for len {} != expected {}, got: {:?}", + hex_string.len(), + expected_len, + result, + ); + } else if hex_string.bytes().all(|b| b.is_ascii_hexdigit()) { + prop_assert!( + result.is_ok(), + "expected Ok for valid hex of correct length {}, got: {:?}", + expected_len, + result, + ); + } else { + prop_assert!( + matches!(result, Err(Error::InvalidObjectId { .. })), + "expected InvalidObjectId for invalid hex chars at correct length {}, got: {:?}", + expected_len, + result, + ); + } + } + } + } +} diff --git a/gix-protocol/src/upload_pack/async_io.rs b/gix-protocol/src/upload_pack/async_io.rs new file mode 100644 index 00000000000..1d167258a07 --- /dev/null +++ b/gix-protocol/src/upload_pack/async_io.rs @@ -0,0 +1,184 @@ +//! Async transport integration for upload-pack server plumbing. +//! +//! This module bridges async byte streams into the blocking upload-pack implementation +//! using `futures_lite::io::BlockOn`, following the same pattern as `receive_pack::async_io`. + +use std::io::Write as _; + +use futures_io::{AsyncRead, AsyncWrite}; +use futures_lite::{AsyncReadExt as _, AsyncWriteExt as _}; +use gix_transport::packetline::blocking_io::{Writer, encode}; +use gix_transport::packetline::Channel; + +use crate::fetch::response::{Acknowledgement, ShallowUpdate, WantedRef}; +use crate::handshake::Ref; + +/// Serve one protocol V2 upload-pack request over async transport streams. +/// +/// This adapts async readers/writers to the existing blocking upload-pack plumbing. +pub async fn serve_v2( + input: &mut R, + output: &mut W, + delegate: &mut D, + config: &super::ServerConfig, +) -> Result +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, + D: super::Delegate, +{ + let outcome = { + let mut blocking_input = futures_lite::io::BlockOn::new(input); + let mut blocking_output = futures_lite::io::BlockOn::new(&mut *output); + super::serve_v2(&mut blocking_input, &mut blocking_output, delegate, config)? + }; + output.flush().await?; + Ok(outcome) +} + +/// Parse a single protocol V2 upload-pack request from an async reader. +/// +/// This adapts the async reader to the existing blocking parser using `BlockOn`. +#[allow(clippy::unused_async)] +pub async fn parse_v2_request(input: &mut R, config: &super::ServerConfig) -> Result +where + R: AsyncRead + Unpin, +{ + let mut blocking_input = futures_lite::io::BlockOn::new(input); + super::parse_v2_request(&mut blocking_input, config) +} + +/// Write a `ls-refs` response body over an async writer. +/// +/// Returns the number of refs written. +pub async fn write_ls_refs_response( + output: &mut W, + request: &super::LsRefs, + refs: &[Ref], +) -> Result +where + W: AsyncWrite + Unpin, +{ + let refs_sent = { + let mut blocking_output = futures_lite::io::BlockOn::new(&mut *output); + super::write_ls_refs_response(&mut blocking_output, request, refs)? + }; + output.flush().await?; + Ok(refs_sent) +} + +/// Write a protocol V2 capability advertisement over an async writer. +/// +/// Includes the `version 2` greeting line followed by each capability. +pub async fn write_v2_capability_advertisement( + output: &mut W, + capabilities: &[super::Capability], +) -> Result<(), super::Error> +where + W: AsyncWrite + Unpin, +{ + { + let mut blocking_output = futures_lite::io::BlockOn::new(&mut *output); + super::write_v2_capability_advertisement(&mut blocking_output, capabilities)?; + } + output.flush().await?; + Ok(()) +} + +/// Output payload for an async `fetch` response. +/// +/// This mirrors [`super::FetchOutput`] but uses an async reader for the pack data source, +/// allowing pack bytes to be streamed without blocking the async executor. +pub struct AsyncFetchOutput { + /// Negotiation acknowledgements to return in the `acknowledgments` section. + pub acknowledgements: Vec, + /// Optional shallow boundary updates to return in the `shallow-info` section. + pub shallow_updates: Vec, + /// Optional `wanted-refs` section entries. + pub wanted_refs: Vec, + /// If present, pack data streamed as sideband channel 1 in the `packfile` section. + pub pack_data: Option>, +} + +impl AsyncFetchOutput { + /// Create a response output with async `pack_data` and no additional sections. + pub fn new(pack_data: impl AsyncRead + Unpin + Send + 'static) -> Self { + Self { + acknowledgements: Vec::new(), + shallow_updates: Vec::new(), + wanted_refs: Vec::new(), + pack_data: Some(Box::new(pack_data)), + } + } + + /// Create a response output without pack data. + pub fn without_pack() -> Self { + Self { + acknowledgements: Vec::new(), + shallow_updates: Vec::new(), + wanted_refs: Vec::new(), + pack_data: None, + } + } +} + +/// Write a V2 `fetch` response with async pack streaming. +/// +/// Metadata sections (acknowledgments, shallow-info, wanted-refs) are written using +/// `BlockOn` since they are small buffered writes. Pack data is streamed natively +/// async to avoid blocking the executor during large transfers. +/// +/// Returns the number of raw pack bytes sent on sideband channel 1. +pub async fn write_fetch_response(output: &mut W, response: &mut AsyncFetchOutput) -> Result +where + W: AsyncWrite + Unpin, +{ + // Write metadata sections (acks, shallow-info, wanted-refs) using BlockOn + // since these are small buffered writes. + { + let mut blocking_output = futures_lite::io::BlockOn::new(&mut *output); + super::write_fetch_metadata_sections( + &mut blocking_output, + &response.acknowledgements, + &response.shallow_updates, + &response.wanted_refs, + response.pack_data.is_some(), + )?; + } + + // Stream pack data natively async. + let mut pack_bytes_sent = 0u64; + if let Some(pack_data) = response.pack_data.as_mut() { + // Write "packfile" section header via BlockOn (single small write). + { + let mut blocking_output = futures_lite::io::BlockOn::new(&mut *output); + let mut writer = Writer::new(&mut blocking_output); + writer.enable_text_mode(); + writer.write_all(b"packfile")?; + } + + // Stream pack data as sideband channel 1 packets using native async I/O. + let mut buffer = vec![0u8; super::MAX_SIDEBAND_DATA_BYTES]; + let mut packet_buf = Vec::with_capacity(super::MAX_SIDEBAND_DATA_BYTES + 10); + loop { + let bytes_read = pack_data.read(&mut buffer).await?; + if bytes_read == 0 { + break; + } + pack_bytes_sent += bytes_read as u64; + // Encode sideband packet into a reused buffer, then async-write it. + packet_buf.clear(); + encode::band_to_write(Channel::Data, &buffer[..bytes_read], &mut packet_buf)?; + output.write_all(&packet_buf).await?; + } + } + + // Write flush packet and flush the stream. + let mut flush_buf = Vec::new(); + encode::flush_to_write(&mut flush_buf)?; + output.write_all(&flush_buf).await?; + output.flush().await?; + Ok(pack_bytes_sent) +} + + diff --git a/gix-protocol/tests/async-server.rs b/gix-protocol/tests/async-server.rs new file mode 100644 index 00000000000..db582d34a94 --- /dev/null +++ b/gix-protocol/tests/async-server.rs @@ -0,0 +1,2 @@ +#[path = "async_server/upload_pack.rs"] +mod upload_pack; diff --git a/gix-protocol/tests/async_server/upload_pack.rs b/gix-protocol/tests/async_server/upload_pack.rs new file mode 100644 index 00000000000..0e698a26f08 --- /dev/null +++ b/gix-protocol/tests/async_server/upload_pack.rs @@ -0,0 +1,862 @@ +//! Async integration tests for `upload_pack::async_io` module. +//! +//! Tests verify that the async bridge functions produce correct protocol output +//! using in-memory async streams. + +use std::io::Write as _; + +use bstr::ByteSlice as _; +use futures_lite::io::Cursor; +use gix_protocol::fetch::response::{Acknowledgement, ShallowUpdate}; +use gix_protocol::handshake::Ref; +use gix_protocol::upload_pack::async_io::{self, AsyncFetchOutput, write_fetch_response}; +use gix_protocol::upload_pack::{self, Delegate, Fetch, FetchOutput, LsRefs, Outcome, ServerConfig}; +use gix_transport::packetline::{ + BandRef, PacketLineRef, + blocking_io::{StreamingPeekableIter, Writer, encode}, +}; + +type BoxError = Box; + +// --------------------------------------------------------------------------- +// Mock delegates +// --------------------------------------------------------------------------- + +/// A mock delegate that returns a fixed set of refs for `ls_refs`. +struct LsRefsDelegate { + refs: Vec, +} + +impl Delegate for LsRefsDelegate { + fn ls_refs(&mut self, _request: &LsRefs) -> Result, BoxError> { + Ok(self.refs.clone()) + } + + fn fetch(&mut self, _request: &Fetch) -> Result { + unreachable!("fetch should not be called in ls-refs test") + } +} + +/// A mock delegate for testing upload-pack fetch operations. +struct FetchDelegate { + acknowledgements: Vec, + pack_data: Option>, +} + +impl Delegate for FetchDelegate { + fn ls_refs(&mut self, _request: &LsRefs) -> Result, BoxError> { + Ok(Vec::new()) + } + + fn fetch(&mut self, _request: &Fetch) -> Result { + let mut output = if let Some(ref data) = self.pack_data { + FetchOutput::new(std::io::Cursor::new(data.clone())) + } else { + FetchOutput::without_pack() + }; + output.acknowledgements.clone_from(&self.acknowledgements); + Ok(output) + } +} + +// --------------------------------------------------------------------------- +// Helper functions +// --------------------------------------------------------------------------- + +/// Build a valid protocol V2 ls-refs request as raw bytes. +fn build_ls_refs_request(arguments: &[&str]) -> Result, Box> { + let mut out = Vec::new(); + { + let mut writer = Writer::new(&mut out); + writer.enable_text_mode(); + writer.write_all(b"command=ls-refs")?; + encode::delim_to_write(writer.inner_mut())?; + for arg in arguments { + writer.write_all(arg.as_bytes())?; + } + encode::flush_to_write(writer.inner_mut())?; + } + Ok(out) +} + +/// Build a valid protocol V2 fetch request as raw bytes. +fn build_fetch_request( + want_oid: &str, + have_oid: Option<&str>, + done: bool, +) -> Result, Box> { + let mut out = Vec::new(); + { + let mut writer = Writer::new(&mut out); + writer.enable_text_mode(); + writer.write_all(b"command=fetch")?; + encode::delim_to_write(writer.inner_mut())?; + writer.write_all(format!("want {want_oid}").as_bytes())?; + if let Some(have) = have_oid { + writer.write_all(format!("have {have}").as_bytes())?; + } + if done { + writer.write_all(b"done")?; + } + encode::flush_to_write(writer.inner_mut())?; + } + Ok(out) +} + +/// Read the next text packetline from the reader. +fn next_text_line( + reader: &mut StreamingPeekableIter<&[u8]>, +) -> Result, Box> { + let line = reader + .read_line() + .expect("expected packetline") + .expect("read should succeed") + .expect("decode should succeed"); + let text = line.as_text().expect("expected text packetline"); + Ok(text.as_slice().to_vec()) +} + +/// Assert the next packetline is a delimiter. +fn expect_delimiter( + reader: &mut StreamingPeekableIter<&[u8]>, +) -> Result<(), Box> { + let line = reader + .read_line() + .expect("expected packetline") + .expect("read should succeed") + .expect("decode should succeed"); + match line { + PacketLineRef::Delimiter => Ok(()), + other => Err(format!("expected delimiter, got {other:?}").into()), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[async_std::test] +async fn serve_v2_bridges_async_transport_for_ls_refs() -> Result<(), Box> { + let request = build_ls_refs_request(&["symrefs", "ref-prefix refs/heads/"])?; + let mut input = Cursor::new(request); + let mut output = Cursor::new(Vec::::new()); + + let oid_a = gix_hash::ObjectId::from_hex(b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .expect("valid hex for oid_a"); + let oid_b = gix_hash::ObjectId::from_hex(b"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + .expect("valid hex for oid_b"); + let oid_c = gix_hash::ObjectId::from_hex(b"cccccccccccccccccccccccccccccccccccccccc") + .expect("valid hex for oid_c"); + + let mut delegate = LsRefsDelegate { + refs: vec![ + Ref::Symbolic { + full_ref_name: "refs/heads/main".into(), + target: "refs/heads/main".into(), + tag: None, + object: oid_a, + }, + Ref::Direct { + full_ref_name: "refs/heads/feature".into(), + object: oid_b, + }, + Ref::Direct { + full_ref_name: "refs/tags/v1.0".into(), + object: oid_c, + }, + ], + }; + + let outcome = async_io::serve_v2(&mut input, &mut output, &mut delegate, &ServerConfig::default()).await?; + + assert_eq!( + outcome, + Outcome::LsRefs { refs_sent: 2 }, + "only refs matching 'refs/heads/' prefix should be sent" + ); + + let output_bytes = output.into_inner(); + let mut reader = + StreamingPeekableIter::new(output_bytes.as_slice(), &[PacketLineRef::Flush], false); + + let line1 = next_text_line(&mut reader)?; + assert!( + line1.contains_str("refs/heads/main"), + "first ref line should contain refs/heads/main, got: {:?}", + line1.as_bstr() + ); + assert!( + line1.contains_str("symref-target:"), + "first ref line should contain symref-target since symrefs was requested, got: {:?}", + line1.as_bstr() + ); + + let line2 = next_text_line(&mut reader)?; + assert!( + line2.contains_str("refs/heads/feature"), + "second ref line should contain refs/heads/feature, got: {:?}", + line2.as_bstr() + ); + let expected_prefix = oid_b.to_string(); + assert!( + line2.starts_with(expected_prefix.as_bytes()), + "second ref line should start with the object id, got: {:?}", + line2.as_bstr() + ); + + assert!( + reader.read_line().is_none(), + "there should be no more lines before flush" + ); + assert_eq!( + reader.stopped_at(), + Some(PacketLineRef::Flush), + "output should end with a flush packet" + ); + + Ok(()) +} + +#[async_std::test] +async fn serve_v2_bridges_async_transport_for_fetch_with_pack_data( +) -> Result<(), Box> { + let want_oid_hex = "808e50d724f604f69ab93c6da2919c014667bedb"; + let have_oid_hex = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let have_oid = + gix_hash::ObjectId::from_hex(have_oid_hex.as_bytes()).expect("valid hex for have OID"); + + let pack_data = b"PACK test data for sideband framing"; + + let request = build_fetch_request(want_oid_hex, Some(have_oid_hex), true)?; + let mut input = Cursor::new(request); + let mut output = Cursor::new(Vec::::new()); + + let mut delegate = FetchDelegate { + acknowledgements: vec![Acknowledgement::Common(have_oid), Acknowledgement::Ready], + pack_data: Some(pack_data.to_vec()), + }; + + let outcome = async_io::serve_v2(&mut input, &mut output, &mut delegate, &ServerConfig::default()).await?; + + match outcome { + Outcome::Fetch { + acknowledgements_sent, + shallow_updates_sent, + wanted_refs_sent, + pack_bytes_sent, + } => { + assert_eq!( + acknowledgements_sent, 2, + "should report 2 acknowledgements (Common + Ready)" + ); + assert_eq!(shallow_updates_sent, 0, "no shallow updates expected"); + assert_eq!(wanted_refs_sent, 0, "no wanted refs expected"); + assert_eq!( + pack_bytes_sent, + pack_data.len() as u64, + "pack_bytes_sent should equal input pack data length" + ); + } + other => panic!("expected Outcome::Fetch, got {other:?}"), + } + + let output_bytes = output.into_inner(); + let mut reader = StreamingPeekableIter::new( + output_bytes.as_slice(), + &[PacketLineRef::Flush, PacketLineRef::Delimiter], + false, + ); + + let ack_header = next_text_line(&mut reader)?; + assert_eq!( + ack_header.as_slice(), + b"acknowledgments", + "first line should be acknowledgments section header" + ); + + let ack_line_1 = next_text_line(&mut reader)?; + let expected_ack = format!("ACK {have_oid_hex} common"); + assert_eq!( + ack_line_1.as_slice(), + expected_ack.as_bytes(), + "first ACK line should be Common acknowledgement" + ); + + let ack_line_2 = next_text_line(&mut reader)?; + assert_eq!( + ack_line_2.as_slice(), + b"ready", + "second ACK line should be Ready" + ); + + // Consume the delimiter that ends the acknowledgments section + assert!( + reader.read_line().is_none(), + "reader should stop at delimiter after acknowledgments section" + ); + assert_eq!( + reader.stopped_at(), + Some(PacketLineRef::Delimiter), + "acknowledgments section should end with delimiter" + ); + + reader.reset_with(&[PacketLineRef::Flush]); + + let packfile_header = next_text_line(&mut reader)?; + assert_eq!( + packfile_header.as_slice(), + b"packfile", + "next section should be packfile header" + ); + + let mut received_pack_data = Vec::new(); + loop { + let line = reader.read_line(); + match line { + None => break, + Some(Ok(Ok(packet))) => match packet.decode_band() { + Ok(band) => match band { + BandRef::Data(data) => received_pack_data.extend_from_slice(data), + BandRef::Progress(_) => {} + BandRef::Error(err) => panic!("unexpected error band in output: {:?}", err), + }, + Err(_) => break, + }, + Some(Ok(Err(decode_err))) => { + panic!("decode error while reading sideband packets: {decode_err}"); + } + Some(Err(io_err)) => { + panic!("IO error while reading sideband packets: {io_err}"); + } + } + } + + assert_eq!( + received_pack_data.as_slice(), + pack_data.as_slice(), + "concatenated sideband payloads should equal original pack data" + ); + + assert_eq!( + reader.stopped_at(), + Some(PacketLineRef::Flush), + "response should end with flush packet" + ); + + Ok(()) +} + +#[async_std::test] +async fn write_fetch_response_streams_async_pack_data_correctly( +) -> Result<(), Box> { + let pack_bytes: &[u8] = b"PACK\x00\x00\x00\x02test pack data bytes here"; + let common_id = gix_hash::ObjectId::from_hex(b"808e50d724f604f69ab93c6da2919c014667bedb") + .expect("valid hex for common_id"); + + let mut response = AsyncFetchOutput::new(Cursor::new(pack_bytes.to_vec())); + response + .acknowledgements + .push(Acknowledgement::Common(common_id)); + + let mut output = Cursor::new(Vec::::new()); + let bytes_sent = write_fetch_response(&mut output, &mut response).await?; + + assert_eq!( + bytes_sent, + pack_bytes.len() as u64, + "returned byte count should equal the input pack data length" + ); + + let output_bytes = output.into_inner(); + let mut reader = + StreamingPeekableIter::new(output_bytes.as_slice(), &[PacketLineRef::Flush], false); + + assert_eq!( + next_text_line(&mut reader)?.as_slice(), + b"acknowledgments", + "response should start with acknowledgments section header" + ); + let expected_ack_line = format!("ACK {common_id} common"); + assert_eq!( + next_text_line(&mut reader)?.as_slice(), + expected_ack_line.as_bytes(), + "acknowledgments section should contain ACK line for common object" + ); + + expect_delimiter(&mut reader)?; + + assert_eq!( + next_text_line(&mut reader)?.as_slice(), + b"packfile", + "packfile section header should follow acknowledgments" + ); + + let mut concatenated_payloads = Vec::new(); + loop { + match reader.read_line() { + None => break, + Some(Ok(Ok(packet))) => match packet.decode_band() { + Ok(band) => match band { + BandRef::Data(data) => concatenated_payloads.extend_from_slice(data), + BandRef::Progress(_) => {} + BandRef::Error(err) => panic!("unexpected error band: {err:?}"), + }, + Err(_) => break, + }, + Some(Ok(Err(e))) => panic!("decode error: {e}"), + Some(Err(e)) => panic!("IO error: {e}"), + } + } + + assert_eq!( + concatenated_payloads.as_slice(), + pack_bytes, + "concatenated sideband channel 1 payloads should equal original pack bytes" + ); + + assert_eq!( + reader.stopped_at(), + Some(PacketLineRef::Flush), + "response should end with a flush packet" + ); + + Ok(()) +} + +/// When `AsyncFetchOutput` has no pack data, only the metadata sections +/// (acknowledgments, shallow-info) should be written, and the returned byte count +/// should be 0. +#[async_std::test] +async fn write_fetch_response_without_pack_data_writes_only_metadata_sections( +) -> Result<(), Box> { + let shallow_id = + gix_hash::ObjectId::from_hex(b"dce0ea858eef7ff61ad345cc5cdac62203fb3c10")?; + + let mut response = AsyncFetchOutput::without_pack(); + response.acknowledgements.push(Acknowledgement::Nak); + response + .shallow_updates + .push(ShallowUpdate::Shallow(shallow_id)); + + let mut output = Cursor::new(Vec::::new()); + let pack_bytes_sent = write_fetch_response(&mut output, &mut response).await?; + + assert_eq!( + pack_bytes_sent, 0, + "no pack bytes should be sent when pack_data is None" + ); + + let output_bytes = output.into_inner(); + let mut reader = + StreamingPeekableIter::new(output_bytes.as_slice(), &[PacketLineRef::Flush], false); + + // Acknowledgments section + assert_eq!( + next_text_line(&mut reader)?.as_slice(), + b"acknowledgments", + "response should start with acknowledgments section header" + ); + assert_eq!( + next_text_line(&mut reader)?.as_slice(), + b"NAK", + "acknowledgments section should contain NAK" + ); + expect_delimiter(&mut reader)?; + + // Shallow-info section (last section, no pack follows) + assert_eq!( + next_text_line(&mut reader)?.as_slice(), + b"shallow-info", + "shallow-info section should follow acknowledgments" + ); + let expected_shallow = format!("shallow {shallow_id}"); + assert_eq!( + next_text_line(&mut reader)?.as_slice(), + expected_shallow.as_bytes(), + "shallow-info should contain shallow line with correct id" + ); + + // No packfile section - should go directly to flush (no trailing delimiter on last section) + assert!( + reader.read_line().is_none(), + "no packfile section should be present; response should end with flush" + ); + assert_eq!( + reader.stopped_at(), + Some(PacketLineRef::Flush), + "response should be terminated by a flush packet" + ); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Property tests +// --------------------------------------------------------------------------- + +/// **Validates: Requirements 2.2, 5.2, 6.2, 7.2, 10.2** +/// +/// Property 1: BlockOn-bridged functions produce identical results to blocking counterparts. +/// For a set of representative inputs (ls-refs, fetch, capability advertisement), call both +/// blocking and async versions. Compare output byte buffers and return values for equality. +#[async_std::test] +async fn property_blockon_bridged_output_matches_blocking_output_byte_for_byte( +) -> Result<(), Box> { + use gix_protocol::upload_pack::Capability; + + // --- Test write_ls_refs_response --- + let test_cases_ls_refs: Vec<(LsRefs, Vec)> = vec![ + // Case 1: empty refs, no filters + (LsRefs::default(), Vec::new()), + // Case 2: refs with symrefs filter + ( + LsRefs { + symrefs: true, + ref_prefixes: vec!["refs/heads/".into()], + ..Default::default() + }, + vec![ + Ref::Symbolic { + full_ref_name: "refs/heads/main".into(), + target: "refs/heads/main".into(), + tag: None, + object: gix_hash::ObjectId::from_hex( + b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .expect("valid hex"), + }, + Ref::Direct { + full_ref_name: "refs/tags/v1.0".into(), + object: gix_hash::ObjectId::from_hex( + b"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ) + .expect("valid hex"), + }, + ], + ), + // Case 3: refs with peel option + ( + LsRefs { + peel: true, + ..Default::default() + }, + vec![Ref::Direct { + full_ref_name: "refs/heads/feature".into(), + object: gix_hash::ObjectId::from_hex( + b"cccccccccccccccccccccccccccccccccccccccc", + ) + .expect("valid hex"), + }], + ), + ]; + + for (request, refs) in &test_cases_ls_refs { + // Blocking version + let mut blocking_output = Vec::::new(); + let blocking_result = + upload_pack::write_ls_refs_response(&mut blocking_output, request, refs)?; + + // Async version + let mut async_output = Cursor::new(Vec::::new()); + let async_result = + async_io::write_ls_refs_response(&mut async_output, request, refs).await?; + + assert_eq!( + blocking_result, async_result, + "write_ls_refs_response return value should be identical for blocking and async" + ); + assert_eq!( + blocking_output, + async_output.into_inner(), + "write_ls_refs_response output bytes should be identical for blocking and async" + ); + } + + // --- Test write_v2_capability_advertisement --- + let test_cases_caps: Vec> = vec![ + // Case 1: empty capabilities + Vec::new(), + // Case 2: single capability with no values + vec![Capability { + name: "ls-refs".into(), + values: Vec::new(), + }], + // Case 3: multiple capabilities with values + vec![ + Capability { + name: "ls-refs".into(), + values: Vec::new(), + }, + Capability { + name: "fetch".into(), + values: vec!["shallow".into(), "filter".into()], + }, + Capability { + name: "server-option".into(), + values: Vec::new(), + }, + ], + ]; + + for capabilities in &test_cases_caps { + // Blocking version + let mut blocking_output = Vec::::new(); + upload_pack::write_v2_capability_advertisement(&mut blocking_output, capabilities)?; + + // Async version + let mut async_output = Cursor::new(Vec::::new()); + async_io::write_v2_capability_advertisement(&mut async_output, capabilities).await?; + + assert_eq!( + blocking_output, + async_output.into_inner(), + "write_v2_capability_advertisement output bytes should be identical for blocking and async" + ); + } + + // --- Test write_fetch_response (blocking FetchOutput vs async AsyncFetchOutput) --- + let common_id = gix_hash::ObjectId::from_hex(b"808e50d724f604f69ab93c6da2919c014667bedb") + .expect("valid hex for common_id"); + let shallow_id = gix_hash::ObjectId::from_hex(b"dce0ea858eef7ff61ad345cc5cdac62203fb3c10") + .expect("valid hex for shallow_id"); + + let pack_test_data: Vec> = vec![ + // Case 1: small pack data + b"PACK test data".to_vec(), + // Case 2: larger pack data + vec![0xAB; 1000], + ]; + + for pack_data in &pack_test_data { + // Blocking version + let mut blocking_fetch_output = FetchOutput::new(std::io::Cursor::new(pack_data.clone())); + blocking_fetch_output + .acknowledgements + .push(Acknowledgement::Common(common_id)); + blocking_fetch_output + .shallow_updates + .push(ShallowUpdate::Shallow(shallow_id)); + let mut blocking_output = Vec::::new(); + let blocking_bytes = + upload_pack::write_fetch_response(&mut blocking_output, &mut blocking_fetch_output)?; + + // Async version + let mut async_fetch_output = AsyncFetchOutput::new(Cursor::new(pack_data.clone())); + async_fetch_output + .acknowledgements + .push(Acknowledgement::Common(common_id)); + async_fetch_output + .shallow_updates + .push(ShallowUpdate::Shallow(shallow_id)); + let mut async_output = Cursor::new(Vec::::new()); + let async_bytes = + write_fetch_response(&mut async_output, &mut async_fetch_output).await?; + + assert_eq!( + blocking_bytes, async_bytes, + "write_fetch_response byte count should be identical for blocking and async" + ); + assert_eq!( + blocking_output, + async_output.into_inner(), + "write_fetch_response output bytes should be identical for blocking and async" + ); + } + + // Case: no pack data + { + let mut blocking_fetch_output = FetchOutput::without_pack(); + blocking_fetch_output + .acknowledgements + .push(Acknowledgement::Nak); + let mut blocking_output = Vec::::new(); + let blocking_bytes = + upload_pack::write_fetch_response(&mut blocking_output, &mut blocking_fetch_output)?; + + let mut async_fetch_output = AsyncFetchOutput::without_pack(); + async_fetch_output + .acknowledgements + .push(Acknowledgement::Nak); + let mut async_output = Cursor::new(Vec::::new()); + let async_bytes = + write_fetch_response(&mut async_output, &mut async_fetch_output).await?; + + assert_eq!( + blocking_bytes, async_bytes, + "write_fetch_response byte count should match for no-pack case" + ); + assert_eq!( + blocking_output, + async_output.into_inner(), + "write_fetch_response output bytes should match for no-pack case" + ); + } + + Ok(()) +} + +/// **Validates: Requirements 3.2, 3.3** +/// +/// Property 2: Async write_fetch_response correctly frames pack data as sideband channel 1. +/// Test with various pack data sizes (empty, small, exactly MAX_SIDEBAND_DATA_BYTES, larger +/// requiring multiple chunks). Extract sideband payloads from output, concatenate, and verify +/// they equal the original input. +#[async_std::test] +async fn property_pack_data_round_trip_through_sideband_framing( +) -> Result<(), Box> { + // Test sizes: 0, 10, 1000, exactly 65515 (MAX_SIDEBAND_DATA_BYTES), and 100000 (multiple chunks) + let test_sizes: &[usize] = &[0, 10, 1000, 65515, 100_000]; + + for &size in test_sizes { + // Create pack data of the given size with a recognizable pattern + let pack_data: Vec = (0..size).map(|i| (i % 256) as u8).collect(); + + let mut response = AsyncFetchOutput::new(Cursor::new(pack_data.clone())); + // Add an acknowledgement so we can identify the packfile section boundary + let ack_id = gix_hash::ObjectId::from_hex(b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .expect("valid hex"); + response + .acknowledgements + .push(Acknowledgement::Common(ack_id)); + + let mut output = Cursor::new(Vec::::new()); + let bytes_sent = write_fetch_response(&mut output, &mut response).await?; + + let output_bytes = output.into_inner(); + + if size == 0 { + // AsyncFetchOutput::new wraps Some(reader), so pack_data is Some even for 0 bytes. + // The code writes "packfile" header then reads 0 bytes, so packfile section exists + // but has no data bands. + assert_eq!( + bytes_sent, 0, + "empty pack data should report 0 bytes sent (size={size})" + ); + } else { + assert_eq!( + bytes_sent, size as u64, + "bytes_sent should equal input size for size={size}" + ); + } + + // Parse output to extract sideband channel 1 payloads + let mut reader = StreamingPeekableIter::new( + output_bytes.as_slice(), + &[PacketLineRef::Flush, PacketLineRef::Delimiter], + false, + ); + + // Skip acknowledgments section + let header = next_text_line(&mut reader)?; + assert_eq!( + header.as_slice(), + b"acknowledgments", + "expected acknowledgments header for size={size}" + ); + // Skip ACK line + let _ack_line = next_text_line(&mut reader)?; + // Consume the delimiter that ends acks section + assert!( + reader.read_line().is_none(), + "reader should stop at delimiter after acks for size={size}" + ); + assert_eq!( + reader.stopped_at(), + Some(PacketLineRef::Delimiter), + "acknowledgments section should end with delimiter for size={size}" + ); + + // Reset to look for packfile section + reader.reset_with(&[PacketLineRef::Flush]); + + // Read packfile header + let packfile_header = next_text_line(&mut reader)?; + assert_eq!( + packfile_header.as_slice(), + b"packfile", + "expected packfile header for size={size}" + ); + + // Extract all sideband channel 1 payloads + let mut concatenated = Vec::new(); + loop { + let line = reader.read_line(); + match line { + None => break, + Some(Ok(Ok(packet))) => match packet.decode_band() { + Ok(band) => match band { + BandRef::Data(data) => { + // Verify each chunk is at most MAX_SIDEBAND_DATA_BYTES + assert!( + data.len() <= 65515, + "sideband payload should not exceed MAX_SIDEBAND_DATA_BYTES, got {} for size={size}", + data.len() + ); + concatenated.extend_from_slice(data); + } + BandRef::Progress(_) => {} + BandRef::Error(err) => { + panic!("unexpected error band for size={size}: {err:?}") + } + }, + Err(_) => break, + }, + Some(Ok(Err(e))) => panic!("decode error for size={size}: {e}"), + Some(Err(e)) => panic!("IO error for size={size}: {e}"), + } + } + + assert_eq!( + concatenated, pack_data, + "concatenated sideband payloads should equal original pack data for size={size}" + ); + + assert_eq!( + reader.stopped_at(), + Some(PacketLineRef::Flush), + "response should end with flush packet for size={size}" + ); + } + + Ok(()) +} + +/// **Validates: Requirements 3.4** +/// +/// Property 3: write_fetch_response byte count equals pack bytes consumed. +/// Test with known-length pack data inputs of varying sizes and verify the returned u64 +/// equals the input length. +#[async_std::test] +async fn property_returned_byte_count_equals_pack_data_length( +) -> Result<(), Box> { + // Test a range of sizes including edge cases + let test_sizes: &[usize] = &[0, 1, 100, 1000, 8192, 65515, 65516, 100_000, 200_000]; + + for &size in test_sizes { + let pack_data: Vec = (0..size).map(|i| (i % 256) as u8).collect(); + + let mut response = AsyncFetchOutput::new(Cursor::new(pack_data)); + let mut output = Cursor::new(Vec::::new()); + let bytes_sent = write_fetch_response(&mut output, &mut response).await?; + + assert_eq!( + bytes_sent, size as u64, + "returned byte count should equal input pack data length for size={size}" + ); + } + + // Also verify that without_pack() returns 0 + { + let mut response = AsyncFetchOutput::without_pack(); + response.acknowledgements.push(Acknowledgement::Nak); + let mut output = Cursor::new(Vec::::new()); + let bytes_sent = write_fetch_response(&mut output, &mut response).await?; + + assert_eq!( + bytes_sent, 0, + "without_pack() should always return 0 bytes sent" + ); + } + + Ok(()) +} diff --git a/gix-protocol/tests/fixtures/receive-pack/v1/delete-main.request b/gix-protocol/tests/fixtures/receive-pack/v1/delete-main.request new file mode 100644 index 0000000000000000000000000000000000000000..851456ece5fb24023c29811454c695570afa680a GIT binary patch literal 189 zcma*hK^B4_6h`5e!mtJ)AbMorpi%4ipMX+{9(lF?Y6sonH@aGrx5*41CfI49!L_~d zGzIH{1o#fnlp;9_LjRdKVyev&5o)s{m(v&a?O<6M{HpS8Ma>~fLJ6{W53$ibPAt=Q itl+UOFamQdJkFULt6Hx*b-wQVGpbu=X*zfLt$YBEPd2jv literal 0 HcmV?d00001 diff --git a/gix-protocol/tests/fixtures/receive-pack/v1/push-basic.request b/gix-protocol/tests/fixtures/receive-pack/v1/push-basic.request new file mode 100644 index 0000000000000000000000000000000000000000..c828b444c26197d5edd2fbe71c08131f4c124e37 GIT binary patch literal 401 zcmXpoNU}5_5-6CMo2Mow87G<=rKKjOr5TwRCR>k6zk_EX67*f1q$+uN_2}$5=%;pb<2ztiZfGEb(0eFQgqEsvK8`^vQm>v zbkp*SauZ8zi!%}p6%x}^^Ga;fGfVW1^o%X_O!YJz3kq^l_1rT{bPY{SG(m0-aCG)& zU|?VZV&-{V6?5jEcI09*pyX?LgpJEV+EzENn3g&X53nsa^SIc1@rX(2e&We z5^?eCS3Tp?eZI9qYn4cofYJ07GQm!MM^EORw|k&B`>pfCYrN-viig*F-wv~Vc2Dnt za`hbBi!AK@9q(4Jo%J$)^Xm0kp<&+{lOLI8EMcmcV{BqzU~CxZar%tsRqd4;i5$Vv ze;@9SJMy*lVA@^p?Cmnzb{{qfFuYmGzu%-{>{sPnj5)?=kw$Nm(&xqq@;93HKM< N#CDkHgs2*_000+wlHvdW literal 0 HcmV?d00001 diff --git a/gix-protocol/tests/fixtures/receive-pack/v1/push-with-option.request b/gix-protocol/tests/fixtures/receive-pack/v1/push-with-option.request new file mode 100644 index 0000000000000000000000000000000000000000..5d3cf2c5306a5e01fd3ae7bdb3f53d14b3345eb4 GIT binary patch literal 461 zcmV;;0W$tDFk?0}H#cQrVl!biGG=9AW@a)pF=IG4IX7lxWi>Q4G&3|gW-&4~FgQ0L zGG;M2IbktmGGjPmI5aahF*#ymWHLEnVP;`rIW=KnFk)t9Vl-l8AaZ49b1!IRVPtbJ zZDDC{03dQ@aBp&SEpv2Xbaitrb}}GyX=G(BVqtD%EjBc3AaHeaXf1DWbZKvHb0BYG zYGq?|EoN_WZDDjhb7)~PAYo@^Zgf3oX>>0#E;BhUH7+PYaBys8E=Or}EipATDKIcF zFfcGMVsvt0V`V)tFfcGMP(edW0000200003oeg-Los2;W!Y~j3?|DV;f@G6yHvtjR zmu!<=6s##R1)p!hA9$RBnbAcog3K_-kQ6g>&8a2=EFq8*);wV@8WK3Oa+lYzkw|zB zj9!IReT0%IlUBiqF~qFk(IqmxjeG0%#_QO+*lQd1RDN)g4=?Lej-@SU3$*Vi@P4uf zaL$?CE{`$fuh_h$v{yA>il{<0qyl)HGc+(TGci#}%gjmDE2$`9X!Q4T(p2A}AK&Ht zr6k}x! D<9)HJ literal 0 HcmV?d00001 diff --git a/gix-protocol/tests/protocol/mod.rs b/gix-protocol/tests/protocol/mod.rs index 498a93a4f06..d37c758b0f8 100644 --- a/gix-protocol/tests/protocol/mod.rs +++ b/gix-protocol/tests/protocol/mod.rs @@ -8,6 +8,10 @@ pub fn fixture_bytes(path: &str) -> Vec { mod command; pub mod fetch; mod handshake; +#[cfg(feature = "blocking-server")] +mod receive_pack; +#[cfg(feature = "blocking-server")] +mod upload_pack; pub use fetch::_impl::{FetchConnection, fetch}; pub mod remote_progress; diff --git a/gix-protocol/tests/protocol/receive_pack.rs b/gix-protocol/tests/protocol/receive_pack.rs new file mode 100644 index 00000000000..a7148879889 --- /dev/null +++ b/gix-protocol/tests/protocol/receive_pack.rs @@ -0,0 +1,192 @@ +//! Receive-pack protocol-contract tests backed by real `git push` client captures. +//! +//! On the currently tested git client (`git/2.39.5`), push requests are framed as V1 command +//! sections (including optional push-options) even when protocol v2 is requested globally. +use std::io::{BufRead as _, BufReader, Cursor, Read as _}; + +use bstr::ByteSlice; +use gix_protocol::receive_pack::{self, RefStatus, Request, Response, UnpackStatus}; +use gix_transport::packetline::{BandRef, PacketLineRef, blocking_io::StreamingPeekableIter}; + +#[derive(Default)] +struct RecordingDelegate { + response: Response, + seen_request: Option, + seen_pack_prefix: Option<[u8; 4]>, +} + +impl receive_pack::Delegate for RecordingDelegate { + fn receive( + &mut self, + request: &Request, + pack_data: &mut dyn std::io::Read, + ) -> Result> { + self.seen_request = Some(request.clone()); + + let mut prefix = [0u8; 4]; + match pack_data.read_exact(&mut prefix) { + Ok(()) => self.seen_pack_prefix = Some(prefix), + Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => self.seen_pack_prefix = None, + Err(err) => return Err(err.into()), + } + + Ok(self.response.clone()) + } +} + +#[test] +fn parse_v1_request_from_real_push_basic_transcript() -> crate::Result { + let mut input = BufReader::new(Cursor::new(fixture_request("push-basic.request"))); + let request = receive_pack::parse_v1_request(&mut input)?; + + assert_eq!(request.updates.len(), 1); + assert_eq!(request.push_options.len(), 0); + assert!(request.has_capability("report-status-v2")); + assert!(request.has_capability("side-band-64k")); + assert!( + request.capabilities.iter().any( + |capability| capability.name.as_bstr() == "object-format".as_bytes().as_bstr() + && capability.value.as_ref().map(|value| value.as_bstr()) == Some("sha1".as_bytes().as_bstr()) + ), + "real client transcript should include object-format=sha1" + ); + assert!( + request + .capabilities + .iter() + .any(|capability| capability.name.as_bstr() == "agent".as_bytes().as_bstr() + && capability + .value + .as_ref() + .is_some_and(|value| value.as_bstr().as_bytes().starts_with(b"git/"))), + "real client transcript should include agent=git/" + ); + assert_eq!( + request.updates[0].old_id.to_string(), + "0000000000000000000000000000000000000000" + ); + assert_eq!( + request.updates[0].new_id.to_string(), + "477eab3a52feaff241c8797fde5454349f125087" + ); + assert_eq!( + request.updates[0].ref_name.as_bstr(), + "refs/heads/main".as_bytes().as_bstr() + ); + + let mut pack_prefix = [0u8; 4]; + input.read_exact(&mut pack_prefix)?; + assert_eq!(pack_prefix, *b"PACK", "pack bytes should follow the command section"); + Ok(()) +} + +#[test] +fn parse_v1_request_from_real_push_with_option_transcript() -> crate::Result { + let mut input = BufReader::new(Cursor::new(fixture_request("push-with-option.request"))); + let request = receive_pack::parse_v1_request(&mut input)?; + + assert_eq!(request.updates.len(), 1); + assert!(request.has_capability("push-options")); + assert_eq!( + request.push_options, + vec![bstr::BString::from("trace=1")], + "client-sent push-options section should be preserved" + ); + assert_eq!( + request.updates[0].old_id.to_string(), + "477eab3a52feaff241c8797fde5454349f125087" + ); + assert_eq!( + request.updates[0].new_id.to_string(), + "2f189a1d2c8b843619bcd29aafaa95aa0bfeb4bd" + ); + + let mut pack_prefix = [0u8; 4]; + input.read_exact(&mut pack_prefix)?; + assert_eq!(pack_prefix, *b"PACK"); + Ok(()) +} + +#[test] +fn parse_v1_request_from_real_delete_transcript_has_no_pack() -> crate::Result { + let mut input = BufReader::new(Cursor::new(fixture_request("delete-main.request"))); + let request = receive_pack::parse_v1_request(&mut input)?; + + assert_eq!(request.updates.len(), 1); + assert_eq!(request.push_options.len(), 0); + assert_eq!( + request.updates[0].old_id.to_string(), + "2f189a1d2c8b843619bcd29aafaa95aa0bfeb4bd" + ); + assert_eq!( + request.updates[0].new_id.to_string(), + "0000000000000000000000000000000000000000" + ); + assert!( + input.fill_buf()?.is_empty(), + "delete transcript should not contain pack data" + ); + Ok(()) +} + +#[test] +fn serve_v1_from_real_push_transcript_writes_sideband_report_status() -> crate::Result { + let request = fixture_request("push-basic.request"); + let mut output = Vec::new(); + let mut delegate = RecordingDelegate { + response: Response { + unpack_status: UnpackStatus::Ok, + ref_statuses: vec![RefStatus::Ok { + ref_name: "refs/heads/main".into(), + }], + sideband_messages: Vec::new(), + }, + ..Default::default() + }; + + let outcome = receive_pack::serve_v1(request.as_slice(), &mut output, &mut delegate)?; + assert_eq!(outcome.updates_received, 1); + assert_eq!(outcome.push_options_received, 0); + assert_eq!(outcome.ref_statuses_sent, 1); + assert!(outcome.report_status_sent); + assert!(outcome.sideband_bytes_sent > 0); + assert_eq!(delegate.seen_pack_prefix, Some(*b"PACK")); + + let mut sideband_reader = StreamingPeekableIter::new(output.as_slice(), &[PacketLineRef::Flush], false); + let mut report_status_payload = Vec::::new(); + while let Some(line) = sideband_reader.read_line() { + let line = line??; + match line.decode_band()? { + BandRef::Data(data) => report_status_payload.extend_from_slice(data), + BandRef::Progress(_) | BandRef::Error(_) => {} + } + } + assert_eq!(sideband_reader.stopped_at(), Some(PacketLineRef::Flush)); + + let mut report_status_reader = + StreamingPeekableIter::new(report_status_payload.as_slice(), &[PacketLineRef::Flush], false); + assert_eq!( + next_text_line(&mut report_status_reader)?.as_bstr(), + "unpack ok".as_bytes().as_bstr() + ); + assert_eq!( + next_text_line(&mut report_status_reader)?.as_bstr(), + "ok refs/heads/main".as_bytes().as_bstr() + ); + assert!(report_status_reader.read_line().is_none()); + assert_eq!(report_status_reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) +} + +fn fixture_request(name: &str) -> Vec { + crate::fixture_bytes(&format!("receive-pack/v1/{name}")) +} + +fn next_text_line(reader: &mut StreamingPeekableIter<&[u8]>) -> Result> { + let line = reader + .read_line() + .expect("expected packetline") + .expect("read should succeed") + .expect("decode should succeed"); + Ok(line.as_text().expect("expected text packetline").as_bstr().to_owned()) +} diff --git a/gix-protocol/tests/protocol/upload_pack.rs b/gix-protocol/tests/protocol/upload_pack.rs new file mode 100644 index 00000000000..eb515667b29 --- /dev/null +++ b/gix-protocol/tests/protocol/upload_pack.rs @@ -0,0 +1,679 @@ +//! Integration tests for upload-pack `serve_v2` verifying wire-level correctness +//! of the `acknowledgments` and `packfile` sections when `done=true`. +use std::io::{Cursor, Write as _}; + +use bstr::ByteSlice; +use gix_protocol::{ + fetch::response::Acknowledgement, + handshake::Ref, + upload_pack::{Delegate, Fetch, FetchOutput, LsRefs, Outcome, ServerConfig, serve_v2}, +}; +use gix_transport::packetline::{BandRef, PacketLineRef, blocking_io::StreamingPeekableIter}; + +type BoxError = Box; + +#[derive(Default)] +struct MockDelegate { + refs: Vec, + fetch_output: Option, + seen_fetch: Option, +} + +impl Delegate for MockDelegate { + fn ls_refs(&mut self, _request: &LsRefs) -> Result, BoxError> { + Ok(self.refs.clone()) + } + + fn fetch(&mut self, request: &Fetch) -> Result { + self.seen_fetch = Some(request.clone()); + self.fetch_output + .take() + .ok_or_else(|| std::io::Error::other("fetch output should be configured").into()) + } +} + +/// Fresh clone scenario: `done=true`, no haves, delegate returns empty acknowledgements + pack data. +/// The wire output must contain `packfile` section WITHOUT a preceding `acknowledgments` section. +#[test] +fn serve_v2_done_fresh_clone_omits_acknowledgments_section() -> crate::Result { + let request = request_bytes( + "fetch", + &["agent=git/test"], + &["want 808e50d724f604f69ab93c6da2919c014667bedb", "done"], + )?; + let mut output = Vec::new(); + let fetch_output = FetchOutput::new(Cursor::new(b"PACK\0\0\0\0".to_vec())); + // Empty acknowledgements = fresh clone with done=true + assert!( + fetch_output.acknowledgements.is_empty(), + "fresh clone should have no acknowledgements" + ); + let mut delegate = MockDelegate { + fetch_output: Some(fetch_output), + ..Default::default() + }; + + let outcome = serve_v2(request.as_slice(), &mut output, &mut delegate, &ServerConfig::default())?; + assert_eq!( + outcome, + Outcome::Fetch { + acknowledgements_sent: 0, + shallow_updates_sent: 0, + wanted_refs_sent: 0, + pack_bytes_sent: 8, + }, + "fresh clone with done=true should send zero acknowledgements" + ); + assert!( + delegate.seen_fetch.as_ref().expect("request should be captured").done, + "done flag should be parsed from request" + ); + + // Parse wire output: first section header must be `packfile`, not `acknowledgments` + let mut reader = StreamingPeekableIter::new(output.as_slice(), &[PacketLineRef::Flush], false); + let first_section = next_text_line(&mut reader)?; + assert_eq!( + first_section.as_bstr(), + "packfile".as_bytes().as_bstr(), + "fresh clone with done=true must start with packfile section, no acknowledgments" + ); + assert_eq!( + next_band_data(&mut reader)?, + b"PACK\0\0\0\0", + "pack data should follow packfile header" + ); + assert!( + reader.read_line().is_none(), + "flush should terminate response" + ); + assert_eq!(reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) +} + +/// Fetch with common objects: `done=true`, delegate returns acknowledgements with +/// `[Common(id), Ready]` and pack data. +/// The wire output must contain `acknowledgments` section with `ready` line followed by `packfile`. +#[test] +fn serve_v2_done_with_common_objects_includes_acknowledgments_with_ready() -> crate::Result { + let common_id = gix_hash::ObjectId::from_hex(b"808e50d724f604f69ab93c6da2919c014667bedb")?; + let request = request_bytes( + "fetch", + &["agent=git/test"], + &[ + "want 9e320b9180e0b5580af68fa3255b7f3d9ecd5af0", + &format!("have {common_id}"), + "done", + ], + )?; + let mut output = Vec::new(); + let mut fetch_output = FetchOutput::new(Cursor::new(b"PACK\0\0\0\0".to_vec())); + fetch_output + .acknowledgements + .push(Acknowledgement::Common(common_id)); + fetch_output.acknowledgements.push(Acknowledgement::Ready); + let mut delegate = MockDelegate { + fetch_output: Some(fetch_output), + ..Default::default() + }; + + let outcome = serve_v2(request.as_slice(), &mut output, &mut delegate, &ServerConfig::default())?; + assert_eq!( + outcome, + Outcome::Fetch { + acknowledgements_sent: 2, + shallow_updates_sent: 0, + wanted_refs_sent: 0, + pack_bytes_sent: 8, + }, + "fetch with common objects and done=true should send Common + Ready" + ); + assert!( + delegate.seen_fetch.as_ref().expect("request should be captured").done, + "done flag should be parsed from request" + ); + + // Parse wire output: acknowledgments section with ready, then packfile + let mut reader = StreamingPeekableIter::new(output.as_slice(), &[PacketLineRef::Flush], false); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "acknowledgments".as_bytes().as_bstr(), + "response should start with acknowledgments section" + ); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + format!("ACK {common_id} common").as_bytes().as_bstr(), + "first acknowledgement should be Common for the shared object" + ); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "ready".as_bytes().as_bstr(), + "acknowledgments section should end with ready line when done=true" + ); + expect_delimiter(&mut reader)?; + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "packfile".as_bytes().as_bstr(), + "packfile section should follow acknowledgments" + ); + assert_eq!( + next_band_data(&mut reader)?, + b"PACK\0\0\0\0", + "pack data should follow packfile header" + ); + assert!( + reader.read_line().is_none(), + "flush should terminate response" + ); + assert_eq!(reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) +} + +/// Client sends `have` lines with objects the server doesn't recognize, `done=true`. +/// Delegate returns empty `FetchOutput` (no acknowledgements, no pack). Wire output should be just a flush. +#[test] +fn serve_v2_done_all_unknown_haves_no_pack_produces_empty_response() -> crate::Result { + let request = request_bytes( + "fetch", + &["agent=git/test"], + &[ + "want 9e320b9180e0b5580af68fa3255b7f3d9ecd5af0", + "have aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "have bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "done", + ], + )?; + let mut output = Vec::new(); + let fetch_output = FetchOutput::without_pack(); + let mut delegate = MockDelegate { + fetch_output: Some(fetch_output), + ..Default::default() + }; + + let outcome = serve_v2(request.as_slice(), &mut output, &mut delegate, &ServerConfig::default())?; + assert_eq!( + outcome, + Outcome::Fetch { + acknowledgements_sent: 0, + shallow_updates_sent: 0, + wanted_refs_sent: 0, + pack_bytes_sent: 0, + }, + "server with nothing to say should send zero in all sections" + ); + assert!( + delegate.seen_fetch.as_ref().expect("request should be captured").done, + "done flag should be parsed from request" + ); + + // Wire output should be just a flush (no sections at all) + let mut reader = StreamingPeekableIter::new(output.as_slice(), &[PacketLineRef::Flush], false); + assert!( + reader.read_line().is_none(), + "empty response should contain only flush" + ); + assert_eq!(reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) +} + +/// Client sends wants but no haves, `done=false`. Delegate returns NAK acknowledgement and no pack. +/// Wire output should have `acknowledgments` section with NAK, delimiter, then flush. +#[test] +fn serve_v2_no_done_ongoing_negotiation_nak() -> crate::Result { + let request = request_bytes( + "fetch", + &["agent=git/test"], + &["want 9e320b9180e0b5580af68fa3255b7f3d9ecd5af0"], + )?; + let mut output = Vec::new(); + let mut fetch_output = FetchOutput::without_pack(); + fetch_output.acknowledgements.push(Acknowledgement::Nak); + let mut delegate = MockDelegate { + fetch_output: Some(fetch_output), + ..Default::default() + }; + + let outcome = serve_v2(request.as_slice(), &mut output, &mut delegate, &ServerConfig::default())?; + assert_eq!( + outcome, + Outcome::Fetch { + acknowledgements_sent: 1, + shallow_updates_sent: 0, + wanted_refs_sent: 0, + pack_bytes_sent: 0, + }, + "ongoing negotiation with NAK should send one acknowledgement and no pack" + ); + assert!( + !delegate.seen_fetch.as_ref().expect("request should be captured").done, + "done flag should be false for ongoing negotiation" + ); + + // Parse wire output: acknowledgments section with NAK, delimiter, then flush + let mut reader = StreamingPeekableIter::new(output.as_slice(), &[PacketLineRef::Flush], false); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "acknowledgments".as_bytes().as_bstr(), + "response should start with acknowledgments section" + ); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "NAK".as_bytes().as_bstr(), + "acknowledgments section should contain NAK" + ); + expect_delimiter(&mut reader)?; + assert!( + reader.read_line().is_none(), + "flush should terminate response after acknowledgments" + ); + assert_eq!(reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) +} + +/// Client sends haves the server knows, `done=false`. Delegate returns Common acknowledgement, no pack. +/// Wire output should have `acknowledgments` section with ACK common, delimiter, then flush. +#[test] +fn serve_v2_no_done_ongoing_negotiation_common_only() -> crate::Result { + let common_id = gix_hash::ObjectId::from_hex(b"808e50d724f604f69ab93c6da2919c014667bedb")?; + let request = request_bytes( + "fetch", + &["agent=git/test"], + &[ + "want 9e320b9180e0b5580af68fa3255b7f3d9ecd5af0", + &format!("have {common_id}"), + ], + )?; + let mut output = Vec::new(); + let mut fetch_output = FetchOutput::without_pack(); + fetch_output + .acknowledgements + .push(Acknowledgement::Common(common_id)); + let mut delegate = MockDelegate { + fetch_output: Some(fetch_output), + ..Default::default() + }; + + let outcome = serve_v2(request.as_slice(), &mut output, &mut delegate, &ServerConfig::default())?; + assert_eq!( + outcome, + Outcome::Fetch { + acknowledgements_sent: 1, + shallow_updates_sent: 0, + wanted_refs_sent: 0, + pack_bytes_sent: 0, + }, + "ongoing negotiation with common should send one acknowledgement and no pack" + ); + assert!( + !delegate.seen_fetch.as_ref().expect("request should be captured").done, + "done flag should be false for ongoing negotiation" + ); + + // Parse wire output: acknowledgments section with ACK common, no ready, delimiter, flush + let mut reader = StreamingPeekableIter::new(output.as_slice(), &[PacketLineRef::Flush], false); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "acknowledgments".as_bytes().as_bstr(), + "response should start with acknowledgments section" + ); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + format!("ACK {common_id} common").as_bytes().as_bstr(), + "acknowledgments section should contain ACK for common object" + ); + expect_delimiter(&mut reader)?; + assert!( + reader.read_line().is_none(), + "flush should terminate response with no packfile section" + ); + assert_eq!(reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) +} + +/// Tests all optional sections together: `done=true`, delegate returns acknowledgements with +/// multiple Common + Ready, wanted-refs, and pack data. +#[test] +fn serve_v2_done_multiple_common_haves_with_wanted_refs_and_pack() -> crate::Result { + use gix_protocol::fetch::response::WantedRef; + + let id1 = gix_hash::ObjectId::from_hex(b"808e50d724f604f69ab93c6da2919c014667bedb")?; + let id2 = gix_hash::ObjectId::from_hex(b"9e320b9180e0b5580af68fa3255b7f3d9ecd5af0")?; + let wanted_id = gix_hash::ObjectId::from_hex(b"dce0ea858eef7ff61ad345cc5cdac62203fb3c10")?; + let request = request_bytes( + "fetch", + &["agent=git/test"], + &[ + "want 9e320b9180e0b5580af68fa3255b7f3d9ecd5af0", + &format!("have {id1}"), + &format!("have {id2}"), + "want-ref refs/heads/main", + "done", + ], + )?; + let mut output = Vec::new(); + let mut fetch_output = FetchOutput::new(Cursor::new(b"PACK\0\0\0\0".to_vec())); + fetch_output + .acknowledgements + .push(Acknowledgement::Common(id1)); + fetch_output + .acknowledgements + .push(Acknowledgement::Common(id2)); + fetch_output.acknowledgements.push(Acknowledgement::Ready); + fetch_output.wanted_refs.push(WantedRef { + id: wanted_id, + path: "refs/heads/main".into(), + }); + let mut delegate = MockDelegate { + fetch_output: Some(fetch_output), + ..Default::default() + }; + + let outcome = serve_v2(request.as_slice(), &mut output, &mut delegate, &ServerConfig::default())?; + assert_eq!( + outcome, + Outcome::Fetch { + acknowledgements_sent: 3, + shallow_updates_sent: 0, + wanted_refs_sent: 1, + pack_bytes_sent: 8, + }, + "all sections should be counted correctly" + ); + + // Parse wire output: acknowledgments, wanted-refs, packfile + let mut reader = StreamingPeekableIter::new(output.as_slice(), &[PacketLineRef::Flush], false); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "acknowledgments".as_bytes().as_bstr(), + "response should start with acknowledgments section" + ); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + format!("ACK {id1} common").as_bytes().as_bstr(), + "first ACK should be for id1" + ); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + format!("ACK {id2} common").as_bytes().as_bstr(), + "second ACK should be for id2" + ); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "ready".as_bytes().as_bstr(), + "acknowledgments section should end with ready" + ); + expect_delimiter(&mut reader)?; + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "wanted-refs".as_bytes().as_bstr(), + "wanted-refs section should follow acknowledgments" + ); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + format!("{wanted_id} refs/heads/main").as_bytes().as_bstr(), + "wanted-ref line should contain id and ref path" + ); + expect_delimiter(&mut reader)?; + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "packfile".as_bytes().as_bstr(), + "packfile section should follow wanted-refs" + ); + assert_eq!( + next_band_data(&mut reader)?, + b"PACK\0\0\0\0", + "pack data should follow packfile header" + ); + assert!( + reader.read_line().is_none(), + "flush should terminate response" + ); + assert_eq!(reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) +} + +/// Delegate returns a completely empty `FetchOutput` (no acknowledgements, no shallow_updates, +/// no wanted_refs, no pack_data). Wire output should be just a flush. +#[test] +fn serve_v2_done_empty_fetch_output_no_sections() -> crate::Result { + let request = request_bytes( + "fetch", + &["agent=git/test"], + &["want 9e320b9180e0b5580af68fa3255b7f3d9ecd5af0", "done"], + )?; + let mut output = Vec::new(); + let fetch_output = FetchOutput::without_pack(); + let mut delegate = MockDelegate { + fetch_output: Some(fetch_output), + ..Default::default() + }; + + let outcome = serve_v2(request.as_slice(), &mut output, &mut delegate, &ServerConfig::default())?; + assert_eq!( + outcome, + Outcome::Fetch { + acknowledgements_sent: 0, + shallow_updates_sent: 0, + wanted_refs_sent: 0, + pack_bytes_sent: 0, + }, + "completely empty FetchOutput should produce zero counts" + ); + + // Wire output should be just a flush + let mut reader = StreamingPeekableIter::new(output.as_slice(), &[PacketLineRef::Flush], false); + assert!( + reader.read_line().is_none(), + "empty FetchOutput should produce only a flush on the wire" + ); + assert_eq!(reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) +} + +/// `done=true`, delegate returns acknowledgements with Common + Ready, shallow updates, +/// and pack data. Verifies section ordering: acknowledgments, shallow-info, packfile. +#[test] +fn serve_v2_done_with_shallow_updates_between_acks_and_pack() -> crate::Result { + use gix_protocol::fetch::response::ShallowUpdate; + + let common_id = gix_hash::ObjectId::from_hex(b"808e50d724f604f69ab93c6da2919c014667bedb")?; + let shallow_id = gix_hash::ObjectId::from_hex(b"dce0ea858eef7ff61ad345cc5cdac62203fb3c10")?; + let request = request_bytes( + "fetch", + &["agent=git/test"], + &[ + "want 9e320b9180e0b5580af68fa3255b7f3d9ecd5af0", + &format!("have {common_id}"), + "done", + ], + )?; + let mut output = Vec::new(); + let mut fetch_output = FetchOutput::new(Cursor::new(b"PACK\0\0\0\0".to_vec())); + fetch_output + .acknowledgements + .push(Acknowledgement::Common(common_id)); + fetch_output.acknowledgements.push(Acknowledgement::Ready); + fetch_output + .shallow_updates + .push(ShallowUpdate::Shallow(shallow_id)); + let mut delegate = MockDelegate { + fetch_output: Some(fetch_output), + ..Default::default() + }; + + let outcome = serve_v2(request.as_slice(), &mut output, &mut delegate, &ServerConfig::default())?; + assert_eq!( + outcome, + Outcome::Fetch { + acknowledgements_sent: 2, + shallow_updates_sent: 1, + wanted_refs_sent: 0, + pack_bytes_sent: 8, + }, + "all sections should be counted correctly with shallow updates" + ); + + // Parse wire output: acknowledgments, shallow-info, packfile + let mut reader = StreamingPeekableIter::new(output.as_slice(), &[PacketLineRef::Flush], false); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "acknowledgments".as_bytes().as_bstr(), + "response should start with acknowledgments section" + ); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + format!("ACK {common_id} common").as_bytes().as_bstr(), + "first line should be ACK for common object" + ); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "ready".as_bytes().as_bstr(), + "acknowledgments section should end with ready" + ); + expect_delimiter(&mut reader)?; + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "shallow-info".as_bytes().as_bstr(), + "shallow-info section should follow acknowledgments" + ); + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + format!("shallow {shallow_id}").as_bytes().as_bstr(), + "shallow-info should contain shallow line with id" + ); + expect_delimiter(&mut reader)?; + assert_eq!( + next_text_line(&mut reader)?.as_bstr(), + "packfile".as_bytes().as_bstr(), + "packfile section should follow shallow-info" + ); + assert_eq!( + next_band_data(&mut reader)?, + b"PACK\0\0\0\0", + "pack data should follow packfile header" + ); + assert!( + reader.read_line().is_none(), + "flush should terminate response" + ); + assert_eq!(reader.stopped_at(), Some(PacketLineRef::Flush)); + Ok(()) +} + +fn next_text_line(reader: &mut StreamingPeekableIter<&[u8]>) -> Result> { + let line = reader + .read_line() + .expect("expected packetline") + .expect("read should succeed") + .expect("decode should succeed"); + Ok(line.as_text().expect("expected text packetline").as_bstr().to_owned()) +} + +fn expect_delimiter(reader: &mut StreamingPeekableIter<&[u8]>) -> Result<(), Box> { + let line = reader + .read_line() + .expect("expected packetline") + .expect("read should succeed") + .expect("decode should succeed"); + match line { + PacketLineRef::Delimiter => Ok(()), + other => Err(format!("expected delimiter, got {other:?}").into()), + } +} + +fn next_band_data(reader: &mut StreamingPeekableIter<&[u8]>) -> Result, Box> { + let line = reader + .read_line() + .expect("expected packetline") + .expect("read should succeed") + .expect("decode should succeed"); + match line.decode_band()? { + BandRef::Data(data) => Ok(data.to_vec()), + other => Err(format!("expected data band, got {other:?}").into()), + } +} + +/// When serve_v2 receives a request with a mismatched `object-format` (e.g., sha256 against a +/// SHA-1 server), the delegate's methods must never be called — validation rejects the request +/// before any repository access occurs. +#[test] +fn serve_v2_delegate_not_called_on_validation_failure() -> crate::Result { + use std::sync::atomic::{AtomicBool, Ordering}; + + /// A delegate that sets a flag (and panics) if any method is invoked. + struct NeverCalledDelegate { + was_called: AtomicBool, + } + + impl Delegate for NeverCalledDelegate { + fn ls_refs(&mut self, _request: &LsRefs) -> Result, BoxError> { + self.was_called.store(true, Ordering::SeqCst); + panic!("delegate ls_refs should not be called on validation failure"); + } + + fn fetch(&mut self, _request: &Fetch) -> Result { + self.was_called.store(true, Ordering::SeqCst); + panic!("delegate fetch should not be called on validation failure"); + } + } + + // Build a fetch request declaring object-format=sha256 + let request = request_bytes( + "fetch", + &["object-format=sha256"], + &["want 9e320b9180e0b5580af68fa3255b7f3d9ecd5af0", "done"], + )?; + let mut output = Vec::new(); + let mut delegate = NeverCalledDelegate { + was_called: AtomicBool::new(false), + }; + + // Server is configured for SHA-1, so sha256 request should be rejected + let config = ServerConfig::default(); + assert_eq!( + config.object_hash, + gix_hash::Kind::Sha1, + "default config should be SHA-1" + ); + + let result = serve_v2(request.as_slice(), &mut output, &mut delegate, &config); + + assert!(result.is_err(), "serve_v2 should return an error for mismatched object-format"); + let err = result.unwrap_err(); + assert!( + matches!(err, gix_protocol::upload_pack::Error::UnsupportedObjectFormat { .. }), + "error should be UnsupportedObjectFormat, got: {err:?}" + ); + assert!( + !delegate.was_called.load(Ordering::SeqCst), + "delegate methods must not be called when validation fails" + ); + + Ok(()) +} + +fn request_bytes( + command: &str, + features: &[&str], + arguments: &[&str], +) -> Result, Box> { + use gix_transport::packetline::blocking_io::{Writer, encode}; + + let mut out = Vec::new(); + let mut writer = Writer::new(&mut out); + writer.enable_text_mode(); + writer.write_all(format!("command={command}").as_bytes())?; + for feature in features { + writer.write_all(feature.as_bytes())?; + } + if arguments.is_empty() { + encode::flush_to_write(writer.inner_mut())?; + return Ok(out); + } + + encode::delim_to_write(writer.inner_mut())?; + for argument in arguments { + writer.write_all(argument.as_bytes())?; + } + encode::flush_to_write(writer.inner_mut())?; + Ok(out) +} diff --git a/gix-transport/src/client/async_io/request.rs b/gix-transport/src/client/async_io/request.rs index 062de3e1352..46b9e3135d8 100644 --- a/gix-transport/src/client/async_io/request.rs +++ b/gix-transport/src/client/async_io/request.rs @@ -87,6 +87,7 @@ impl<'a> RequestWriter<'a> { } MessageKind::Text(t) => { if self.trace { + #[allow(unused_imports)] use bstr::ByteSlice; gix_features::trace::trace!(">> {}", t.as_bstr()); } From 01de3ec47cdfb26b124695c7861b6ea500b3c279 Mon Sep 17 00:00:00 2001 From: Ethan Brooks Date: Mon, 3 Aug 2026 23:43:12 +1000 Subject: [PATCH 2/3] feat: add server-side transport module with Connection type and connect message parsing Add gix-transport::server with primitives for accepting incoming git protocol connections: - ConnectRequest: parsed client connect message (service, path, host, protocol) - parse_connect_message(): parses git-proto-request format from raw bytes - Connection: bundles packetline reader + writer + connection metadata - Connection::new(): for HTTP/SSH where setup is handled externally - accept(): reads first packetline and returns a ready Connection The blocking_io submodule is gated behind the existing blocking-client feature flag. The base types and parsing are always available. Co-authored-by: Kiro feat: add async server-side transport module Add gix-transport::server::async_io with the async equivalent of the blocking server connection primitives: - Connection: async packetline reader + writer + metadata - Connection::new(): for HTTP/SSH where setup is external - accept(): async version reading first packetline to establish connection Gated behind the existing async-client feature flag. Integration tests mirror the blocking_io test suite. Co-authored-by: Kiro --- gix-transport/src/lib.rs | 3 + gix-transport/src/server/async_io/mod.rs | 235 ++++++++++++++++++ gix-transport/src/server/blocking_io/mod.rs | 162 ++++++++++++ gix-transport/src/server/mod.rs | 207 +++++++++++++++ gix-transport/tests/async-transport.rs | 2 + gix-transport/tests/blocking-transport.rs | 2 + gix-transport/tests/server/async_io.rs | 172 +++++++++++++ gix-transport/tests/server/blocking_io.rs | 142 +++++++++++ gix-transport/tests/server/mod.rs | 5 + .../tests/server/parse_connect_message.rs | 122 +++++++++ 10 files changed, 1052 insertions(+) create mode 100644 gix-transport/src/server/async_io/mod.rs create mode 100644 gix-transport/src/server/blocking_io/mod.rs create mode 100644 gix-transport/src/server/mod.rs create mode 100644 gix-transport/tests/server/async_io.rs create mode 100644 gix-transport/tests/server/blocking_io.rs create mode 100644 gix-transport/tests/server/mod.rs create mode 100644 gix-transport/tests/server/parse_connect_message.rs diff --git a/gix-transport/src/lib.rs b/gix-transport/src/lib.rs index a580448e685..d92b5e5f531 100644 --- a/gix-transport/src/lib.rs +++ b/gix-transport/src/lib.rs @@ -91,3 +91,6 @@ pub use traits::IsSpuriousError; /// pub mod client; + +/// +pub mod server; diff --git a/gix-transport/src/server/async_io/mod.rs b/gix-transport/src/server/async_io/mod.rs new file mode 100644 index 00000000000..2b40959b482 --- /dev/null +++ b/gix-transport/src/server/async_io/mod.rs @@ -0,0 +1,235 @@ +//! Async server-side transport I/O primitives. + +use crate::{ + packetline::{PacketLineRef, async_io::StreamingPeekableIter}, + server::{ConnectRequest, Error, parse_connect_message}, + Protocol, Service, +}; +use bstr::BString; +use futures_io::AsyncRead; + +/// A server-side connection wrapping an async packetline reader and a raw writer. +/// +/// Created by [`accept()`] after parsing the client's initial connect message, +/// or constructed directly for protocols where connection setup is handled +/// externally (e.g. HTTP or SSH). +pub struct Connection { + /// The async packetline reader for incoming client data. + pub line_provider: StreamingPeekableIter, + /// The writer for outgoing server responses. + pub writer: W, + /// The service the client requested. + pub service: Service, + /// The repository path the client wants to access. + pub repository_path: BString, + /// The negotiated protocol version. + pub protocol: Protocol, +} + +impl std::fmt::Debug for Connection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Connection") + .field("service", &self.service) + .field("repository_path", &self.repository_path) + .field("protocol", &self.protocol) + .finish_non_exhaustive() + } +} + +impl Connection +where + R: AsyncRead + Unpin, + W: futures_io::AsyncWrite + Unpin, +{ + /// Create a connection directly from an async reader/writer pair. + /// + /// Use this when connection setup is handled externally (HTTP, SSH) + /// and you already know the service, path, and protocol version. + pub fn new( + reader: R, + writer: W, + service: Service, + repository_path: impl Into, + protocol: Protocol, + ) -> Self { + Connection { + line_provider: StreamingPeekableIter::new(reader, &[PacketLineRef::Flush], false), + writer, + service, + repository_path: repository_path.into(), + protocol, + } + } +} + +/// Accept a git daemon connection by reading the initial connect message asynchronously. +/// +/// Reads the first packetline from `reader`, parses it as a +/// `git-proto-request`, and returns a [`Connection`] ready for async protocol +/// communication along with the full [`ConnectRequest`] metadata. +/// +/// This is the async equivalent of [`super::super::blocking_io::accept()`]. +pub async fn accept(reader: R, writer: W) -> Result<(Connection, ConnectRequest), Error> +where + R: AsyncRead + Unpin, + W: futures_io::AsyncWrite + Unpin, +{ + let mut line_provider = StreamingPeekableIter::new(reader, &[PacketLineRef::Flush], false); + + let line = line_provider + .read_line() + .await + .ok_or(Error::MalformedMessage)? + .map_err(|_| Error::MalformedMessage)? + .map_err(|_| Error::MalformedMessage)?; + + let data = match line { + PacketLineRef::Data(d) => d, + _ => return Err(Error::MalformedMessage), + }; + + let request = parse_connect_message(data)?; + + let connection = Connection { + line_provider, + writer, + service: request.service, + repository_path: request.repository_path.clone(), + protocol: request.protocol, + }; + + Ok((connection, request)) +} + +#[cfg(test)] +mod tests { + use super::*; + use futures_lite::future::block_on; + + /// Build a packetline data frame: 4-hex-digit length prefix + payload. + fn pkt_line(data: &[u8]) -> Vec { + let len = data.len() + 4; + let mut buf = format!("{len:04x}").into_bytes(); + buf.extend_from_slice(data); + buf + } + + /// A flush packet (0000). + fn pkt_flush() -> Vec { + b"0000".to_vec() + } + + fn build_connect_packet(message: &[u8]) -> Vec { + let mut buf = pkt_line(message); + buf.extend(pkt_flush()); + buf + } + + #[test] + fn accept_upload_pack_v2() { + block_on(async { + let input = build_connect_packet( + b"git-upload-pack /repo.git\0host=example.org\0\0version=2\0", + ); + + let output = futures_lite::io::Cursor::new(Vec::new()); + let (conn, request) = accept(input.as_slice(), output).await.expect("accept succeeds"); + + assert_eq!(request.service, Service::UploadPack); + assert_eq!(request.repository_path, "/repo.git"); + assert_eq!(request.protocol, Protocol::V2); + assert_eq!(request.virtual_host, Some(("example.org".to_owned(), None))); + + assert_eq!(conn.service, Service::UploadPack); + assert_eq!(conn.protocol, Protocol::V2); + }); + } + + #[test] + fn accept_receive_pack_v1() { + block_on(async { + let input = build_connect_packet( + b"git-receive-pack /project.git\0host=git.example.com:9418\0", + ); + + let output = futures_lite::io::Cursor::new(Vec::new()); + let (conn, request) = accept(input.as_slice(), output).await.expect("accept succeeds"); + + assert_eq!(request.service, Service::ReceivePack); + assert_eq!(conn.protocol, Protocol::V1); + assert_eq!(request.virtual_host, Some(("git.example.com".to_owned(), Some(9418)))); + }); + } + + #[test] + fn accept_empty_input_returns_error() { + block_on(async { + let input: &[u8] = &[]; + let output = futures_lite::io::Cursor::new(Vec::new()); + let err = accept(input, output).await.unwrap_err(); + assert!(matches!(err, Error::MalformedMessage)); + }); + } + + #[test] + fn accept_flush_only_returns_error() { + block_on(async { + let input = pkt_flush(); + let output = futures_lite::io::Cursor::new(Vec::new()); + let err = accept(input.as_slice(), output).await.unwrap_err(); + assert!(matches!(err, Error::MalformedMessage)); + }); + } + + #[test] + fn accept_unknown_service_returns_error() { + block_on(async { + let input = build_connect_packet(b"git-frobnicate /repo.git\0"); + let output = futures_lite::io::Cursor::new(Vec::new()); + let err = accept(input.as_slice(), output).await.unwrap_err(); + assert!(matches!(err, Error::UnknownService { .. })); + }); + } + + #[test] + fn connection_new_sets_fields() { + let input: &[u8] = &[]; + let output = futures_lite::io::Cursor::new(Vec::new()); + let conn = Connection::new(input, output, Service::ReceivePack, "/project.git", Protocol::V1); + assert_eq!(conn.service, Service::ReceivePack); + assert_eq!(conn.repository_path, "/project.git"); + assert_eq!(conn.protocol, Protocol::V1); + } + + #[test] + fn subsequent_data_readable_after_accept() { + block_on(async { + let mut input = pkt_line( + b"git-upload-pack /repo.git\0host=h\0\0version=2\0", + ); + input.extend(pkt_line(b"command=ls-refs\n")); + input.extend(pkt_flush()); + + let output = futures_lite::io::Cursor::new(Vec::new()); + let (mut conn, _) = accept(input.as_slice(), output).await.expect("accept succeeds"); + + conn.line_provider.reset(); + let next = conn.line_provider.read_line().await; + assert!(next.is_some(), "should have subsequent data"); + let line = next.unwrap().expect("io ok").expect("decode ok"); + let text = line.as_text().expect("text line"); + assert_eq!(text.0, b"command=ls-refs".as_slice()); + }); + } + + #[test] + fn debug_output_shows_metadata() { + let input: &[u8] = &[]; + let output = futures_lite::io::Cursor::new(Vec::new()); + let conn = Connection::new(input, output, Service::UploadPack, "/repo.git", Protocol::V2); + let debug = format!("{conn:?}"); + assert!(debug.contains("UploadPack")); + assert!(debug.contains("/repo.git")); + assert!(debug.contains("V2")); + } +} diff --git a/gix-transport/src/server/blocking_io/mod.rs b/gix-transport/src/server/blocking_io/mod.rs new file mode 100644 index 00000000000..c582fec0747 --- /dev/null +++ b/gix-transport/src/server/blocking_io/mod.rs @@ -0,0 +1,162 @@ +//! Blocking server-side transport I/O primitives. + +use crate::{ + packetline::{PacketLineRef, blocking_io::StreamingPeekableIter}, + server::{ConnectRequest, Error, parse_connect_message}, + Protocol, Service, +}; +use bstr::BString; + +/// A server-side connection wrapping a packetline reader and a raw writer. +/// +/// Created by [`accept()`] after parsing the client's initial connect message, +/// or constructed directly for protocols where connection setup is handled +/// externally (e.g. HTTP or SSH). +pub struct Connection { + /// The packetline reader for incoming client data. + pub line_provider: StreamingPeekableIter, + /// The writer for outgoing server responses. + pub writer: W, + /// The service the client requested. + pub service: Service, + /// The repository path the client wants to access. + pub repository_path: BString, + /// The negotiated protocol version. + pub protocol: Protocol, +} + +impl std::fmt::Debug for Connection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Connection") + .field("service", &self.service) + .field("repository_path", &self.repository_path) + .field("protocol", &self.protocol) + .finish_non_exhaustive() + } +} + +impl Connection +where + R: std::io::Read, + W: std::io::Write, +{ + /// Create a connection directly from a reader/writer pair. + /// + /// Use this when connection setup is handled externally (HTTP, SSH) + /// and you already know the service, path, and protocol version. + pub fn new( + reader: R, + writer: W, + service: Service, + repository_path: impl Into, + protocol: Protocol, + ) -> Self { + Connection { + line_provider: StreamingPeekableIter::new(reader, &[PacketLineRef::Flush], false), + writer, + service, + repository_path: repository_path.into(), + protocol, + } + } +} + +/// Accept a git daemon connection by reading the initial connect message. +/// +/// Reads the first packetline from `reader`, parses it as a +/// `git-proto-request`, and returns a [`Connection`] ready for protocol +/// communication along with the full [`ConnectRequest`] metadata. +/// +/// This is the entry point for implementing a `git-daemon` style server. +/// For HTTP or SSH transports where connection setup is handled externally, +/// use [`Connection::new()`] directly. +pub fn accept(reader: R, writer: W) -> Result<(Connection, ConnectRequest), Error> +where + R: std::io::Read, + W: std::io::Write, +{ + let mut line_provider = StreamingPeekableIter::new(reader, &[PacketLineRef::Flush], false); + + let line = line_provider + .read_line() + .ok_or(Error::MalformedMessage)? + .map_err(|_| Error::MalformedMessage)? + .map_err(|_| Error::MalformedMessage)?; + + let data = match line { + PacketLineRef::Data(d) => d, + _ => return Err(Error::MalformedMessage), + }; + + let request = parse_connect_message(data)?; + + let connection = Connection { + line_provider, + writer, + service: request.service, + repository_path: request.repository_path.clone(), + protocol: request.protocol, + }; + + Ok((connection, request)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::packetline::blocking_io::encode; + + #[test] + fn accept_upload_pack_connection() -> Result<(), Box> { + let mut input = Vec::new(); + encode::data_to_write(b"git-upload-pack /repo.git\0host=example.org\0\0version=2\0", &mut input)?; + // Simulate a subsequent flush (end of ref advertisement request) + encode::flush_to_write(&mut input)?; + + let output = Vec::new(); + let (connection, request) = accept(input.as_slice(), output)?; + + assert_eq!(request.service, Service::UploadPack); + assert_eq!(request.repository_path, "/repo.git"); + assert_eq!(request.protocol, Protocol::V2); + assert_eq!(request.virtual_host, Some(("example.org".to_owned(), None))); + + assert_eq!(connection.service, Service::UploadPack); + assert_eq!(connection.repository_path, "/repo.git"); + assert_eq!(connection.protocol, Protocol::V2); + Ok(()) + } + + #[test] + fn accept_receive_pack_v1() -> Result<(), Box> { + let mut input = Vec::new(); + encode::data_to_write(b"git-receive-pack /project.git\0host=git.example.com:9418\0", &mut input)?; + encode::flush_to_write(&mut input)?; + + let output = Vec::new(); + let (connection, request) = accept(input.as_slice(), output)?; + + assert_eq!(request.service, Service::ReceivePack); + assert_eq!(connection.protocol, Protocol::V1); + assert_eq!(request.virtual_host, Some(("git.example.com".to_owned(), Some(9418)))); + Ok(()) + } + + #[test] + fn accept_empty_input_fails() { + let input: &[u8] = &[]; + let output = Vec::new(); + let err = accept(input, output).unwrap_err(); + assert!(matches!(err, Error::MalformedMessage)); + } + + #[test] + fn connection_new_for_http_style_setup() { + let input: &[u8] = &[]; + let output = Vec::new(); + let conn = Connection::new(input, output, Service::UploadPack, "/repo.git", Protocol::V2); + assert_eq!(conn.service, Service::UploadPack); + assert_eq!(conn.repository_path, "/repo.git"); + assert_eq!(conn.protocol, Protocol::V2); + } +} diff --git a/gix-transport/src/server/mod.rs b/gix-transport/src/server/mod.rs new file mode 100644 index 00000000000..e3005fd059b --- /dev/null +++ b/gix-transport/src/server/mod.rs @@ -0,0 +1,207 @@ +//! Server-side transport primitives for handling incoming git protocol connections. +//! +//! This module provides the types and parsing logic needed to accept a git protocol +//! connection, determine which service the client is requesting, and hand off to the +//! appropriate protocol handler (e.g. `upload-pack` or `receive-pack`). + +use bstr::{BString, ByteSlice}; + +use crate::{Protocol, Service}; + +/// +#[cfg(feature = "blocking-client")] +pub mod blocking_io; + +/// +#[cfg(feature = "async-client")] +pub mod async_io; + +/// The request parsed from a client's initial connect message. +/// +/// Parsed from the `git-proto-request` format described in the +/// [git pack-protocol documentation](https://git-scm.com/docs/pack-protocol#_git_transport). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConnectRequest { + /// The requested service, e.g. `UploadPack` or `ReceivePack`. + pub service: Service, + /// The repository path the client wants to access, e.g. `/repo.git`. + pub repository_path: BString, + /// The virtual host and optional port from `host=[:]`. + pub virtual_host: Option<(String, Option)>, + /// The protocol version requested via `version=N` extra parameter. + /// Defaults to `V1` if unspecified. + pub protocol: Protocol, + /// Additional key-value parameters beyond `version=` and `host=`. + pub extra_parameters: Vec<(BString, Option)>, +} + +/// Errors from parsing a git daemon connect message. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error("Unknown service: {service:?}")] + UnknownService { service: BString }, + #[error("Malformed connect message")] + MalformedMessage, +} + +/// Parse a git daemon connect message into a [`ConnectRequest`]. +/// +/// The input `bytes` should be the raw data payload of the first packetline sent by the client, +/// in the `git-proto-request` format: ` \0[host=[:]\0][extra params]`. +pub fn parse_connect_message(bytes: &[u8]) -> Result { + let (service_bytes, rest) = bytes.split_once_str(b" ").ok_or(Error::MalformedMessage)?; + + let service = match service_bytes { + b"git-upload-pack" => Service::UploadPack, + b"git-receive-pack" => Service::ReceivePack, + _ => { + return Err(Error::UnknownService { + service: service_bytes.into(), + }); + } + }; + + let mut segments = rest.split_str(b"\0"); + let path: BString = segments.next().ok_or(Error::MalformedMessage)?.into(); + + let mut virtual_host = None; + let mut protocol = Protocol::V1; + let mut extra_parameters = Vec::new(); + + for segment in segments { + if segment.is_empty() { + continue; + } + + if let Some(host_value) = segment.strip_prefix(b"host=") { + let host_str = std::str::from_utf8(host_value).map_err(|_| Error::MalformedMessage)?; + virtual_host = Some(parse_host_port(host_str)?); + } else if let Some(version_value) = segment.strip_prefix(b"version=") { + let version_str = std::str::from_utf8(version_value).map_err(|_| Error::MalformedMessage)?; + protocol = match version_str { + "0" => Protocol::V0, + "1" => Protocol::V1, + "2" => Protocol::V2, + _ => return Err(Error::MalformedMessage), + }; + } else { + match segment.split_once_str(b"=") { + Some((key, value)) => extra_parameters.push((key.into(), Some(value.into()))), + None => extra_parameters.push((segment.into(), None)), + } + } + } + + Ok(ConnectRequest { + service, + repository_path: path, + virtual_host, + protocol, + extra_parameters, + }) +} + +fn parse_host_port(host_str: &str) -> Result<(String, Option), Error> { + // IPv6 bracket notation: [::1]:port + if let Some(bracketed) = host_str.strip_prefix('[') { + if let Some((addr, rest)) = bracketed.split_once(']') { + let port = if let Some(port_str) = rest.strip_prefix(':') { + Some(port_str.parse::().map_err(|_| Error::MalformedMessage)?) + } else { + None + }; + return Ok((addr.to_owned(), port)); + } + return Err(Error::MalformedMessage); + } + + // Regular host:port — only split on the last colon to avoid confusing IPv6 without brackets. + match host_str.rsplit_once(':') { + Some((host, port_str)) => match port_str.parse::() { + Ok(port) => Ok((host.to_owned(), Some(port))), + Err(_) => Ok((host_str.to_owned(), None)), + }, + None => Ok((host_str.to_owned(), None)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_upload_pack_simple() { + let msg = b"git-upload-pack /repo.git\0"; + let req = parse_connect_message(msg).expect("valid message"); + assert_eq!(req.service, Service::UploadPack); + assert_eq!(req.repository_path, "/repo.git"); + assert_eq!(req.virtual_host, None); + assert_eq!(req.protocol, Protocol::V1); + assert!(req.extra_parameters.is_empty()); + } + + #[test] + fn parse_receive_pack_with_host() { + let msg = b"git-receive-pack /project.git\0host=example.org\0"; + let req = parse_connect_message(msg).expect("valid message"); + assert_eq!(req.service, Service::ReceivePack); + assert_eq!(req.repository_path, "/project.git"); + assert_eq!(req.virtual_host, Some(("example.org".to_owned(), None))); + } + + #[test] + fn parse_with_host_and_port() { + let msg = b"git-upload-pack /repo.git\0host=example.org:9418\0"; + let req = parse_connect_message(msg).expect("valid message"); + assert_eq!(req.virtual_host, Some(("example.org".to_owned(), Some(9418)))); + } + + #[test] + fn parse_with_protocol_v2() { + let msg = b"git-upload-pack /repo.git\0host=example.org\0\0version=2\0"; + let req = parse_connect_message(msg).expect("valid message"); + assert_eq!(req.protocol, Protocol::V2); + } + + #[test] + fn parse_with_extra_parameters() { + let msg = b"git-upload-pack /repo.git\0host=example.org\0\0version=2\0ci=true\0key=value\0"; + let req = parse_connect_message(msg).expect("valid message"); + assert_eq!(req.protocol, Protocol::V2); + assert_eq!( + req.extra_parameters, + vec![ + (BString::from("ci"), Some(BString::from("true"))), + (BString::from("key"), Some(BString::from("value"))), + ] + ); + } + + #[test] + fn parse_unknown_service_fails() { + let msg = b"git-unknown /repo.git\0"; + let err = parse_connect_message(msg).unwrap_err(); + assert!(matches!(err, Error::UnknownService { .. })); + } + + #[test] + fn parse_empty_message_fails() { + let err = parse_connect_message(b"").unwrap_err(); + assert!(matches!(err, Error::MalformedMessage)); + } + + #[test] + fn parse_ipv6_host_with_brackets() { + let msg = b"git-upload-pack /repo.git\0host=[::1]:9418\0"; + let req = parse_connect_message(msg).expect("valid message"); + assert_eq!(req.virtual_host, Some(("::1".to_owned(), Some(9418)))); + } + + #[test] + fn parse_ipv6_host_without_port() { + let msg = b"git-upload-pack /repo.git\0host=[::1]\0"; + let req = parse_connect_message(msg).expect("valid message"); + assert_eq!(req.virtual_host, Some(("::1".to_owned(), None))); + } +} diff --git a/gix-transport/tests/async-transport.rs b/gix-transport/tests/async-transport.rs index b5897a454ff..215a74d1fd8 100644 --- a/gix-transport/tests/async-transport.rs +++ b/gix-transport/tests/async-transport.rs @@ -14,3 +14,5 @@ pub fn fixture_bytes(path: &str) -> Vec { #[cfg(not(any(feature = "blocking-client", feature = "http-client-curl")))] mod client; + +mod server; diff --git a/gix-transport/tests/blocking-transport.rs b/gix-transport/tests/blocking-transport.rs index c492bc9ee99..c910ba8ed79 100644 --- a/gix-transport/tests/blocking-transport.rs +++ b/gix-transport/tests/blocking-transport.rs @@ -16,3 +16,5 @@ mod http_helpers; #[cfg(not(feature = "http-client-curl"))] mod client; + +mod server; diff --git a/gix-transport/tests/server/async_io.rs b/gix-transport/tests/server/async_io.rs new file mode 100644 index 00000000000..db3d3219ba1 --- /dev/null +++ b/gix-transport/tests/server/async_io.rs @@ -0,0 +1,172 @@ +use bstr::ByteSlice; +use futures_lite::future::block_on; +use gix_transport::{ + Protocol, Service, + server::async_io::{self, Connection}, +}; + +/// Build a packetline data frame: 4-hex-digit length prefix + payload. +fn pkt_line(data: &[u8]) -> Vec { + let len = data.len() + 4; + let mut buf = format!("{len:04x}").into_bytes(); + buf.extend_from_slice(data); + buf +} + +/// A flush packet (0000). +fn pkt_flush() -> Vec { + b"0000".to_vec() +} + +fn build_connect_packet(message: &[u8]) -> Vec { + let mut buf = pkt_line(message); + buf.extend(pkt_flush()); + buf +} + +mod accept { + use super::*; + + #[test] + fn upload_pack_v2() { + block_on(async { + let input = build_connect_packet( + b"git-upload-pack /repo.git\0host=example.org\0\0version=2\0", + ); + let output = futures_lite::io::Cursor::new(Vec::new()); + + let (conn, request) = async_io::accept(input.as_slice(), output) + .await + .expect("accept succeeds"); + + assert_eq!(request.service, Service::UploadPack); + assert_eq!(request.repository_path, "/repo.git"); + assert_eq!(request.protocol, Protocol::V2); + assert_eq!(request.virtual_host, Some(("example.org".to_owned(), None))); + + assert_eq!(conn.service, Service::UploadPack); + assert_eq!(conn.repository_path, "/repo.git"); + assert_eq!(conn.protocol, Protocol::V2); + }); + } + + #[test] + fn receive_pack_v1_with_port() { + block_on(async { + let input = build_connect_packet( + b"git-receive-pack /project.git\0host=git.example.com:9418\0", + ); + let output = futures_lite::io::Cursor::new(Vec::new()); + + let (conn, request) = async_io::accept(input.as_slice(), output) + .await + .expect("accept succeeds"); + + assert_eq!(request.service, Service::ReceivePack); + assert_eq!(conn.protocol, Protocol::V1); + assert_eq!( + request.virtual_host, + Some(("git.example.com".to_owned(), Some(9418))) + ); + }); + } + + #[test] + fn subsequent_data_is_available_through_line_provider() { + block_on(async { + let mut input = pkt_line( + b"git-upload-pack /repo.git\0host=h\0\0version=2\0", + ); + input.extend(pkt_line(b"command=ls-refs\n")); + input.extend(pkt_flush()); + + let output = futures_lite::io::Cursor::new(Vec::new()); + let (mut conn, _) = async_io::accept(input.as_slice(), output) + .await + .expect("accept succeeds"); + + conn.line_provider.reset(); + let next_line = conn.line_provider.read_line().await; + assert!(next_line.is_some(), "subsequent data should be readable"); + let line = next_line.unwrap().expect("io ok").expect("decode ok"); + let text = line.as_text().expect("text line"); + assert_eq!(text.0.as_bstr(), "command=ls-refs".as_bytes().as_bstr()); + }); + } + + #[test] + fn empty_input_returns_error() { + block_on(async { + let input: &[u8] = &[]; + let output = futures_lite::io::Cursor::new(Vec::new()); + let err = async_io::accept(input, output).await.unwrap_err(); + assert!(matches!(err, gix_transport::server::Error::MalformedMessage)); + }); + } + + #[test] + fn flush_only_input_returns_error() { + block_on(async { + let input = pkt_flush(); + let output = futures_lite::io::Cursor::new(Vec::new()); + let err = async_io::accept(input.as_slice(), output).await.unwrap_err(); + assert!(matches!(err, gix_transport::server::Error::MalformedMessage)); + }); + } + + #[test] + fn unknown_service_returns_error() { + block_on(async { + let input = build_connect_packet(b"git-frobnicate /repo.git\0"); + let output = futures_lite::io::Cursor::new(Vec::new()); + let err = async_io::accept(input.as_slice(), output).await.unwrap_err(); + assert!(matches!(err, gix_transport::server::Error::UnknownService { .. })); + }); + } +} + +mod connection_new { + use super::*; + + #[test] + fn creates_connection_with_given_parameters() { + let input: &[u8] = b""; + let output = futures_lite::io::Cursor::new(Vec::new()); + let conn = Connection::new(input, output, Service::UploadPack, "/repo.git", Protocol::V2); + + assert_eq!(conn.service, Service::UploadPack); + assert_eq!(conn.repository_path, "/repo.git"); + assert_eq!(conn.protocol, Protocol::V2); + } + + #[test] + fn line_provider_reads_subsequent_data() { + block_on(async { + let mut input = pkt_line(b"want abc123\n"); + input.extend(pkt_flush()); + + let output = futures_lite::io::Cursor::new(Vec::new()); + let mut conn = + Connection::new(input.as_slice(), output, Service::UploadPack, "/repo.git", Protocol::V1); + + let next_line = conn.line_provider.read_line().await; + assert!(next_line.is_some()); + let line = next_line.unwrap().expect("io ok").expect("decode ok"); + let text = line.as_text().expect("text line"); + assert_eq!(text.0.as_bstr(), "want abc123".as_bytes().as_bstr()); + }); + } + + #[test] + fn debug_output_shows_metadata_not_buffers() { + let input: &[u8] = b""; + let output = futures_lite::io::Cursor::new(Vec::new()); + let conn = + Connection::new(input, output, Service::ReceivePack, "/secret/repo.git", Protocol::V1); + + let debug = format!("{conn:?}"); + assert!(debug.contains("ReceivePack"), "should show service"); + assert!(debug.contains("/secret/repo.git"), "should show path"); + assert!(debug.contains("V1"), "should show protocol"); + } +} diff --git a/gix-transport/tests/server/blocking_io.rs b/gix-transport/tests/server/blocking_io.rs new file mode 100644 index 00000000000..0f687ef9c21 --- /dev/null +++ b/gix-transport/tests/server/blocking_io.rs @@ -0,0 +1,142 @@ +use bstr::ByteSlice; +use gix_transport::{ + Protocol, Service, + packetline::blocking_io::encode, + server::blocking_io::{self, Connection}, +}; + +fn build_connect_packet(message: &[u8]) -> Vec { + let mut buf = Vec::new(); + encode::data_to_write(message, &mut buf).expect("encoding works"); + encode::flush_to_write(&mut buf).expect("flush works"); + buf +} + +mod accept { + use super::*; + + #[test] + fn upload_pack_v2() { + let input = build_connect_packet(b"git-upload-pack /repo.git\0host=example.org\0\0version=2\0"); + let output = Vec::new(); + + let (conn, request) = blocking_io::accept(input.as_slice(), output).expect("accept succeeds"); + + assert_eq!(request.service, Service::UploadPack); + assert_eq!(request.repository_path, "/repo.git"); + assert_eq!(request.protocol, Protocol::V2); + assert_eq!(request.virtual_host, Some(("example.org".to_owned(), None))); + + assert_eq!(conn.service, Service::UploadPack); + assert_eq!(conn.repository_path, "/repo.git"); + assert_eq!(conn.protocol, Protocol::V2); + } + + #[test] + fn receive_pack_v1_with_port() { + let input = build_connect_packet(b"git-receive-pack /project.git\0host=git.example.com:9418\0"); + let output = Vec::new(); + + let (conn, request) = blocking_io::accept(input.as_slice(), output).expect("accept succeeds"); + + assert_eq!(request.service, Service::ReceivePack); + assert_eq!(conn.protocol, Protocol::V1); + assert_eq!(request.virtual_host, Some(("git.example.com".to_owned(), Some(9418)))); + } + + #[test] + fn subsequent_data_is_available_through_line_provider() { + let mut input = Vec::new(); + encode::data_to_write(b"git-upload-pack /repo.git\0host=h\0\0version=2\0", &mut input) + .expect("encode works"); + // Simulate a subsequent command the client sends after the connect message. + encode::data_to_write(b"command=ls-refs\n", &mut input).expect("encode works"); + encode::flush_to_write(&mut input).expect("flush works"); + + let output = Vec::new(); + let (mut conn, _request) = blocking_io::accept(input.as_slice(), output).expect("accept succeeds"); + + // The line provider should be able to read the next packetline. + conn.line_provider.reset(); + let next_line = conn.line_provider.read_line(); + assert!(next_line.is_some(), "subsequent data should be readable"); + let line = next_line.unwrap().expect("io ok").expect("decode ok"); + let text = line.as_text().expect("text line"); + assert_eq!(text.as_bstr(), "command=ls-refs".as_bytes().as_bstr()); + } + + #[test] + fn empty_input_returns_error() { + let input: &[u8] = &[]; + let output = Vec::new(); + let err = blocking_io::accept(input, output).unwrap_err(); + assert!( + matches!(err, gix_transport::server::Error::MalformedMessage), + "empty input should be malformed" + ); + } + + #[test] + fn flush_only_input_returns_error() { + let mut input = Vec::new(); + encode::flush_to_write(&mut input).expect("flush works"); + + let output = Vec::new(); + let err = blocking_io::accept(input.as_slice(), output).unwrap_err(); + assert!( + matches!(err, gix_transport::server::Error::MalformedMessage), + "a flush without data should be malformed" + ); + } + + #[test] + fn unknown_service_returns_error() { + let input = build_connect_packet(b"git-frobnicate /repo.git\0"); + let output = Vec::new(); + let err = blocking_io::accept(input.as_slice(), output).unwrap_err(); + assert!(matches!(err, gix_transport::server::Error::UnknownService { .. })); + } +} + +mod connection_new { + use super::*; + + #[test] + fn creates_connection_with_given_parameters() { + let input: &[u8] = b""; + let output = Vec::new(); + let conn = Connection::new(input, output, Service::UploadPack, "/repo.git", Protocol::V2); + + assert_eq!(conn.service, Service::UploadPack); + assert_eq!(conn.repository_path, "/repo.git"); + assert_eq!(conn.protocol, Protocol::V2); + } + + #[test] + fn line_provider_reads_subsequent_data() { + let mut input = Vec::new(); + encode::data_to_write(b"want abc123\n", &mut input).expect("encode works"); + encode::flush_to_write(&mut input).expect("flush works"); + + let output = Vec::new(); + let mut conn = Connection::new(input.as_slice(), output, Service::UploadPack, "/repo.git", Protocol::V1); + + let next_line = conn.line_provider.read_line(); + assert!(next_line.is_some()); + let line = next_line.unwrap().expect("io ok").expect("decode ok"); + let text = line.as_text().expect("text line"); + assert_eq!(text.as_bstr(), "want abc123".as_bytes().as_bstr()); + } + + #[test] + fn debug_output_shows_metadata_not_buffers() { + let input: &[u8] = b""; + let output = Vec::new(); + let conn = Connection::new(input, output, Service::ReceivePack, "/secret/repo.git", Protocol::V1); + + let debug = format!("{conn:?}"); + assert!(debug.contains("ReceivePack"), "should show service"); + assert!(debug.contains("/secret/repo.git"), "should show path"); + assert!(debug.contains("V1"), "should show protocol"); + } +} diff --git a/gix-transport/tests/server/mod.rs b/gix-transport/tests/server/mod.rs new file mode 100644 index 00000000000..16da7bfbe01 --- /dev/null +++ b/gix-transport/tests/server/mod.rs @@ -0,0 +1,5 @@ +mod parse_connect_message; +#[cfg(feature = "blocking-client")] +mod blocking_io; +#[cfg(feature = "async-client")] +mod async_io; diff --git a/gix-transport/tests/server/parse_connect_message.rs b/gix-transport/tests/server/parse_connect_message.rs new file mode 100644 index 00000000000..d78df87f99a --- /dev/null +++ b/gix-transport/tests/server/parse_connect_message.rs @@ -0,0 +1,122 @@ +use bstr::BString; +use gix_transport::{Protocol, Service, server}; + +#[test] +fn minimal_upload_pack() { + let req = server::parse_connect_message(b"git-upload-pack /repo.git\0").expect("valid"); + assert_eq!(req.service, Service::UploadPack); + assert_eq!(req.repository_path, "/repo.git"); + assert_eq!(req.virtual_host, None); + assert_eq!(req.protocol, Protocol::V1); + assert!(req.extra_parameters.is_empty()); +} + +#[test] +fn minimal_receive_pack() { + let req = server::parse_connect_message(b"git-receive-pack /project.git\0").expect("valid"); + assert_eq!(req.service, Service::ReceivePack); + assert_eq!(req.repository_path, "/project.git"); +} + +#[test] +fn with_host_no_port() { + let req = server::parse_connect_message(b"git-upload-pack /repo.git\0host=git.example.com\0").expect("valid"); + assert_eq!(req.virtual_host, Some(("git.example.com".to_owned(), None))); +} + +#[test] +fn with_host_and_port() { + let req = + server::parse_connect_message(b"git-upload-pack /repo.git\0host=git.example.com:9418\0").expect("valid"); + assert_eq!(req.virtual_host, Some(("git.example.com".to_owned(), Some(9418)))); +} + +#[test] +fn protocol_v0() { + let req = + server::parse_connect_message(b"git-upload-pack /repo.git\0host=h\0\0version=0\0").expect("valid"); + assert_eq!(req.protocol, Protocol::V0); +} + +#[test] +fn protocol_v2() { + let req = + server::parse_connect_message(b"git-upload-pack /repo.git\0host=h\0\0version=2\0").expect("valid"); + assert_eq!(req.protocol, Protocol::V2); +} + +#[test] +fn extra_parameters_are_preserved() { + let req = server::parse_connect_message( + b"git-upload-pack /repo.git\0host=h\0\0version=2\0object-format=sha256\0bare\0", + ) + .expect("valid"); + assert_eq!(req.protocol, Protocol::V2); + assert_eq!( + req.extra_parameters, + vec![ + (BString::from("object-format"), Some(BString::from("sha256"))), + (BString::from("bare"), None), + ] + ); +} + +#[test] +fn path_with_tilde_expansion() { + let req = server::parse_connect_message(b"git-upload-pack ~user/repo.git\0").expect("valid"); + assert_eq!(req.repository_path, "~user/repo.git"); +} + +#[test] +fn path_with_special_characters() { + let req = server::parse_connect_message(b"git-upload-pack /path/to/my repo.git\0").expect("valid"); + assert_eq!(req.repository_path, "/path/to/my repo.git"); +} + +#[test] +fn unknown_service_is_rejected() { + let err = server::parse_connect_message(b"git-unknown /repo.git\0").unwrap_err(); + assert!(matches!(err, server::Error::UnknownService { .. })); +} + +#[test] +fn missing_space_is_malformed() { + let err = server::parse_connect_message(b"git-upload-pack").unwrap_err(); + assert!(matches!(err, server::Error::MalformedMessage)); +} + +#[test] +fn empty_input_is_malformed() { + let err = server::parse_connect_message(b"").unwrap_err(); + assert!(matches!(err, server::Error::MalformedMessage)); +} + +#[test] +fn ipv6_bracketed_with_port() { + let req = server::parse_connect_message(b"git-upload-pack /repo.git\0host=[::1]:9418\0").expect("valid"); + assert_eq!(req.virtual_host, Some(("::1".to_owned(), Some(9418)))); +} + +#[test] +fn ipv6_bracketed_without_port() { + let req = server::parse_connect_message(b"git-upload-pack /repo.git\0host=[fe80::1]\0").expect("valid"); + assert_eq!(req.virtual_host, Some(("fe80::1".to_owned(), None))); +} + +#[test] +fn invalid_protocol_version_is_malformed() { + let err = server::parse_connect_message(b"git-upload-pack /repo.git\0\0version=99\0").unwrap_err(); + assert!(matches!(err, server::Error::MalformedMessage)); +} + +#[test] +fn multiple_null_separators_are_handled() { + // The format uses double-NUL to separate host from extra params. + // Extra empty segments between NULs should be skipped. + let req = server::parse_connect_message( + b"git-upload-pack /repo.git\0host=h\0\0\0version=2\0", + ) + .expect("valid"); + assert_eq!(req.protocol, Protocol::V2); + assert_eq!(req.virtual_host, Some(("h".to_owned(), None))); +} From 3075ca550809473cbacf40adff29c4b5fd16fdb3 Mon Sep 17 00:00:00 2001 From: Ethan Brooks Date: Thu, 6 Aug 2026 07:52:20 +1000 Subject: [PATCH 3/3] feat: add experimental built-in upload-pack transport for file:// URLs Introduce an `experimental` Cargo feature on the `gix` crate that gates an in-process `BuiltinUploadPack` transport and `RepositoryDelegate`. When activated via `--builtin-upload-pack` on clone/fetch for file:// URLs, this drives `gix-protocol`'s `serve_v2()` directly instead of spawning an external `git-upload-pack` process. - Add `experimental` feature to `gix` (enables `gix-protocol/blocking-server`) - Forward feature in workspace root, included in `max`/`max-pure` only - Implement `BuiltinUploadPack` transport in `gix/src/transport/` - Add `--builtin-upload-pack` CLI flag to clone and fetch commands - Wire flag through gitoxide-core into the connection path - Add journey test validating equivalence with external upload-pack - Early-exit error when flag is used without experimental feature compiled in Co-authored-by: Kiro --- Cargo.toml | 8 +- crate-status.md | 2 + gitoxide-core/Cargo.toml | 3 + gitoxide-core/src/repository/clone.rs | 48 ++ gitoxide-core/src/repository/fetch.rs | 53 +- gix/Cargo.toml | 7 + gix/src/lib.rs | 4 + gix/src/transport/builtin_upload_pack.rs | 683 +++++++++++++++++++++++ gix/src/transport/mod.rs | 8 + src/plumbing/main.rs | 10 + src/plumbing/options/mod.rs | 10 + tests/journey/gix.sh | 40 ++ 12 files changed, 872 insertions(+), 4 deletions(-) create mode 100644 gix/src/transport/builtin_upload_pack.rs create mode 100644 gix/src/transport/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 38434b2a0ba..27958a23508 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ default = ["max"] ## ## When used in conjunction with `http-client-curl-rustls`, the `openssl` crates will still be compiled, but won't be used. To bypass this, disable ## default dependencies and specify the features yourself. -max = ["hashes", "max-control", "fast", "gitoxide-core-tools-query", "gitoxide-core-tools-corpus", "gitoxide-core-blocking-client", "http-client-curl-openssl"] +max = ["hashes", "max-control", "fast", "gitoxide-core-tools-query", "gitoxide-core-tools-corpus", "gitoxide-core-blocking-client", "http-client-curl-openssl", "experimental"] ## Like `max`, but only Rust is allowed. ## @@ -53,7 +53,7 @@ max = ["hashes", "max-control", "fast", "gitoxide-core-tools-query", "gitoxide-c ## This uses Rust's HTTP implementation. ## ## As fast as possible, with TUI progress, progress line rendering with auto-configuration, all transports available but less mature pure Rust HTTP implementation, all `ein` tools, CLI colors and local-time support, JSON output, regex support for rev-specs. -max-pure = ["hashes", "max-control", "http-client-reqwest", "gitoxide-core-blocking-client"] +max-pure = ["hashes", "max-control", "http-client-reqwest", "gitoxide-core-blocking-client", "experimental"] ## Like `max`, but with more control for configuration. See the *Package Maintainers* headline for more information. ## Needs to chose its own hash(es). @@ -82,6 +82,10 @@ small = ["hashes", "pretty-cli", "prodash-render-line", "is-terminal"] ## It uses, however, a fully asynchronous networking implementation which can serve a real-world example on how to implement custom async transports. lean-async = ["hashes", "fast", "tracing", "pretty-cli", "tix", "gitoxide-core-tools", "gitoxide-core-tools-query", "gitoxide-core-tools-corpus", "gitoxide-core-async-client", "prodash-render-line"] +## Enable experimental features in gix for development builds. +## Currently gates the built-in upload-pack for file:// transports. +experimental = ["gix/experimental", "gitoxide-core/experimental"] + #! ### Package Maintainers #! `*-control` features leave it to you to configure C libraries, involving choices for HTTP transport implementation. #! diff --git a/crate-status.md b/crate-status.md index 4a4afcb12da..5289ed29bda 100644 --- a/crate-status.md +++ b/crate-status.md @@ -151,12 +151,14 @@ The top-level crate that acts as hub to all functionality provided by the `gix-* * [ ] include-tags when shallow is used (needs separate fetch) * [ ] prune non-existing shallow commits * [ ] [bundles](https://git-scm.com/docs/git-bundle) + * [x] in-process `file://` via built-in upload-pack (experimental feature gate, no external process) * [x] fetch * [x] shallow (remains shallow, options to adjust shallow boundary) * [ ] a way to auto-explode small packs to avoid them to pile up * [x] 'ref-in-want' * [ ] 'wanted-ref' * [x] standard negotiation algorithms `consecutive`, `skipping` and `noop`. + * [x] in-process `file://` via built-in upload-pack (experimental feature gate, no external process) * [ ] push * [x] ls-refs * [x] ls-refs with ref-spec filter diff --git a/gitoxide-core/Cargo.toml b/gitoxide-core/Cargo.toml index 14e72997281..a956528360c 100644 --- a/gitoxide-core/Cargo.toml +++ b/gitoxide-core/Cargo.toml @@ -17,6 +17,9 @@ test = true [features] default = [] +## Enable experimental features, currently the built-in upload-pack transport for `file://` URLs. +experimental = ["gix/experimental", "gix/parallel"] + #! ### Tools ## Discover all git repositories within a directory. Particularly useful with [skim](https://github.com/lotabout/skim). organize = ["dep:dua-core", "dep:gix-url", "dep:parking_lot"] diff --git a/gitoxide-core/src/repository/clone.rs b/gitoxide-core/src/repository/clone.rs index e5988373fb9..37243de7515 100644 --- a/gitoxide-core/src/repository/clone.rs +++ b/gitoxide-core/src/repository/clone.rs @@ -8,6 +8,8 @@ pub struct Options { pub shallow: gix::remote::fetch::Shallow, pub ref_name: Option, pub revision: Option, + /// Use the built-in in-process upload-pack instead of spawning git-upload-pack. + pub builtin_upload_pack: bool, } pub const PROGRESS_RANGE: std::ops::RangeInclusive = 1..=3; @@ -36,6 +38,7 @@ pub(crate) mod function { ref_name, revision, shallow, + builtin_upload_pack, }: Options, ) -> anyhow::Result<()> where @@ -77,6 +80,9 @@ pub(crate) mod function { if no_tags { prepare = prepare.configure_remote(|r| Ok(r.with_fetch_tags(gix::remote::fetch::Tags::None))); } + if builtin_upload_pack { + prepare = prepare.configure_connection(configure_builtin_transport); + } let (mut checkout, fetch_outcome) = prepare .with_shallow(shallow) .with_ref_name(ref_name.as_ref())? @@ -145,4 +151,46 @@ pub(crate) mod function { } Ok(()) } + + /// Configure the connection to use the built-in upload-pack transport for `file://` URLs. + /// + /// When the remote URL uses the `file://` scheme, this replaces the standard + /// `SpawnProcessOnDemand` transport with an in-process `BuiltinUploadPack`. + /// For non-file schemes the connection is left unchanged. + #[cfg(feature = "experimental")] + fn configure_builtin_transport( + connection: &mut gix::remote::Connection< + '_, + '_, + '_, + Box, + >, + ) -> Result<(), Box> { + let url = connection + .remote() + .url(gix::remote::Direction::Fetch) + .expect("remote always has a fetch URL during clone/fetch"); + if url.scheme == gix::url::Scheme::File { + let path = url.path.clone(); + let transport = gix::transport::builtin_upload_pack::BuiltinUploadPack::new( + path, + gix::protocol::transport::Protocol::V2, + false, + ); + *connection.transport_mut() = Box::new(transport); + } + Ok(()) + } + + #[cfg(not(feature = "experimental"))] + fn configure_builtin_transport( + _connection: &mut gix::remote::Connection< + '_, + '_, + '_, + Box, + >, + ) -> Result<(), Box> { + Err("--builtin-upload-pack requires the 'experimental' feature (build with --features experimental)".into()) + } } diff --git a/gitoxide-core/src/repository/fetch.rs b/gitoxide-core/src/repository/fetch.rs index 00a2efdc772..c7e24d0ef9b 100644 --- a/gitoxide-core/src/repository/fetch.rs +++ b/gitoxide-core/src/repository/fetch.rs @@ -12,6 +12,8 @@ pub struct Options { pub handshake_info: bool, pub negotiation_info: bool, pub open_negotiation_graph: Option, + /// Use the built-in in-process upload-pack instead of spawning git-upload-pack. + pub builtin_upload_pack: bool, } pub const PROGRESS_RANGE: std::ops::RangeInclusive = 1..=3; @@ -46,6 +48,7 @@ pub(crate) mod function { open_negotiation_graph, shallow, ref_specs, + builtin_upload_pack, }: Options, ) -> anyhow::Result<()> where @@ -61,8 +64,12 @@ pub(crate) mod function { remote.replace_refspecs(ref_specs.iter(), gix::remote::Direction::Fetch)?; remote = remote.with_fetch_tags(gix::remote::fetch::Tags::None); } - let res: gix::remote::fetch::Outcome = remote - .connect(gix::remote::Direction::Fetch)? + let mut connection = remote.connect(gix::remote::Direction::Fetch)?; + if builtin_upload_pack { + configure_builtin_transport(&mut connection) + .map_err(|err| anyhow::anyhow!("{err}"))?; + } + let res: gix::remote::fetch::Outcome = connection .prepare_fetch(&mut progress, Default::default())? .with_dry_run(dry_run) .with_shallow(shallow) @@ -331,4 +338,46 @@ pub(crate) mod function { } Ok(()) } + + /// Configure the connection to use the built-in upload-pack transport for `file://` URLs. + /// + /// When the remote URL uses the `file://` scheme, this replaces the standard + /// `SpawnProcessOnDemand` transport with an in-process `BuiltinUploadPack`. + /// For non-file schemes the connection is left unchanged. + #[cfg(feature = "experimental")] + fn configure_builtin_transport( + connection: &mut gix::remote::Connection< + '_, + '_, + '_, + Box, + >, + ) -> Result<(), Box> { + let url = connection + .remote() + .url(gix::remote::Direction::Fetch) + .expect("remote always has a fetch URL during fetch"); + if url.scheme == gix::url::Scheme::File { + let path = url.path.clone(); + let transport = gix::transport::builtin_upload_pack::BuiltinUploadPack::new( + path, + gix::protocol::transport::Protocol::V2, + false, + ); + *connection.transport_mut() = Box::new(transport); + } + Ok(()) + } + + #[cfg(not(feature = "experimental"))] + fn configure_builtin_transport( + _connection: &mut gix::remote::Connection< + '_, + '_, + '_, + Box, + >, + ) -> Result<(), Box> { + Err("--builtin-upload-pack requires the 'experimental' feature (build with --features experimental)".into()) + } } diff --git a/gix/Cargo.toml b/gix/Cargo.toml index 702a9fd805d..047cd71aa7a 100644 --- a/gix/Cargo.toml +++ b/gix/Cargo.toml @@ -178,6 +178,13 @@ worktree-stream = ["gix-worktree-stream", "attributes"] ## Your application should add it as dependency and re-activate the desired features. worktree-archive = ["gix-archive", "worktree-stream", "attributes"] +#! #### Experimental +#! +#! Features that are not yet stable. They may change or be removed without notice. + +## Experimental features not yet stable. Enables built-in upload-pack for file:// transports. +experimental = ["gix-protocol/blocking-server", "dep:gix-transport"] + #! #### Mutually Exclusive Network Client #! #! Either `async-*` or `blocking-*` versions of these toggles may be enabled at a time. diff --git a/gix/src/lib.rs b/gix/src/lib.rs index a7baea5c597..3c48f5647c6 100644 --- a/gix/src/lib.rs +++ b/gix/src/lib.rs @@ -476,6 +476,10 @@ pub mod filter; /// pub mod remote; +/// Transport implementations specific to the `gix` crate, gated behind feature flags. +#[cfg(feature = "experimental")] +pub mod transport; + /// pub mod init; diff --git a/gix/src/transport/builtin_upload_pack.rs b/gix/src/transport/builtin_upload_pack.rs new file mode 100644 index 00000000000..56252799014 --- /dev/null +++ b/gix/src/transport/builtin_upload_pack.rs @@ -0,0 +1,683 @@ +//! An in-process upload-pack transport that drives `gix-protocol`'s `serve_v2()` directly, +//! avoiding the need to spawn an external `git-upload-pack` process for `file://` URLs. +//! +//! This module is gated behind `#[cfg(feature = "experimental")]`. + +use std::{ + any::Any, + borrow::Cow, + io, + sync::{Arc, Mutex}, +}; + +use crate::bstr::{BStr, BString, ByteSlice, ByteVec}; +use gix_pack::Find as _; +use gix_ref::file::ReferenceExt as _; +use gix_transport::{ + Protocol, Service, + client::{ + self, Capabilities, MessageKind, WriteMode, + blocking_io::{ExtendedBufRead, HandleProgress, ReadlineBufRead, RequestWriter, SetServiceResponse}, + }, + packetline::PacketLineRef, +}; + +use crate::protocol::upload_pack::{ + Capability, Delegate, Fetch, FetchOutput, LsRefs, ServerConfig, + negotiate_fetch_with_repository, serve_v2, +}; +use crate::protocol::handshake::Ref; + +type BoxError = Box; + +// --------------------------------------------------------------------------- +// BuiltinUploadPack transport +// --------------------------------------------------------------------------- + +/// State captured after a successful handshake with the target repository. +struct HandshakeState { + ref_store: gix_ref::file::Store, + odb: gix_odb::Handle, + #[allow(dead_code)] + capabilities: Capabilities, + object_hash: gix_hash::Kind, +} + +/// An in-process transport that serves upload-pack protocol V2 by directly opening +/// the target repository's ref store and object database. +/// +/// This avoids spawning `git-upload-pack` for `file://` URLs when the `experimental` +/// feature is active and the runtime option requests it. +pub struct BuiltinUploadPack { + /// Path to the target repository's git directory. + path: BString, + /// The protocol version to advertise. + #[allow(dead_code)] + desired_version: Protocol, + /// Whether to trace packet lines. + #[allow(dead_code)] + trace: bool, + /// State populated after `handshake()` is called. + state: Option, +} + +impl BuiltinUploadPack { + /// Create a new in-process upload-pack transport targeting the repository at `path`. + pub fn new(path: impl Into, version: Protocol, trace: bool) -> Self { + Self { + path: path.into(), + desired_version: version, + trace, + state: None, + } + } + + /// Open the target repository's ref store and ODB. + fn open_repository(&self) -> Result<(gix_ref::file::Store, gix_odb::Handle, gix_hash::Kind), client::Error> { + let git_dir = gix_path::from_bstr(self.path.as_bstr()); + let git_dir = git_dir.as_ref(); + + // Determine if this is a bare repo or has a .git subdir + let actual_git_dir = if git_dir.join("objects").is_dir() && git_dir.join("refs").is_dir() { + git_dir.to_owned() + } else if git_dir.join(".git").is_dir() { + git_dir.join(".git") + } else { + git_dir.to_owned() + }; + + let object_hash = gix_hash::Kind::Sha1; + let ref_store = gix_ref::file::Store::at( + actual_git_dir.clone().into(), + gix_ref::store::init::Options { + write_reflog: gix_ref::store::WriteReflog::Disable, + object_hash, + ..Default::default() + }, + ); + + let objects_dir = actual_git_dir.join("objects"); + let odb = gix_odb::at(objects_dir).map_err(|err| { + client::Error::Io(io::Error::new( + io::ErrorKind::Other, + format!("failed to open object database at '{}': {err}", self.path), + )) + })?; + + Ok((ref_store, odb, object_hash)) + } + + /// Build the V2 capability advertisement lines and return them as a `Capabilities` value. + fn build_capabilities(_object_hash: gix_hash::Kind) -> Capabilities { + // Build a capabilities buffer that Capabilities::from_lines can parse. + let mut buf = BString::from("version 2\n"); + buf.push_str(b"ls-refs\n"); + buf.push_str(b"fetch=shallow wait-for-done\n"); + buf.push_str(b"object-format=sha1\n"); + buf.push_str(b"agent=gix-builtin-upload-pack/0.1\n"); + + Capabilities::from_lines(buf).expect("statically valid capability advertisement") + } + + /// Build capability advertisement lines for `write_v2_capability_advertisement`. + #[allow(dead_code, reason = "Prepared for capability advertisement in handshake; wired in a follow-up.")] + fn advertisement_capabilities(_object_hash: gix_hash::Kind) -> Vec { + vec![ + Capability { + name: "ls-refs".into(), + values: vec![], + }, + Capability { + name: "fetch".into(), + values: vec!["shallow".into(), "wait-for-done".into()], + }, + Capability { + name: "object-format".into(), + values: vec!["sha1".into()], + }, + Capability { + name: "agent".into(), + values: vec!["gix-builtin-upload-pack/0.1".into()], + }, + ] + } +} + +impl client::TransportWithoutIO for BuiltinUploadPack { + fn to_url(&self) -> Cow<'_, BStr> { + let mut url = BString::from("file://"); + url.push_str(&self.path); + Cow::Owned(url) + } + + fn connection_persists_across_multiple_requests(&self) -> bool { + true + } + + fn configure(&mut self, _config: &dyn Any) -> Result<(), BoxError> { + Ok(()) + } +} + +impl client::blocking_io::Transport for BuiltinUploadPack { + fn handshake<'a>( + &mut self, + _service: Service, + _extra_parameters: &'a [(&'a str, Option<&'a str>)], + ) -> Result, client::Error> { + let (ref_store, odb, object_hash) = self.open_repository()?; + let capabilities = Self::build_capabilities(object_hash); + + self.state = Some(HandshakeState { + ref_store, + odb, + capabilities: capabilities.clone(), + object_hash, + }); + + Ok(SetServiceResponse { + actual_protocol: Protocol::V2, + capabilities, + refs: None, + }) + } + + fn request( + &mut self, + write_mode: WriteMode, + on_into_read: MessageKind, + trace: bool, + ) -> Result, client::Error> { + let state = self.state.as_ref().ok_or(client::Error::MissingHandshake)?; + + // Shared buffer: the writer appends the request here, and the reader + // consumes it when first read. This is safe because the protocol flow + // is strictly sequential: write phase completes fully before read phase begins. + let shared_request = Arc::new(Mutex::new(Vec::::new())); + + let reader: Box + Unpin + '_> = Box::new(BuiltinReader::new( + state.ref_store.clone(), + state.odb.clone(), + state.object_hash, + self.path.clone(), + Arc::clone(&shared_request), + )); + + let writer = BuiltinWriter { + buf: shared_request, + }; + + Ok(RequestWriter::new_from_bufread( + writer, + reader, + write_mode, + on_into_read, + trace, + )) + } +} + +// --------------------------------------------------------------------------- +// BuiltinReader - processes the request lazily on first read +// --------------------------------------------------------------------------- + +/// A reader that, on first access, takes the buffered request from a shared buffer, +/// processes it through `serve_v2()`, and serves the response. +struct BuiltinReader { + ref_store: gix_ref::file::Store, + odb: gix_odb::Handle, + object_hash: gix_hash::Kind, + path: BString, + /// Shared request buffer populated by the writer. + shared_request: Arc>>, + /// The response data produced by serve_v2(). + response_buf: Vec, + /// Current read position in response_buf. + response_pos: usize, + /// Whether we've already processed the request. + processed: bool, + /// The stop reason (always Flush for V2 responses). + stopped_at: Option, +} + +impl BuiltinReader { + fn new( + ref_store: gix_ref::file::Store, + odb: gix_odb::Handle, + object_hash: gix_hash::Kind, + path: BString, + shared_request: Arc>>, + ) -> Self { + Self { + ref_store, + odb, + object_hash, + path, + shared_request, + response_buf: Vec::new(), + response_pos: 0, + processed: false, + stopped_at: None, + } + } + + fn process_request(&mut self) -> io::Result<()> { + if self.processed { + return Ok(()); + } + self.processed = true; + + let request_data = self + .shared_request + .lock() + .map_err(|err| io::Error::new(io::ErrorKind::Other, format!("lock poisoned: {err}")))? + .clone(); + + let config = ServerConfig { + object_hash: self.object_hash, + }; + let mut delegate = RepositoryDelegate { + ref_store: self.ref_store.clone(), + odb: self.odb.clone(), + object_hash: self.object_hash, + path: self.path.clone(), + }; + + let input = io::Cursor::new(request_data); + self.response_buf.clear(); + + serve_v2(input, &mut self.response_buf, &mut delegate, &config).map_err(|err| { + io::Error::new( + io::ErrorKind::Other, + format!("built-in upload-pack failed for '{}': {err}", self.path), + ) + })?; + + self.response_pos = 0; + self.stopped_at = Some(MessageKind::Flush); + Ok(()) + } +} + +impl io::Read for BuiltinReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.process_request()?; + let available = &self.response_buf[self.response_pos..]; + let to_copy = buf.len().min(available.len()); + buf[..to_copy].copy_from_slice(&available[..to_copy]); + self.response_pos += to_copy; + Ok(to_copy) + } +} + +impl io::BufRead for BuiltinReader { + fn fill_buf(&mut self) -> io::Result<&[u8]> { + self.process_request()?; + Ok(&self.response_buf[self.response_pos..]) + } + + fn consume(&mut self, amt: usize) { + self.response_pos += amt; + } +} + +impl ReadlineBufRead for BuiltinReader { + fn readline( + &mut self, + ) -> Option, gix_transport::packetline::decode::Error>>> { + if let Err(e) = self.process_request() { + return Some(Err(e)); + } + if self.response_pos >= self.response_buf.len() { + return None; + } + let remaining = &self.response_buf[self.response_pos..]; + if remaining.len() < 4 { + return None; + } + match gix_transport::packetline::decode::all_at_once(remaining) { + Ok(line) => { + // Compute bytes consumed: for special lines it's 4 bytes, for data lines + // the length is encoded in the first 4 hex bytes. + let bytes_consumed = match line { + PacketLineRef::Flush | PacketLineRef::Delimiter | PacketLineRef::ResponseEnd => 4, + PacketLineRef::Data(data) => 4 + data.len(), + }; + self.response_pos += bytes_consumed; + match line { + PacketLineRef::Flush => { + self.stopped_at = Some(MessageKind::Flush); + None + } + PacketLineRef::Delimiter => { + self.stopped_at = Some(MessageKind::Delimiter); + None + } + PacketLineRef::ResponseEnd => { + self.stopped_at = Some(MessageKind::ResponseEnd); + None + } + _ => Some(Ok(Ok(line))), + } + } + Err(err) => Some(Ok(Err(err))), + } + } + + fn readline_str(&mut self, line: &mut String) -> io::Result { + self.process_request()?; + if self.response_pos >= self.response_buf.len() { + return Ok(0); + } + let remaining = &self.response_buf[self.response_pos..]; + if remaining.len() < 4 { + return Ok(0); + } + match gix_transport::packetline::decode::all_at_once(remaining) { + Ok(pkt_line) => { + let bytes_consumed = match pkt_line { + PacketLineRef::Flush | PacketLineRef::Delimiter | PacketLineRef::ResponseEnd => 4, + PacketLineRef::Data(data) => 4 + data.len(), + }; + self.response_pos += bytes_consumed; + match pkt_line { + PacketLineRef::Flush => { + self.stopped_at = Some(MessageKind::Flush); + Ok(0) + } + PacketLineRef::Delimiter => { + self.stopped_at = Some(MessageKind::Delimiter); + Ok(0) + } + PacketLineRef::ResponseEnd => { + self.stopped_at = Some(MessageKind::ResponseEnd); + Ok(0) + } + PacketLineRef::Data(data) => { + let s = std::str::from_utf8(data) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + line.push_str(s); + Ok(data.len()) + } + } + } + Err(err) => Err(io::Error::new(io::ErrorKind::InvalidData, err)), + } + } +} + +impl<'a> ExtendedBufRead<'a> for BuiltinReader { + fn set_progress_handler(&mut self, _handle_progress: Option>) { + // Progress handling is not needed for the in-process transport. + } + + fn peek_data_line(&mut self) -> Option>> { + if let Err(e) = self.process_request() { + return Some(Err(e)); + } + let remaining = &self.response_buf[self.response_pos..]; + if remaining.len() < 4 { + return None; + } + match gix_transport::packetline::decode::all_at_once(remaining) { + Ok(PacketLineRef::Data(data)) => Some(Ok(Ok(data))), + Ok(PacketLineRef::Flush | PacketLineRef::Delimiter | PacketLineRef::ResponseEnd) => None, + Err(err) => Some(Ok(Err(client::Error::LineDecode { err }))), + } + } + + fn reset(&mut self, _version: Protocol) { + self.stopped_at = None; + } + + fn stopped_at(&self) -> Option { + self.stopped_at + } +} + +// --------------------------------------------------------------------------- +// BuiltinWriter - writes request data to the shared buffer +// --------------------------------------------------------------------------- + +/// A writer that appends data to a shared request buffer. +struct BuiltinWriter { + buf: Arc>>, +} + +impl io::Write for BuiltinWriter { + fn write(&mut self, data: &[u8]) -> io::Result { + let mut buf = self + .buf + .lock() + .map_err(|err| io::Error::new(io::ErrorKind::Other, format!("lock poisoned: {err}")))?; + buf.extend_from_slice(data); + Ok(data.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// RepositoryDelegate +// --------------------------------------------------------------------------- + +/// A delegate implementation that opens a repository's ref store and object database +/// to serve upload-pack requests in-process. +pub(crate) struct RepositoryDelegate { + ref_store: gix_ref::file::Store, + odb: gix_odb::Handle, + object_hash: gix_hash::Kind, + path: BString, +} + +impl RepositoryDelegate { + /// Create a new delegate for the repository at `path`. + #[allow(dead_code)] + pub(crate) fn new( + ref_store: gix_ref::file::Store, + odb: gix_odb::Handle, + object_hash: gix_hash::Kind, + path: BString, + ) -> Self { + Self { + ref_store, + odb, + object_hash, + path, + } + } +} + +impl Delegate for RepositoryDelegate { + fn ls_refs(&mut self, request: &LsRefs) -> Result, BoxError> { + let packed = self.ref_store.cached_packed_buffer().map_err(|err| -> BoxError { + Box::new(io::Error::new( + io::ErrorKind::Other, + format!("failed to open packed-refs for '{}': {err}", self.path), + )) + })?; + let packed = packed.as_ref().map(|b| &***b); + + let iter = if request.ref_prefixes.is_empty() { + self.ref_store.iter_packed(packed).map_err(|err| -> BoxError { + Box::new(io::Error::new( + io::ErrorKind::Other, + format!("failed to iterate refs for '{}': {err}", self.path), + )) + })? + } else { + // Use the first prefix for the underlying iteration, then filter in-memory + // for all prefixes. This matches how git filters refs with multiple prefixes. + let first_prefix = &request.ref_prefixes[0]; + let prefix_path: &gix_path::RelativePath = + first_prefix.as_bstr().try_into().map_err(|err| -> BoxError { + Box::new(io::Error::new( + io::ErrorKind::Other, + format!( + "invalid ref prefix '{}' for '{}': {err}", + first_prefix, self.path + ), + )) + })?; + self.ref_store + .iter_prefixed_packed(prefix_path, packed) + .map_err(|err| -> BoxError { + Box::new(io::Error::new( + io::ErrorKind::Other, + format!("failed to iterate refs for '{}': {err}", self.path), + )) + })? + }; + + let mut refs = Vec::new(); + for reference in iter { + let reference = reference.map_err(|err| -> BoxError { + Box::new(io::Error::new( + io::ErrorKind::Other, + format!("failed to read ref in '{}': {err}", self.path), + )) + })?; + + let full_ref_name: BString = reference.name.as_bstr().to_owned(); + + // Apply prefix filter for multi-prefix case + if !request.ref_prefixes.is_empty() { + let matches = request + .ref_prefixes + .iter() + .any(|prefix| full_ref_name.starts_with(prefix.as_bytes())); + if !matches { + continue; + } + } + + let r = self.build_ref_entry(reference, &full_ref_name, request, packed)?; + refs.push(r); + } + + Ok(refs) + } + + fn fetch(&mut self, request: &Fetch) -> Result { + let negotiation = + negotiate_fetch_with_repository(request, &self.ref_store, |oid| self.odb.contains(oid)).map_err( + |err| -> BoxError { + Box::new(io::Error::new( + io::ErrorKind::Other, + format!("fetch negotiation failed for '{}': {err}", self.path), + )) + }, + )?; + + negotiation + .into_output_with_repository_pack(request, self.odb.clone(), self.object_hash) + .map_err(|err| -> BoxError { + Box::new(io::Error::new( + io::ErrorKind::Other, + format!("pack generation failed for '{}': {err}", self.path), + )) + }) + } +} + +impl RepositoryDelegate { + /// Build a `Ref` entry from a raw reference, resolving symref targets and peeled OIDs as needed. + fn build_ref_entry( + &self, + mut reference: gix_ref::Reference, + full_ref_name: &BString, + request: &LsRefs, + packed: Option<&gix_ref::packed::Buffer>, + ) -> Result { + // Determine if this is a symbolic ref + let symref_target = match &reference.target { + gix_ref::Target::Symbolic(target) => Some(target.as_bstr().to_owned()), + gix_ref::Target::Object(_) => None, + }; + + // Resolve to object ID + let object_id = reference + .follow_to_object_packed(&self.ref_store, packed) + .map_err(|err| -> BoxError { + Box::new(io::Error::new( + io::ErrorKind::Other, + format!( + "failed to resolve ref '{}' in '{}': {err}", + full_ref_name, self.path + ), + )) + })?; + + // Try to peel (for tags) + let peeled = if request.peel { + let mut buf = Vec::new(); + match gix_object::Find::try_find(&self.odb, object_id.as_ref(), &mut buf) { + Ok(Some(obj)) if obj.kind == gix_object::Kind::Tag => { + // Peel through tags to find the final object + self.peel_tag(object_id, &mut buf).ok() + } + _ => None, + } + } else { + // Use peeled info from packed refs if available + reference.peeled + }; + + match (symref_target, peeled) { + (Some(target), _) if request.symrefs => Ok(Ref::Symbolic { + full_ref_name: full_ref_name.clone(), + target, + tag: if peeled.is_some() && peeled != Some(object_id) { + Some(object_id) + } else { + None + }, + object: peeled.unwrap_or(object_id), + }), + (_, Some(peeled_id)) if peeled_id != object_id => Ok(Ref::Peeled { + full_ref_name: full_ref_name.clone(), + tag: object_id, + object: peeled_id, + }), + _ => Ok(Ref::Direct { + full_ref_name: full_ref_name.clone(), + object: object_id, + }), + } + } + + /// Peel a tag object to its final non-tag target. + fn peel_tag( + &self, + start_id: gix_hash::ObjectId, + buf: &mut Vec, + ) -> Result { + let mut id = start_id; + for _ in 0..100 { + // limit to prevent infinite loops + let obj = gix_object::Find::try_find(&self.odb, id.as_ref(), buf) + .map_err(|err| -> BoxError { + Box::new(io::Error::new( + io::ErrorKind::Other, + format!("failed to find object {} in '{}': {err}", id, self.path), + )) + })?; + match obj { + Some(obj) if obj.kind == gix_object::Kind::Tag => { + id = gix_object::TagRefIter::from_bytes(obj.data, obj.object_hash) + .target_id() + .map_err(|err| -> BoxError { + Box::new(io::Error::new( + io::ErrorKind::Other, + format!("failed to decode tag {} in '{}': {err}", id, self.path), + )) + })?; + } + _ => return Ok(id), + } + } + Ok(id) + } +} diff --git a/gix/src/transport/mod.rs b/gix/src/transport/mod.rs new file mode 100644 index 00000000000..950748d52b8 --- /dev/null +++ b/gix/src/transport/mod.rs @@ -0,0 +1,8 @@ +//! Transport implementations specific to the `gix` crate. +//! +//! These live here rather than in `gix-transport` because they depend on higher-level +//! crates (`gix-odb`, `gix-ref`, `gix-protocol`) that would create circular dependencies +//! if placed in a low-level transport crate. + +#[cfg(feature = "experimental")] +pub mod builtin_upload_pack; diff --git a/src/plumbing/main.rs b/src/plumbing/main.rs index e672cef82ce..225f6a8bd0b 100644 --- a/src/plumbing/main.rs +++ b/src/plumbing/main.rs @@ -669,6 +669,7 @@ pub fn main() -> Result<()> { #[cfg(feature = "gitoxide-core-blocking-client")] Subcommands::Clone(crate::plumbing::options::clone::Platform { handshake_info, + builtin_upload_pack, bare, no_tags, ref_name, @@ -677,6 +678,9 @@ pub fn main() -> Result<()> { shallow, directory, }) => { + if builtin_upload_pack && cfg!(not(feature = "experimental")) { + anyhow::bail!("--builtin-upload-pack requires the 'experimental' feature (build with --features experimental)"); + } let opts = core::repository::clone::Options { format, bare, @@ -685,6 +689,7 @@ pub fn main() -> Result<()> { ref_name, revision, shallow: shallow.into(), + builtin_upload_pack, }; prepare_and_run( "clone", @@ -700,12 +705,16 @@ pub fn main() -> Result<()> { Subcommands::Fetch(crate::plumbing::options::fetch::Platform { dry_run, handshake_info, + builtin_upload_pack, negotiation_info, open_negotiation_graph, remote, shallow, ref_spec, }) => { + if builtin_upload_pack && cfg!(not(feature = "experimental")) { + anyhow::bail!("--builtin-upload-pack requires the 'experimental' feature (build with --features experimental)"); + } let opts = core::repository::fetch::Options { format, dry_run, @@ -715,6 +724,7 @@ pub fn main() -> Result<()> { open_negotiation_graph, shallow: shallow.into(), ref_specs: ref_spec, + builtin_upload_pack, }; prepare_and_run( "fetch", diff --git a/src/plumbing/options/mod.rs b/src/plumbing/options/mod.rs index c253ff11b27..8f567e312a2 100644 --- a/src/plumbing/options/mod.rs +++ b/src/plumbing/options/mod.rs @@ -704,6 +704,11 @@ pub mod fetch { #[clap(long, short = 'H')] pub handshake_info: bool, + /// Use the built-in in-process upload-pack instead of spawning git-upload-pack. + /// Requires the `experimental` feature to be compiled in. + #[clap(long, hide = cfg!(not(feature = "experimental")))] + pub builtin_upload_pack: bool, + /// Print statistics about negotiation phase. #[clap(long, short = 's')] pub negotiation_info: bool, @@ -783,6 +788,11 @@ pub mod clone { #[clap(long, short = 'H')] pub handshake_info: bool, + /// Use the built-in in-process upload-pack instead of spawning git-upload-pack. + /// Requires the `experimental` feature to be compiled in. + #[clap(long, hide = cfg!(not(feature = "experimental")))] + pub builtin_upload_pack: bool, + /// The clone will be bare and a working tree checkout won't be available. #[clap(long)] pub bare: bool, diff --git a/tests/journey/gix.sh b/tests/journey/gix.sh index afaff803263..95882d6f03a 100644 --- a/tests/journey/gix.sh +++ b/tests/journey/gix.sh @@ -561,6 +561,46 @@ title "gix commit-graph" ) ) fi + if test "$kind" = "max" || test "$kind" = "max-pure"; then + title "gix clone (builtin-upload-pack)" + (when "cloning with the built-in upload-pack" + snapshot="$snapshot/builtin-upload-pack" + (with "a repository that has branches and tags" + (sandbox + git init -q fixture-repo + ( + cd fixture-repo + git checkout -q -b main + git config commit.gpgsign false + git config tag.gpgsign false + echo "initial content" >file.txt + git add file.txt + git commit -q -m "initial commit" + git tag v1.0 -m "first release" + git branch feature + ) + fixture_repo="$PWD/fixture-repo" + + it "succeeds with the --builtin-upload-pack flag" && { + expect_run $SUCCESSFULLY "$exe_plumbing" clone --builtin-upload-pack "file://$fixture_repo" builtin-clone + } + it "produces a valid HEAD" && { + expect_run $SUCCESSFULLY git -C builtin-clone rev-parse HEAD + } + it "has the same refs as a standard clone" && { + expect_run $SUCCESSFULLY "$exe_plumbing" clone "file://$fixture_repo" standard-clone + expect_run_sh $SUCCESSFULLY 'test "$(git -C builtin-clone for-each-ref --format="%(refname) %(objectname)")" = "$(git -C standard-clone for-each-ref --format="%(refname) %(objectname)")"' + } + it "contains the expected branch ref" && { + expect_run_sh $SUCCESSFULLY 'git -C builtin-clone for-each-ref --format="%(refname)" | grep -q "refs/remotes/origin/feature"' + } + it "contains the expected tag ref" && { + expect_run_sh $SUCCESSFULLY 'git -C builtin-clone for-each-ref --format="%(refname)" | grep -q "refs/tags/v1.0"' + } + ) + ) + ) + fi (with "the 'index' sub-command" snapshot="$snapshot/index" title "gix free pack index create"