Skip to main content

git_ark/
github.rs

1//! Optional private GitHub mirror.
2//!
3//! `mirror` ensures the target GitHub repo exists (creating it under the right
4//! account kind when it doesn't) and pushes the requested branches to it.
5//!
6//! SECURITY — the token must never leak:
7//! * The GitHub REST calls carry the token in the `Authorization` **header**,
8//!   never in a URL or query string, so a formatted request URL is tokenless.
9//! * The `git push` carries the token in an **environment variable**
10//!   (`GIT_CONFIG_VALUE_0`), applied by git as an `http.extraheader` — it is
11//!   never in the push URL or in `argv`, so it can't be seen in `ps`.
12//! * Error messages format only `HTTP {status}` / transport kind, never a
13//!   response body (which could echo request data) and never the token. As
14//!   defense-in-depth, git's stderr is scrubbed of the token before it is used.
15
16use anyhow::{anyhow, bail, Result};
17use serde_json::json;
18use std::io::Write;
19use std::path::Path;
20use std::process::Command;
21
22/// Standard GitHub REST API base.
23const API: &str = "https://api.github.com";
24const API_VERSION: &str = "2022-11-28";
25
26/// Apply the standard GitHub API headers — including the bearer token — to a
27/// request. The token rides in the `Authorization` header only.
28fn 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
35/// Format a ureq error from a GitHub API call without leaking anything.
36///
37/// GitHub API URLs carry no token (it's in a header), so a status code is safe
38/// to surface — but response bodies can echo request data, so we never include
39/// them, and the token is never formatted here regardless.
40fn 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/// A token's access to the mirror's target GitHub repo, from `GET
48/// /repos/{owner}/{repo}`.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum RepoAccess {
51    /// The repo exists; `push`/`admin` come from its `permissions` object.
52    Exists { push: bool, admin: bool },
53    /// `GET` 404'd — the repo doesn't exist yet (git-ark would create it).
54    Absent,
55    /// Anything else — a transport error or a non-404 status.
56    Error(String),
57}
58
59/// The result of preflighting a GitHub token against a repo's mirror config
60/// (`mirror check`): whether it authenticates, what it can see, and whether
61/// it can push `.github/workflows/` if the repo ships any.
62#[derive(Debug, Clone)]
63pub struct TokenCheck {
64    pub valid: bool,
65    pub login: Option<String>,
66    /// Classic PATs return their scopes in the `X-OAuth-Scopes` response
67    /// header; fine-grained PATs omit it entirely, which is why this is
68    /// `Option` rather than an empty `Vec` on the "can't tell" case.
69    pub scopes: Option<Vec<String>>,
70    pub repo: RepoAccess,
71    /// Whether the repo ships `.github/workflows/` — if so, pushing it needs
72    /// the `workflow` scope (classic) or Workflows:write (fine-grained).
73    pub needs_workflow: bool,
74}
75
76impl TokenCheck {
77    /// Whether the token's scopes include `workflow`. `None` when the scopes
78    /// couldn't be introspected at all (a fine-grained PAT) — distinct from
79    /// `Some(false)`, which is a real, checkable absence.
80    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
87/// Preflight `token` against `owner/repo`: does it authenticate, what scopes
88/// does it carry (if introspectable), and what access does it have to the
89/// repo. Never returns an `Err` — a failed call surfaces as `valid: false` or
90/// `RepoAccess::Error` so the caller can report every check even when one of
91/// the two calls fails.
92pub 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            // The header must be read before `into_string()`, which consumes
97            // the response.
98            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
148/// The JSON body for creating a repo. Extracted so its shape is unit-testable.
149fn create_body(repo_name: &str, private: bool) -> serde_json::Value {
150    json!({ "name": repo_name, "private": private })
151}
152
153/// From the just-updated refs, the short branch names the policy opts into the
154/// mirror: a `refs/heads/<b>` update whose `<b>` appears in `configured`.
155pub 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
164/// Is `owner` a GitHub Organization (vs. a User)? Reads `type` from
165/// `GET /users/{owner}` — orgs and users take different repo-create endpoints.
166fn 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
179/// The `login` of the account the token authenticates as (`GET /user`).
180fn 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
196/// Create the mirror repo under the correct account kind (org vs. user).
197fn 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        // A non-org owner means `/user/repos`, which ALWAYS creates under the
202        // token's own account — never under some other user. Only proceed when
203        // `owner` IS the token account; otherwise creating here would silently
204        // make a repo under the wrong account and the later push to
205        // github.com/{owner}/{repo} would fail. GitHub logins are
206        // case-insensitive, so compare that way.
207        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
227/// Ensure the mirror repo exists — `GET /repos/{owner}/{repo}`; a 404 means we
228/// create it, any other error is surfaced.
229fn 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
241/// Build the `git push` command that mirrors `branches` to the GitHub repo.
242///
243/// SECURITY: the token VALUE is passed ONLY through the `GIT_ARK_TOKEN`
244/// environment variable. Git authenticates via an inline credential helper that
245/// reads the token from that env var and supplies it as the HTTPS password for
246/// the `x-access-token` user (GitHub's git-over-HTTPS token auth). The token
247/// value never appears in the push URL or in `argv`, so it can't surface in
248/// `ps` / the process table. (The helper *script* is in argv, but it only
249/// references the env var by name — it never contains the token itself.)
250fn 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        // Clear any inherited credential helpers, then supply auth via an inline
261        // helper that reads the token from GIT_ARK_TOKEN — keeps the token value
262        // out of argv and the URL.
263        .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        // Also mirror annotated tags reachable from the pushed branches — so a
269        // release tag (e.g. `v0.2.0`) reaches GitHub and triggers the release
270        // workflow. `--follow-tags` only pushes annotated, reachable, not-yet-
271        // present tags, so it never surprises with lightweight/unrelated tags.
272        .arg("--follow-tags")
273        .arg(format!("https://github.com/{owner}/{repo_name}.git"))
274        .args(branches)
275        .env("GIT_ARK_TOKEN", token)
276        // Never block the post-receive hook on an interactive credential
277        // prompt — a bad/expired token must fail fast, not hang the push.
278        .env("GIT_TERMINAL_PROMPT", "0");
279    cmd
280}
281
282/// Remove the token from text before it is surfaced (defense-in-depth for the
283/// unlikely case git echoes the `extraheader` value into its stderr).
284fn scrub_token(text: &str, token: &str) -> String {
285    text.replace(token, "***")
286}
287
288/// Does a scrubbed `git push` stderr look like a *transient* server/network
289/// failure worth retrying? This is an allowlist of clear transient signals —
290/// 5xx server errors and connection failures. It deliberately does NOT match
291/// auth failures, 404s, or non-fast-forward rejections, which retrying can't
292/// fix. Note a GitHub 500 arrives as `[remote rejected] … (Internal Server
293/// Error)`, so we can't key off "rejected" — we key off the 5xx text itself.
294fn 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", // e.g. "error: RPC failed; HTTP 502"
303        "the requested url returned error: 5", // curl-style 5xx
304        "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
314/// Push the branches to the mirror, retrying a couple of times on transient
315/// server/network failures (e.g. a GitHub 500). On failure git's stderr is
316/// scrubbed of the token (defense-in-depth) before being surfaced or matched.
317fn 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/// Mirror `branches` of the bare repo at `repo_path` to `github.com/{owner}/{repo_name}`,
352/// creating the (private-by-default) GitHub repo first if it doesn't exist.
353#[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        // Only `main` was both updated and configured. `feature` (updated, not
402        // configured), `release` (configured, not updated), and the tag are out.
403        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        // No argument carries the token, and none carries inline `user:pass@`
424        // URL credentials.
425        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        // The push URL is present and tokenless.
434        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        // The token value rides ONLY in GIT_ARK_TOKEN, and nowhere else in env.
441        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        // A stderr line that literally embeds the token (as git could if it
468        // echoed the extraheader) must come back with the token gone and the
469        // redaction marker present.
470        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        // A fine-grained PAT gets no `X-OAuth-Scopes` header at all — that's
511        // un-introspectable, not "no scopes", so it must read `None` rather
512        // than `Some(false)` (which would wrongly read as a hard failure).
513        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        // Drive a real, guaranteed-to-fail push at a bogus local path so we
526        // exercise the non-zero-exit branch, then assert the surfaced error
527        // never contains the token. `.git-ark-nonexistent` is not a repo.
528        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        // The exact shape of the GitHub 500 we hit — note it says "rejected",
543        // so the match must key off the 5xx text, not "rejected".
544        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        // Auth, 404, and non-fast-forward must fail fast — retrying can't fix
565        // them, and a 404/non-ff must never be mistaken for the 500 case.
566        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}