chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

123 lines · 3.7 KB Raw History
//! git smart HTTP, read only, by `git upload-pack --stateless-rpc`.

use crate::{App, AppError, repo_name};
use anyhow::Context;
use axum::body::{Body, Bytes};
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::Response;
use knotless::git::Git;
use serde::Deserialize;
use std::io::Read;
use std::process::Stdio;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio_util::io::ReaderStream;

#[derive(Deserialize)]
pub struct Service {
    service: Option<String>,
}

fn protocol(headers: &HeaderMap) -> String {
    headers
        .get("git-protocol")
        .and_then(|v| v.to_str().ok())
        .unwrap_or_default()
        .to_string()
}

fn command(git: &Git, protocol: &str, advertise: bool) -> Command {
    let mut cmd = Command::new("git");
    cmd.arg("upload-pack").arg("--stateless-rpc");
    if advertise {
        cmd.arg("--http-backend-info-refs");
    }
    cmd.arg(&git.dir)
        .env("GIT_PROTOCOL", protocol)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .kill_on_drop(true);
    cmd
}

fn pkt(line: &str) -> String {
    format!("{:04x}{line}", line.len() + 4)
}

pub async fn info_refs(
    State(app): State<Arc<App>>,
    Path((authority, repo)): Path<(String, String)>,
    Query(q): Query<Service>,
    headers: HeaderMap,
) -> Result<Response, AppError> {
    if q.service.as_deref() != Some("git-upload-pack") {
        return Err(AppError(
            StatusCode::FORBIDDEN,
            "read only: push with knotless:// instead".into(),
        ));
    }
    let git = app.mirror(&authority, repo_name(&repo)).await?;
    let protocol = protocol(&headers);
    let out = command(&git, &protocol, true)
        .output()
        .await
        .context("running git upload-pack")
        .map_err(AppError::internal)?;
    let mut body = Vec::new();
    if !protocol.contains("version=2") {
        body.extend(pkt("# service=git-upload-pack\n").as_bytes());
        body.extend(b"0000");
    }
    body.extend(out.stdout);
    Ok(Response::builder()
        .header(
            header::CONTENT_TYPE,
            "application/x-git-upload-pack-advertisement",
        )
        .header(header::CACHE_CONTROL, "no-cache")
        .body(Body::from(body))
        .expect("static headers are valid"))
}

pub async fn upload_pack(
    State(app): State<Arc<App>>,
    Path((authority, repo)): Path<(String, String)>,
    headers: HeaderMap,
    body: Bytes,
) -> Result<Response, AppError> {
    let input = if headers
        .get(header::CONTENT_ENCODING)
        .is_some_and(|v| v == "gzip")
    {
        let mut out = Vec::new();
        flate2::read::GzDecoder::new(&body[..])
            .read_to_end(&mut out)
            .map_err(|e| AppError(StatusCode::BAD_REQUEST, format!("bad gzip body: {e}")))?;
        out
    } else {
        body.to_vec()
    };
    let git = app.mirror(&authority, repo_name(&repo)).await?;
    let mut child = command(&git, &protocol(&headers), false)
        .spawn()
        .context("running git upload-pack")
        .map_err(AppError::internal)?;
    let mut stdin = child.stdin.take().expect("stdin is piped");
    tokio::spawn(async move {
        let _ = stdin.write_all(&input).await;
    });
    let stdout = child.stdout.take().expect("stdout is piped");
    // The child is reaped when the stream ends; kill_on_drop covers a
    // client that hangs up first.
    tokio::spawn(async move {
        let _ = child.wait().await;
    });
    Ok(Response::builder()
        .header(header::CONTENT_TYPE, "application/x-git-upload-pack-result")
        .header(header::CACHE_CONTROL, "no-cache")
        .body(Body::from_stream(ReaderStream::new(stdout)))
        .expect("static headers are valid"))
}