Krolyxon
Commit 3cb9866

ferreniumsrchandlersarchive.rs

108 lines · 3.7 KB · Rust

1//! `/{repo}/archive/{rev}.tar.gz|.zip` — a snapshot of a revision,
2//! streamed while it is being written.
3
4use axum::body::Body;
5use axum::http::{HeaderValue, StatusCode, header};
6use axum::response::{IntoResponse, Response};
7use tokio_util::io::{ReaderStream, SyncIoBridge};
8
9use super::repo::{Ctx, set_cache_headers};
10use crate::config::SnapshotFormat;
11use crate::git::archive::{self, Format};
12use crate::git::refs;
13use crate::render::encode_path;
14
15const PIPE_BYTES: usize = 64 * 1024;
16
17pub fn serve(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
18 let Ok(name) = String::from_utf8(segments.join(&b'/')) else {
19 return ctx.not_found("UNKNOWN REVISION");
20 };
21 let (rev_name, format) = if let Some(stem) = name.strip_suffix(".tar.gz") {
22 (stem, Format::TarGz)
23 } else if let Some(stem) = name.strip_suffix(".zip") {
24 (stem, Format::Zip)
25 } else {
26 return ctx.not_found("UNKNOWN ARCHIVE FORMAT");
27 };
28 let wanted = match format {
29 Format::TarGz => SnapshotFormat::TarGz,
30 Format::Zip => SnapshotFormat::Zip,
31 };
32 if !ctx.config.repos.snapshots.contains(&wanted) {
33 return ctx.not_found("ARCHIVE FORMAT DISABLED");
34 }
35 let Some(rev) = refs::resolve(&ctx.git, rev_name) else {
36 return ctx.not_found("UNKNOWN REVISION");
37 };
38 let etag = format!("W/\"{}-{}\"", rev.commit, format.extension());
39 let immutable = rev.is_immutable();
40 if let Some(res) = ctx.not_modified(&etag, immutable) {
41 return res;
42 }
43
44 let base = archive_name(&ctx.repo.name, rev_name);
45 let prefix = format!("{base}/");
46 let (reader, writer) = tokio::io::duplex(PIPE_BYTES);
47 let gix = ctx.repo.gix.clone();
48 let permit = ctx.take_heavy_permit();
49 let repo_name = ctx.repo.name.clone();
50 let commit = rev.commit;
51 tokio::task::spawn_blocking(move || {
52 let _permit = permit;
53 let repo = crate::git::handle(&gix);
54 let out = std::io::BufWriter::with_capacity(PIPE_BYTES, SyncIoBridge::new(writer));
55 if let Err(e) = archive::write(&repo, commit, format, &prefix, out) {
56 // Usually the client went away.
57 tracing::debug!("{repo_name}: archive of {commit} stopped: {e:#}");
58 }
59 });
60
61 let mut res = Body::from_stream(ReaderStream::new(reader)).into_response();
62 *res.status_mut() = StatusCode::OK;
63 let headers = res.headers_mut();
64 headers.insert(
65 header::CONTENT_TYPE,
66 HeaderValue::from_static(format.content_type()),
67 );
68 let file = format!("{base}.{}", format.extension());
69 let disposition = format!(
70 "attachment; filename=\"{file}\"; filename*=UTF-8''{}",
71 encode_path(&file)
72 );
73 if let Ok(value) = HeaderValue::from_str(&disposition) {
74 headers.insert(header::CONTENT_DISPOSITION, value);
75 }
76 set_cache_headers(&mut res, &etag, immutable);
77 res
78}
79
80/// `{repo}-{rev}`, using the last component of the repository name and
81/// a revision name made filename-safe (`feature/x` → `feature-x`).
82pub fn archive_name(repo: &str, rev: &str) -> String {
83 let repo = repo.rsplit('/').next().unwrap_or(repo);
84 let clean = |s: &str| -> String {
85 s.chars()
86 .map(|c| {
87 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
88 c
89 } else {
90 '-'
91 }
92 })
93 .collect()
94 };
95 format!("{}-{}", clean(repo), clean(rev))
96}
97
98#[cfg(test)]
99mod tests {
100 use super::archive_name;
101
102 #[test]
103 fn names() {
104 assert_eq!(archive_name("forks/foo", "v1.0"), "foo-v1.0");
105 assert_eq!(archive_name("foo", "feature/x"), "foo-feature-x");
106 assert_eq!(archive_name("a b", "\"x\""), "a-b--x-");
107 }
108}