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/// Push the branches to the mirror. On failure, git's stderr is scrubbed of the
289/// token (defense-in-depth) before being surfaced.
290fn 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/// Mirror `branches` of the bare repo at `repo_path` to `github.com/{owner}/{repo_name}`,
312/// creating the (private-by-default) GitHub repo first if it doesn't exist.
313#[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        // Only `main` was both updated and configured. `feature` (updated, not
362        // configured), `release` (configured, not updated), and the tag are out.
363        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        // No argument carries the token, and none carries inline `user:pass@`
384        // URL credentials.
385        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        // The push URL is present and tokenless.
394        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        // The token value rides ONLY in GIT_ARK_TOKEN, and nowhere else in env.
401        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        // A stderr line that literally embeds the token (as git could if it
428        // echoed the extraheader) must come back with the token gone and the
429        // redaction marker present.
430        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        // A fine-grained PAT gets no `X-OAuth-Scopes` header at all — that's
471        // un-introspectable, not "no scopes", so it must read `None` rather
472        // than `Some(false)` (which would wrongly read as a hard failure).
473        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        // Drive a real, guaranteed-to-fail push at a bogus local path so we
486        // exercise the non-zero-exit branch, then assert the surfaced error
487        // never contains the token. `.git-ark-nonexistent` is not a repo.
488        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}