// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Mirror transport: fetch from / push to a remote by spawning the `git` binary. //! //! Like [`crate::pack`], these never touch libgit2 (which has no server pack //! side) and never interpolate into a shell. Credentials, when present, are //! percent-encoded into the http(s) URL; the plaintext secret is scrubbed from //! any error text so it never lands in a stored `last_error`. use std::path::Path; use tokio::process::Command; /// Optional credentials for authenticating to a remote. #[derive(Debug, Default, Clone, Copy)] pub struct RemoteCred<'a> { /// The username (for http(s) basic auth). pub username: Option<&'a str>, /// The password or access token. pub secret: Option<&'a str>, } /// Standard mirror refspecs: all branches and tags, force-updated. #[must_use] pub fn all_refs() -> Vec { vec![ "+refs/heads/*:refs/heads/*".to_string(), "+refs/tags/*:refs/tags/*".to_string(), ] } /// Percent-encode a URL credential component (everything but the unreserved set). fn encode(component: &str) -> String { let mut out = String::with_capacity(component.len()); for b in component.bytes() { if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') { out.push(b as char); } else { out.push('%'); out.push( char::from_digit(u32::from(b >> 4), 16) .unwrap_or('0') .to_ascii_uppercase(), ); out.push( char::from_digit(u32::from(b & 0xf), 16) .unwrap_or('0') .to_ascii_uppercase(), ); } } out } /// Embed credentials into an http(s) URL. Non-http URLs (ssh, git) are returned /// unchanged — those authenticate with keys, not inline credentials. fn authed_url(url: &str, cred: RemoteCred) -> String { let Some(rest) = url .strip_prefix("https://") .map(|r| ("https://", r)) .or_else(|| url.strip_prefix("http://").map(|r| ("http://", r))) else { return url.to_string(); }; let (scheme, host_path) = rest; // Drop any credentials already present in the URL before adding ours. let host_path = host_path.rsplit_once('@').map_or(host_path, |(_, hp)| hp); match (cred.username, cred.secret) { (Some(u), Some(s)) => format!("{scheme}{}:{}@{host_path}", encode(u), encode(s)), (Some(u), None) => format!("{scheme}{}@{host_path}", encode(u)), (None, Some(s)) => format!("{scheme}{}@{host_path}", encode(s)), (None, None) => url.to_string(), } } /// Run a `git` invocation in `repo_dir` with a hermetic environment, returning /// `Ok` on exit 0 or `Err(message)` with the secret scrubbed out. async fn run( binary: &str, repo_dir: &Path, home: &Path, args: &[&str], secret: Option<&str>, ) -> Result<(), String> { let mut cmd = Command::new(binary); cmd.args(args); cmd.env("GIT_DIR", repo_dir); cmd.env("GIT_TERMINAL_PROMPT", "0"); cmd.env("GIT_CONFIG_NOSYSTEM", "1"); cmd.env("HOME", home); cmd.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES"); cmd.env_remove("GIT_OBJECT_DIRECTORY"); let output = cmd .output() .await .map_err(|e| format!("could not run git: {e}"))?; if output.status.success() { return Ok(()); } let mut msg = String::from_utf8_lossy(&output.stderr).trim().to_string(); if msg.is_empty() { msg = format!("git exited with status {}", output.status); } // Never leak the credential into a stored error. if let Some(secret) = secret && !secret.is_empty() { msg = msg.replace(secret, "***"); } Err(msg) } /// Fetch all branches and tags from `url` into the bare repo at `repo_dir`. /// /// # Errors /// /// Returns the scrubbed git error text on failure. pub async fn fetch( binary: &str, repo_dir: &Path, url: &str, cred: RemoteCred<'_>, home: &Path, ) -> Result<(), String> { let authed = authed_url(url, cred); let mut args: Vec<&str> = vec!["fetch", "--prune", "--quiet", &authed]; let refs = all_refs(); args.extend(refs.iter().map(String::as_str)); run(binary, repo_dir, home, &args, cred.secret).await } /// Push `refspecs` from the bare repo at `repo_dir` to `url`. /// /// # Errors /// /// Returns the scrubbed git error text on failure. pub async fn push( binary: &str, repo_dir: &Path, url: &str, cred: RemoteCred<'_>, refspecs: &[String], home: &Path, ) -> Result<(), String> { let authed = authed_url(url, cred); let mut args: Vec<&str> = vec!["push", "--quiet", &authed]; args.extend(refspecs.iter().map(String::as_str)); run(binary, repo_dir, home, &args, cred.secret).await } #[cfg(test)] mod tests { use super::*; #[test] fn authed_url_embeds_and_encodes_credentials() { let cred = RemoteCred { username: Some("me@x"), secret: Some("p/@ss"), }; assert_eq!( authed_url("https://host/o/r.git", cred), "https://me%40x:p%2F%40ss@host/o/r.git" ); } #[test] fn authed_url_replaces_existing_credentials() { let cred = RemoteCred { username: Some("u"), secret: Some("t"), }; assert_eq!( authed_url("https://old:cred@host/r", cred), "https://u:t@host/r" ); } #[test] fn authed_url_leaves_ssh_untouched() { let cred = RemoteCred { username: Some("u"), secret: Some("t"), }; assert_eq!(authed_url("git@host:o/r.git", cred), "git@host:o/r.git"); } }