johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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----");
}
}