forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
24.0 KiB
1//! Pull and push mirrors: `git fetch` from a remote URL into a repo, and `git push` of a repo's
2//! refs to a remote URL. Only `refs/heads/*` and `refs/tags/*` are synced, with prune.
3//!
4//! Safety for every remote call: protocols are limited to https (and http when the caller allows
5//! it), redirects are not followed, no credential helper runs, and a prompt is never shown. The
6//! credentials travel only in the child environment (`GIT_CONFIG_*` for an `http.extraHeader`),
7//! never in argv, the URL or an error. Errors are scrubbed before they leave this module.
8
9use std::fmt;
10use std::future::Future;
11use std::net::IpAddr;
12use std::path::Path;
13use std::time::Duration;
14
15use base64::Engine;
16use base64::engine::general_purpose::STANDARD;
17
18use crate::command::{GitCommand, GitEnv};
19use crate::error::{Error, Result};
20
21/// The refspecs both directions sync. Never `refs/pull/*`.
22pub const MIRROR_REFSPECS: [&str; 2] = ["+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"];
23
24/// Most git stderr kept in an error, in bytes.
25const MAX_STDERR_BYTES: usize = 2 * 1024;
26const REDACTED: &str = "[redacted]";
27
28/// A username and password or token for a remote. The secret is never printed: the `Debug`
29/// impl redacts it.
30#[derive(Clone, PartialEq, Eq)]
31pub struct RemoteAuth {
32 pub username: String,
33 pub secret: Vec<u8>,
34}
35
36impl fmt::Debug for RemoteAuth {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 f.debug_struct("RemoteAuth")
39 .field("username", &self.username)
40 .field("secret", &REDACTED)
41 .finish()
42 }
43}
44
45/// A host and the one address it was checked to resolve to. Git is told to connect to that address
46/// (`http.curloptResolve`), so the connection cannot be sent somewhere a second DNS lookup returns.
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct PinnedHost {
49 pub host: String,
50 pub port: u16,
51 pub ip: IpAddr,
52}
53
54impl PinnedHost {
55 /// The `-c` pair for git: `http.curloptResolve=host:port:ip`, with an IPv6 address in brackets.
56 /// The host must be a plain DNS name, so the value cannot smuggle in another option.
57 pub fn config_pair(&self) -> Result<String> {
58 let plain_host = !self.host.is_empty()
59 && self
60 .host
61 .bytes()
62 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.');
63 if !plain_host {
64 return Err(Error::InvalidInput(
65 "pinned host must be a plain DNS name".into(),
66 ));
67 }
68 let ip = match self.ip {
69 IpAddr::V4(v4) => v4.to_string(),
70 IpAddr::V6(v6) => format!("[{v6}]"),
71 };
72 Ok(format!(
73 "http.curloptResolve={}:{}:{ip}",
74 self.host, self.port
75 ))
76 }
77}
78
79impl RemoteAuth {
80 /// The `Authorization` header value, Basic over `username:secret`.
81 fn header_value(&self) -> String {
82 let mut raw = self.username.as_bytes().to_vec();
83 raw.push(b':');
84 raw.extend_from_slice(&self.secret);
85 format!("Authorization: Basic {}", STANDARD.encode(raw))
86 }
87}
88
89/// Fetches the mirror refs of `url` into the bare repo at `repo`, then points HEAD at the remote's
90/// default branch when our HEAD's branch is missing. With `pin`, git connects to the pinned
91/// address. Times out after `timeout`, killing git.
92pub async fn mirror_fetch(
93 env: &GitEnv,
94 repo: &Path,
95 url: &str,
96 auth: Option<&RemoteAuth>,
97 allow_http: bool,
98 pin: Option<&PinnedHost>,
99 timeout: Duration,
100) -> Result<()> {
101 check_url(url, allow_http)?;
102 with_timeout(timeout, fetch(env, repo, url, auth, allow_http, pin, false)).await
103}
104
105/// Pushes the mirror refs of the bare repo at `repo` to `url`, with prune. With `pin`, git connects
106/// to the pinned address. Times out after `timeout`, killing git.
107pub async fn mirror_push(
108 env: &GitEnv,
109 repo: &Path,
110 url: &str,
111 auth: Option<&RemoteAuth>,
112 allow_http: bool,
113 pin: Option<&PinnedHost>,
114 timeout: Duration,
115) -> Result<()> {
116 check_url(url, allow_http)?;
117 with_timeout(timeout, push(env, repo, url, auth, allow_http, pin, false)).await
118}
119
120/// Only https, or http when allowed. No userinfo, whitespace or control characters. Never
121/// `file://`, `ssh://` or `ext::`.
122fn check_url(url: &str, allow_http: bool) -> Result<()> {
123 let rest = if let Some(rest) = url.strip_prefix("https://") {
124 rest
125 } else if let Some(rest) = url.strip_prefix("http://").filter(|_| allow_http) {
126 rest
127 } else {
128 return Err(Error::InvalidInput(
129 "mirror URL must use https (or http when allowed)".into(),
130 ));
131 };
132 let authority = rest.split('/').next().unwrap_or_default();
133 if authority.is_empty() || authority.contains('@') {
134 return Err(Error::InvalidInput(
135 "mirror URL needs a host and must not contain credentials".into(),
136 ));
137 }
138 if url.chars().any(|c| c.is_whitespace() || c.is_control()) {
139 return Err(Error::InvalidInput(
140 "mirror URL contains whitespace or control characters".into(),
141 ));
142 }
143 Ok(())
144}
145
146async fn with_timeout<T>(timeout: Duration, fut: impl Future<Output = Result<T>>) -> Result<T> {
147 match tokio::time::timeout(timeout, fut).await {
148 Ok(result) => result,
149 Err(_) => Err(Error::Failed(format!(
150 "git timed out after {} seconds",
151 timeout.as_secs()
152 ))),
153 }
154}
155
156/// The git invocation with the safety flags, the optional repo and the optional credentials.
157/// `local` allows the file transport; only unit tests pass true.
158fn base_command(
159 env: &GitEnv,
160 repo: Option<&Path>,
161 subcommand: &'static str,
162 auth: Option<&RemoteAuth>,
163 allow_http: bool,
164 pin: Option<&PinnedHost>,
165 local: bool,
166) -> Result<GitCommand> {
167 let mut cmd = GitCommand::new(env, subcommand)
168 .config("protocol.allow=never")
169 .config("protocol.https.allow=always")
170 .config("http.followRedirects=false")
171 .config("credential.helper=");
172 if allow_http {
173 cmd = cmd.config("protocol.http.allow=always");
174 }
175 if let Some(pin) = pin {
176 cmd = cmd.config_checked(pin.config_pair()?)?;
177 }
178 if let Some(repo) = repo {
179 cmd = cmd.git_dir(repo);
180 }
181 if local {
182 cmd = cmd.allow_local_transport();
183 }
184 if let Some(auth) = auth {
185 cmd = cmd
186 .env("GIT_CONFIG_COUNT", "1")
187 .env("GIT_CONFIG_KEY_0", "http.extraHeader")
188 .env("GIT_CONFIG_VALUE_0", auth.header_value());
189 }
190 Ok(cmd)
191}
192
193async fn fetch(
194 env: &GitEnv,
195 repo: &Path,
196 url: &str,
197 auth: Option<&RemoteAuth>,
198 allow_http: bool,
199 pin: Option<&PinnedHost>,
200 local: bool,
201) -> Result<()> {
202 let mut revs = vec![url];
203 revs.extend(MIRROR_REFSPECS);
204 let cmd = base_command(env, Some(repo), "fetch", auth, allow_http, pin, local)?
205 .arg("--prune")
206 .arg("--no-recurse-submodules")
207 .user_revs(&revs)?;
208 run_checked(cmd, "fetch", auth, url).await?;
209
210 let head = base_command(env, Some(repo), "ls-remote", auth, allow_http, pin, local)?
211 .arg("--symref")
212 .user_revs(&[url, "HEAD"])?;
213 let listing = run_checked(head, "ls-remote", auth, url).await?;
214 let listing = String::from_utf8_lossy(&listing);
215 let Some(default_ref) = remote_default_branch(&listing) else {
216 return Ok(());
217 };
218 point_head_at_default(env, repo, &default_ref).await;
219 Ok(())
220}
221
222/// The `refs/heads/...` target of the remote HEAD symref in `ls-remote --symref` output.
223fn remote_default_branch(listing: &str) -> Option<String> {
224 listing.lines().find_map(|line| {
225 let (target, name) = line.strip_prefix("ref: ")?.split_once('\t')?;
226 (name == "HEAD" && target.starts_with("refs/heads/")).then(|| target.to_string())
227 })
228}
229
230/// Sets HEAD to `default_ref` when HEAD's branch does not exist and `default_ref` does. Best
231/// effort: failures leave HEAD as it was.
232async fn point_head_at_default(env: &GitEnv, repo: &Path, default_ref: &str) {
233 let current = GitCommand::new(env, "symbolic-ref")
234 .git_dir(repo)
235 .arg("-q")
236 .arg("HEAD")
237 .output()
238 .await
239 .ok()
240 .map(|out| String::from_utf8_lossy(&out).trim().to_string());
241 if let Some(current) = current
242 && ref_exists(env, repo, &current).await
243 {
244 return;
245 }
246 if !ref_exists(env, repo, default_ref).await {
247 return;
248 }
249 let set = GitCommand::new(env, "symbolic-ref")
250 .git_dir(repo)
251 .arg("HEAD")
252 .user_arg(default_ref);
253 if let Ok(cmd) = set {
254 let _ = cmd.output().await;
255 }
256}
257
258/// True when `refname` is a valid ref name that exists in `repo`.
259async fn ref_exists(env: &GitEnv, repo: &Path, refname: &str) -> bool {
260 let Ok(cmd) = GitCommand::new(env, "show-ref")
261 .git_dir(repo)
262 .arg("--verify")
263 .arg("--quiet")
264 .user_arg(refname)
265 else {
266 return false;
267 };
268 cmd.output().await.is_ok()
269}
270
271async fn push(
272 env: &GitEnv,
273 repo: &Path,
274 url: &str,
275 auth: Option<&RemoteAuth>,
276 allow_http: bool,
277 pin: Option<&PinnedHost>,
278 local: bool,
279) -> Result<()> {
280 let mut revs = vec![url];
281 revs.extend(MIRROR_REFSPECS);
282 let cmd = base_command(env, Some(repo), "push", auth, allow_http, pin, local)?
283 .arg("--prune")
284 .arg("--force")
285 .user_revs(&revs)?;
286 run_checked(cmd, "push", auth, url).await?;
287 Ok(())
288}
289
290/// Runs the command and returns stdout. A failure carries git's scrubbed stderr.
291async fn run_checked(
292 cmd: GitCommand,
293 what: &str,
294 auth: Option<&RemoteAuth>,
295 url: &str,
296) -> Result<Vec<u8>> {
297 let out = cmd.into_tokio().output().await?;
298 if out.status.success() {
299 return Ok(out.stdout);
300 }
301 let stderr = scrub(&String::from_utf8_lossy(&out.stderr), auth, url);
302 Err(Error::Failed(format!(
303 "git {what} exited with {}: {}",
304 out.status,
305 truncate(stderr.trim())
306 )))
307}
308
309/// Removes credentials from text that may reach an error: the secret and its encoded form, any
310/// `Authorization:` line, and any `user:pass@` in a URL.
311fn scrub(text: &str, auth: Option<&RemoteAuth>, url: &str) -> String {
312 let mut out = text.to_string();
313 if let Some(auth) = auth {
314 let secret = String::from_utf8_lossy(&auth.secret).into_owned();
315 if !secret.is_empty() {
316 out = out.replace(&secret, REDACTED);
317 }
318 let header = auth.header_value();
319 out = out.replace(&header, REDACTED);
320 if let Some(encoded) = header.strip_prefix("Authorization: Basic ") {
321 out = out.replace(encoded, REDACTED);
322 }
323 }
324 out = out
325 .lines()
326 .map(|line| {
327 if line.to_ascii_lowercase().contains("authorization:") {
328 format!("Authorization: {REDACTED}")
329 } else {
330 line.to_string()
331 }
332 })
333 .collect::<Vec<_>>()
334 .join("\n");
335 let mut stripped = strip_userinfo(&out);
336 if url.contains('@') {
337 stripped = stripped.replace(url, REDACTED);
338 }
339 stripped
340}
341
342/// Replaces `user:pass@` after each `://` with `***@`.
343fn strip_userinfo(text: &str) -> String {
344 let mut out = String::with_capacity(text.len());
345 let mut rest = text;
346 while let Some(pos) = rest.find("://") {
347 let (head, tail) = rest.split_at(pos + 3);
348 out.push_str(head);
349 let authority_end = tail
350 .find(|c: char| c == '/' || c.is_whitespace())
351 .unwrap_or(tail.len());
352 let authority = &tail[..authority_end];
353 match authority.rfind('@') {
354 Some(at) => {
355 out.push_str("***");
356 out.push_str(&authority[at..]);
357 }
358 None => out.push_str(authority),
359 }
360 rest = &tail[authority_end..];
361 }
362 out.push_str(rest);
363 out
364}
365
366/// At most [`MAX_STDERR_BYTES`], cut at a character boundary.
367fn truncate(text: &str) -> String {
368 if text.len() <= MAX_STDERR_BYTES {
369 return text.to_string();
370 }
371 let mut end = MAX_STDERR_BYTES;
372 while !text.is_char_boundary(end) {
373 end -= 1;
374 }
375 format!("{}...", &text[..end])
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 fn env(dir: &Path) -> GitEnv {
383 GitEnv {
384 git_bin: "git".into(),
385 data_dir: dir.to_path_buf(),
386 }
387 }
388
389 fn auth() -> RemoteAuth {
390 RemoteAuth {
391 username: "mirror-bot".into(),
392 secret: b"s3cret-token-XYZ".to_vec(),
393 }
394 }
395
396 /// Runs plain git against a git dir for test setup.
397 fn plumbing(git_dir: &Path, args: &[&str]) -> String {
398 let out = std::process::Command::new("git")
399 .arg(format!("--git-dir={}", git_dir.display()))
400 .args(args)
401 .env("GIT_AUTHOR_NAME", "t")
402 .env("GIT_AUTHOR_EMAIL", "t@example.com")
403 .env("GIT_COMMITTER_NAME", "t")
404 .env("GIT_COMMITTER_EMAIL", "t@example.com")
405 .output()
406 .expect("git runs");
407 assert!(out.status.success(), "git {args:?}: {out:?}");
408 String::from_utf8_lossy(&out.stdout).trim().to_string()
409 }
410
411 /// A bare repo whose `main` and tag `v1` point at one empty commit.
412 fn source_repo(dir: &Path) -> std::path::PathBuf {
413 let git_dir = dir.join("source.git");
414 plumbing(dir, &["init", "--bare", "-q", git_dir.to_str().unwrap()]);
415 let tree = plumbing(&git_dir, &["hash-object", "-t", "tree", "-w", "--stdin"]);
416 let commit = plumbing(&git_dir, &["commit-tree", &tree, "-m", "first"]);
417 plumbing(&git_dir, &["update-ref", "refs/heads/main", &commit]);
418 plumbing(&git_dir, &["symbolic-ref", "HEAD", "refs/heads/main"]);
419 plumbing(&git_dir, &["update-ref", "refs/tags/v1", &commit]);
420 git_dir
421 }
422
423 fn file_url(path: &Path) -> String {
424 path.to_str().unwrap().to_string()
425 }
426
427 fn refs(git_dir: &Path) -> String {
428 plumbing(git_dir, &["for-each-ref", "--format=%(refname)"])
429 }
430
431 #[test]
432 fn urls_are_limited_to_https_without_credentials() {
433 assert!(check_url("https://example.com/a.git", false).is_ok());
434 assert!(check_url("http://example.com/a.git", false).is_err());
435 assert!(check_url("http://example.com/a.git", true).is_ok());
436 assert!(check_url("file:///etc/passwd", true).is_err());
437 assert!(check_url("ssh://example.com/a.git", true).is_err());
438 assert!(check_url("ext::sh -c x", true).is_err());
439 assert!(check_url("https://user:pw@example.com/a.git", false).is_err());
440 assert!(check_url("https://example.com/a b.git", false).is_err());
441 }
442
443 #[test]
444 fn credentials_stay_out_of_argv() {
445 let dir = tempfile::tempdir().unwrap();
446 let a = auth();
447 let cmd = base_command(
448 &env(dir.path()),
449 Some(dir.path()),
450 "fetch",
451 Some(&a),
452 false,
453 None,
454 false,
455 )
456 .expect("command builds")
457 .arg("--prune")
458 .user_revs(&["https://example.com/a.git", "+refs/heads/*:refs/heads/*"])
459 .unwrap();
460 let std_cmd = cmd.into_tokio();
461 let std_cmd = std_cmd.as_std();
462 let argv: Vec<String> = std_cmd
463 .get_args()
464 .map(|a| a.to_string_lossy().into_owned())
465 .collect();
466 let joined = argv.join(" ");
467 assert!(!joined.contains("s3cret"), "{joined}");
468 assert!(!joined.contains("Authorization"), "{joined}");
469 assert!(!joined.contains(&STANDARD.encode(b"mirror-bot:s3cret-token-XYZ")));
470 // The header travels in the environment instead.
471 let env_value = std_cmd
472 .get_envs()
473 .find(|(k, _)| *k == "GIT_CONFIG_VALUE_0")
474 .and_then(|(_, v)| v.map(|v| v.to_string_lossy().into_owned()))
475 .expect("header in env");
476 assert_eq!(env_value, a.header_value());
477 assert!(env_value.starts_with("Authorization: Basic "));
478 }
479
480 #[test]
481 fn scrub_removes_secret_header_and_userinfo() {
482 let a = auth();
483 let encoded = STANDARD.encode(b"mirror-bot:s3cret-token-XYZ");
484 let stderr = format!(
485 "fatal: unable to access 'https://mirror-bot:s3cret-token-XYZ@h.example/x.git/': \
486 Authorization: Basic {encoded}\nerror: s3cret-token-XYZ rejected"
487 );
488 let scrubbed = scrub(&stderr, Some(&a), "https://h.example/x.git");
489 assert!(!scrubbed.contains("s3cret"), "{scrubbed}");
490 assert!(!scrubbed.contains(&encoded), "{scrubbed}");
491 assert!(!scrubbed.contains("Basic"), "{scrubbed}");
492 assert!(scrubbed.contains("h.example"), "{scrubbed}");
493 assert!(scrubbed.contains(REDACTED), "{scrubbed}");
494 }
495
496 #[test]
497 fn debug_hides_the_secret() {
498 let shown = format!("{:?}", auth());
499 assert!(!shown.contains("s3cret"), "{shown}");
500 assert!(shown.contains("mirror-bot"), "{shown}");
501 }
502
503 #[test]
504 fn truncation_is_bounded_and_on_a_boundary() {
505 let long = "é".repeat(4000);
506 let t = truncate(&long);
507 assert!(t.len() <= MAX_STDERR_BYTES + 3);
508 assert!(t.ends_with("..."));
509 }
510
511 #[test]
512 fn remote_head_symref_is_parsed() {
513 let listing = "ref: refs/heads/trunk\tHEAD\nabc\tHEAD\nabc\trefs/heads/trunk\n";
514 assert_eq!(
515 remote_default_branch(listing).as_deref(),
516 Some("refs/heads/trunk")
517 );
518 assert_eq!(remote_default_branch("abc\tHEAD\n"), None);
519 }
520
521 #[tokio::test]
522 async fn mirror_fetch_and_push_sync_heads_and_tags_with_prune() {
523 let dir = tempfile::tempdir().unwrap();
524 let source = source_repo(dir.path());
525 let env = env(dir.path());
526 let timeout = Duration::from_secs(60);
527
528 // Pull: fetch the source into an empty bare repo, through the local transport that
529 // only unit tests may use.
530 let pulled = dir.path().join("pulled.git");
531 plumbing(
532 dir.path(),
533 &["init", "--bare", "-q", pulled.to_str().unwrap()],
534 );
535 fetch(&env, &pulled, &file_url(&source), None, false, None, true)
536 .await
537 .unwrap();
538 let fetched = refs(&pulled);
539 assert!(fetched.contains("refs/heads/main"), "{fetched}");
540 assert!(fetched.contains("refs/tags/v1"), "{fetched}");
541 assert_eq!(
542 plumbing(&pulled, &["symbolic-ref", "HEAD"]),
543 "refs/heads/main"
544 );
545
546 // A branch that the remote no longer has is pruned by the next fetch.
547 let head = plumbing(&source, &["rev-parse", "refs/heads/main"]);
548 plumbing(&pulled, &["update-ref", "refs/heads/gone", &head]);
549 plumbing(&source, &["update-ref", "refs/heads/new", &head]);
550 fetch(&env, &pulled, &file_url(&source), None, false, None, true)
551 .await
552 .unwrap();
553 let fetched = refs(&pulled);
554 assert!(fetched.contains("refs/heads/new"), "{fetched}");
555 assert!(!fetched.contains("refs/heads/gone"), "{fetched}");
556
557 // Push: mirror our refs to an empty bare remote, then prune a stale branch there.
558 let remote = dir.path().join("remote.git");
559 plumbing(
560 dir.path(),
561 &["init", "--bare", "-q", remote.to_str().unwrap()],
562 );
563 plumbing(
564 &remote,
565 &[
566 "fetch",
567 "-q",
568 source.to_str().unwrap(),
569 "refs/heads/main:refs/heads/stale",
570 ],
571 );
572 push(&env, &pulled, &file_url(&remote), None, false, None, true)
573 .await
574 .unwrap();
575 let pushed = refs(&remote);
576 assert!(pushed.contains("refs/heads/main"), "{pushed}");
577 assert!(pushed.contains("refs/tags/v1"), "{pushed}");
578 assert!(!pushed.contains("refs/heads/stale"), "{pushed}");
579
580 // The public entry points reject the file transport outright.
581 assert!(
582 mirror_fetch(
583 &env,
584 &pulled,
585 &file_url(&source),
586 None,
587 false,
588 None,
589 timeout
590 )
591 .await
592 .is_err()
593 );
594 assert!(
595 mirror_push(
596 &env,
597 &pulled,
598 &file_url(&remote),
599 None,
600 false,
601 None,
602 timeout
603 )
604 .await
605 .is_err()
606 );
607 }
608
609 #[tokio::test]
610 async fn failure_text_never_carries_the_secret() {
611 let dir = tempfile::tempdir().unwrap();
612 let env = env(dir.path());
613 let pulled = dir.path().join("pulled.git");
614 plumbing(
615 dir.path(),
616 &["init", "--bare", "-q", pulled.to_str().unwrap()],
617 );
618 let a = auth();
619 // Port 9 on loopback refuses the connection, so git fails with its own error text.
620 let url = "https://127.0.0.1:9/x.git";
621 let err = mirror_fetch(
622 &env,
623 &pulled,
624 url,
625 Some(&a),
626 false,
627 None,
628 Duration::from_secs(60),
629 )
630 .await
631 .unwrap_err();
632 let text = err.to_string();
633 assert!(!text.contains("s3cret"), "{text}");
634 assert!(
635 !text.contains(&STANDARD.encode(b"mirror-bot:s3cret-token-XYZ")),
636 "{text}"
637 );
638 }
639
640 fn pin(ip: &str) -> PinnedHost {
641 PinnedHost {
642 host: "pin.invalid".into(),
643 port: 443,
644 ip: ip.parse().expect("ip"),
645 }
646 }
647
648 #[test]
649 fn pinned_config_pair_formats_ipv4_and_ipv6() {
650 let v4 = PinnedHost {
651 host: "example.com".into(),
652 port: 443,
653 ip: "93.184.216.34".parse().expect("ip"),
654 };
655 assert_eq!(
656 v4.config_pair().expect("pair"),
657 "http.curloptResolve=example.com:443:93.184.216.34"
658 );
659 let v6 = PinnedHost {
660 host: "example.com".into(),
661 port: 80,
662 ip: "2001:db8::1".parse().expect("ip"),
663 };
664 assert_eq!(
665 v6.config_pair().expect("pair"),
666 "http.curloptResolve=example.com:80:[2001:db8::1]"
667 );
668 }
669
670 #[test]
671 fn pinned_host_must_be_a_plain_name() {
672 for host in ["", "a b", "[::1]", "x,y", "evil\nname"] {
673 let bad = PinnedHost {
674 host: host.into(),
675 port: 443,
676 ip: "93.184.216.34".parse().expect("ip"),
677 };
678 assert!(bad.config_pair().is_err(), "{host:?}");
679 }
680 }
681
682 #[test]
683 fn pin_becomes_a_checked_config_arg() {
684 let dir = tempfile::tempdir().expect("tempdir");
685 let cmd = base_command(
686 &env(dir.path()),
687 None,
688 "ls-remote",
689 None,
690 false,
691 Some(&pin("127.0.0.1")),
692 false,
693 )
694 .expect("command builds");
695 let std_cmd = cmd.into_tokio();
696 let argv: Vec<String> = std_cmd
697 .as_std()
698 .get_args()
699 .map(|a| a.to_string_lossy().into_owned())
700 .collect();
701 let at = argv
702 .iter()
703 .position(|a| a == "http.curloptResolve=pin.invalid:443:127.0.0.1")
704 .expect("pinned config arg present");
705 assert_eq!(argv[at - 1], "-c", "{argv:?}");
706 }
707
708 #[tokio::test]
709 async fn fetch_connects_to_the_pinned_address() {
710 let dir = tempfile::tempdir().expect("tempdir");
711 let env = env(dir.path());
712 let pulled = dir.path().join("pulled.git");
713 plumbing(
714 dir.path(),
715 &["init", "--bare", "-q", pulled.to_str().expect("utf8 path")],
716 );
717 // The host never resolves, so only the pin can get git past name lookup. Nothing listens
718 // on loopback port 443, so the run fails at connect, not at resolve.
719 let err = mirror_fetch(
720 &env,
721 &pulled,
722 "https://pin.invalid/x.git",
723 None,
724 false,
725 Some(&pin("127.0.0.1")),
726 Duration::from_secs(60),
727 )
728 .await
729 .unwrap_err()
730 .to_string();
731 assert!(!err.contains("Could not resolve host"), "{err}");
732 }
733}