1use anyhow::{anyhow, bail, Result};
17use serde_json::json;
18use std::io::Write;
19use std::path::Path;
20use std::process::Command;
21
22const API: &str = "https://api.github.com";
24const API_VERSION: &str = "2022-11-28";
25
26fn with_headers(req: ureq::Request, token: &str) -> ureq::Request {
29 req.set("Authorization", &format!("Bearer {token}"))
30 .set("User-Agent", "git-ark")
31 .set("Accept", "application/vnd.github+json")
32 .set("X-GitHub-Api-Version", API_VERSION)
33}
34
35fn api_err(e: ureq::Error) -> String {
41 match e {
42 ureq::Error::Status(code, _resp) => format!("HTTP {code}"),
43 ureq::Error::Transport(t) => format!("transport error: {}", t.kind()),
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum RepoAccess {
51 Exists { push: bool, admin: bool },
53 Absent,
55 Error(String),
57}
58
59#[derive(Debug, Clone)]
63pub struct TokenCheck {
64 pub valid: bool,
65 pub login: Option<String>,
66 pub scopes: Option<Vec<String>>,
70 pub repo: RepoAccess,
71 pub needs_workflow: bool,
74}
75
76impl TokenCheck {
77 pub fn has_workflow_scope(&self) -> Option<bool> {
81 self.scopes
82 .as_ref()
83 .map(|s| s.iter().any(|x| x == "workflow"))
84 }
85}
86
87pub fn check_token(token: &str, owner: &str, repo: &str, needs_workflow: bool) -> TokenCheck {
93 let (valid, login, scopes) = match with_headers(ureq::get(&format!("{API}/user")), token).call()
94 {
95 Ok(resp) => {
96 let scopes = resp.header("X-OAuth-Scopes").map(|h| {
99 h.split(',')
100 .map(|s| s.trim().to_string())
101 .filter(|s| !s.is_empty())
102 .collect::<Vec<_>>()
103 });
104 let login = resp
105 .into_string()
106 .ok()
107 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
108 .and_then(|json| {
109 json.get("login")
110 .and_then(|l| l.as_str())
111 .map(str::to_string)
112 });
113 (true, login, scopes)
114 }
115 Err(_) => (false, None, None),
116 };
117
118 let repo_access =
119 match with_headers(ureq::get(&format!("{API}/repos/{owner}/{repo}")), token).call() {
120 Ok(resp) => {
121 let text = resp.into_string().unwrap_or_default();
122 let json: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
123 let push = json
124 .get("permissions")
125 .and_then(|p| p.get("push"))
126 .and_then(|v| v.as_bool())
127 .unwrap_or(false);
128 let admin = json
129 .get("permissions")
130 .and_then(|p| p.get("admin"))
131 .and_then(|v| v.as_bool())
132 .unwrap_or(false);
133 RepoAccess::Exists { push, admin }
134 }
135 Err(ureq::Error::Status(404, _)) => RepoAccess::Absent,
136 Err(e) => RepoAccess::Error(api_err(e)),
137 };
138
139 TokenCheck {
140 valid,
141 login,
142 scopes,
143 repo: repo_access,
144 needs_workflow,
145 }
146}
147
148fn create_body(repo_name: &str, private: bool) -> serde_json::Value {
150 json!({ "name": repo_name, "private": private })
151}
152
153pub fn branches_to_mirror(updated: &[String], configured: &[String]) -> Vec<String> {
156 updated
157 .iter()
158 .filter_map(|r| r.strip_prefix("refs/heads/"))
159 .filter(|b| configured.iter().any(|c| c == b))
160 .map(str::to_string)
161 .collect()
162}
163
164fn owner_is_org(token: &str, owner: &str) -> Result<bool> {
167 let url = format!("{API}/users/{owner}");
168 let resp = with_headers(ureq::get(&url), token)
169 .call()
170 .map_err(|e| anyhow!("looking up GitHub owner {owner}: {}", api_err(e)))?;
171 let text = resp
172 .into_string()
173 .map_err(|_| anyhow!("reading GitHub owner {owner} response"))?;
174 let json: serde_json::Value = serde_json::from_str(&text)
175 .map_err(|_| anyhow!("parsing GitHub owner {owner} response"))?;
176 Ok(json.get("type").and_then(|t| t.as_str()) == Some("Organization"))
177}
178
179fn authenticated_login(token: &str) -> Result<String> {
181 let url = format!("{API}/user");
182 let resp = with_headers(ureq::get(&url), token)
183 .call()
184 .map_err(|e| anyhow!("looking up the token's own GitHub account: {}", api_err(e)))?;
185 let text = resp
186 .into_string()
187 .map_err(|_| anyhow!("reading GitHub /user response"))?;
188 let json: serde_json::Value =
189 serde_json::from_str(&text).map_err(|_| anyhow!("parsing GitHub /user response"))?;
190 json.get("login")
191 .and_then(|l| l.as_str())
192 .map(str::to_string)
193 .ok_or_else(|| anyhow!("GitHub /user response had no login"))
194}
195
196fn create_repo(token: &str, owner: &str, repo_name: &str, private: bool) -> Result<()> {
198 let url = if owner_is_org(token, owner)? {
199 format!("{API}/orgs/{owner}/repos")
200 } else {
201 let login = authenticated_login(token)?;
208 if !owner.eq_ignore_ascii_case(&login) {
209 bail!(
210 "github owner '{owner}' is a user account that isn't this token's \
211 account and isn't an org the token can administer; set \
212 `github.owner` to an org (or to your own username), or pre-create \
213 the repo"
214 );
215 }
216 format!("{API}/user/repos")
217 };
218 let body = serde_json::to_string(&create_body(repo_name, private))
219 .map_err(|_| anyhow!("serializing repo-create body"))?;
220 with_headers(ureq::post(&url), token)
221 .set("Content-Type", "application/json")
222 .send_string(&body)
223 .map_err(|e| anyhow!("creating GitHub repo {owner}/{repo_name}: {}", api_err(e)))?;
224 Ok(())
225}
226
227fn ensure_repo(token: &str, owner: &str, repo_name: &str, private: bool) -> Result<()> {
230 let url = format!("{API}/repos/{owner}/{repo_name}");
231 match with_headers(ureq::get(&url), token).call() {
232 Ok(_) => Ok(()),
233 Err(ureq::Error::Status(404, _)) => create_repo(token, owner, repo_name, private),
234 Err(e) => Err(anyhow!(
235 "checking GitHub repo {owner}/{repo_name}: {}",
236 api_err(e)
237 )),
238 }
239}
240
241fn push_command(
251 token: &str,
252 owner: &str,
253 repo_name: &str,
254 repo_path: &Path,
255 branches: &[String],
256) -> Command {
257 let mut cmd = Command::new("git");
258 cmd.arg("-C")
259 .arg(repo_path)
260 .arg("-c")
264 .arg("credential.helper=")
265 .arg("-c")
266 .arg(r#"credential.helper=!f() { echo username=x-access-token; echo "password=$GIT_ARK_TOKEN"; }; f"#)
267 .arg("push")
268 .arg("--follow-tags")
273 .arg(format!("https://github.com/{owner}/{repo_name}.git"))
274 .args(branches)
275 .env("GIT_ARK_TOKEN", token)
276 .env("GIT_TERMINAL_PROMPT", "0");
279 cmd
280}
281
282fn scrub_token(text: &str, token: &str) -> String {
285 text.replace(token, "***")
286}
287
288fn is_transient_push_error(stderr: &str) -> bool {
295 let s = stderr.to_ascii_lowercase();
296 const TRANSIENT: &[&str] = &[
297 "internal server error",
298 "http 500",
299 "http 502",
300 "http 503",
301 "http 504",
302 "rpc failed; http 5", "the requested url returned error: 5", "gnutls recv error",
305 "could not resolve host",
306 "failed to connect",
307 "connection timed out",
308 "connection reset",
309 "operation timed out",
310 ];
311 TRANSIENT.iter().any(|m| s.contains(m))
312}
313
314fn push_branches(
318 token: &str,
319 owner: &str,
320 repo_name: &str,
321 repo_path: &Path,
322 branches: &[String],
323) -> Result<()> {
324 const MAX_ATTEMPTS: u32 = 3;
325 let mut attempt = 1;
326 loop {
327 let out = push_command(token, owner, repo_name, repo_path, branches)
328 .output()
329 .map_err(|e| anyhow!("spawning git push to github.com/{owner}/{repo_name}: {e}"))?;
330 if out.status.success() {
331 return Ok(());
332 }
333 let stderr = String::from_utf8_lossy(&out.stderr);
334 let scrubbed = scrub_token(&stderr, token);
335 if attempt < MAX_ATTEMPTS && is_transient_push_error(&scrubbed) {
336 eprintln!(
337 "⚠ mirror push to github.com/{owner}/{repo_name} hit a transient error \
338 (attempt {attempt}/{MAX_ATTEMPTS}), retrying in {attempt}s…"
339 );
340 std::thread::sleep(std::time::Duration::from_secs(attempt as u64));
341 attempt += 1;
342 continue;
343 }
344 bail!(
345 "git push to github.com/{owner}/{repo_name} failed: {}",
346 scrubbed.trim()
347 );
348 }
349}
350
351#[allow(clippy::too_many_arguments)]
354pub fn mirror(
355 token: &str,
356 owner: &str,
357 repo_name: &str,
358 private: bool,
359 repo_path: &Path,
360 branches: &[String],
361 out: &mut dyn Write,
362) -> Result<()> {
363 ensure_repo(token, owner, repo_name, private)?;
364 push_branches(token, owner, repo_name, repo_path, branches)?;
365 let visibility_word = if private { "private" } else { "public" };
366 writeln!(
367 out,
368 "✓ mirrored → github.com/{owner}/{repo_name} ({visibility_word} {})",
369 branches.join(",")
370 )
371 .ok();
372 out.flush().ok();
373 Ok(())
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 const TOKEN: &str = "ghp_SECRET_do_not_leak_0123456789";
381
382 #[test]
383 fn create_body_shape() {
384 let b = create_body("custom-name", true);
385 assert_eq!(b["name"], "custom-name");
386 assert_eq!(b["private"], true);
387
388 let b2 = create_body("pub-repo", false);
389 assert_eq!(b2["name"], "pub-repo");
390 assert_eq!(b2["private"], false);
391 }
392
393 #[test]
394 fn branches_to_mirror_matches_configured_heads() {
395 let updated = vec![
396 "refs/heads/main".to_string(),
397 "refs/heads/feature".to_string(),
398 "refs/tags/v1".to_string(),
399 ];
400 let configured = vec!["main".to_string(), "release".to_string()];
401 assert_eq!(branches_to_mirror(&updated, &configured), vec!["main"]);
404 }
405
406 #[test]
407 fn branches_to_mirror_empty_when_nothing_matches() {
408 let updated = vec!["refs/heads/dev".to_string()];
409 let configured = vec!["main".to_string()];
410 assert!(branches_to_mirror(&updated, &configured).is_empty());
411 }
412
413 #[test]
414 fn push_command_keeps_token_out_of_argv_and_url() {
415 let cmd = push_command(
416 TOKEN,
417 "some-org",
418 "custom-name",
419 Path::new("/srv/repos/app.git"),
420 &["main".to_string()],
421 );
422
423 let args: Vec<String> = cmd
426 .get_args()
427 .map(|a| a.to_string_lossy().into_owned())
428 .collect();
429 for a in &args {
430 assert!(!a.contains(TOKEN), "token leaked into argv: {a}");
431 assert!(!a.contains('@'), "arg looks like inline credentials: {a}");
432 }
433 assert!(
435 args.iter()
436 .any(|a| a == "https://github.com/some-org/custom-name.git"),
437 "expected tokenless push URL in argv, got {args:?}"
438 );
439
440 let envs: Vec<(String, Option<String>)> = cmd
442 .get_envs()
443 .map(|(k, v)| {
444 (
445 k.to_string_lossy().into_owned(),
446 v.map(|v| v.to_string_lossy().into_owned()),
447 )
448 })
449 .collect();
450 let auth = envs
451 .iter()
452 .find(|(k, _)| k == "GIT_ARK_TOKEN")
453 .and_then(|(_, v)| v.clone());
454 assert_eq!(auth, Some(TOKEN.to_string()));
455 for (k, v) in &envs {
456 if k == "GIT_ARK_TOKEN" {
457 continue;
458 }
459 if let Some(v) = v {
460 assert!(!v.contains(TOKEN), "token leaked into env {k}={v}");
461 }
462 }
463 }
464
465 #[test]
466 fn scrub_token_removes_literal_token() {
467 let raw = format!(
471 "fatal: unable to access repo: Authorization: Bearer {TOKEN} — bad credentials"
472 );
473 let scrubbed = scrub_token(&raw, TOKEN);
474 assert!(
475 !scrubbed.contains(TOKEN),
476 "token survived scrub: {scrubbed}"
477 );
478 assert!(
479 scrubbed.contains("***"),
480 "expected redaction marker: {scrubbed}"
481 );
482 }
483
484 #[test]
485 fn has_workflow_scope_true_when_scope_present() {
486 let c = TokenCheck {
487 valid: true,
488 login: Some("me".to_string()),
489 scopes: Some(vec!["repo".to_string(), "workflow".to_string()]),
490 repo: RepoAccess::Absent,
491 needs_workflow: true,
492 };
493 assert_eq!(c.has_workflow_scope(), Some(true));
494 }
495
496 #[test]
497 fn has_workflow_scope_false_when_scope_missing() {
498 let c = TokenCheck {
499 valid: true,
500 login: Some("me".to_string()),
501 scopes: Some(vec!["repo".to_string()]),
502 repo: RepoAccess::Absent,
503 needs_workflow: true,
504 };
505 assert_eq!(c.has_workflow_scope(), Some(false));
506 }
507
508 #[test]
509 fn has_workflow_scope_none_when_scopes_uninspectable() {
510 let c = TokenCheck {
514 valid: true,
515 login: Some("me".to_string()),
516 scopes: None,
517 repo: RepoAccess::Absent,
518 needs_workflow: true,
519 };
520 assert_eq!(c.has_workflow_scope(), None);
521 }
522
523 #[test]
524 fn push_failure_scrubs_token_from_stderr() {
525 let err = push_branches(
529 TOKEN,
530 "owner",
531 "repo",
532 Path::new("/nonexistent/git-ark/repo.git"),
533 &["main".to_string()],
534 )
535 .unwrap_err();
536 let msg = format!("{err:#}");
537 assert!(!msg.contains(TOKEN), "token leaked in push error: {msg}");
538 }
539
540 #[test]
541 fn transient_push_error_matches_github_500() {
542 let stderr = "remote: Internal Server Error\n \
545 ! [remote rejected] main -> main (Internal Server Error)";
546 assert!(is_transient_push_error(stderr));
547 }
548
549 #[test]
550 fn transient_push_error_matches_5xx_and_network() {
551 for s in [
552 "error: RPC failed; HTTP 502 curl 22",
553 "The requested URL returned error: 503",
554 "fatal: unable to access '...': Could not resolve host: github.com",
555 "Failed to connect to github.com port 443: Connection timed out",
556 "GnuTLS recv error (-54): Error in the pull function.",
557 ] {
558 assert!(is_transient_push_error(s), "should be transient: {s}");
559 }
560 }
561
562 #[test]
563 fn non_transient_push_errors_do_not_retry() {
564 for s in [
567 "remote: Support for password authentication was removed.\nfatal: Authentication failed",
568 "remote: Repository not found.\nfatal: repository not found",
569 "! [rejected] main -> main (non-fast-forward)\nUpdates were rejected because the tip is behind",
570 "error: failed to push some refs",
571 ] {
572 assert!(!is_transient_push_error(s), "should NOT be transient: {s}");
573 }
574 }
575}