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 push_branches(
291 token: &str,
292 owner: &str,
293 repo_name: &str,
294 repo_path: &Path,
295 branches: &[String],
296) -> Result<()> {
297 let out = push_command(token, owner, repo_name, repo_path, branches)
298 .output()
299 .map_err(|e| anyhow!("spawning git push to github.com/{owner}/{repo_name}: {e}"))?;
300 if !out.status.success() {
301 let stderr = String::from_utf8_lossy(&out.stderr);
302 let scrubbed = scrub_token(&stderr, token);
303 bail!(
304 "git push to github.com/{owner}/{repo_name} failed: {}",
305 scrubbed.trim()
306 );
307 }
308 Ok(())
309}
310
311#[allow(clippy::too_many_arguments)]
314pub fn mirror(
315 token: &str,
316 owner: &str,
317 repo_name: &str,
318 private: bool,
319 repo_path: &Path,
320 branches: &[String],
321 out: &mut dyn Write,
322) -> Result<()> {
323 ensure_repo(token, owner, repo_name, private)?;
324 push_branches(token, owner, repo_name, repo_path, branches)?;
325 let visibility_word = if private { "private" } else { "public" };
326 writeln!(
327 out,
328 "✓ mirrored → github.com/{owner}/{repo_name} ({visibility_word} {})",
329 branches.join(",")
330 )
331 .ok();
332 out.flush().ok();
333 Ok(())
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 const TOKEN: &str = "ghp_SECRET_do_not_leak_0123456789";
341
342 #[test]
343 fn create_body_shape() {
344 let b = create_body("custom-name", true);
345 assert_eq!(b["name"], "custom-name");
346 assert_eq!(b["private"], true);
347
348 let b2 = create_body("pub-repo", false);
349 assert_eq!(b2["name"], "pub-repo");
350 assert_eq!(b2["private"], false);
351 }
352
353 #[test]
354 fn branches_to_mirror_matches_configured_heads() {
355 let updated = vec![
356 "refs/heads/main".to_string(),
357 "refs/heads/feature".to_string(),
358 "refs/tags/v1".to_string(),
359 ];
360 let configured = vec!["main".to_string(), "release".to_string()];
361 assert_eq!(branches_to_mirror(&updated, &configured), vec!["main"]);
364 }
365
366 #[test]
367 fn branches_to_mirror_empty_when_nothing_matches() {
368 let updated = vec!["refs/heads/dev".to_string()];
369 let configured = vec!["main".to_string()];
370 assert!(branches_to_mirror(&updated, &configured).is_empty());
371 }
372
373 #[test]
374 fn push_command_keeps_token_out_of_argv_and_url() {
375 let cmd = push_command(
376 TOKEN,
377 "some-org",
378 "custom-name",
379 Path::new("/srv/repos/app.git"),
380 &["main".to_string()],
381 );
382
383 let args: Vec<String> = cmd
386 .get_args()
387 .map(|a| a.to_string_lossy().into_owned())
388 .collect();
389 for a in &args {
390 assert!(!a.contains(TOKEN), "token leaked into argv: {a}");
391 assert!(!a.contains('@'), "arg looks like inline credentials: {a}");
392 }
393 assert!(
395 args.iter()
396 .any(|a| a == "https://github.com/some-org/custom-name.git"),
397 "expected tokenless push URL in argv, got {args:?}"
398 );
399
400 let envs: Vec<(String, Option<String>)> = cmd
402 .get_envs()
403 .map(|(k, v)| {
404 (
405 k.to_string_lossy().into_owned(),
406 v.map(|v| v.to_string_lossy().into_owned()),
407 )
408 })
409 .collect();
410 let auth = envs
411 .iter()
412 .find(|(k, _)| k == "GIT_ARK_TOKEN")
413 .and_then(|(_, v)| v.clone());
414 assert_eq!(auth, Some(TOKEN.to_string()));
415 for (k, v) in &envs {
416 if k == "GIT_ARK_TOKEN" {
417 continue;
418 }
419 if let Some(v) = v {
420 assert!(!v.contains(TOKEN), "token leaked into env {k}={v}");
421 }
422 }
423 }
424
425 #[test]
426 fn scrub_token_removes_literal_token() {
427 let raw = format!(
431 "fatal: unable to access repo: Authorization: Bearer {TOKEN} — bad credentials"
432 );
433 let scrubbed = scrub_token(&raw, TOKEN);
434 assert!(
435 !scrubbed.contains(TOKEN),
436 "token survived scrub: {scrubbed}"
437 );
438 assert!(
439 scrubbed.contains("***"),
440 "expected redaction marker: {scrubbed}"
441 );
442 }
443
444 #[test]
445 fn has_workflow_scope_true_when_scope_present() {
446 let c = TokenCheck {
447 valid: true,
448 login: Some("me".to_string()),
449 scopes: Some(vec!["repo".to_string(), "workflow".to_string()]),
450 repo: RepoAccess::Absent,
451 needs_workflow: true,
452 };
453 assert_eq!(c.has_workflow_scope(), Some(true));
454 }
455
456 #[test]
457 fn has_workflow_scope_false_when_scope_missing() {
458 let c = TokenCheck {
459 valid: true,
460 login: Some("me".to_string()),
461 scopes: Some(vec!["repo".to_string()]),
462 repo: RepoAccess::Absent,
463 needs_workflow: true,
464 };
465 assert_eq!(c.has_workflow_scope(), Some(false));
466 }
467
468 #[test]
469 fn has_workflow_scope_none_when_scopes_uninspectable() {
470 let c = TokenCheck {
474 valid: true,
475 login: Some("me".to_string()),
476 scopes: None,
477 repo: RepoAccess::Absent,
478 needs_workflow: true,
479 };
480 assert_eq!(c.has_workflow_scope(), None);
481 }
482
483 #[test]
484 fn push_failure_scrubs_token_from_stderr() {
485 let err = push_branches(
489 TOKEN,
490 "owner",
491 "repo",
492 Path::new("/nonexistent/git-ark/repo.git"),
493 &["main".to_string()],
494 )
495 .unwrap_err();
496 let msg = format!("{err:#}");
497 assert!(!msg.contains(TOKEN), "token leaked in push error: {msg}");
498 }
499}