diff --git a/Cargo.lock b/Cargo.lock index a89a440b65f..2cbb09ef390 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2353,6 +2353,24 @@ dependencies = [ name = "gix-sequencer" version = "0.0.0" +[[package]] +name = "gix-serve" +version = "0.1.0" +dependencies = [ + "bstr", + "gix-features", + "gix-hash", + "gix-odb", + "gix-pack", + "gix-packetline", + "gix-protocol", + "gix-ref", + "gix-serve", + "gix-testtools", + "gix-transport", + "thiserror 2.0.18", +] + [[package]] name = "gix-shallow" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index 9e35c0ef80d..f280da84826 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -268,6 +268,7 @@ members = [ "gix-transport", "gix-credentials", "gix-protocol", + "gix-serve", "gix-pack", "gix-odb", "gix-tempfile", diff --git a/gix-protocol/Cargo.toml b/gix-protocol/Cargo.toml index 75b139c8895..0ddcbb7adcd 100644 --- a/gix-protocol/Cargo.toml +++ b/gix-protocol/Cargo.toml @@ -53,6 +53,17 @@ fetch = [ "dep:gix-trace", ] +#! ### _Server_ +#! The _server_ portion uses `gix-transport` to communicate with a client. + +## If set, blocking server-side protocol implementations are available. +blocking-server = [ + "gix-transport/blocking-server", + "serve", +] +## Add implementations for serving (upload-pack). +serve = [] + #! ### Other ## Enable support for the SHA-1 hash by enabling the respective feature in the `gix-hash` crate. sha1 = ["gix-hash/sha1"] @@ -69,6 +80,11 @@ name = "async" path = "tests/async-protocol.rs" required-features = ["async-client"] +[[test]] +name = "blocking-server" +path = "tests/blocking-server.rs" +required-features = ["blocking-server"] + [dependencies] gix-features = { version = "^0.46.2", path = "../gix-features", features = [ "progress", diff --git a/gix-protocol/src/lib.rs b/gix-protocol/src/lib.rs index 9f0f070e3fa..e32d4e3327e 100644 --- a/gix-protocol/src/lib.rs +++ b/gix-protocol/src/lib.rs @@ -71,3 +71,7 @@ pub use ls_refs::function::LsRefsCommand; mod util; pub use util::*; + +#[cfg(feature = "serve")] +/// +pub mod serve; diff --git a/gix-protocol/src/serve/mod.rs b/gix-protocol/src/serve/mod.rs new file mode 100644 index 00000000000..af816d48a46 --- /dev/null +++ b/gix-protocol/src/serve/mod.rs @@ -0,0 +1,17 @@ +mod ref_advertisement; +pub use ref_advertisement::{write_capabilities_v2, write_v1, write_v2_ls_refs}; + +/// +pub mod upload_pack; + +/// A reference to advertise to clients. +pub struct RefAdvertisement<'a> { + /// The ref name, e.g. `refs/heads/main`. + pub name: &'a [u8], + /// The object ID the ref points to. + pub object_id: &'a gix_hash::oid, + /// The peeled object ID, if this is an annotated tag. + pub peeled: Option<&'a gix_hash::oid>, + /// The symref target, e.g. `refs/heads/main` for `HEAD`. + pub symref_target: Option<&'a [u8]>, +} diff --git a/gix-protocol/src/serve/ref_advertisement.rs b/gix-protocol/src/serve/ref_advertisement.rs new file mode 100644 index 00000000000..cb270f88edc --- /dev/null +++ b/gix-protocol/src/serve/ref_advertisement.rs @@ -0,0 +1,98 @@ +use std::io::{self, Write}; + +use crate::transport::packetline::blocking_io::encode::{data_to_write, flush_to_write}; + +use crate::serve::RefAdvertisement; + +/// Write a V1 ref advertisement to `writer`. +pub fn write_v1(writer: &mut W, refs: &[RefAdvertisement<'_>], capabilities: &[&str]) -> io::Result<()> { + let mut all_caps: Vec = refs + .iter() + .filter_map(|r| { + r.symref_target.map(|target| { + format!( + "symref={}:{}", + std::str::from_utf8(r.name).expect("valid UTF-8 ref name"), + std::str::from_utf8(target).expect("valid UTF-8 symref target") + ) + }) + }) + .collect(); + all_caps.extend(capabilities.iter().map(ToString::to_string)); + let caps = all_caps.join(" "); + + if refs.is_empty() { + let mut line = Vec::new(); + gix_hash::ObjectId::null(gix_hash::Kind::Sha1).write_hex_to(&mut line)?; + line.extend_from_slice(b" capabilities^{}\0"); + line.extend_from_slice(caps.as_bytes()); + line.push(b'\n'); + data_to_write(&line, &mut *writer)?; + } else { + for (i, r) in refs.iter().enumerate() { + let mut line = Vec::new(); + r.object_id.write_hex_to(&mut line)?; + line.push(b' '); + line.extend_from_slice(r.name); + if i == 0 { + line.push(b'\0'); + line.extend_from_slice(caps.as_bytes()); + } + line.push(b'\n'); + data_to_write(&line, &mut *writer)?; + + if let Some(peeled) = r.peeled { + let mut line = Vec::new(); + peeled.write_hex_to(&mut line)?; + line.push(b' '); + line.extend_from_slice(r.name); + line.extend_from_slice(b"^{}\n"); + data_to_write(&line, &mut *writer)?; + } + } + } + + flush_to_write(&mut *writer)?; + Ok(()) +} + +/// Write a V2 ls-refs response to `writer`. +pub fn write_v2_ls_refs(writer: &mut W, refs: &[RefAdvertisement<'_>]) -> io::Result<()> { + for r in refs.iter() { + let mut line = Vec::new(); + r.object_id.write_hex_to(&mut line)?; + line.push(b' '); + line.extend_from_slice(r.name); + + if let Some(symref_target) = r.symref_target { + line.extend_from_slice(b" symref-target:"); + line.extend_from_slice(symref_target); + } + + if let Some(peeled) = r.peeled { + line.extend_from_slice(b" peeled:"); + peeled.write_hex_to(&mut line)?; + } + line.push(b'\n'); + data_to_write(&line, &mut *writer)?; + } + flush_to_write(&mut *writer)?; + Ok(()) +} + +/// Write a V2 capabilities advertisement to `writer`. +pub fn write_capabilities_v2(writer: &mut W, capabilities: &[(&str, Option<&str>)]) -> io::Result<()> { + data_to_write(b"version 2\n", &mut *writer)?; + for (name, val) in capabilities { + let mut line = Vec::new(); + line.extend_from_slice(name.as_bytes()); + if let Some(value) = val { + line.push(b'='); + line.extend_from_slice(value.as_bytes()); + } + line.push(b'\n'); + data_to_write(&line, &mut *writer)?; + } + flush_to_write(&mut *writer)?; + Ok(()) +} diff --git a/gix-protocol/src/serve/upload_pack/ack.rs b/gix-protocol/src/serve/upload_pack/ack.rs new file mode 100644 index 00000000000..7b754161fb1 --- /dev/null +++ b/gix-protocol/src/serve/upload_pack/ack.rs @@ -0,0 +1,38 @@ +use std::io::{self, Write}; + +use crate::transport::packetline::blocking_io::encode::data_to_write; + +/// The status of an ACK response during negotiation. +pub enum AckStatus { + /// The server has this object in common with the client. + Common, + /// The server has enough common objects and is ready to send the pack. + Ready, + /// Final ACK sent after the client's `done`, before the pack. + Final, +} + +/// Write an ACK line. +pub fn write_ack(writer: &mut W, id: &gix_hash::oid, status: AckStatus) -> io::Result<()> { + let mut line = Vec::new(); + line.extend_from_slice(b"ACK "); + id.write_hex_to(&mut line)?; + match status { + AckStatus::Common => { + line.extend_from_slice(b" common"); + } + AckStatus::Ready => { + line.extend_from_slice(b" ready"); + } + AckStatus::Final => {} + } + line.push(b'\n'); + data_to_write(&line, &mut *writer)?; + Ok(()) +} + +/// Write a NAK line. +pub fn write_nak(writer: &mut W) -> io::Result<()> { + data_to_write(b"NAK\n", &mut *writer)?; + Ok(()) +} diff --git a/gix-protocol/src/serve/upload_pack/function.rs b/gix-protocol/src/serve/upload_pack/function.rs new file mode 100644 index 00000000000..47680c52dd0 --- /dev/null +++ b/gix-protocol/src/serve/upload_pack/function.rs @@ -0,0 +1,172 @@ +use std::io::{self, Read, Write}; + +use gix_hash::ObjectId; + +use bstr::ByteSlice; + +use crate::serve::{ + upload_pack::{parse_haves, parse_wants, write_ack, write_nak, AckStatus, Error}, + write_capabilities_v2, write_v1, write_v2_ls_refs, RefAdvertisement, +}; +use crate::transport::{ + packetline::{ + blocking_io::encode::{data_to_write, delim_to_write, flush_to_write}, + PacketLineRef, + }, + server::blocking_io::connection::Connection, +}; +use crate::Command; + +/// Serve a V1 upload-pack session. +pub fn serve_upload_pack_v1( + connection: &mut Connection, + refs: &[RefAdvertisement<'_>], + has_object: impl Fn(&gix_hash::oid) -> bool, + generate_pack: impl FnOnce(&[ObjectId], &[ObjectId], &mut dyn Write) -> io::Result<()>, + capabilities: &[&str], +) -> Result<(), Error> { + write_v1(&mut connection.writer, refs, capabilities)?; + + let wants = parse_wants(&mut connection.line_provider)?; + if wants.wants.is_empty() { + return Ok(()); + } + + connection.line_provider.reset(); + + let mut common = Vec::new(); + loop { + let haves = parse_haves(&mut connection.line_provider)?; + let mut found_common = false; + for oid in &haves.haves { + if has_object(oid) { + write_ack(&mut connection.writer, oid, AckStatus::Common)?; + common.push(*oid); + found_common = true; + } + } + + if haves.done { + break; + } + + if !found_common { + write_nak(&mut connection.writer)?; + } + connection.line_provider.reset(); + } + + if let Some(last) = common.last() { + write_ack(&mut connection.writer, last, AckStatus::Final)?; + } else { + write_nak(&mut connection.writer)?; + } + + let want_ids: Vec = wants.wants.iter().map(|w| w.id).collect(); + generate_pack(&want_ids, &common, &mut connection.writer)?; + + Ok(()) +} + +/// Serve a V2 upload-pack session. +pub fn serve_upload_pack_v2( + connection: &mut Connection, + refs: &[RefAdvertisement<'_>], + has_object: impl Fn(&gix_hash::oid) -> bool, + generate_pack: impl FnOnce(&[ObjectId], &[ObjectId], &mut dyn Write) -> io::Result<()>, + capabilities: &[(&str, Option<&str>)], +) -> Result<(), Error> { + write_capabilities_v2(&mut connection.writer, capabilities)?; + + loop { + connection.line_provider.reset(); + + let line = match connection.line_provider.read_line() { + Some(Ok(line)) => line?, + Some(Err(e)) if e.kind() == io::ErrorKind::UnexpectedEof => break, + Some(Err(e)) => return Err(e.into()), + None => break, // connection closed + }; + let command = match line { + PacketLineRef::Data(data) => parse_command(data)?, + _ => break, + }; + + match command { + Command::LsRefs => { + while let Some(line) = connection.line_provider.read_line() { + let _ = line??; + } + write_v2_ls_refs(&mut connection.writer, refs)?; + } + Command::Fetch => { + // V2 sends all arguments in one flush-terminated group. + let mut want_ids = Vec::new(); + let mut have_ids = Vec::new(); + let mut done = false; + + while let Some(line) = connection.line_provider.read_line() { + let line = line??; + match line { + PacketLineRef::Data(data) => { + let data = data.trim(); + if let Some(hex) = data.strip_prefix(b"want ") { + let id = + ObjectId::from_hex(hex).map_err(|_| Error::UnexpectedLine { line: hex.into() })?; + want_ids.push(id); + } else if let Some(hex) = data.strip_prefix(b"have ") { + let id = + ObjectId::from_hex(hex).map_err(|_| Error::UnexpectedLine { line: hex.into() })?; + have_ids.push(id); + } else if data == b"done" { + done = true; + } + // Skip unknown lines (capabilities like thin-pack, ofs-delta). + } + PacketLineRef::Delimiter => {} + _ => break, + } + } + + data_to_write(b"acknowledgments\n", &mut connection.writer)?; + let mut common = Vec::new(); + for oid in &have_ids { + if has_object(oid) { + write_ack(&mut connection.writer, oid, AckStatus::Common)?; + common.push(*oid); + } + } + if common.is_empty() { + write_nak(&mut connection.writer)?; + } + + if !done { + flush_to_write(&mut connection.writer)?; + continue; + } + + data_to_write(b"ready\n", &mut connection.writer)?; + delim_to_write(&mut connection.writer)?; + + data_to_write(b"packfile\n", &mut connection.writer)?; + generate_pack(&want_ids, &common, &mut connection.writer)?; + flush_to_write(&mut connection.writer)?; + break; + } + } + } + + Ok(()) +} + +fn parse_command(data: &[u8]) -> Result { + let name = data + .trim() + .strip_prefix(b"command=") + .ok_or_else(|| Error::UnexpectedLine { line: data.into() })?; + match name { + b"ls-refs" => Ok(Command::LsRefs), + b"fetch" => Ok(Command::Fetch), + _ => Err(Error::UnexpectedLine { line: data.into() }), + } +} diff --git a/gix-protocol/src/serve/upload_pack/mod.rs b/gix-protocol/src/serve/upload_pack/mod.rs new file mode 100644 index 00000000000..c2178d3e36a --- /dev/null +++ b/gix-protocol/src/serve/upload_pack/mod.rs @@ -0,0 +1,25 @@ +/// +pub mod want_haves; +pub use want_haves::{parse_haves, parse_wants}; + +/// +pub mod ack; +pub use ack::{write_ack, write_nak, AckStatus}; + +/// +pub mod function; +pub use function::{serve_upload_pack_v1, serve_upload_pack_v2}; + +/// Errors from serving upload-pack. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("Failed to parse client wants/haves")] + WantHaves(#[from] want_haves::Error), + #[error("Packetline decode error")] + PacketlineDecode(#[from] crate::transport::packetline::decode::Error), + #[error("Unexpected line: {line}")] + UnexpectedLine { line: bstr::BString }, +} diff --git a/gix-protocol/src/serve/upload_pack/want_haves.rs b/gix-protocol/src/serve/upload_pack/want_haves.rs new file mode 100644 index 00000000000..eceff05c3d4 --- /dev/null +++ b/gix-protocol/src/serve/upload_pack/want_haves.rs @@ -0,0 +1,118 @@ +use std::io::Read; + +use crate::transport::packetline::blocking_io::StreamingPeekableIter; +use crate::transport::packetline::PacketLineRef; +use bstr::{BString, ByteSlice}; +use gix_hash::ObjectId; + +/// A parsed `want` from the client. +#[derive(Debug)] +pub struct Want { + /// The object ID the client wants. + pub id: ObjectId, +} + +/// The result of parsing client `want` lines. +#[derive(Debug)] +pub struct Wants { + /// The wanted object IDs. + pub wants: Vec, + /// Capabilities sent on the first `want` line (V1 only). + pub capabilities: Vec, +} + +/// The result of parsing client `have` lines. +#[derive(Debug)] +pub struct Haves { + /// The object IDs the client already has. + pub haves: Vec, + /// Whether the client sent `done`. + pub done: bool, +} + +/// Errors from parsing want/have lines. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("Invalid object ID: {hex}")] + InvalidObjectId { hex: BString }, + #[error("Packetline decode error")] + PacketlineDecode(#[from] crate::transport::packetline::decode::Error), + #[error("Unexpected line: {line}")] + UnexpectedLine { line: BString }, +} + +/// Parse `want` lines from the packetline reader until a flush packet. +/// +/// In V1, the first `want` line may include capabilities after the OID. +pub fn parse_wants(reader: &mut StreamingPeekableIter) -> Result { + let mut wants = Vec::new(); + let mut capabilities = Vec::new(); + let mut is_first = true; + + while let Some(line) = reader.read_line() { + let line = line??; + match line { + PacketLineRef::Data(data) => { + let data = data.trim(); + let rest = data + .strip_prefix(b"want ") + .ok_or_else(|| Error::UnexpectedLine { line: data.into() })?; + + let (hex, caps_str) = if rest.len() > 40 { + (&rest[..40], Some(&rest[41..])) + } else { + (rest, None) + }; + + let id = ObjectId::from_hex(hex).map_err(|_| Error::InvalidObjectId { hex: hex.into() })?; + wants.push(Want { id }); + + if is_first { + if let Some(caps) = caps_str { + capabilities = caps.split(|b| *b == b' ').map(Into::into).collect(); + } + is_first = false; + } + } + _ => { + return Err(Error::UnexpectedLine { + line: "non-data packet".into(), + }) + } + } + } + + Ok(Wants { wants, capabilities }) +} + +/// Parse `have` lines from the packetline reader. +/// +/// Returns the OIDs and whether `done` was seen. +pub fn parse_haves(reader: &mut StreamingPeekableIter) -> Result { + let mut haves = Vec::new(); + let mut done = false; + + while let Some(line) = reader.read_line() { + let line = line??; + match line { + PacketLineRef::Data(data) => { + let data = data.trim(); + if data == b"done" { + done = true; + break; + } + let hex = data + .strip_prefix(b"have ") + .ok_or_else(|| Error::UnexpectedLine { line: data.into() })?; + let id = ObjectId::from_hex(hex).map_err(|_| Error::InvalidObjectId { hex: hex.into() })?; + haves.push(id); + } + _ => break, + } + } + + Ok(Haves { haves, done }) +} diff --git a/gix-protocol/tests/blocking-server.rs b/gix-protocol/tests/blocking-server.rs new file mode 100644 index 00000000000..fff33f1dd79 --- /dev/null +++ b/gix-protocol/tests/blocking-server.rs @@ -0,0 +1,831 @@ +use gix_hash::ObjectId; +use gix_packetline::blocking_io::encode::{data_to_write, delim_to_write, flush_to_write}; +use gix_packetline::blocking_io::StreamingPeekableIter; +use gix_packetline::PacketLineRef; +use gix_protocol::serve::upload_pack::ack::{write_ack, write_nak, AckStatus}; +use gix_protocol::serve::upload_pack::want_haves::{parse_haves, parse_wants}; +use gix_protocol::serve::upload_pack::{serve_upload_pack_v1, serve_upload_pack_v2}; +use gix_protocol::serve::{write_capabilities_v2, write_v1, write_v2_ls_refs, RefAdvertisement}; +use gix_transport::server::blocking_io::connection::Connection; +use gix_transport::{Protocol, Service}; + +fn read_data_line(reader: &mut StreamingPeekableIter<&[u8]>) -> Vec { + match reader.read_line().unwrap().unwrap().unwrap() { + PacketLineRef::Data(d) => d.to_vec(), + other => panic!("expected data line, got {other:?}"), + } +} + +fn assert_flushed(reader: &mut StreamingPeekableIter<&[u8]>) { + assert!(reader.read_line().is_none(), "expected flush/end of iteration"); +} + +fn hex_id(byte: u8) -> ObjectId { + ObjectId::from([byte; 20]) +} + +#[test] +fn empty_refs_writes_null_oid_with_capabilities() { + let mut out = Vec::new(); + write_v1(&mut out, &[], &["ofs-delta", "side-band-64k"]).unwrap(); + + let mut reader = StreamingPeekableIter::new(&out[..], &[PacketLineRef::Flush], false); + let line = read_data_line(&mut reader); + let null_hex = "0000000000000000000000000000000000000000"; + let expected = format!("{null_hex} capabilities^{{}}\0ofs-delta side-band-64k\n"); + assert_eq!(line, expected.as_bytes()); + assert_flushed(&mut reader); +} + +#[test] +fn single_ref_has_capabilities_on_first_line() { + let oid = hex_id(0xaa); + let refs = [RefAdvertisement { + name: b"refs/heads/main", + object_id: &oid, + peeled: None, + symref_target: None, + }]; + let mut out = Vec::new(); + write_v1(&mut out, &refs, &["ofs-delta"]).unwrap(); + + let mut reader = StreamingPeekableIter::new(&out[..], &[PacketLineRef::Flush], false); + let line = read_data_line(&mut reader); + let expected = format!("{} refs/heads/main\0ofs-delta\n", oid.to_hex()); + assert_eq!(line, expected.as_bytes()); + assert_flushed(&mut reader); +} + +#[test] +fn multiple_refs_only_first_has_capabilities() { + let oid1 = hex_id(0xaa); + let oid2 = hex_id(0xbb); + let refs = [ + RefAdvertisement { + name: b"refs/heads/main", + object_id: &oid1, + peeled: None, + symref_target: None, + }, + RefAdvertisement { + name: b"refs/heads/dev", + object_id: &oid2, + peeled: None, + symref_target: None, + }, + ]; + let mut out = Vec::new(); + write_v1(&mut out, &refs, &["ofs-delta"]).unwrap(); + + let mut reader = StreamingPeekableIter::new(&out[..], &[PacketLineRef::Flush], false); + + let first = read_data_line(&mut reader); + let expected_first = format!("{} refs/heads/main\0ofs-delta\n", oid1.to_hex()); + assert_eq!(first, expected_first.as_bytes()); + + let second = read_data_line(&mut reader); + let expected_second = format!("{} refs/heads/dev\n", oid2.to_hex()); + assert_eq!(second, expected_second.as_bytes()); + + assert_flushed(&mut reader); +} + +#[test] +fn peeled_tag_emits_caret_brace_line() { + let tag_oid = hex_id(0xcc); + let commit_oid = hex_id(0xdd); + let refs = [RefAdvertisement { + name: b"refs/tags/v1.0", + object_id: &tag_oid, + peeled: Some(&commit_oid), + symref_target: None, + }]; + let mut out = Vec::new(); + write_v1(&mut out, &refs, &["ofs-delta"]).unwrap(); + + let mut reader = StreamingPeekableIter::new(&out[..], &[PacketLineRef::Flush], false); + + let tag_line = read_data_line(&mut reader); + let expected_tag = format!("{} refs/tags/v1.0\0ofs-delta\n", tag_oid.to_hex()); + assert_eq!(tag_line, expected_tag.as_bytes()); + + let peel_line = read_data_line(&mut reader); + let expected_peel = format!("{} refs/tags/v1.0^{{}}\n", commit_oid.to_hex()); + assert_eq!(peel_line, expected_peel.as_bytes()); + + assert_flushed(&mut reader); +} + +#[test] +fn mixed_refs_and_peeled_tags() { + let head_oid = hex_id(0xaa); + let tag_oid = hex_id(0xbb); + let commit_oid = hex_id(0xcc); + let dev_oid = hex_id(0xdd); + let refs = [ + RefAdvertisement { + name: b"HEAD", + object_id: &head_oid, + peeled: None, + symref_target: None, + }, + RefAdvertisement { + name: b"refs/tags/v1.0", + object_id: &tag_oid, + peeled: Some(&commit_oid), + symref_target: None, + }, + RefAdvertisement { + name: b"refs/heads/dev", + object_id: &dev_oid, + peeled: None, + symref_target: None, + }, + ]; + let mut out = Vec::new(); + write_v1(&mut out, &refs, &["multi_ack", "thin-pack"]).unwrap(); + + let mut reader = StreamingPeekableIter::new(&out[..], &[PacketLineRef::Flush], false); + + let line = read_data_line(&mut reader); + assert_eq!( + line, + format!("{} HEAD\0multi_ack thin-pack\n", head_oid.to_hex()).as_bytes() + ); + + let line = read_data_line(&mut reader); + assert_eq!(line, format!("{} refs/tags/v1.0\n", tag_oid.to_hex()).as_bytes()); + + let line = read_data_line(&mut reader); + assert_eq!( + line, + format!("{} refs/tags/v1.0^{{}}\n", commit_oid.to_hex()).as_bytes() + ); + + let line = read_data_line(&mut reader); + assert_eq!(line, format!("{} refs/heads/dev\n", dev_oid.to_hex()).as_bytes()); + + assert_flushed(&mut reader); +} + +#[test] +fn symref_is_encoded_in_capabilities() { + let head_oid = hex_id(0xaa); + let main_oid = hex_id(0xaa); + let refs = [ + RefAdvertisement { + name: b"HEAD", + object_id: &head_oid, + peeled: None, + symref_target: Some(b"refs/heads/main"), + }, + RefAdvertisement { + name: b"refs/heads/main", + object_id: &main_oid, + peeled: None, + symref_target: None, + }, + ]; + let mut out = Vec::new(); + write_v1(&mut out, &refs, &["ofs-delta"]).unwrap(); + + let mut reader = StreamingPeekableIter::new(&out[..], &[PacketLineRef::Flush], false); + + let line = read_data_line(&mut reader); + let expected = format!("{} HEAD\0symref=HEAD:refs/heads/main ofs-delta\n", head_oid.to_hex()); + assert_eq!(line, expected.as_bytes()); + + let line = read_data_line(&mut reader); + assert_eq!(line, format!("{} refs/heads/main\n", main_oid.to_hex()).as_bytes()); + + assert_flushed(&mut reader); +} + +// --- V2 ls-refs tests --- + +#[test] +fn v2_ls_refs_single_ref() { + let oid = hex_id(0xaa); + let refs = [RefAdvertisement { + name: b"refs/heads/main", + object_id: &oid, + peeled: None, + symref_target: None, + }]; + let mut out = Vec::new(); + write_v2_ls_refs(&mut out, &refs).unwrap(); + + let mut reader = StreamingPeekableIter::new(&out[..], &[PacketLineRef::Flush], false); + let line = read_data_line(&mut reader); + assert_eq!(line, format!("{} refs/heads/main\n", oid.to_hex()).as_bytes()); + assert_flushed(&mut reader); +} + +#[test] +fn v2_ls_refs_with_symref_and_peeled() { + let head_oid = hex_id(0xaa); + let tag_oid = hex_id(0xbb); + let commit_oid = hex_id(0xcc); + let refs = [ + RefAdvertisement { + name: b"HEAD", + object_id: &head_oid, + peeled: None, + symref_target: Some(b"refs/heads/main"), + }, + RefAdvertisement { + name: b"refs/tags/v1.0", + object_id: &tag_oid, + peeled: Some(&commit_oid), + symref_target: None, + }, + ]; + let mut out = Vec::new(); + write_v2_ls_refs(&mut out, &refs).unwrap(); + + let mut reader = StreamingPeekableIter::new(&out[..], &[PacketLineRef::Flush], false); + + let line = read_data_line(&mut reader); + assert_eq!( + line, + format!("{} HEAD symref-target:refs/heads/main\n", head_oid.to_hex()).as_bytes() + ); + + let line = read_data_line(&mut reader); + assert_eq!( + line, + format!("{} refs/tags/v1.0 peeled:{}\n", tag_oid.to_hex(), commit_oid.to_hex()).as_bytes() + ); + + assert_flushed(&mut reader); +} + +// --- V2 capabilities tests --- + +#[test] +fn v2_capabilities_plain() { + let mut out = Vec::new(); + write_capabilities_v2(&mut out, &[("ls-refs", None), ("fetch", None)]).unwrap(); + + let mut reader = StreamingPeekableIter::new(&out[..], &[PacketLineRef::Flush], false); + + let line = read_data_line(&mut reader); + assert_eq!(line, b"version 2\n"); + + let line = read_data_line(&mut reader); + assert_eq!(line, b"ls-refs\n"); + + let line = read_data_line(&mut reader); + assert_eq!(line, b"fetch\n"); + + assert_flushed(&mut reader); +} + +#[test] +fn v2_capabilities_with_values() { + let mut out = Vec::new(); + write_capabilities_v2( + &mut out, + &[("ls-refs", None), ("fetch", Some("shallow")), ("server-option", None)], + ) + .unwrap(); + + let mut reader = StreamingPeekableIter::new(&out[..], &[PacketLineRef::Flush], false); + + let line = read_data_line(&mut reader); + assert_eq!(line, b"version 2\n"); + + let line = read_data_line(&mut reader); + assert_eq!(line, b"ls-refs\n"); + + let line = read_data_line(&mut reader); + assert_eq!(line, b"fetch=shallow\n"); + + let line = read_data_line(&mut reader); + assert_eq!(line, b"server-option\n"); + + assert_flushed(&mut reader); +} + +// --- want/have parsing tests --- + +fn write_want_have_input(lines: &[&str]) -> Vec { + let mut buf = Vec::new(); + for line in lines { + data_to_write(format!("{line}\n").as_bytes(), &mut buf).unwrap(); + } + flush_to_write(&mut buf).unwrap(); + buf +} + +#[test] +fn parse_wants_single_no_caps() { + let oid = hex_id(0xaa); + let input = write_want_have_input(&[&format!("want {}", oid.to_hex())]); + let mut reader = StreamingPeekableIter::new(&input[..], &[PacketLineRef::Flush], false); + + let result = parse_wants(&mut reader).unwrap(); + assert_eq!(result.wants.len(), 1); + assert_eq!(result.wants[0].id, oid); + assert!(result.capabilities.is_empty()); +} + +#[test] +fn parse_wants_first_line_has_capabilities() { + let oid1 = hex_id(0xaa); + let oid2 = hex_id(0xbb); + let input = write_want_have_input(&[ + &format!("want {} ofs-delta side-band-64k", oid1.to_hex()), + &format!("want {}", oid2.to_hex()), + ]); + let mut reader = StreamingPeekableIter::new(&input[..], &[PacketLineRef::Flush], false); + + let result = parse_wants(&mut reader).unwrap(); + assert_eq!(result.wants.len(), 2); + assert_eq!(result.wants[0].id, oid1); + assert_eq!(result.wants[1].id, oid2); + assert_eq!(result.capabilities, vec!["ofs-delta", "side-band-64k"]); +} + +#[test] +fn parse_wants_ignores_caps_on_subsequent_lines() { + let oid1 = hex_id(0xaa); + let oid2 = hex_id(0xbb); + let input = write_want_have_input(&[ + &format!("want {} cap1", oid1.to_hex()), + &format!("want {} cap2", oid2.to_hex()), + ]); + let mut reader = StreamingPeekableIter::new(&input[..], &[PacketLineRef::Flush], false); + + let result = parse_wants(&mut reader).unwrap(); + assert_eq!(result.wants.len(), 2); + assert_eq!(result.capabilities, vec!["cap1"]); +} + +#[test] +fn parse_haves_with_done() { + let oid1 = hex_id(0xaa); + let oid2 = hex_id(0xbb); + let mut input = Vec::new(); + data_to_write(format!("have {}\n", oid1.to_hex()).as_bytes(), &mut input).unwrap(); + data_to_write(format!("have {}\n", oid2.to_hex()).as_bytes(), &mut input).unwrap(); + data_to_write(b"done\n", &mut input).unwrap(); + flush_to_write(&mut input).unwrap(); + + let mut reader = StreamingPeekableIter::new(&input[..], &[PacketLineRef::Flush], false); + + let result = parse_haves(&mut reader).unwrap(); + assert_eq!(result.haves.len(), 2); + assert_eq!(result.haves[0], oid1); + assert_eq!(result.haves[1], oid2); + assert!(result.done); +} + +#[test] +fn parse_haves_without_done_ends_at_flush() { + let oid = hex_id(0xaa); + let input = write_want_have_input(&[&format!("have {}", oid.to_hex())]); + let mut reader = StreamingPeekableIter::new(&input[..], &[PacketLineRef::Flush], false); + + let result = parse_haves(&mut reader).unwrap(); + assert_eq!(result.haves.len(), 1); + assert_eq!(result.haves[0], oid); + assert!(!result.done); +} + +#[test] +fn parse_haves_empty_round() { + let mut input = Vec::new(); + flush_to_write(&mut input).unwrap(); + + let mut reader = StreamingPeekableIter::new(&input[..], &[PacketLineRef::Flush], false); + + let result = parse_haves(&mut reader).unwrap(); + assert!(result.haves.is_empty()); + assert!(!result.done); +} + +// --- ACK/NAK tests --- + +#[test] +fn ack_common() { + let oid = hex_id(0xaa); + let mut out = Vec::new(); + write_ack(&mut out, &oid, AckStatus::Common).unwrap(); + + let mut reader = StreamingPeekableIter::new(&out[..], &[PacketLineRef::Flush], false); + let line = read_data_line(&mut reader); + assert_eq!(line, format!("ACK {} common\n", oid.to_hex()).as_bytes()); +} + +#[test] +fn ack_ready() { + let oid = hex_id(0xbb); + let mut out = Vec::new(); + write_ack(&mut out, &oid, AckStatus::Ready).unwrap(); + + let mut reader = StreamingPeekableIter::new(&out[..], &[PacketLineRef::Flush], false); + let line = read_data_line(&mut reader); + assert_eq!(line, format!("ACK {} ready\n", oid.to_hex()).as_bytes()); +} + +#[test] +fn ack_final() { + let oid = hex_id(0xcc); + let mut out = Vec::new(); + write_ack(&mut out, &oid, AckStatus::Final).unwrap(); + + let mut reader = StreamingPeekableIter::new(&out[..], &[PacketLineRef::Flush], false); + let line = read_data_line(&mut reader); + assert_eq!(line, format!("ACK {}\n", oid.to_hex()).as_bytes()); +} + +#[test] +fn nak() { + let mut out = Vec::new(); + write_nak(&mut out).unwrap(); + + let mut reader = StreamingPeekableIter::new(&out[..], &[PacketLineRef::Flush], false); + let line = read_data_line(&mut reader); + assert_eq!(line, b"NAK\n"); +} + +// --- upload-pack orchestrator tests --- + +fn build_client_input(wants: &[ObjectId], haves: &[ObjectId], done: bool) -> Vec { + let mut buf = Vec::new(); + for (i, oid) in wants.iter().enumerate() { + let line = if i == 0 { + format!("want {} ofs-delta\n", oid.to_hex()) + } else { + format!("want {}\n", oid.to_hex()) + }; + data_to_write(line.as_bytes(), &mut buf).unwrap(); + } + flush_to_write(&mut buf).unwrap(); + for oid in haves { + data_to_write(format!("have {}\n", oid.to_hex()).as_bytes(), &mut buf).unwrap(); + } + if done { + data_to_write(b"done\n", &mut buf).unwrap(); + } + flush_to_write(&mut buf).unwrap(); + buf +} + +#[test] +fn upload_pack_v1_fresh_clone() { + let ref_oid = hex_id(0xaa); + let input = build_client_input(&[ref_oid], &[], true); + let mut output = Vec::new(); + + let mut conn = Connection::new( + &input[..], + &mut output, + Service::UploadPack, + "/repo.git", + Protocol::V1, + false, + ); + + let refs = [RefAdvertisement { + name: b"refs/heads/main", + object_id: &ref_oid, + peeled: None, + symref_target: None, + }]; + let mut pack_written = false; + serve_upload_pack_v1( + &mut conn, + &refs, + |_oid| false, // client has nothing + |wants, common, _writer| { + assert_eq!(wants.len(), 1); + assert_eq!(wants[0], ref_oid); + assert!(common.is_empty()); + pack_written = true; + Ok(()) + }, + &["ofs-delta"], + ) + .unwrap(); + + assert!(pack_written); + + // Verify output: ref advertisement + NAK + NAK + let mut reader = StreamingPeekableIter::new(&output[..], &[PacketLineRef::Flush], false); + // First line: ref advertisement + let line = read_data_line(&mut reader); + assert_eq!( + line, + format!("{} refs/heads/main\0ofs-delta\n", ref_oid.to_hex()).as_bytes() + ); + // Flush after ref advertisement + assert_flushed(&mut reader); +} + +#[test] +fn upload_pack_v1_with_common_objects() { + let ref_oid = hex_id(0xaa); + let common_oid = hex_id(0xbb); + let input = build_client_input(&[ref_oid], &[common_oid], true); + let mut output = Vec::new(); + + let mut conn = Connection::new( + &input[..], + &mut output, + Service::UploadPack, + "/repo.git", + Protocol::V1, + false, + ); + + let refs = [RefAdvertisement { + name: b"refs/heads/main", + object_id: &ref_oid, + peeled: None, + symref_target: None, + }]; + serve_upload_pack_v1( + &mut conn, + &refs, + |oid| oid == common_oid, // server has this object + |wants, common, _writer| { + assert_eq!(wants, &[ref_oid]); + assert_eq!(common, &[common_oid]); + Ok(()) + }, + &["ofs-delta"], + ) + .unwrap(); +} + +#[test] +fn upload_pack_v1_empty_wants_returns_early() { + // Client sends no wants — up to date + let mut input = Vec::new(); + flush_to_write(&mut input).unwrap(); // empty wants section + let mut output = Vec::new(); + + let ref_oid = hex_id(0xaa); + let mut conn = Connection::new( + &input[..], + &mut output, + Service::UploadPack, + "/repo.git", + Protocol::V1, + false, + ); + + let refs = [RefAdvertisement { + name: b"refs/heads/main", + object_id: &ref_oid, + peeled: None, + symref_target: None, + }]; + serve_upload_pack_v1( + &mut conn, + &refs, + |_| false, + |_, _, _| panic!("should not generate pack"), + &["ofs-delta"], + ) + .unwrap(); +} + +#[test] +fn upload_pack_v1_multi_round_negotiation() { + let ref_oid = hex_id(0xaa); + let have1 = hex_id(0xbb); + let have2 = hex_id(0xcc); + let have3 = hex_id(0xdd); + + // Build input: wants, then two rounds of haves, then done + let mut input = Vec::new(); + // wants + data_to_write(format!("want {} ofs-delta\n", ref_oid.to_hex()).as_bytes(), &mut input).unwrap(); + flush_to_write(&mut input).unwrap(); + // round 1: server doesn't have these + data_to_write(format!("have {}\n", have1.to_hex()).as_bytes(), &mut input).unwrap(); + flush_to_write(&mut input).unwrap(); + // round 2: server has have2, doesn't have have3 + data_to_write(format!("have {}\n", have2.to_hex()).as_bytes(), &mut input).unwrap(); + data_to_write(format!("have {}\n", have3.to_hex()).as_bytes(), &mut input).unwrap(); + data_to_write(b"done\n", &mut input).unwrap(); + flush_to_write(&mut input).unwrap(); + + let mut output = Vec::new(); + let mut conn = Connection::new( + &input[..], + &mut output, + Service::UploadPack, + "/repo.git", + Protocol::V1, + false, + ); + + let refs = [RefAdvertisement { + name: b"refs/heads/main", + object_id: &ref_oid, + peeled: None, + symref_target: None, + }]; + serve_upload_pack_v1( + &mut conn, + &refs, + |oid| oid == have2, // only have2 is common + |wants, common, _writer| { + assert_eq!(wants, &[ref_oid]); + assert_eq!(common, &[have2]); + Ok(()) + }, + &["ofs-delta"], + ) + .unwrap(); + + // Verify output after ref advertisement + flush: + // Round 1: NAK (no common) + // Round 2: ACK have2 common, then final ACK have2 + let mut reader = StreamingPeekableIter::new(&output[..], &[PacketLineRef::Flush], false); + // ref advertisement + let line = read_data_line(&mut reader); + assert_eq!( + line, + format!("{} refs/heads/main\0ofs-delta\n", ref_oid.to_hex()).as_bytes() + ); + assert_flushed(&mut reader); + // reset to read past flush + reader.reset(); + // round 1: NAK + let line = read_data_line(&mut reader); + assert_eq!(line, b"NAK\n"); + // round 2: ACK common + let line = read_data_line(&mut reader); + assert_eq!(line, format!("ACK {} common\n", have2.to_hex()).as_bytes()); + // final ACK + let line = read_data_line(&mut reader); + assert_eq!(line, format!("ACK {}\n", have2.to_hex()).as_bytes()); +} + +// --- V2 upload-pack orchestrator tests --- + +/// Build V2 fetch command input: command=fetch + 0001 + wants + haves + done + 0000. +fn build_v2_fetch_input(wants: &[ObjectId], haves: &[ObjectId], done: bool) -> Vec { + let mut buf = Vec::new(); + data_to_write(b"command=fetch\n", &mut buf).unwrap(); + delim_to_write(&mut buf).unwrap(); + for oid in wants { + data_to_write(format!("want {}\n", oid.to_hex()).as_bytes(), &mut buf).unwrap(); + } + for oid in haves { + data_to_write(format!("have {}\n", oid.to_hex()).as_bytes(), &mut buf).unwrap(); + } + if done { + data_to_write(b"done\n", &mut buf).unwrap(); + } + flush_to_write(&mut buf).unwrap(); + buf +} + +/// Build V2 ls-refs command input: command=ls-refs + flush. +fn build_v2_ls_refs_input() -> Vec { + let mut buf = Vec::new(); + data_to_write(b"command=ls-refs\n", &mut buf).unwrap(); + flush_to_write(&mut buf).unwrap(); + buf +} + +#[test] +fn upload_pack_v2_ls_refs_command() { + let ref_oid = hex_id(0xaa); + let input = build_v2_ls_refs_input(); + let mut output = Vec::new(); + + let mut conn = Connection::new( + &input[..], + &mut output, + Service::UploadPack, + "/repo.git", + Protocol::V2, + false, + ); + + let refs = [RefAdvertisement { + name: b"refs/heads/main", + object_id: &ref_oid, + peeled: None, + symref_target: None, + }]; + serve_upload_pack_v2( + &mut conn, + &refs, + |_| false, + |_, _, _| panic!("should not generate pack for ls-refs"), + &[("ls-refs", None), ("fetch", None)], + ) + .unwrap(); + + // Output: capabilities + ls-refs response + let mut reader = StreamingPeekableIter::new(&output[..], &[PacketLineRef::Flush], false); + // capabilities + let line = read_data_line(&mut reader); + assert_eq!(line, b"version 2\n"); + let line = read_data_line(&mut reader); + assert_eq!(line, b"ls-refs\n"); + let line = read_data_line(&mut reader); + assert_eq!(line, b"fetch\n"); + assert_flushed(&mut reader); + reader.reset(); + // ls-refs response + let line = read_data_line(&mut reader); + assert_eq!(line, format!("{} refs/heads/main\n", ref_oid.to_hex()).as_bytes()); + assert_flushed(&mut reader); +} + +#[test] +fn upload_pack_v2_fresh_clone() { + let ref_oid = hex_id(0xaa); + let input = build_v2_fetch_input(&[ref_oid], &[], true); + let mut output = Vec::new(); + + let mut conn = Connection::new( + &input[..], + &mut output, + Service::UploadPack, + "/repo.git", + Protocol::V2, + false, + ); + + let refs = [RefAdvertisement { + name: b"refs/heads/main", + object_id: &ref_oid, + peeled: None, + symref_target: None, + }]; + let mut pack_written = false; + serve_upload_pack_v2( + &mut conn, + &refs, + |_| false, + |wants, common, _writer| { + assert_eq!(wants, &[ref_oid]); + assert!(common.is_empty()); + pack_written = true; + Ok(()) + }, + &[("ls-refs", None), ("fetch", None)], + ) + .unwrap(); + + assert!(pack_written); +} + +#[test] +fn upload_pack_v2_fetch_with_common_objects() { + let ref_oid = hex_id(0xaa); + let common_oid = hex_id(0xbb); + let input = build_v2_fetch_input(&[ref_oid], &[common_oid], true); + let mut output = Vec::new(); + + let mut conn = Connection::new( + &input[..], + &mut output, + Service::UploadPack, + "/repo.git", + Protocol::V2, + false, + ); + + let refs = [RefAdvertisement { + name: b"refs/heads/main", + object_id: &ref_oid, + peeled: None, + symref_target: None, + }]; + serve_upload_pack_v2( + &mut conn, + &refs, + |oid| oid == common_oid, + |wants, common, _writer| { + assert_eq!(wants, &[ref_oid]); + assert_eq!(common, &[common_oid]); + Ok(()) + }, + &[("ls-refs", None), ("fetch", None)], + ) + .unwrap(); + + // Verify output contains acknowledgments section with ACK + let mut reader = StreamingPeekableIter::new(&output[..], &[PacketLineRef::Flush], false); + // skip capabilities + while reader.read_line().is_some() {} + reader.reset(); + // acknowledgments section + let line = read_data_line(&mut reader); + assert_eq!(line, b"acknowledgments\n"); + let line = read_data_line(&mut reader); + assert_eq!(line, format!("ACK {} common\n", common_oid.to_hex()).as_bytes()); + let line = read_data_line(&mut reader); + assert_eq!(line, b"ready\n"); +} diff --git a/gix-serve/Cargo.toml b/gix-serve/Cargo.toml new file mode 100644 index 00000000000..b201032b778 --- /dev/null +++ b/gix-serve/Cargo.toml @@ -0,0 +1,50 @@ +lints.workspace = true + +[package] +name = "gix-serve" +version = "0.1.0" +repository = "https://github.com/GitoxideLabs/gitoxide" +license = "MIT OR Apache-2.0" +description = "A crate of the gitoxide project for serving git repositories" +authors = ["Sebastian Thiel "] +edition = "2021" +include = ["src/**/*", "LICENSE-*"] +rust-version = "1.82" + +[lib] +doctest = false + +[features] +## Enable support for SHA-1 hashes. +sha1 = ["gix-hash/sha1"] +## Blocking server support. +blocking-server = [ + "gix-protocol/blocking-server", + "gix-transport/blocking-server", +] + +[dependencies] +gix-ref = { version = "^0.61.0", path = "../gix-ref" } +gix-hash = { version = "^0.23.0", path = "../gix-hash" } +gix-pack = { version = "^0.68.0", path = "../gix-pack", features = [ + "generate", +] } +gix-features = { version = "^0.46.2", path = "../gix-features" } +gix-protocol = { version = "^0.59.0", path = "../gix-protocol", optional = true } +gix-transport = { version = "^0.55.1", path = "../gix-transport", optional = true } +bstr = { version = "1.12.0", default-features = false, features = ["std"] } +thiserror = "2.0.18" + +[dev-dependencies] +gix-testtools = { path = "../tests/tools" } +gix-odb = { version = "^0.78.0", path = "../gix-odb" } +gix-packetline = { version = "^0.21.2", path = "../gix-packetline", features = [ + "blocking-io", +] } +gix-transport = { version = "^0.55.1", path = "../gix-transport", features = [ + "blocking-server", +] } +gix-serve = { path = ".", features = ["blocking-server"] } + +[package.metadata.docs.rs] +all-features = true diff --git a/gix-serve/LICENSE-APACHE b/gix-serve/LICENSE-APACHE new file mode 120000 index 00000000000..965b606f331 --- /dev/null +++ b/gix-serve/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/gix-serve/LICENSE-MIT b/gix-serve/LICENSE-MIT new file mode 120000 index 00000000000..76219eb72e8 --- /dev/null +++ b/gix-serve/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/gix-serve/fixtures/make_repo_empty.sh b/gix-serve/fixtures/make_repo_empty.sh new file mode 100755 index 00000000000..4bfd8ffd40b --- /dev/null +++ b/gix-serve/fixtures/make_repo_empty.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q diff --git a/gix-serve/fixtures/make_repo_multi_branch.sh b/gix-serve/fixtures/make_repo_multi_branch.sh new file mode 100755 index 00000000000..1da23ae40e0 --- /dev/null +++ b/gix-serve/fixtures/make_repo_multi_branch.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q +git checkout -q -b main +git commit -q --allow-empty -m 'first commit' +git branch feature +git checkout -q -b dev +git commit -q --allow-empty -m 'dev commit' +git checkout -q main diff --git a/gix-serve/fixtures/make_repo_simple.sh b/gix-serve/fixtures/make_repo_simple.sh new file mode 100755 index 00000000000..2869a0288cc --- /dev/null +++ b/gix-serve/fixtures/make_repo_simple.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q +git checkout -q -b main +git commit -q --allow-empty -m 'initial commit' +git branch feature diff --git a/gix-serve/fixtures/make_repo_with_tags.sh b/gix-serve/fixtures/make_repo_with_tags.sh new file mode 100755 index 00000000000..b32bc57b141 --- /dev/null +++ b/gix-serve/fixtures/make_repo_with_tags.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q +git checkout -q -b main +git commit -q --allow-empty -m 'initial commit' +git tag lightweight-tag +git tag -a v1.0 -m 'version 1.0' +git pack-refs --all diff --git a/gix-serve/src/lib.rs b/gix-serve/src/lib.rs new file mode 100644 index 00000000000..7bd77571e12 --- /dev/null +++ b/gix-serve/src/lib.rs @@ -0,0 +1,39 @@ +//! Server-side git protocol support for serving repositories. +#![deny(missing_docs, rust_2018_idioms, unsafe_code)] + +/// +pub mod pack; +/// +pub mod refs; +/// +#[cfg(feature = "blocking-server")] +pub mod serve; + +use bstr::BString; +use gix_hash::ObjectId; + +/// A reference ready for advertisement to clients. +pub struct AdvertisableRef { + /// The ref name, e.g. `refs/heads/main`. + pub name: BString, + /// The object ID the ref points to. + pub object_id: ObjectId, + /// The peeled object ID, if this is an annotated tag. + pub peeled: Option, + /// The symref target, e.g. `refs/heads/main` for `HEAD`. + pub symref_target: Option, +} + +/// Errors from collecting refs. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + RefIter(#[from] gix_ref::packed::buffer::open::Error), + #[error(transparent)] + RefIterEntry(#[from] gix_ref::file::iter::loose_then_packed::Error), + #[error(transparent)] + RefFind(#[from] gix_ref::file::find::Error), +} diff --git a/gix-serve/src/pack.rs b/gix-serve/src/pack.rs new file mode 100644 index 00000000000..5a53be473c7 --- /dev/null +++ b/gix-serve/src/pack.rs @@ -0,0 +1,68 @@ +use std::{io::Write, sync::atomic::AtomicBool}; + +use gix_features::{parallel::InOrderIter, progress::Discard}; +use gix_hash::ObjectId; +use gix_pack::{ + data::{ + output::{ + bytes::FromEntriesIter, + count::{objects::ObjectExpansion, objects_unthreaded}, + entry::iter_from_counts, + }, + Version, + }, + Find, +}; + +/// Generate a packfile containing objects reachable from `wants` but not from `haves`. +pub fn generate_pack( + db: F, + wants: &[ObjectId], + _haves: &[ObjectId], + out: &mut dyn Write, +) -> Result<(), Error> { + let (counts, _outcome) = objects_unthreaded( + &db, + &mut wants.iter().copied().map(Ok), + &Discard, + &AtomicBool::new(false), + ObjectExpansion::TreeContents, + )?; + + let mut entries_iter = iter_from_counts(counts, db, Box::new(Discard), Default::default()); + + let entries: Vec<_> = InOrderIter::from(entries_iter.by_ref()) + .collect::, _>>()? + .into_iter() + .flatten() + .collect(); + + let num_entries = entries.len() as u32; + let pack_writer = FromEntriesIter::new( + std::iter::once(Ok::<_, iter_from_counts::Error>(entries)), + out, + num_entries, + Version::V2, + gix_hash::Kind::Sha1, + ); + + for result in pack_writer { + result?; + } + + Ok(()) +} + +/// Errors from pack generation. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Count(#[from] gix_pack::data::output::count::objects::Error), + #[error(transparent)] + Entry(#[from] iter_from_counts::Error), + #[error(transparent)] + PackWrite(#[from] gix_pack::data::output::bytes::Error), +} diff --git a/gix-serve/src/refs.rs b/gix-serve/src/refs.rs new file mode 100644 index 00000000000..c0c678668c1 --- /dev/null +++ b/gix-serve/src/refs.rs @@ -0,0 +1,36 @@ +use gix_ref::file::Store; + +use crate::{AdvertisableRef, Error}; + +/// Collect all refs from a ref store for advertisement to clients. +pub fn collect_refs(store: &Store) -> Result, Error> { + let mut ref_adverts = Vec::new(); + let platform = store.iter()?; + + for ref_res in platform.pseudo()?.chain(platform.all()?) { + let reference = ref_res?; + match reference.target { + gix_ref::Target::Object(object_id) => { + ref_adverts.push(AdvertisableRef { + name: reference.name.as_bstr().to_owned(), + object_id, + peeled: reference.peeled, + symref_target: None, + }); + } + gix_ref::Target::Symbolic(target_name) => { + if let Some(resolved) = store.try_find(target_name.as_bstr())? { + if let Some(oid) = resolved.target.try_id() { + ref_adverts.push(AdvertisableRef { + name: reference.name.as_bstr().to_owned(), + object_id: oid.to_owned(), + peeled: reference.peeled, + symref_target: Some(target_name.as_bstr().to_owned()), + }); + } + } + } + } + } + Ok(ref_adverts) +} diff --git a/gix-serve/src/serve.rs b/gix-serve/src/serve.rs new file mode 100644 index 00000000000..b78a6a2b6e0 --- /dev/null +++ b/gix-serve/src/serve.rs @@ -0,0 +1,70 @@ +use std::io::{self, Read, Write}; + +use gix_hash::{oid, ObjectId}; +use gix_pack::Find; +use gix_protocol::serve::{ + upload_pack::{self, serve_upload_pack_v1, serve_upload_pack_v2}, + RefAdvertisement, +}; +use gix_ref::file::Store; +use gix_transport::{server::blocking_io::connection::Connection, Protocol}; + +use crate::{pack::generate_pack, refs::collect_refs, AdvertisableRef}; + +impl AdvertisableRef { + /// Borrow as a `RefAdvertisement` for the protocol layer. + pub fn as_advertisement(&self) -> RefAdvertisement<'_> { + RefAdvertisement { + name: &self.name, + object_id: &self.object_id, + peeled: self.peeled.as_deref(), + symref_target: self.symref_target.as_ref().map(AsRef::as_ref), + } + } +} + +/// Errors from serving upload-pack. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error(transparent)] + Refs(#[from] crate::Error), + #[error(transparent)] + Protocol(#[from] upload_pack::Error), + #[error(transparent)] + Pack(#[from] crate::pack::Error), +} + +/// Serve an upload-pack session over a connection. +pub fn serve_upload_pack( + ref_store: &Store, + db: F, + connection: &mut Connection, + protocol: Protocol, +) -> Result<(), Error> { + let owned_refs = collect_refs(ref_store)?; + let refs: Vec<_> = owned_refs.iter().map(AdvertisableRef::as_advertisement).collect(); + + let has_object = |oid: &oid| db.contains(oid); + + let generate_pack = |wants: &[ObjectId], haves: &[ObjectId], out: &mut dyn Write| { + generate_pack(db.clone(), wants, haves, out).map_err(io::Error::other) + }; + + match protocol { + Protocol::V1 | Protocol::V0 => { + serve_upload_pack_v1(connection, &refs, has_object, generate_pack, &[])?; + } + Protocol::V2 => { + serve_upload_pack_v2( + connection, + &refs, + has_object, + generate_pack, + &[("fetch", None), ("ls-refs", None)], + )?; + } + } + + Ok(()) +} diff --git a/gix-serve/tests/pack.rs b/gix-serve/tests/pack.rs new file mode 100644 index 00000000000..4dbfd1460e4 --- /dev/null +++ b/gix-serve/tests/pack.rs @@ -0,0 +1,68 @@ +use std::sync::Arc; + +use gix_odb::store::init::Options as OdbOptions; +use gix_ref::file::Store; +use gix_serve::{pack::generate_pack, refs::collect_refs}; + +fn store_from(script: &str) -> Store { + let path = gix_testtools::scripted_fixture_read_only_standalone(script).expect("fixture script should run"); + Store::at( + path.join(".git"), + gix_ref::store::init::Options { + write_reflog: gix_ref::store::WriteReflog::Disable, + object_hash: gix_hash::Kind::Sha1, + precompose_unicode: false, + prohibit_windows_device_names: false, + }, + ) +} + +fn odb_from(script: &str) -> gix_odb::HandleArc { + let path = gix_testtools::scripted_fixture_read_only_standalone(script).expect("fixture script should run"); + let store = gix_odb::Store::at_opts(path.join(".git/objects"), &mut None.into_iter(), OdbOptions::default()) + .expect("odb should open"); + let mut cache = Arc::new(store).to_cache_arc(); + cache.prevent_pack_unload(); + cache +} + +#[test] +fn pack_starts_with_magic_and_version() { + let db = odb_from("make_repo_simple.sh"); + let refs = collect_refs(&store_from("make_repo_simple.sh")).unwrap(); + let main_oid = refs.iter().find(|r| r.name == "refs/heads/main").unwrap().object_id; + + let mut buf = Vec::new(); + generate_pack(db, &[main_oid], &[], &mut buf).unwrap(); + + assert!(buf.len() > 12, "pack should have header + entries + checksum"); + assert_eq!(&buf[..4], b"PACK", "magic bytes"); + assert_eq!(&buf[4..8], &[0, 0, 0, 2], "version 2"); +} + +#[test] +fn pack_has_nonzero_entry_count() { + let db = odb_from("make_repo_simple.sh"); + let refs = collect_refs(&store_from("make_repo_simple.sh")).unwrap(); + let main_oid = refs.iter().find(|r| r.name == "refs/heads/main").unwrap().object_id; + + let mut buf = Vec::new(); + generate_pack(db, &[main_oid], &[], &mut buf).unwrap(); + + let num_entries = u32::from_be_bytes(buf[8..12].try_into().unwrap()); + assert!(num_entries > 0, "pack should contain at least one object"); +} + +#[test] +fn pack_ends_with_20_byte_checksum() { + let db = odb_from("make_repo_simple.sh"); + let refs = collect_refs(&store_from("make_repo_simple.sh")).unwrap(); + let main_oid = refs.iter().find(|r| r.name == "refs/heads/main").unwrap().object_id; + + let mut buf = Vec::new(); + generate_pack(db, &[main_oid], &[], &mut buf).unwrap(); + + assert!(buf.len() >= 32, "pack needs header + at least one entry + checksum"); + let checksum = &buf[buf.len() - 20..]; + assert!(checksum.iter().any(|&b| b != 0), "checksum should not be all zeros"); +} diff --git a/gix-serve/tests/refs.rs b/gix-serve/tests/refs.rs new file mode 100644 index 00000000000..b90e99f2acb --- /dev/null +++ b/gix-serve/tests/refs.rs @@ -0,0 +1,117 @@ +use gix_ref::file::Store; +use gix_serve::refs::collect_refs; + +fn store_from(script: &str) -> Store { + let path = gix_testtools::scripted_fixture_read_only_standalone(script).expect("fixture script should run"); + Store::at( + path.join(".git"), + gix_ref::store::init::Options { + write_reflog: gix_ref::store::WriteReflog::Disable, + object_hash: gix_hash::Kind::Sha1, + precompose_unicode: false, + prohibit_windows_device_names: false, + }, + ) +} + +// --- simple repo: HEAD, main, feature --- + +#[test] +fn simple_returns_head_as_symref() { + let store = store_from("make_repo_simple.sh"); + let refs = collect_refs(&store).unwrap(); + + let head = refs.iter().find(|r| r.name == "HEAD").expect("HEAD should exist"); + assert_eq!( + head.symref_target.as_ref().map(|s| s.as_slice()), + Some(b"refs/heads/main".as_slice()), + ); +} + +#[test] +fn simple_includes_branches() { + let store = store_from("make_repo_simple.sh"); + let refs = collect_refs(&store).unwrap(); + + assert!(refs.iter().any(|r| r.name == "refs/heads/main")); + assert!(refs.iter().any(|r| r.name == "refs/heads/feature")); +} + +#[test] +fn simple_head_and_main_share_oid() { + let store = store_from("make_repo_simple.sh"); + let refs = collect_refs(&store).unwrap(); + + let head = refs.iter().find(|r| r.name == "HEAD").unwrap(); + let main = refs.iter().find(|r| r.name == "refs/heads/main").unwrap(); + assert_eq!(head.object_id, main.object_id); +} + +#[test] +fn simple_all_have_valid_oids() { + let store = store_from("make_repo_simple.sh"); + let refs = collect_refs(&store).unwrap(); + + assert!(!refs.is_empty()); + for r in &refs { + assert!(!r.object_id.is_null(), "{} has null oid", r.name); + } +} + +// --- repo with tags: lightweight + annotated, packed refs --- + +#[test] +fn tags_includes_both_tag_types() { + let store = store_from("make_repo_with_tags.sh"); + let refs = collect_refs(&store).unwrap(); + + assert!(refs.iter().any(|r| r.name == "refs/tags/lightweight-tag")); + assert!(refs.iter().any(|r| r.name == "refs/tags/v1.0")); +} + +#[test] +fn tags_annotated_tag_has_peeled_oid() { + let store = store_from("make_repo_with_tags.sh"); + let refs = collect_refs(&store).unwrap(); + + let tag = refs.iter().find(|r| r.name == "refs/tags/v1.0").unwrap(); + let main = refs.iter().find(|r| r.name == "refs/heads/main").unwrap(); + + assert!(tag.peeled.is_some(), "annotated tag should have peeled oid"); + assert_eq!(tag.peeled.unwrap(), main.object_id, "peeled should point to the commit"); + assert_ne!(tag.object_id, main.object_id, "tag object differs from commit"); +} + +#[test] +fn tags_lightweight_tag_has_no_peeled() { + let store = store_from("make_repo_with_tags.sh"); + let refs = collect_refs(&store).unwrap(); + + let tag = refs.iter().find(|r| r.name == "refs/tags/lightweight-tag").unwrap(); + assert!(tag.peeled.is_none(), "lightweight tag should not have peeled oid"); +} + +// --- multi-branch: branches at different commits --- + +#[test] +fn multi_branch_dev_has_different_oid() { + let store = store_from("make_repo_multi_branch.sh"); + let refs = collect_refs(&store).unwrap(); + + let main = refs.iter().find(|r| r.name == "refs/heads/main").unwrap(); + let dev = refs.iter().find(|r| r.name == "refs/heads/dev").unwrap(); + let feature = refs.iter().find(|r| r.name == "refs/heads/feature").unwrap(); + + assert_ne!(main.object_id, dev.object_id, "dev has an extra commit"); + assert_eq!(main.object_id, feature.object_id, "feature branched from main"); +} + +// --- empty repo: no commits, dangling HEAD --- + +#[test] +fn empty_repo_returns_no_refs() { + let store = store_from("make_repo_empty.sh"); + let refs = collect_refs(&store).unwrap(); + + assert!(refs.is_empty(), "empty repo should have no advertisable refs"); +} diff --git a/gix-serve/tests/upload_pack.rs b/gix-serve/tests/upload_pack.rs new file mode 100644 index 00000000000..15812fc8130 --- /dev/null +++ b/gix-serve/tests/upload_pack.rs @@ -0,0 +1,144 @@ +use std::sync::Arc; + +use gix_hash::ObjectId; +use gix_odb::store::init::Options as OdbOptions; +use gix_packetline::blocking_io::encode::{data_to_write, delim_to_write, flush_to_write}; +use gix_ref::file::Store; +use gix_ref::store::init::Options as RefOptions; +use gix_serve::refs::collect_refs; +use gix_serve::serve::serve_upload_pack; +use gix_transport::server::blocking_io::connection::Connection; +use gix_transport::{Protocol, Service}; + +fn ref_store(script: &str) -> Store { + let path = gix_testtools::scripted_fixture_read_only_standalone(script).expect("fixture"); + Store::at( + path.join(".git"), + RefOptions { + write_reflog: gix_ref::store::WriteReflog::Disable, + object_hash: gix_hash::Kind::Sha1, + precompose_unicode: false, + prohibit_windows_device_names: false, + }, + ) +} + +fn odb(script: &str) -> gix_odb::HandleArc { + let path = gix_testtools::scripted_fixture_read_only_standalone(script).expect("fixture"); + let store = + gix_odb::Store::at_opts(path.join(".git/objects"), &mut None.into_iter(), OdbOptions::default()).expect("odb"); + let mut cache = Arc::new(store).to_cache_arc(); + cache.prevent_pack_unload(); + cache +} + +fn build_v1_want_done(oid: &ObjectId) -> Vec { + let mut buf = Vec::new(); + data_to_write(format!("want {}\n", oid.to_hex()).as_bytes(), &mut buf).unwrap(); + flush_to_write(&mut buf).unwrap(); + data_to_write(b"done\n", &mut buf).unwrap(); + flush_to_write(&mut buf).unwrap(); + buf +} + +fn build_v2_fetch_input(wants: &[ObjectId]) -> Vec { + let mut buf = Vec::new(); + data_to_write(b"command=fetch\n", &mut buf).unwrap(); + delim_to_write(&mut buf).unwrap(); + for oid in wants { + data_to_write(format!("want {}\n", oid.to_hex()).as_bytes(), &mut buf).unwrap(); + } + data_to_write(b"done\n", &mut buf).unwrap(); + flush_to_write(&mut buf).unwrap(); + buf +} + +fn assert_has_valid_pack(output: &[u8]) { + let pack_pos = output.windows(4).position(|w| w == b"PACK"); + assert!(pack_pos.is_some(), "output should contain pack data"); + + let pack_start = pack_pos.unwrap(); + assert_eq!( + &output[pack_start + 4..pack_start + 8], + &[0, 0, 0, 2], + "pack format version 2" + ); + + let num_entries = u32::from_be_bytes(output[pack_start + 8..pack_start + 12].try_into().unwrap()); + assert!(num_entries > 0, "pack should contain objects"); +} + +#[test] +fn v1_fresh_clone() { + let store = ref_store("make_repo_simple.sh"); + let db = odb("make_repo_simple.sh"); + let refs = collect_refs(&store).unwrap(); + let main_oid = refs.iter().find(|r| r.name == "refs/heads/main").unwrap().object_id; + + let input = build_v1_want_done(&main_oid); + let mut output = Vec::new(); + let mut conn = Connection::new( + &input[..], + &mut output, + Service::UploadPack, + "/repo.git", + Protocol::V1, + false, + ); + + serve_upload_pack(&store, db, &mut conn, Protocol::V1).unwrap(); + + assert!(!output.is_empty()); + let nak_pos = output.windows(3).position(|w| w == b"NAK"); + let pack_pos = output.windows(4).position(|w| w == b"PACK").unwrap(); + assert!(nak_pos.is_some()); + assert!(nak_pos.unwrap() < pack_pos); + assert_has_valid_pack(&output); +} + +#[test] +fn v1_empty_wants() { + let store = ref_store("make_repo_simple.sh"); + let db = odb("make_repo_simple.sh"); + + let mut input = Vec::new(); + flush_to_write(&mut input).unwrap(); + let mut output = Vec::new(); + let mut conn = Connection::new( + &input[..], + &mut output, + Service::UploadPack, + "/repo.git", + Protocol::V1, + false, + ); + + serve_upload_pack(&store, db, &mut conn, Protocol::V1).unwrap(); + + assert!(!output.is_empty()); + assert!(!output.windows(4).any(|w| w == b"PACK")); +} + +#[test] +fn v2_fresh_clone() { + let store = ref_store("make_repo_simple.sh"); + let db = odb("make_repo_simple.sh"); + let refs = collect_refs(&store).unwrap(); + let main_oid = refs.iter().find(|r| r.name == "refs/heads/main").unwrap().object_id; + + let input = build_v2_fetch_input(&[main_oid]); + let mut output = Vec::new(); + let mut conn = Connection::new( + &input[..], + &mut output, + Service::UploadPack, + "/repo.git", + Protocol::V2, + false, + ); + + serve_upload_pack(&store, db, &mut conn, Protocol::V2).unwrap(); + + assert!(!output.is_empty()); + assert_has_valid_pack(&output); +} diff --git a/gix-transport/Cargo.toml b/gix-transport/Cargo.toml index 379b570392e..e0891aa8364 100644 --- a/gix-transport/Cargo.toml +++ b/gix-transport/Cargo.toml @@ -65,6 +65,9 @@ async-client = [ "pin-project-lite", ] +## If set, blocking server-side transport support becomes available +blocking-server = ["gix-packetline/blocking-io"] + #! ### Other ## Data structures implement `serde::Serialize` and `serde::Deserialize`. serde = ["dep:serde", "bstr/serde"] @@ -79,6 +82,11 @@ name = "blocking-transport-http-only" path = "tests/blocking-transport-http.rs" required-features = ["http-client-curl", "http-client-insecure-credentials", "maybe-async/is_sync"] +[[test]] +name = "blocking-server" +path = "tests/blocking-server.rs" +required-features = ["blocking-server"] + [[test]] name = "async-transport" path = "tests/async-transport.rs" diff --git a/gix-transport/src/lib.rs b/gix-transport/src/lib.rs index e29026a252e..a18d077100e 100644 --- a/gix-transport/src/lib.rs +++ b/gix-transport/src/lib.rs @@ -84,3 +84,7 @@ pub use traits::IsSpuriousError; /// pub mod client; + +#[cfg(feature = "blocking-server")] +/// +pub mod server; diff --git a/gix-transport/src/server/blocking_io/connection.rs b/gix-transport/src/server/blocking_io/connection.rs new file mode 100644 index 00000000000..6599ec28c97 --- /dev/null +++ b/gix-transport/src/server/blocking_io/connection.rs @@ -0,0 +1,46 @@ +use bstr::BString; +use gix_packetline::PacketLineRef; + +use crate::{packetline::blocking_io::StreamingPeekableIter, Protocol, Service}; + +/// A server-side connection over a reader/writer pair. +/// +/// Wraps a packetline reader and raw writer, holding the parsed +/// service and repository information. Suitable for SSH-tunneled +/// connections or HTTP `--stateless-rpc` mode. +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 protocol version to use. + pub protocol: Protocol, +} + +impl Connection +where + R: std::io::Read, + W: std::io::Write, +{ + /// Create a new server-side connection from the given `reader` and `writer`. + pub fn new( + reader: R, + writer: W, + service: Service, + repository_path: impl Into, + protocol: Protocol, + trace: bool, + ) -> Self { + Connection { + line_provider: StreamingPeekableIter::new(reader, &[PacketLineRef::Flush], trace), + writer, + service, + repository_path: repository_path.into(), + protocol, + } + } +} diff --git a/gix-transport/src/server/blocking_io/daemon.rs b/gix-transport/src/server/blocking_io/daemon.rs new file mode 100644 index 00000000000..d931c11cf30 --- /dev/null +++ b/gix-transport/src/server/blocking_io/daemon.rs @@ -0,0 +1,42 @@ +use gix_packetline::PacketLineRef; + +use crate::{ + packetline::blocking_io::StreamingPeekableIter, + server::{blocking_io::connection::Connection, parse_connect_message, ClientRequest, Error}, +}; + +/// 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 [`ClientRequest`]. +pub fn accept(reader: R, writer: W, trace: bool) -> Result<(Connection, ClientRequest), Error> +where + R: std::io::Read, + W: std::io::Write, +{ + let mut line_provider = StreamingPeekableIter::new(reader, &[PacketLineRef::Flush], trace); + + 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.desired_protocol, + }; + + Ok((connection, request)) +} 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..e216fc05d8f --- /dev/null +++ b/gix-transport/src/server/blocking_io/mod.rs @@ -0,0 +1,5 @@ +/// +pub mod daemon; + +/// +pub mod connection; diff --git a/gix-transport/src/server/mod.rs b/gix-transport/src/server/mod.rs new file mode 100644 index 00000000000..67f4a495371 --- /dev/null +++ b/gix-transport/src/server/mod.rs @@ -0,0 +1,95 @@ +use bstr::{BString, ByteSlice}; + +use crate::{Protocol, Service}; + +/// +#[cfg(feature = "blocking-server")] +pub mod blocking_io; + +/// The request parsed from a client's initial connect message. +/// +/// Parsed from the `git-proto-request` format described in the +/// [git transport documentation](https://git-scm.com/docs/pack-protocol#_git_transport). +#[derive(Debug, Clone)] +pub struct ClientRequest { + /// The requested service, e.g. `UploadPack` or `ReceivePack`. + pub service: Service, + /// The repository path, 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, defaulting to V1 if unspecified. + pub desired_protocol: Protocol, + /// Additional key-value parameters beyond `version=`. + pub extra_parameters: Vec<(BString, Option)>, +} + +/// Errors from parsing a client connect message +#[derive(thiserror::Error, Debug)] +#[allow(missing_docs)] +pub enum Error { + #[error("Unknown service: {service}")] + UnknownService { service: BString }, + #[error("Malformed message - unable to parse message")] + MalformedMessage, +} + +/// Parse a git daemon connect message into a [`ClientRequest`]. +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 desired_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(match host_str.rsplit_once(':') { + Some((host, port)) => { + let port = port.parse::().map_err(|_| Error::MalformedMessage)?; + (host.to_owned(), Some(port)) + } + None => (host_str.to_owned(), None), + }); + } else if let Some(version_value) = segment.strip_prefix(b"version=") { + let version_str = std::str::from_utf8(version_value).map_err(|_| Error::MalformedMessage)?; + desired_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(ClientRequest { + service, + repository_path: path, + virtual_host, + desired_protocol, + extra_parameters, + }) +} diff --git a/gix-transport/tests/blocking-server.rs b/gix-transport/tests/blocking-server.rs new file mode 100644 index 00000000000..ef832c2b421 --- /dev/null +++ b/gix-transport/tests/blocking-server.rs @@ -0,0 +1,149 @@ +use std::io::Write; + +use gix_transport::{packetline::blocking_io::Writer, server, Protocol, Service}; + +/// Helper: write a connect message as a packetline, the way a real git client does. +fn write_connect_message(message: &[u8]) -> Vec { + let mut buf = Vec::new(); + let mut writer = Writer::new(&mut buf); + writer.enable_binary_mode(); + writer.write_all(message).expect("write to vec cannot fail"); + writer.flush().expect("flush to vec cannot fail"); + buf +} + +#[test] +fn version_1_without_host_and_version() { + let request = server::parse_connect_message(b"git-upload-pack hello/world\0").expect("valid message"); + + assert_eq!(request.service, Service::UploadPack); + assert_eq!(request.repository_path, "hello/world"); + assert_eq!(request.virtual_host, None); + assert_eq!(request.desired_protocol, Protocol::V1); + assert!(request.extra_parameters.is_empty()); +} + +#[test] +fn version_2_without_host_and_version() { + let request = server::parse_connect_message(b"git-upload-pack hello\\world\0\0version=2\0").expect("valid message"); + + assert_eq!(request.service, Service::UploadPack); + assert_eq!(request.repository_path, r"hello\world"); + assert_eq!(request.virtual_host, None); + assert_eq!(request.desired_protocol, Protocol::V2); + assert!(request.extra_parameters.is_empty()); +} + +#[test] +fn version_2_with_extra_parameters() { + let request = + server::parse_connect_message(b"git-upload-pack /path/project.git\0\0version=2\0key=value\0value-only\0") + .expect("valid message"); + + assert_eq!(request.service, Service::UploadPack); + assert_eq!(request.repository_path, "/path/project.git"); + assert_eq!(request.virtual_host, None); + assert_eq!(request.desired_protocol, Protocol::V2); + assert_eq!(request.extra_parameters.len(), 2); + assert_eq!(request.extra_parameters[0].0, "key"); + assert_eq!(request.extra_parameters[0].1, Some("value".into())); + assert_eq!(request.extra_parameters[1].0, "value-only"); + assert_eq!(request.extra_parameters[1].1, None); +} + +#[test] +fn with_host_without_port() { + let request = server::parse_connect_message(b"git-upload-pack hello\\world\0host=host\0").expect("valid message"); + + assert_eq!(request.service, Service::UploadPack); + assert_eq!(request.repository_path, r"hello\world"); + assert_eq!(request.virtual_host, Some(("host".to_owned(), None))); + assert_eq!(request.desired_protocol, Protocol::V1); + assert!(request.extra_parameters.is_empty()); +} + +#[test] +fn with_host_without_port_and_extra_parameters() { + let request = server::parse_connect_message(b"git-upload-pack hello\\world\0host=host\0\0key=value\0value-only\0") + .expect("valid message"); + + assert_eq!(request.service, Service::UploadPack); + assert_eq!(request.repository_path, r"hello\world"); + assert_eq!(request.virtual_host, Some(("host".to_owned(), None))); + assert_eq!(request.desired_protocol, Protocol::V1); + assert_eq!(request.extra_parameters.len(), 2); + assert_eq!(request.extra_parameters[0].0, "key"); + assert_eq!(request.extra_parameters[0].1, Some("value".into())); + assert_eq!(request.extra_parameters[1].0, "value-only"); + assert_eq!(request.extra_parameters[1].1, None); +} + +#[test] +fn with_host_with_port() { + let request = + server::parse_connect_message(b"git-upload-pack hello\\world\0host=host:404\0").expect("valid message"); + + assert_eq!(request.service, Service::UploadPack); + assert_eq!(request.repository_path, r"hello\world"); + assert_eq!(request.virtual_host, Some(("host".to_owned(), Some(404)))); + assert_eq!(request.desired_protocol, Protocol::V1); + assert!(request.extra_parameters.is_empty()); +} + +#[test] +fn with_strange_host_and_port() { + let request = + server::parse_connect_message(b"git-upload-pack --upload-pack=attack\0host=--proxy=other-attack:404\0") + .expect("valid message"); + + assert_eq!(request.service, Service::UploadPack); + assert_eq!(request.repository_path, "--upload-pack=attack"); + assert_eq!( + request.virtual_host, + Some(("--proxy=other-attack".to_owned(), Some(404))) + ); + assert_eq!(request.desired_protocol, Protocol::V1); + assert!(request.extra_parameters.is_empty()); +} + +// --- daemon::accept tests --- +// These simulate a real client writing the connect message as a packetline, +// then verify the server's daemon::accept() reads and parses it correctly. + +#[test] +fn daemon_accept_v1_with_host() { + let client_bytes = write_connect_message(b"git-upload-pack /repo.git\0host=myhost\0"); + + let (connection, request) = + server::blocking_io::daemon::accept(&client_bytes[..], Vec::new(), false).expect("valid connection"); + + assert_eq!(connection.service, Service::UploadPack); + assert_eq!(connection.repository_path, "/repo.git"); + assert_eq!(connection.protocol, Protocol::V1); + assert_eq!(request.virtual_host, Some(("myhost".to_owned(), None))); + assert!(request.extra_parameters.is_empty()); +} + +#[test] +fn daemon_accept_v2_with_host_and_port() { + let client_bytes = write_connect_message(b"git-upload-pack /repo.git\0host=myhost:9418\0\0version=2\0"); + + let (connection, request) = + server::blocking_io::daemon::accept(&client_bytes[..], Vec::new(), false).expect("valid connection"); + + assert_eq!(connection.service, Service::UploadPack); + assert_eq!(connection.repository_path, "/repo.git"); + assert_eq!(connection.protocol, Protocol::V2); + assert_eq!(request.virtual_host, Some(("myhost".to_owned(), Some(9418)))); +} + +#[test] +fn daemon_accept_receive_pack() { + let client_bytes = write_connect_message(b"git-receive-pack /repo.git\0host=myhost\0"); + + let (connection, _request) = + server::blocking_io::daemon::accept(&client_bytes[..], Vec::new(), false).expect("valid connection"); + + assert_eq!(connection.service, Service::ReceivePack); + assert_eq!(connection.repository_path, "/repo.git"); +}