]> quire.kejadlen.dev Git - quire.git/commitdiff
Use HTTP Basic auth for mirror push to GitHub
authorAlpha Chen <alpha@kejadlen.dev>
Mon, 27 Apr 2026 19:02:42 +0000 (12:02 -0700)
committerAlpha Chen <alpha@kejadlen.dev>
Mon, 27 Apr 2026 19:38:18 +0000 (12:38 -0700)
GitHub's git smart HTTP endpoint rejects `Authorization: Bearer <PAT>`
even though the REST API accepts it. The 401 made git fall back to
prompting for a username, which fails in a post-receive hook (no TTY).

Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
Cargo.lock
Cargo.toml
src/quire.rs

index a5dd126732a17824c4817cd64ac7f671cf8fcd63..e6513d6a23333730d946a58feeb1fa4e6c6fbf5c 100644 (file)
@@ -1855,6 +1855,7 @@ version = "0.1.0"
 dependencies = [
  "assert_cmd",
  "axum",
+ "base64",
  "clap",
  "clap_complete",
  "fs-err",
index 9f18de7787629b5da7340d8f84083902d769cd35..ae0fbd6d07c2a12d5363e2d02571d4be3d1c8d5f 100644 (file)
@@ -9,6 +9,7 @@ path = "src/bin/quire/main.rs"
 
 [dependencies]
 axum = "*"
+base64 = "*"
 mlua = { version = "*", features = ["lua54", "serde", "vendored"] }
 regex = "*"
 serde = { version = "*", features = ["derive"] }
index 0a19669687ed482fbbdfe02187211b24015a1d75..c1c363a67256cad229d8068cc146b92ab10bf476 100644 (file)
@@ -159,10 +159,7 @@ impl Repo {
             .git(&args)
             .env("GIT_CONFIG_COUNT", "1")
             .env("GIT_CONFIG_KEY_0", "http.extraHeader")
-            .env(
-                "GIT_CONFIG_VALUE_0",
-                format!("Authorization: Bearer {token}"),
-            )
+            .env("GIT_CONFIG_VALUE_0", github_auth_header(token))
             .stdout(std::process::Stdio::null())
             .status()
             .map_err(crate::Error::Io)?;
@@ -219,6 +216,21 @@ impl Repo {
     }
 }
 
+/// Build the `Authorization` header value used to authenticate `git push`
+/// to GitHub over HTTPS.
+///
+/// GitHub's git smart HTTP endpoint (`/info/refs`, `git-receive-pack`)
+/// rejects `Authorization: Bearer <PAT>` with 401, even though the REST
+/// API accepts the same token via Bearer. Git then falls back to
+/// prompting for a username, which fails inside a post-receive hook
+/// because there's no TTY. HTTP Basic with any non-empty username and
+/// the token as the password is the documented form for git push.
+fn github_auth_header(token: &str) -> String {
+    use base64::{Engine, engine::general_purpose::STANDARD};
+    let encoded = STANDARD.encode(format!("x-access-token:{token}"));
+    format!("Authorization: Basic {encoded}")
+}
+
 /// Application runtime context.
 ///
 /// Carries configuration and provides resolved paths to repositories.
@@ -717,6 +729,24 @@ mod tests {
             .to_string()
     }
 
+    #[test]
+    fn github_auth_header_is_basic_with_x_access_token_username() {
+        use base64::{Engine, engine::general_purpose::STANDARD};
+
+        let header = super::github_auth_header("ghp_test");
+        let encoded = header
+            .strip_prefix("Authorization: Basic ")
+            .unwrap_or_else(|| panic!("missing Basic prefix: {header}"));
+        // libcurl rejects header values containing newlines, so the encoded
+        // form must not wrap.
+        assert!(
+            !encoded.contains('\n'),
+            "encoded header value must not wrap: {encoded:?}"
+        );
+        let decoded = STANDARD.decode(encoded).expect("valid base64");
+        assert_eq!(decoded, b"x-access-token:ghp_test");
+    }
+
     #[test]
     fn push_to_mirror_pushes_main_to_file_mirror() {
         let dir = tempfile::tempdir().expect("tempdir");