Files
sausage/userland/git/src/push.rs
T

730 lines
26 KiB
Rust

//! 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<String>,
/// Short local branch, default current branch.
pub branch: Option<String>,
/// Single branch refspec: source:destination (including HEAD:destination), source, or :destination to delete. No wildcards.
pub refspec: Option<String>,
/// Allow non-fast-forward replacement. A leading + in refspec also enables this.
pub force: Option<bool>,
/// Set source branch upstream to the remote destination after success.
pub set_upstream: Option<bool>,
/// Delete the destination instead of sending objects (default false).
pub delete: Option<bool>,
}
/// 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<String>,
/// New object ID, or null for deletion.
pub new: Option<String>,
/// 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<String> {
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/<name>; 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<String>,
pub destination: String,
pub new: Option<gix::ObjectId>,
pub force: bool,
}
pub(crate) fn plan(repo: &gix::Repository, p: &PushParams) -> Result<Plan> {
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<gix::ObjectId> {
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<Option<Vec<u8>>> {
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<String, gix::ObjectId>,
pub caps: BTreeSet<String>,
}
fn advertise(input: &mut impl Read) -> Result<Advertisement> {
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<Advertisement> {
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<gix::ObjectId>,
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<bool> {
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<gix::ObjectId>,
boundary: &BTreeSet<gix::ObjectId>,
) -> Result<usize> {
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<Item = gix::ObjectId>,
) -> Result<BTreeSet<gix::ObjectId>> {
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<gix::ObjectId>,
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<PushResult> {
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<PushResult> {
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,
})
}