fabrica

hanna/fabrica

5949 bytes
Raw
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
12use std::path::Path;
13
14use tokio::process::Command;
15
16/// Optional credentials for authenticating to a remote.
17#[derive(Debug, Default, Clone, Copy)]
18pub 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]
27pub 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).
35fn 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.
59fn 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.
80async 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.
121pub 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.
140pub 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)]
155mod 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}