~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
125 lines · 4.6 KB Raw History
//! A repository's files at a revision as one download, `.tar.gz` or
//! `.zip`, as `git archive` writes it from the mirror.

use super::{Repo, RestPath, Result, cache_for, not_found};
use crate::{App, AppError};
use anyhow::Context;
use axum::body::Body;
use axum::extract::State;
use axum::http::{HeaderMap, HeaderValue, header};
use axum::response::Response;
use knotless::read;
use std::process::Stdio;
use std::sync::Arc;
use tokio::process::{Child, Command};
use tokio_util::io::ReaderStream;

/// How many archives are written at once: each reads the whole tree and
/// compresses it, so the rest wait their turn.
static ARCHIVING: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);

/// How long an archive may take to write, its client reading it: past
/// that, git is killed and the download ends cut short, so that clients
/// that stall cannot keep the rest waiting.
const WRITING: std::time::Duration = std::time::Duration::from_secs(10 * 60);

/// The kinds of archive, by the ending that asks for each: `git archive`'s
/// name for it, and its media type.
const KINDS: [(&str, &str, &str); 2] = [
    (".tar.gz", "tar.gz", "application/gzip"),
    (".zip", "zip", "application/zip"),
];

/// `/{authority}/{repo}/archive/{rev}.tar.gz` (or `.zip`): the files at
/// `rev`, a branch, tag or commit, in a folder named for the repository
/// and the revision.
pub async fn archive(
    State(app): State<Arc<App>>,
    headers: HeaderMap,
    path: RestPath,
) -> Result<Response> {
    let viewer = crate::login::viewer(&app, &headers);
    let (made, touched) = crate::private::reading(viewer, None, archived(&app, path)).await;
    let mut res = made?;
    if touched {
        res = crate::private::unshared(res);
    }
    res.headers_mut()
        .append(header::VARY, HeaderValue::from_static("cookie"));
    Ok(res)
}

async fn archived(
    app: &App,
    axum::extract::Path((authority, name, rest)): RestPath,
) -> Result<Response> {
    let repo = Repo::open(app, &authority, &name).await?;
    let Some((rev, format, kind)) = KINDS
        .iter()
        .find_map(|(end, format, kind)| Some((rest.strip_suffix(end)?, *format, *kind)))
    else {
        return Err(not_found(anyhow::anyhow!(
            "no archive {rest:?}: ask for one ending .tar.gz or .zip"
        )));
    };
    let rev = read::rev(Some(rev)).map_err(not_found)?;
    let commit = repo.objects()?.resolve(&rev).map_err(not_found)?;
    let folder = filename(&format!("{}-{rev}", repo.name));
    let ending = &rest[rev.len()..];
    let permit = ARCHIVING.acquire().await.expect("never closed");
    let mut child = spawn(&repo.git.dir, format, &folder, &commit).map_err(AppError::internal)?;
    let stdout = child.stdout.take().expect("stdout is piped");
    // The permit is held until git is done writing, or killed at the
    // deadline; kill_on_drop covers a client that hangs up first.
    tokio::spawn(async move {
        let waited = tokio::time::timeout(WRITING, child.wait()).await;
        drop((waited, child, permit));
    });
    let disposition = format!("attachment; filename=\"{folder}{ending}\"");
    Ok(Response::builder()
        .header(header::CONTENT_TYPE, kind)
        .header(header::CONTENT_DISPOSITION, disposition)
        .header(header::CACHE_CONTROL, cache_for(&rev))
        .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
        .body(Body::from_stream(ReaderStream::new(stdout)))
        .expect("the headers are valid"))
}

/// `git archive` writing `commit` from `dir` as `format`, its files in
/// `folder`.
fn spawn(dir: &std::path::Path, format: &str, folder: &str, commit: &str) -> anyhow::Result<Child> {
    let child = Command::new("git")
        .arg("--git-dir")
        .arg(dir)
        .args(["archive", &format!("--format={format}")])
        .arg(format!("--prefix={folder}/"))
        .arg(commit)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .kill_on_drop(true)
        .spawn();
    child.context("running git archive")
}

/// `name` as a file's name: what is not a letter, a digit, `.`, `_` or
/// `-` becomes `-`, so a branch's `/` and anything a header would not
/// carry are left out.
fn filename(name: &str) -> String {
    name.chars()
        .map(|c| match c {
            'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => c,
            _ => '-',
        })
        .collect()
}

#[cfg(test)]
mod tests {
    #[test]
    fn filenames_keep_only_what_a_header_carries() {
        assert_eq!(super::filename("tally-web/mermaid"), "tally-web-mermaid");
        assert_eq!(super::filename("a-v1.0_b"), "a-v1.0_b");
        assert_eq!(super::filename("x-\"é\""), "x----");
    }
}