use gix_object::bstr::{BStr, BString, ByteVec}; use crate::{Options, Update}; /// The command block could not be built. #[derive(Debug, thiserror::Error)] pub enum Error { #[error("there is nothing to push")] NoUpdates, #[error("the receiving end does not support `{0}`")] UnsupportedCapability(&'static str), #[error(transparent)] Io(#[from] std::io::Error), } /// Intersect what this push needs with what the remote advertised, producing the capability list /// that rides the first command. pub(crate) fn select_capabilities( caps: &gix_transport::client::Capabilities, updates: &[Update], opts: &Options<'_>, agent: Option<&str>, ) -> Result { // TODO(boltless): support `report-status-v2` let mut out = BString::from("report-status"); let mut want = |name: &'static str| { if caps.contains(name) { out.push(b' '); out.push_str(name); true } else { false } }; want("ofs-delta"); want("side-band-64k"); if opts.quiet && !want("quiet") { return Err(Error::UnsupportedCapability("quiet")); } if opts.atomic && !want("atomic") { return Err(Error::UnsupportedCapability("atomic")); } if !opts.push_options.is_empty() && !want("push-options") { return Err(Error::UnsupportedCapability("push-options")); } if let Some(agent) = agent.filter(|_| caps.contains("agent")) { out.push_str(" agent="); out.push_str(agent); } if updates.iter().any(|u| u.new.is_null()) && !caps.contains("delete-refs") { return Err(Error::UnsupportedCapability("delete-refs")); } Ok(out) } /// Encode the reference update request that precedes the packfile: /// /// ```text /// \0 <- capabilities ride the FIRST command only /// <- bare thereafter /// 0000 /// <- only when there are options... /// 0000 <- ...and then this second flush is mandatory /// ``` pub(crate) fn encode_command_block( updates: &[Update], capabilities: &BStr, push_options: &[BString], ) -> Result, Error> { use gix_packetline::blocking_io::encode; use std::io::Write; if updates.is_empty() { return Err(Error::NoUpdates); } let mut out = Vec::new(); let mut line = Vec::new(); for (index, update) in updates.iter().enumerate() { line.clear(); write!( &mut line, "{} {} {}", update.expected_old.to_hex(), update.new.to_hex(), update.name )?; if index == 0 { line.push(0); line.extend_from_slice(capabilities); } encode::text_to_write(&line, &mut out)?; } encode::flush_to_write(&mut out)?; if !push_options.is_empty() { for option in push_options { encode::text_to_write(option, &mut out)?; } encode::flush_to_write(&mut out)?; } Ok(out) } #[cfg(test)] mod tests { use std::collections::HashMap; use gix_hash::ObjectId; use gix_object::bstr::ByteSlice; use crate::ReceivePackAdvertisement; use super::*; const A: &str = "1111111111111111111111111111111111111111"; const B: &str = "2222222222222222222222222222222222222222"; const NULL: &str = "0000000000000000000000000000000000000000"; fn oid(hex: &str) -> ObjectId { ObjectId::from_hex(hex.as_bytes()).unwrap() } /// Frame a payload as `text_to_write` would: 4 hex length digits over payload + `\n`. fn pkt(payload: &str) -> String { format!("{:04x}{payload}\n", payload.len() + 5) } fn advertised(caps: &str) -> ReceivePackAdvertisement { let (capabilities, _) = gix_transport::client::Capabilities::from_bytes(format!("\0{caps}").as_bytes()) .unwrap(); ReceivePackAdvertisement { server_protocol_version: gix_transport::Protocol::V1, capabilities, remote_refs: HashMap::new(), advertised_haves: Vec::new(), } } fn opts() -> Options<'static> { Options { atomic: false, push_options: &[], quiet: false, } } fn update(name: &str, old: &str, new: &str) -> Update { Update { name: gix_ref::FullName::try_from(name).unwrap(), expected_old: oid(old), new: oid(new), } } #[test] fn capabilities_ride_the_first_command_only() { let block = encode_command_block( &[ update("refs/heads/main", A, B), update("refs/heads/next", A, B), ], "report-status side-band-64k".into(), &[], ) .unwrap(); assert_eq!( block.to_str().unwrap(), format!( "{}{}0000", pkt(&format!( "{A} {B} refs/heads/main\0report-status side-band-64k" )), pkt(&format!("{A} {B} refs/heads/next")) ), "one NUL in the whole block, and exactly one newline per line" ); } #[test] fn creates_and_deletes_use_the_null_id() { let block = encode_command_block( &[ update("refs/heads/new", NULL, B), update("refs/heads/gone", A, NULL), ], "report-status".into(), &[], ) .unwrap(); assert_eq!( block.to_str().unwrap(), format!( "{}{}0000", pkt(&format!("{NULL} {B} refs/heads/new\0report-status")), pkt(&format!("{A} {NULL} refs/heads/gone")) ) ); } #[test] fn a_null_id_goes_on_the_wire_at_the_width_the_caller_gave_it() { let block = encode_command_block( &[Update { name: gix_ref::FullName::try_from("refs/heads/gone").unwrap(), expected_old: ObjectId::from_hex(&[b'1'; 64]).unwrap(), new: ObjectId::null(gix_hash::Kind::Sha256), }], "report-status delete-refs".into(), &[], ) .unwrap(); assert!( block.to_str().unwrap().contains(&"0".repeat(64)), "64 zeroes, not 40: {:?}", block.to_str().unwrap() ); } #[test] fn push_options_add_a_second_flush() { let one = |options: &[BString]| { encode_command_block( &[update("refs/heads/main", A, B)], "report-status".into(), options, ) .unwrap() }; assert!( one(&[]).ends_with(b"0000"), "one flush when there are no options" ); assert_eq!( one(&["ci.skip".into()]).to_str().unwrap(), format!( "{}0000{}0000", pkt(&format!("{A} {B} refs/heads/main\0report-status")), pkt("ci.skip") ) ); } #[test] fn nothing_to_push_is_an_error() { assert!(matches!( encode_command_block(&[], "report-status".into(), &[]), Err(Error::NoUpdates) )); } #[test] fn selection_only_asks_for_what_was_advertised() { let adv = advertised("report-status ofs-delta side-band-64k agent=git/2.0"); let caps = select_capabilities( &adv.capabilities, &[update("refs/heads/main", A, B)], &opts(), Some("gix/1.0"), ) .unwrap(); assert_eq!(caps, "report-status ofs-delta side-band-64k agent=gix/1.0"); let bare = advertised("report-status"); let caps = select_capabilities( &bare.capabilities, &[update("refs/heads/main", A, B)], &opts(), Some("gix/1.0"), ) .unwrap(); assert_eq!( caps, "report-status", "no agent, no side-band, no ofs-delta" ); } #[test] fn opt_in_capabilities_that_were_not_advertised_are_errors() { let adv = advertised("report-status"); let up = [update("refs/heads/main", A, B)]; let atomic = Options { atomic: true, ..opts() }; assert!(matches!( select_capabilities(&adv.capabilities, &up, &atomic, None), Err(Error::UnsupportedCapability("atomic")) )); let options = [BString::from("ci.skip")]; let with_options = Options { push_options: &options, ..opts() }; assert!(matches!( select_capabilities(&adv.capabilities, &up, &with_options, None), Err(Error::UnsupportedCapability("push-options")) )); } #[test] fn deleting_needs_delete_refs_but_never_sends_it() { let delete = [update("refs/heads/gone", A, NULL)]; assert!(matches!( select_capabilities( &advertised("report-status").capabilities, &delete, &opts(), None ), Err(Error::UnsupportedCapability("delete-refs")) )); let caps = select_capabilities( &advertised("report-status delete-refs").capabilities, &delete, &opts(), None, ) .unwrap(); assert_eq!( caps, "report-status", "the client does not echo `delete-refs`" ); } }