g1t

syntaqx/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/services/repos/src/git_http.rs

249 lines8,411 bytes
//! Git over HTTPS: the smart HTTP remote at `/<namespace>/<repo>.git`,
//! proxied to the git store with a short-lived token.

use g1t_contracts::identity::GitCredentialsArgs;
use g1t_contracts::repos::{GitAccess, GitService, RepoPath};
use g1t_contracts::{FailureCode, Outcome, Viewer};
use worker::js_sys::Uint8Array;
use worker::{Fetch, Fetcher, Headers, Method, Request, RequestInit, Response, Result, Url};

const ENDPOINTS: [&str; 3] = ["info/refs", "git-upload-pack", "git-receive-pack"];
const FORWARDED_HEADERS: [&str; 5] = [
    "accept",
    "content-encoding",
    "content-type",
    "git-protocol",
    "user-agent",
];

/// A git request, parsed from its URL.
pub struct GitRequest {
    pub path: RepoPath,
    pub endpoint: &'static str,
    pub service: GitService,
}

/// Parses `/<namespace>/<name>[.git]/<endpoint>`, or returns `None` if the
/// request is not git's.
pub fn parse(url: &Url) -> Option<GitRequest> {
    let path = url.path().strip_prefix('/')?;
    let endpoint = ENDPOINTS
        .into_iter()
        .find(|endpoint| path.ends_with(&format!("/{endpoint}")))?;
    let repo = &path[..path.len() - endpoint.len() - 1];
    let (namespace, name) = repo.split_once('/')?;
    let name = name.strip_suffix(".git").unwrap_or(name);
    if namespace.is_empty() || name.is_empty() || name.contains('/') {
        return None;
    }
    let service = if endpoint == "info/refs" {
        url.query_pairs()
            .find(|(key, _)| key == "service")
            .map(|(_, value)| value.into_owned())?
    } else {
        endpoint.to_owned()
    };
    let service = match service.as_str() {
        "git-upload-pack" => GitService::UploadPack,
        "git-receive-pack" => GitService::ReceivePack,
        _ => return None,
    };
    Some(GitRequest {
        path: RepoPath {
            namespace: namespace.to_owned(),
            name: name.to_owned(),
        },
        endpoint,
        service,
    })
}

/// The user named by an HTTP Basic `Authorization` header, as git sends it.
pub async fn viewer(request: &Request, identity: &Fetcher) -> Result<Viewer> {
    let Some(header) = request.headers().get("authorization")? else {
        return Ok(None);
    };
    let Some((scheme, encoded)) = header.split_once(' ') else {
        return Ok(None);
    };
    if !scheme.eq_ignore_ascii_case("basic") {
        return Ok(None);
    }
    let Some(decoded) = decode_base64(encoded.trim()) else {
        return Ok(None);
    };
    let Some((username, secret)) = decoded.split_once(':') else {
        return Ok(None);
    };
    g1t_kit::call(
        identity,
        "user_for_git_credentials",
        &GitCredentialsArgs {
            username: username.to_owned(),
            secret: secret.to_owned(),
        },
    )
    .await
}

/// Standard base64 to a UTF-8 string, or `None` if either step fails.
fn decode_base64(input: &str) -> Option<String> {
    let mut bytes = Vec::with_capacity(input.len() * 3 / 4);
    let mut buffer = 0u32;
    let mut bits = 0;
    for byte in input.bytes().filter(|byte| *byte != b'=') {
        let value = match byte {
            b'A'..=b'Z' => byte - b'A',
            b'a'..=b'z' => byte - b'a' + 26,
            b'0'..=b'9' => byte - b'0' + 52,
            b'+' => 62,
            b'/' => 63,
            _ => return None,
        };
        buffer = (buffer << 6) | u32::from(value);
        bits += 6;
        if bits >= 8 {
            bits -= 8;
            bytes.push((buffer >> bits) as u8);
        }
    }
    String::from_utf8(bytes).ok()
}

/// The response for a refused git request. Anonymous callers are asked to
/// authenticate, which is what makes git prompt for credentials.
pub fn refuse<T>(outcome: Outcome<T>) -> Result<Response> {
    let Outcome::Fail(failure) = outcome else {
        return Response::error("Not found", 404);
    };
    let mut response = Response::error(failure.message, failure.code.http_status())?;
    if failure.code == FailureCode::Unauthenticated {
        response
            .headers_mut()
            .set("www-authenticate", "Basic realm=\"g1t\"")?;
    }
    Ok(response)
}

const ZERO_ID: &str = "0000000000000000000000000000000000000000";
const HEADS: &str = "refs/heads/";

/// The branches a push asks to move, as `(branch, new commit)`, read from
/// the commands at the start of a receive-pack request. Deletions and refs
/// that are not branches are left out.
fn pushed_branches(body: &[u8]) -> Vec<(String, String)> {
    let mut branches = Vec::new();
    let mut position = 0;
    // Commands are pkt-lines; a flush packet ends them and the pack follows.
    while let Some(length) = body
        .get(position..position + 4)
        .and_then(|hex| std::str::from_utf8(hex).ok())
        .and_then(|hex| usize::from_str_radix(hex, 16).ok())
    {
        if length < 4 || position + length > body.len() {
            break;
        }
        let line = &body[position + 4..position + length];
        position += length;
        // `<old> <new> <ref>`, and on the first command a NUL then capabilities.
        let line = line.split(|byte| *byte == 0).next().unwrap_or_default();
        let Ok(line) = std::str::from_utf8(line) else {
            continue;
        };
        let mut parts = line.trim_end().splitn(3, ' ');
        let (Some(_old), Some(new), Some(name)) = (parts.next(), parts.next(), parts.next()) else {
            continue;
        };
        if let Some(branch) = name.strip_prefix(HEADS)
            && new != ZERO_ID
        {
            branches.push((branch.to_owned(), new.to_owned()));
        }
    }
    branches
}

/// The git store's answer, and what the request asked it to change.
pub struct Forwarded {
    pub response: Response,
    /// For a push: the branches it asks to move and the commits to move
    /// them to. Whether each moved is for the caller to confirm.
    pub pushed: Vec<(String, String)>,
}

/// Sends the request on to the git store and returns its response as is.
pub async fn forward(
    mut request: Request,
    git: &GitRequest,
    access: &GitAccess,
) -> Result<Forwarded> {
    let headers = Headers::new();
    headers.set("authorization", &format!("Bearer {}", access.token))?;
    for name in FORWARDED_HEADERS {
        if let Some(value) = request.headers().get(name)? {
            headers.set(name, &value)?;
        }
    }
    let query = request
        .url()?
        .query()
        .map(|query| format!("?{query}"))
        .unwrap_or_default();
    let mut init = RequestInit::new();
    init.with_method(request.method()).with_headers(headers);
    let mut pushed = Vec::new();
    if request.method() == Method::Post {
        // Pushes are capped at 100 MB by the platform, so buffering is safe.
        let body = request.bytes().await?;
        if git.endpoint == "git-receive-pack" {
            pushed = pushed_branches(&body);
        }
        init.with_body(Some(Uint8Array::from(body.as_slice()).into()));
    }
    let upstream =
        Request::new_with_init(&format!("{}/{}{query}", access.remote, git.endpoint), &init)?;
    Ok(Forwarded {
        response: Fetch::Request(upstream).send().await?,
        pushed,
    })
}

#[cfg(test)]
mod tests {
    use super::{ZERO_ID, pushed_branches};

    fn pkt(payload: &str) -> Vec<u8> {
        format!("{:04x}{payload}", payload.len() + 4).into_bytes()
    }

    #[test]
    fn pushed_branches_are_read_from_the_commands() {
        let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
        let new = "4807077b296e6edbf410d55e72749d3e1170c291";
        let body = [
            pkt(&format!(
                "{old} {new} refs/heads/main\0 report-status side-band-64k\n"
            )),
            pkt(&format!("{ZERO_ID} {new} refs/heads/feature/x\n")),
            pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")),
            pkt(&format!("{ZERO_ID} {new} refs/tags/v1\n")),
            b"0000".to_vec(),
            b"PACK\0\0\0\x02\0\0\0\0".to_vec(),
        ]
        .concat();
        assert_eq!(
            pushed_branches(&body),
            [
                ("main".to_owned(), new.to_owned()),
                ("feature/x".to_owned(), new.to_owned()),
            ]
        );
    }

    #[test]
    fn a_fetch_request_names_no_branches() {
        assert!(
            pushed_branches(b"0032want c71546fcd893ef8b0f57388b65e620d759705dda\n0000").is_empty()
        );
    }
}