fabrica

hanna/fabrica

feat(git): mirror fetch/push subprocess transport

32dc260 · hanna committed on 2026-07-26

Add git::mirror with fetch (pull a remote's branches+tags into a bare
repo) and push (send refspecs out), each spawning the git binary with a
hermetic environment. Credentials are percent-encoded into http(s) URLs
(ssh URLs untouched) and scrubbed from any error text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
2 files changed · +191 −0UnifiedSplit
crates/git/src/lib.rs +1 −0
@@ -29,6 +29,7 @@
2929
3030 mod attributes;
3131 pub mod merge;
32+pub mod mirror;
3233 pub mod pack;
3334 mod pubkey;
3435 mod repo;
crates/git/src/mirror.rs +190 −0
@@ -0,0 +1,190 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Mirror transport: fetch from / push to a remote by spawning the `git` binary.
6+//!
7+//! Like [`crate::pack`], these never touch libgit2 (which has no server pack
8+//! side) and never interpolate into a shell. Credentials, when present, are
9+//! percent-encoded into the http(s) URL; the plaintext secret is scrubbed from
10+//! any error text so it never lands in a stored `last_error`.
11+
12+use std::path::Path;
13+
14+use tokio::process::Command;
15+
16+/// Optional credentials for authenticating to a remote.
17+#[derive(Debug, Default, Clone, Copy)]
18+pub struct RemoteCred<'a> {
19+ /// The username (for http(s) basic auth).
20+ pub username: Option<&'a str>,
21+ /// The password or access token.
22+ pub secret: Option<&'a str>,
23+}
24+
25+/// Standard mirror refspecs: all branches and tags, force-updated.
26+#[must_use]
27+pub fn all_refs() -> Vec<String> {
28+ vec![
29+ "+refs/heads/*:refs/heads/*".to_string(),
30+ "+refs/tags/*:refs/tags/*".to_string(),
31+ ]
32+}
33+
34+/// Percent-encode a URL credential component (everything but the unreserved set).
35+fn encode(component: &str) -> String {
36+ let mut out = String::with_capacity(component.len());
37+ for b in component.bytes() {
38+ if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
39+ out.push(b as char);
40+ } else {
41+ out.push('%');
42+ out.push(
43+ char::from_digit(u32::from(b >> 4), 16)
44+ .unwrap_or('0')
45+ .to_ascii_uppercase(),
46+ );
47+ out.push(
48+ char::from_digit(u32::from(b & 0xf), 16)
49+ .unwrap_or('0')
50+ .to_ascii_uppercase(),
51+ );
52+ }
53+ }
54+ out
55+}
56+
57+/// Embed credentials into an http(s) URL. Non-http URLs (ssh, git) are returned
58+/// unchanged — those authenticate with keys, not inline credentials.
59+fn authed_url(url: &str, cred: RemoteCred) -> String {
60+ let Some(rest) = url
61+ .strip_prefix("https://")
62+ .map(|r| ("https://", r))
63+ .or_else(|| url.strip_prefix("http://").map(|r| ("http://", r)))
64+ else {
65+ return url.to_string();
66+ };
67+ let (scheme, host_path) = rest;
68+ // Drop any credentials already present in the URL before adding ours.
69+ let host_path = host_path.rsplit_once('@').map_or(host_path, |(_, hp)| hp);
70+ match (cred.username, cred.secret) {
71+ (Some(u), Some(s)) => format!("{scheme}{}:{}@{host_path}", encode(u), encode(s)),
72+ (Some(u), None) => format!("{scheme}{}@{host_path}", encode(u)),
73+ (None, Some(s)) => format!("{scheme}{}@{host_path}", encode(s)),
74+ (None, None) => url.to_string(),
75+ }
76+}
77+
78+/// Run a `git` invocation in `repo_dir` with a hermetic environment, returning
79+/// `Ok` on exit 0 or `Err(message)` with the secret scrubbed out.
80+async fn run(
81+ binary: &str,
82+ repo_dir: &Path,
83+ home: &Path,
84+ args: &[&str],
85+ secret: Option<&str>,
86+) -> Result<(), String> {
87+ let mut cmd = Command::new(binary);
88+ cmd.args(args);
89+ cmd.env("GIT_DIR", repo_dir);
90+ cmd.env("GIT_TERMINAL_PROMPT", "0");
91+ cmd.env("GIT_CONFIG_NOSYSTEM", "1");
92+ cmd.env("HOME", home);
93+ cmd.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES");
94+ cmd.env_remove("GIT_OBJECT_DIRECTORY");
95+
96+ let output = cmd
97+ .output()
98+ .await
99+ .map_err(|e| format!("could not run git: {e}"))?;
100+ if output.status.success() {
101+ return Ok(());
102+ }
103+ let mut msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
104+ if msg.is_empty() {
105+ msg = format!("git exited with status {}", output.status);
106+ }
107+ // Never leak the credential into a stored error.
108+ if let Some(secret) = secret
109+ && !secret.is_empty()
110+ {
111+ msg = msg.replace(secret, "***");
112+ }
113+ Err(msg)
114+}
115+
116+/// Fetch all branches and tags from `url` into the bare repo at `repo_dir`.
117+///
118+/// # Errors
119+///
120+/// Returns the scrubbed git error text on failure.
121+pub async fn fetch(
122+ binary: &str,
123+ repo_dir: &Path,
124+ url: &str,
125+ cred: RemoteCred<'_>,
126+ home: &Path,
127+) -> Result<(), String> {
128+ let authed = authed_url(url, cred);
129+ let mut args: Vec<&str> = vec!["fetch", "--prune", "--quiet", &authed];
130+ let refs = all_refs();
131+ args.extend(refs.iter().map(String::as_str));
132+ run(binary, repo_dir, home, &args, cred.secret).await
133+}
134+
135+/// Push `refspecs` from the bare repo at `repo_dir` to `url`.
136+///
137+/// # Errors
138+///
139+/// Returns the scrubbed git error text on failure.
140+pub async fn push(
141+ binary: &str,
142+ repo_dir: &Path,
143+ url: &str,
144+ cred: RemoteCred<'_>,
145+ refspecs: &[String],
146+ home: &Path,
147+) -> Result<(), String> {
148+ let authed = authed_url(url, cred);
149+ let mut args: Vec<&str> = vec!["push", "--quiet", &authed];
150+ args.extend(refspecs.iter().map(String::as_str));
151+ run(binary, repo_dir, home, &args, cred.secret).await
152+}
153+
154+#[cfg(test)]
155+mod tests {
156+ use super::*;
157+
158+ #[test]
159+ fn authed_url_embeds_and_encodes_credentials() {
160+ let cred = RemoteCred {
161+ username: Some("me@x"),
162+ secret: Some("p/@ss"),
163+ };
164+ assert_eq!(
165+ authed_url("https://host/o/r.git", cred),
166+ "https://me%40x:p%2F%40ss@host/o/r.git"
167+ );
168+ }
169+
170+ #[test]
171+ fn authed_url_replaces_existing_credentials() {
172+ let cred = RemoteCred {
173+ username: Some("u"),
174+ secret: Some("t"),
175+ };
176+ assert_eq!(
177+ authed_url("https://old:cred@host/r", cred),
178+ "https://u:t@host/r"
179+ );
180+ }
181+
182+ #[test]
183+ fn authed_url_leaves_ssh_untouched() {
184+ let cred = RemoteCred {
185+ username: Some("u"),
186+ secret: Some("t"),
187+ };
188+ assert_eq!(authed_url("git@host:o/r.git", cred), "git@host:o/r.git");
189+ }
190+}