| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | use std::fmt; |
| 10 | use std::future::Future; |
| 11 | use std::net::IpAddr; |
| 12 | use std::path::Path; |
| 13 | use std::time::Duration; |
| 14 | |
| 15 | use base64::Engine; |
| 16 | use base64::engine::general_purpose::STANDARD; |
| 17 | |
| 18 | use crate::command::{GitCommand, GitEnv}; |
| 19 | use crate::error::{Error, Result}; |
| 20 | |
| 21 | |
| 22 | pub const MIRROR_REFSPECS: [&str; 2] = ["+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"]; |
| 23 | |
| 24 | |
| 25 | const MAX_STDERR_BYTES: usize = 2 * 1024; |
| 26 | const REDACTED: &str = "[redacted]"; |
| 27 | |
| 28 | |
| 29 | |
| 30 | #[derive(Clone, PartialEq, Eq)] |
| 31 | pub struct RemoteAuth { |
| 32 | pub username: String, |
| 33 | pub secret: Vec<u8>, |
| 34 | } |
| 35 | |
| 36 | impl 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 | |
| 46 | |
| 47 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 48 | pub struct PinnedHost { |
| 49 | pub host: String, |
| 50 | pub port: u16, |
| 51 | pub ip: IpAddr, |
| 52 | } |
| 53 | |
| 54 | impl PinnedHost { |
| 55 | |
| 56 | |
| 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 | |
| 79 | impl RemoteAuth { |
| 80 | |
| 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 | |
| 90 | |
| 91 | |
| 92 | pub 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 | |
| 106 | |
| 107 | pub 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 | |
| 121 | |
| 122 | fn 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 | |
| 146 | async 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 | |
| 157 | |
| 158 | fn 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 | |
| 193 | async 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 | |
| 223 | fn 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 | |
| 231 | |
| 232 | async 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, ¤t).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 | |
| 259 | async 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 | |
| 271 | async 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 | |
| 291 | async 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 | |
| 310 | |
| 311 | fn 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 | |
| 343 | fn 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 | |
| 367 | fn 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)] |
| 379 | mod 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 | |
| 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 | |
| 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 | |
| 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 | |
| 529 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 718 | |
| 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 | } |