//! Single-ref receive-pack over standard WASI HTTP or in-process SSH exec channels. use crate::Result; use crate::{ http::{WasiHttp, clean_url}, remotes, repo, repo::error, }; use gix::bstr::ByteSlice; use gix_transport::client::blocking_io::http::{Http, PostBodyDataKind}; use std::{ collections::{BTreeMap, BTreeSet}, io::{Read, Write}, }; /// Push one branch or explicit single branch refspec over HTTPS or the SSH exec transport. #[derive(serde::Serialize, serde::Deserialize)] pub struct PushParams { /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). pub repo: String, /// Configured remote, default origin (pushurl takes precedence). pub remote: Option, /// Short local branch, default current branch. pub branch: Option, /// Single branch refspec: source:destination (including HEAD:destination), source, or :destination to delete. No wildcards. pub refspec: Option, /// Allow non-fast-forward replacement. A leading + in refspec also enables this. pub force: Option, /// Set source branch upstream to the remote destination after success. pub set_upstream: Option, /// Delete the destination instead of sending objects (default false). pub delete: Option, } /// Confirmed remote update; local tracking state is updated only after report-status success. #[derive(serde::Serialize, serde::Deserialize)] pub struct PushResult { /// Remote used. pub remote: String, /// Full destination ref. pub reference: String, /// Advertised old object ID, or null for a new ref. pub old: Option, /// New object ID, or null for deletion. pub new: Option, /// Whether the server already had the requested value. pub up_to_date: bool, /// Number of objects sent (no delta compression). pub objects: usize, } fn branch_ref(name: &str) -> Result { let full = if name.starts_with("refs/") { name.to_owned() } else { format!("refs/heads/{name}") }; if name.is_empty() || name.starts_with('-') || !full.starts_with("refs/heads/") { return Err(error( "push refspec", "expected a branch name or refs/heads/; tags and wildcard/multi-ref pushes are not supported", )); } let _: gix::refs::FullName = full .as_str() .try_into() .map_err(|e| error("push refspec", e))?; Ok(full) } pub(crate) struct Plan { pub source: Option, pub destination: String, pub new: Option, pub force: bool, } pub(crate) fn plan(repo: &gix::Repository, p: &PushParams) -> Result { let mut force = p.force.unwrap_or(false); let (source, destination, deleting) = if let Some(spec) = &p.refspec { if p.branch.is_some() { return Err(error("push", "branch and refspec are mutually exclusive")); } let spec = if let Some(s) = spec.strip_prefix('+') { force = true; s } else { spec.as_str() }; let (src, dst) = spec.split_once(':').unwrap_or((spec, spec)); if dst.is_empty() || dst.contains(':') || spec.contains('*') { return Err(error( "push refspec", "expected one source:destination or :destination", )); } let deleting = p.delete.unwrap_or(false) || src.is_empty(); let source = if deleting { None } else if src == "HEAD" { Some( crate::head_info(repo)? .branch .unwrap_or_else(|| "HEAD".into()), ) } else { Some(branch_ref(src)?) }; (source, branch_ref(dst)?, deleting) } else { let source = match &p.branch { Some(branch) => branch_ref(branch)?, None => crate::head_info(repo)? .branch .ok_or_else(|| error("push", "detached HEAD: specify branch or refspec"))?, }; let deleting = p.delete.unwrap_or(false); ( if deleting { None } else { Some(source.clone()) }, source, deleting, ) }; if deleting && p.set_upstream.unwrap_or(false) { return Err(error("push", "set_upstream cannot be used with deletion")); } if source.as_deref() == Some("HEAD") && p.set_upstream.unwrap_or(false) { return Err(error( "push upstream", "detached HEAD has no branch to set upstream for", )); } let new = source .as_deref() .map(|src| -> Result { let id = if src == "HEAD" { repo.head_id() .map_err(|e| error("push source", e))? .detach() } else { let reference = repo .find_reference(src) .map_err(|e| error("push source", e))?; reference .try_id() .ok_or_else(|| error("push source", "source must directly name a commit"))? .detach() }; repo.find_commit(id) .map_err(|e| error("push source commit", e))?; Ok(id) }) .transpose()?; Ok(Plan { source, destination, new, force, }) } // pkt-line readers reject truncation and reserved lengths rather than treating missing status as success. pub(crate) fn pkt(out: &mut impl Write, data: &[u8]) -> Result<()> { if data.len() > 65516 { return Err(error("push packet", "packet too large")); } write!(out, "{:04x}", data.len() + 4) .and_then(|_| out.write_all(data)) .map_err(|e| error("push packet", e)) } fn packet(input: &mut impl Read) -> Result>> { let mut header = [0; 4]; input .read_exact(&mut header) .map_err(|e| error("push packet header", e))?; let len = std::str::from_utf8(&header) .ok() .and_then(|s| u16::from_str_radix(s, 16).ok()) .ok_or_else(|| error("push packet", "invalid length"))? as usize; if len == 0 { return Ok(None); } if !(4..=65520).contains(&len) { return Err(error("push packet", "invalid packet length")); } let mut bytes = vec![0; len - 4]; input .read_exact(&mut bytes) .map_err(|e| error("push packet body", e))?; Ok(Some(bytes)) } pub(crate) struct Advertisement { pub refs: BTreeMap, pub caps: BTreeSet, } fn advertise(input: &mut impl Read) -> Result { if packet(input)?.as_deref() != Some(b"# service=git-receive-pack\n") || packet(input)?.is_some() { return Err(error( "push advertisement", "expected smart HTTP git-receive-pack service", )); } advertise_refs(input) } /// SSH hook: git-receive-pack exec channels begin directly with these ref packets, /// without the smart-HTTP service prelude. commands(), pack(), and report() are also transport-neutral. pub(crate) fn advertise_refs(input: &mut impl Read) -> Result { let mut refs = BTreeMap::new(); let mut caps = BTreeSet::new(); let mut first = true; let mut count = 0; while let Some(line) = packet(input)? { count += 1; if count > 100_000 { return Err(error("push advertisement", "too many refs")); } let text = std::str::from_utf8(&line) .map_err(|e| error("push advertisement", e))? .trim_end_matches('\n'); if let Some(message) = text.strip_prefix("ERR ") { return Err(error("push remote", message)); } if text.starts_with("shallow ") { return Err(error( "push advertisement", "shallow remotes are not supported", )); } let (reference, capabilities) = text .split_once('\0') .map(|(a, b)| (a, Some(b))) .unwrap_or((text, None)); if let Some(capabilities) = capabilities { if !first { return Err(error("push advertisement", "capabilities after first ref")); } caps.extend(capabilities.split_ascii_whitespace().map(str::to_owned)); } first = false; let (oid, name) = reference .split_once(' ') .ok_or_else(|| error("push advertisement", "invalid ref line"))?; let id = gix::ObjectId::from_hex(oid.as_bytes()).map_err(|e| error("push advertisement", e))?; if id.kind() != gix::hash::Kind::Sha1 { return Err(error("push", "only SHA-1 repositories supported")); } if !id.is_null() { refs.insert(name.to_owned(), id); } } if !caps.contains("report-status") { return Err(error("push", "remote does not support report-status")); } if caps.contains("object-format=sha256") { return Err(error("push", "SHA-256 remote is unsupported")); } Ok(Advertisement { refs, caps }) } pub(crate) fn commands( old: Option, plan: &Plan, caps: &[&str], out: &mut impl Write, ) -> Result<()> { let zero = gix::hash::Kind::Sha1.null(); let command = format!( "{} {} {}\0{}\n", old.unwrap_or(zero), plan.new.unwrap_or(zero), plan.destination, caps.join(" ") ); pkt(out, command.as_bytes())?; out.write_all(b"0000") .map_err(|e| error("push commands", e)) } /// Unknown remote tips are not assumed to be ancestors. Force is required until fetched. pub(crate) fn is_ancestor( repo: &gix::Repository, old: gix::ObjectId, new: gix::ObjectId, ) -> Result { if old == new { return Ok(true); } if repo .try_find_object(old) .map_err(|e| error("push ancestry", e))? .is_none() { return Ok(false); } let walk = repo .find_commit(new) .map_err(|e| error("push ancestry", e))? .ancestors() .use_commit_graph(false) .all() .map_err(|e| error("push ancestry", e))?; for item in walk { if item.map_err(|e| error("push ancestry", e))?.id == old { return Ok(true); } } Ok(false) } /// Walk the object graph once, stopping BEFORE decoding seen objects or remote boundaries. /// Returns the number of objects decoded, also used by the traversal-cost regression tests. /// The boundary must be transitively closed (the reachable remote object set). /// Gitlink commits belong to another repository and are never followed. pub(crate) fn reachable( repo: &gix::Repository, tip: gix::ObjectId, seen: &mut BTreeSet, boundary: &BTreeSet, ) -> Result { let mut pending = vec![tip]; let mut decoded = 0; while let Some(id) = pending.pop() { if boundary.contains(&id) || !seen.insert(id) { continue; } let obj = repo .find_object(id) .map_err(|e| error("push reachable object", e))?; decoded += 1; match obj.kind { gix::objs::Kind::Commit => { let commit = obj.try_into_commit().map_err(|e| error("push commit", e))?; pending.push( commit .tree_id() .map_err(|e| error("push tree", e))? .detach(), ); pending.extend(commit.parent_ids().map(|id| id.detach())); } // Follow one tag at a time, including intermediate annotated tags in seen. gix::objs::Kind::Tag => { let tag = obj .try_into_tag() .map_err(|e| error("push remote tag", e))?; pending.push( tag.target_id() .map_err(|e| error("push remote tag target", e))? .detach(), ); } gix::objs::Kind::Tree => { let tree = obj.try_into_tree().map_err(|e| error("push tree", e))?; for entry in tree.iter() { let entry = entry.map_err(|e| error("push tree entry", e))?; if entry.mode().is_tree() { pending.push(entry.object_id()); } else if !entry.mode().is_commit() && !boundary.contains(&entry.object_id()) { seen.insert(entry.object_id()); } } } gix::objs::Kind::Blob => {} } } Ok(decoded) } pub(crate) fn objects_to_send( repo: &gix::Repository, new: gix::ObjectId, remote: impl IntoIterator, ) -> Result> { let mut theirs = BTreeSet::new(); let empty = BTreeSet::new(); for id in remote { if !theirs.contains(&id) && repo .try_find_object(id) .map_err(|e| error("push remote object", e))? .is_some() { reachable(repo, id, &mut theirs, &empty)?; } } let mut ours = BTreeSet::new(); reachable(repo, new, &mut ours, &theirs)?; Ok(ours) } /// Compress one object at a time and stream pack bytes to the HTTP request spool. pub(crate) fn pack( repo: &gix::Repository, ids: &BTreeSet, out: &mut impl Write, ) -> Result<()> { use gix_pack::data::output::{Count, Entry, bytes::FromEntriesIter}; let count = u32::try_from(ids.len()).map_err(|e| error("push pack count", e))?; let entries = ids.iter().map(|id| { let object = repo.find_object(*id)?; Entry::from_data( &Count::from_data(*id, None), &gix::objs::Data { kind: object.kind, data: &object.data, object_hash: gix::hash::Kind::Sha1, }, gix_zlib::Compression::default(), ) .map(|entry| vec![entry]) }); for written in FromEntriesIter::new( entries, out, count, gix_pack::data::Version::V2, gix::hash::Kind::Sha1, ) { written.map_err(|e| error("push pack", format!("{e:#}")))?; } Ok(()) } fn parse_status(input: &mut impl Read, reference: &str) -> Result<()> { let unpack = packet(input)?.ok_or_else(|| error("push status", "missing unpack status"))?; let unpack = String::from_utf8_lossy(&unpack); if unpack.trim_end() != "unpack ok" { return Err(error("push remote", unpack.trim_end())); } let mut confirmed = false; while let Some(line) = packet(input)? { let line = String::from_utf8_lossy(&line); let line = line.trim_end(); if line == format!("ok {reference}") && !confirmed { confirmed = true; } else if let Some(message) = line.strip_prefix("ng ") { return Err(error("push rejected", message)); } else { return Err(error("push status", format!("unexpected status: {line}"))); } } if !confirmed { return Err(error( "push status", "remote did not confirm the destination ref", )); } Ok(()) } // Hook explanations often arrive on band 2, with only a generic reason in ng. // Keep a bounded byte tail (lossy UTF-8 conversion happens only when rendering it). const REMOTE_DETAIL_LIMIT: usize = 8192; fn with_remote_detail(result: Result<()>, tail: &[u8]) -> Result<()> { result.map_err(|mut e| { let detail = String::from_utf8_lossy(tail); if !detail.trim().is_empty() { e.message.push_str(&format!("\nremote: {}", detail.trim())); } e }) } pub(crate) fn report(input: &mut impl Read, reference: &str, sideband: bool) -> Result<()> { if !sideband { return parse_status(input, reference); } let mut status = Vec::new(); let mut remote_detail = Vec::new(); while let Some(line) = packet(input)? { match line.split_first() { Some((1, bytes)) => { if status.len() + bytes.len() > 1024 * 1024 { return Err(error("push status", "report-status too large")); } status.extend_from_slice(bytes); } Some((2, bytes)) => { remote_detail.extend_from_slice(bytes); if remote_detail.len() > REMOTE_DETAIL_LIMIT { remote_detail.drain(..remote_detail.len() - REMOTE_DETAIL_LIMIT); } } Some((3, bytes)) => { return with_remote_detail( Err(error( "push remote", String::from_utf8_lossy(bytes).trim_end(), )), &remote_detail, ); } _ => return Err(error("push sideband", "invalid channel")), } } with_remote_detail( parse_status(&mut status.as_slice(), reference), &remote_detail, ) } fn update_local(repo: &gix::Repository, remote: &str, plan: &Plan, upstream: bool) -> Result<()> { let short = plan .destination .strip_prefix("refs/heads/") .ok_or_else(|| error("push tracking", "not a branch"))?; let tracking = format!("refs/remotes/{remote}/{short}"); use gix::refs::{ Target, transaction::{Change, PreviousValue, RefEdit}, }; if let Some(new) = plan.new { repo.edit_references_as( [RefEdit { name: tracking .as_str() .try_into() .map_err(|e| error("push tracking", e))?, deref: false, change: Change::Update { log: Default::default(), expected: PreviousValue::Any, new: Target::Object(new), }, }], None, ) .map_err(|e| error("push tracking", e))?; } else if let Some(reference) = repo .try_find_reference(&tracking) .map_err(|e| error("push tracking", e))? { reference.delete().map_err(|e| error("push tracking", e))?; } if upstream { let branch = plan .source .as_deref() .and_then(|s| s.strip_prefix("refs/heads/")) .ok_or_else(|| error("push upstream", "missing source branch"))?; let mut config = remotes::load(repo)?; let ids: Vec<_> = config .sections_and_ids_by_name("branch") .into_iter() .flatten() .filter(|(s, _)| { s.header() .subsection_name() .is_some_and(|s| s == branch.as_bytes()) }) .map(|(_, id)| id) .collect(); for id in ids { if let Some(mut section) = config.section_mut_by_id(id) { while section.remove("remote").is_some() {} while section.remove("merge").is_some() {} } } let mut section = config .section_mut_or_create_new("branch", branch) .map_err(|e| error("push upstream", e))?; section .push("remote", Some(remote.into())) .map_err(|e| error("push upstream", e))?; section .push("merge", Some(plan.destination.as_str().into())) .map_err(|e| error("push upstream", e))?; remotes::save(repo, &config)?; } Ok(()) } /// Send one reference update through the smart-HTTP receive-pack service. pub fn push(p: PushParams) -> Result { let mut repo = repo::open(&p.repo)?; repo.refs.write_reflog = gix::refs::store::WriteReflog::Disable; let remote = p.remote.as_deref().unwrap_or("origin"); remotes::validate_name(remote)?; let plan = plan(&repo, &p)?; if plan.new.is_some() && repo .path() .join("shallow") .try_exists() .map_err(|e| error("push shallow", e))? { return Err(error( "push", "shallow pushes are not supported; fetch full history first", )); } let configured = repo .find_remote(remote) .map_err(|_| error("push", "configured remote not found"))?; let url = configured .url(gix::remote::Direction::Push) .ok_or_else(|| error("push", "remote has no push URL"))? .to_bstring() .to_str_lossy() .into_owned(); if crate::ssh::is_ssh(&url) { return ssh_push(&repo, &url, remote, &plan, p.set_upstream.unwrap_or(false)); } let base = clean_url(&url) .map_err(|e| error("push URL", e))? .to_string() .trim_end_matches('/') .to_owned(); let mut http = WasiHttp::default(); let result = (|| { let mut discovery = http .get( &format!("{base}/info/refs?service=git-receive-pack"), &base, ["Accept: application/x-git-receive-pack-advertisement"], ) .map_err(|e| error("push discovery", format!("{e:#}")))?; let advertised = advertise(&mut discovery.body)?; drop(discovery); let old = advertised.refs.get(&plan.destination).copied(); if let (Some(old), Some(new)) = (old, plan.new) { if old != new && !plan.force && !is_ancestor(&repo, old, new)? { return Err(error( "push", "non-fast-forward (or remote tip unknown locally); fetch first or use force", )); } } let up_to_date = old == plan.new; let mut objects = 0; if !up_to_date { if plan.new.is_none() && !advertised.caps.contains("delete-refs") { return Err(error("push", "remote does not allow deleting refs")); } let ids = match plan.new { Some(new) => objects_to_send(&repo, new, advertised.refs.values().copied())?, None => BTreeSet::new(), }; objects = ids.len(); let sideband = advertised.caps.contains("side-band-64k"); let mut caps = vec!["report-status"]; if sideband { caps.push("side-band-64k"); } if advertised.caps.iter().any(|c| c.starts_with("agent=")) { caps.push("agent=sausage-git/0.1"); } let base = http.redirected_base_url().unwrap_or(base); let mut post = http .post( &format!("{base}/git-receive-pack"), &base, [ "Content-Type: application/x-git-receive-pack-request", "Accept: application/x-git-receive-pack-result", ], PostBodyDataKind::Unbounded, ) .map_err(|e| error("push request", format!("{e:#}")))?; commands(old, &plan, &caps, &mut post.post_body)?; if plan.new.is_some() { pack(&repo, &ids, &mut post.post_body)?; } drop(post.post_body); report(&mut post.body, &plan.destination, sideband)?; } update_local(&repo, remote, &plan, p.set_upstream.unwrap_or(false)).map_err(|e| { error( "push succeeded remotely, but local state update failed", e.message, ) })?; Ok(PushResult { remote: remote.into(), reference: plan.destination, old: old.map(|id| id.to_string()), new: plan.new.map(|id| id.to_string()), up_to_date, objects, }) })(); result } fn ssh_push( repo: &gix::Repository, url: &str, remote: &str, plan: &Plan, upstream: bool, ) -> Result { let (mut read, mut write) = crate::ssh::connection(url, gix_transport::Service::ReceivePack)?.into_inner(); let advertised = advertise_refs(&mut read)?; let old = advertised.refs.get(&plan.destination).copied(); if let (Some(old), Some(new)) = (old, plan.new) { if old != new && !plan.force && !is_ancestor(repo, old, new)? { return Err(error("push", "non-fast-forward; fetch first or use force")); } } let up_to_date = old == plan.new; let mut objects = 0; if !up_to_date { if plan.new.is_none() && !advertised.caps.contains("delete-refs") { return Err(error("push", "remote does not allow deleting refs")); } let ids = match plan.new { Some(new) => objects_to_send(repo, new, advertised.refs.values().copied())?, None => BTreeSet::new(), }; objects = ids.len(); let sideband = advertised.caps.contains("side-band-64k"); let mut caps = vec!["report-status"]; if sideband { caps.push("side-band-64k"); } commands(old, plan, &caps, &mut write)?; if plan.new.is_some() { pack(repo, &ids, &mut write)?; } write.flush().map_err(|e| error("push SSH flush", e))?; report(&mut read, &plan.destination, sideband)?; } else { write .write_all(b"0000") .and_then(|_| write.flush()) .map_err(|e| error("push SSH finish", e))?; } update_local(repo, remote, plan, upstream)?; Ok(PushResult { remote: remote.into(), reference: plan.destination.clone(), old: old.map(|id| id.to_string()), new: plan.new.map(|id| id.to_string()), up_to_date, objects, }) }