OXYGENHUB

Coverage / crates/hub/src/mirror.rs 81.2%

138 of 170 lines run, 23 of 55 functions · run #529 on main · run missed

1
//! The read-only Git mirror (hub spec §14): the default bookmark exported
2
//! with oxy's own `git export` into `<data>/mirrors/<owner>/<name>.git`
3
//! after every update, and served over Git's smart HTTP by `git
4
//! http-backend` behind the hub's access checks. Pushing to it is refused.
5
6
use crate::auth::{self, Access};
7
use crate::db::{self, repos::RepoRow};
8
use crate::repos::RepoName;
9
use crate::Shared;
10
use axum::body::Bytes;
11
use axum::extract::{Path, RawQuery, State};
12
use axum::http::{header, HeaderMap, Method, StatusCode};
13
use axum::response::{IntoResponse, Response};
14
use std::path::PathBuf;
15
use tokio::io::AsyncWriteExt;
16
1710×
pub fn mirror_dir(app: &Shared, repo: &RepoName) -> PathBuf {
1810×
    app.config.data.join("mirrors").join(&repo.owner).join(format!("{}.git", repo.name))
1910×
}
20
21
/// Export the default bookmark into the mirror, if the mirror is on.
22223×
pub async fn refresh(app: &Shared, row: &RepoRow) -> Result<(), String> {
23223×
    if !row.git_mirror {
24221×
        return Ok(());
252×
    }
262×
    let Some(repo) = RepoName::new(&row.owner, &row.name) else { return Ok(()) };
272×
    let dir = mirror_dir(app, &repo);
282×
    let bookmark = row.default_bookmark.clone();
292×
    let result = app
302×
        .repos
312×
        .write(&repo, move |r, _| {
322×
            let mut r = oxy_core::repo::Repo::open(r.root())?;
332×
            r.config.bookmark = bookmark.clone();
342×
            let report = r.git_export_as(&dir, &bookmark)?;
35
            // Point HEAD at the exported branch so clones check it out.
362×
            std::fs::write(dir.join("HEAD"), format!("ref: refs/heads/{bookmark}\n"))?;
372×
            let state = r.view()?.bookmarks.get(&bookmark).map(|s| s.to_hex()).unwrap_or_default();
382×
            Ok((report.exported, state))
392×
        })
402×
        .await;
412×
    match result {
422×
        Ok((_, state)) => {
432×
            db::repos::set_git_mirror_state(&app.db, row.id, &state).await.ok();
442×
            Ok(())
45
        }
460×
        Err(e) => {
470×
            tracing::warn!("git mirror of {}: {e}", row.slug());
480×
            Err(format!("git mirror of {}: {e}", row.slug()))
49
        }
50
    }
51223×
}
52
53
/// Who a Git client is: HTTP Basic with a personal access token as the
54
/// password (any user name), or anonymous.
557×
async fn git_caller(app: &Shared, headers: &HeaderMap) -> Result<Option<db::accounts::Account>, StatusCode> {
567×
    let Some(v) = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()) else { return Ok(None) };
570×
    let token = if let Some(b) = v.strip_prefix("Basic ") {
580×
        let decoded = base64_decode(b.trim()).ok_or(StatusCode::UNAUTHORIZED)?;
590×
        let s = String::from_utf8(decoded).map_err(|_| StatusCode::UNAUTHORIZED)?;
600×
        s.split_once(':').map(|(_, p)| p.to_string()).ok_or(StatusCode::UNAUTHORIZED)?
610×
    } else if let Some(t) = v.strip_prefix("Bearer ") {
620×
        t.trim().to_string()
63
    } else {
640×
        return Err(StatusCode::UNAUTHORIZED);
65
    };
660×
    let tok = db::accounts::token_by_hash(&app.db, &auth::token_hash(&token)).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
670×
    match tok {
680×
        Some(t) if !t.expired(db::now_ms()) && (t.has_scope("repo:read") || t.has_scope("repo:write") || t.has_scope("repo:admin")) => {
690×
            db::accounts::account_by_id(&app.db, t.account_id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
70
        }
710×
        _ => Err(StatusCode::UNAUTHORIZED),
72
    }
737×
}
74
751×
fn base64_decode(s: &str) -> Option<Vec<u8>> {
76
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
771×
    let mut out = Vec::new();
781×
    let mut buf = 0u32;
791×
    let mut bits = 0;
8016×
    for c in s.bytes().filter(|c| *c != b'=') {
81465×
        let v = T.iter().position(|x| *x == c)? as u32;
8215×
        buf = (buf << 6) | v;
8315×
        bits += 6;
8415×
        if bits >= 8 {
8511×
            bits -= 8;
8611×
            out.push((buf >> bits) as u8);
8711×
            buf &= (1 << bits) - 1;
8811×
        }
89
    }
901×
    Some(out)
911×
}
92
930×
fn challenge() -> Response {
940×
    (StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, "Basic realm=\"OxygenHub\"")], "authentication required").into_response()
950×
}
96
97
/// Every smart-HTTP path under `/{owner}/{name}.git/`.
9810×
pub async fn serve(
9910×
    State(app): State<Shared>,
10010×
    Path((owner, name_git, rest)): Path<(String, String, String)>,
10110×
    method: Method,
10210×
    RawQuery(query): RawQuery,
10310×
    headers: HeaderMap,
10410×
    body: Bytes,
10510×
) -> Response {
10610×
    let Some(name) = name_git.strip_suffix(".git") else { return StatusCode::NOT_FOUND.into_response() };
1079×
    let Some(repo) = RepoName::new(&owner, name) else { return StatusCode::NOT_FOUND.into_response() };
1089×
    let query = query.unwrap_or_default();
1099×
    if rest.contains("git-receive-pack") || query.contains("git-receive-pack") {
1101×
        return (StatusCode::FORBIDDEN, "This is a read-only Git mirror of an Oxygen repository; push with oxy instead.").into_response();
1118×
    }
1128×
    let row = match db::repos::get(&app.db, &owner, name).await {
1137×
        Ok(Some(r)) if r.git_mirror => r,
1141×
        _ => return StatusCode::NOT_FOUND.into_response(),
115
    };
1167×
    let account = match git_caller(&app, &headers).await {
1177×
        Ok(a) => a,
1180×
        Err(_) => return challenge(),
119
    };
1207×
    let role = auth::roles::role_for(&app.db, account.as_ref(), &row).await.ok().flatten();
1217×
    if !auth::roles::allows(role, Access::Read) {
1220×
        return if account.is_none() { challenge() } else { StatusCode::NOT_FOUND.into_response() };
1237×
    }
1247×
    let dir = mirror_dir(&app, &repo);
1257×
    if !dir.join("HEAD").exists() {
1260×
        refresh(&app, &row).await.ok();
1277×
    }
1287×
    let root = app.config.data.join("mirrors");
1297×
    let git = std::env::var("OXYHUB_GIT").unwrap_or_else(|_| "git".into());
1307×
    let mut cmd = tokio::process::Command::new(git);
1317×
    cmd.arg("http-backend")
1327×
        .env_clear()
1337×
        .env("PATH", std::env::var("PATH").unwrap_or_default())
1347×
        .env("GIT_PROJECT_ROOT", &root)
1357×
        .env("GIT_HTTP_EXPORT_ALL", "1")
1367×
        .env("PATH_INFO", format!("/{owner}/{name_git}/{rest}"))
1377×
        .env("REQUEST_METHOD", method.as_str())
1387×
        .env("QUERY_STRING", &query)
1397×
        .env("CONTENT_TYPE", headers.get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or(""))
1407×
        .env("CONTENT_LENGTH", body.len().to_string())
1417×
        .env("GIT_CONFIG_NOSYSTEM", "1")
1427×
        .env("HOME", &root)
1437×
        .stdin(std::process::Stdio::piped())
1447×
        .stdout(std::process::Stdio::piped())
1457×
        .stderr(std::process::Stdio::piped());
1467×
    if let Some(enc) = headers.get(header::CONTENT_ENCODING).and_then(|v| v.to_str().ok()) {
1470×
        cmd.env("HTTP_CONTENT_ENCODING", enc);
1487×
    }
1497×
    if let Some(p) = headers.get("git-protocol").and_then(|v| v.to_str().ok()) {
1507×
        cmd.env("GIT_PROTOCOL", p);
1517×
    }
1527×
    let mut child = match cmd.spawn() {
1537×
        Ok(c) => c,
1540×
        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("git http-backend: {e}")).into_response(),
155
    };
1567×
    if let Some(mut stdin) = child.stdin.take() {
1577×
        let _ = stdin.write_all(&body).await;
1580×
    }
1597×
    let out = match child.wait_with_output().await {
1607×
        Ok(o) => o,
1610×
        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
162
    };
1637×
    cgi_response(&out.stdout)
16410×
}
165
166
/// Turn CGI output (headers, blank line, body) into a response.
1678×
fn cgi_response(raw: &[u8]) -> Response {
1688×
    let split =
1691196×
        raw.windows(4).position(|w| w == b"\r\n\r\n").map(|i| (i, 4)).or_else(|| raw.windows(2).position(|w| w == b"\n\n").map(|i| (i, 2)));
1708×
    let Some((at, sep)) = split else { return (StatusCode::BAD_GATEWAY, "malformed CGI output").into_response() };
1718×
    let head = String::from_utf8_lossy(&raw[..at]);
1728×
    let body = raw[at + sep..].to_vec();
1738×
    let mut status = StatusCode::OK;
1748×
    let mut builder = Response::builder();
17530×
    for line in head.lines() {
17630×
        let Some((k, v)) = line.split_once(':') else { continue };
17730×
        let (k, v) = (k.trim(), v.trim());
17830×
        if k.eq_ignore_ascii_case("status") {
1791×
            if let Some(code) = v.split_whitespace().next().and_then(|c| c.parse::<u16>().ok()) {
1801×
                status = StatusCode::from_u16(code).unwrap_or(StatusCode::OK);
1811×
            }
18229×
        } else {
18329×
            builder = builder.header(k, v);
18429×
        }
185
    }
1868×
    builder.status(status).body(axum::body::Body::from(body)).unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1878×
}
188
189
#[cfg(test)]
190
mod tests {
191
    use super::*;
192
193
    #[test]
1941×
    fn base64_and_cgi() {
1951×
        assert_eq!(base64_decode("dXNlcjpzM2NyZXQ=").unwrap(), b"user:s3cret");
1961×
        let r = cgi_response(b"Status: 404 Not Found\r\nContent-Type: text/plain\r\n\r\nnope");
1971×
        assert_eq!(r.status(), StatusCode::NOT_FOUND);
1981×
    }
199
}