forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
6.7 KiB
1//! Page: source archive at `/{owner}/{repo}/archive/{ref}.tar.gz` or `.zip`. The `git archive`
2//! stdout is streamed as the response body.
3
4use std::process::Stdio;
5
6use forge_git::browse;
7use forge_git::history::{self, ArchiveFormat};
8use futures_util::TryStreamExt;
9use http::header::{
10 CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, HeaderValue, X_CONTENT_TYPE_OPTIONS,
11};
12use http_body::Frame;
13use http_body_util::StreamBody;
14use tokio::io::AsyncReadExt;
15use tokio::process::Child;
16use tokio_util::io::ReaderStream;
17use topcoat::Result;
18use topcoat::context::{Cx, app_context};
19use topcoat::router::error::not_found;
20use topcoat::router::response::Response;
21use topcoat::router::{Body, path_param, route};
22
23use crate::pages::commits::blocking;
24use crate::pages::repo_ctx::load_repo;
25use crate::state::AppState;
26
27path_param!(owner);
28path_param!(repo);
29path_param!(*rest);
30
31/// Splits `main.tar.gz` or `feature/x.zip` into the ref and the archive format.
32fn split_format(name: &str) -> Option<(&str, ArchiveFormat)> {
33 let (reference, format) = name
34 .strip_suffix(".tar.gz")
35 .map(|r| (r, ArchiveFormat::TarGz))
36 .or_else(|| name.strip_suffix(".zip").map(|r| (r, ArchiveFormat::Zip)))?;
37 (!reference.is_empty()).then_some((reference, format))
38}
39
40/// `<repo>-<sanitized ref>`, or `<repo>-<short oid>` when the ref is a hex id or sanitizes to
41/// nothing. The result satisfies `history::archive_command`'s prefix rules.
42fn archive_prefix(repo: &str, reference: &str, commit: &str) -> String {
43 let sanitized: String = reference
44 .chars()
45 .map(|c| {
46 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
47 c
48 } else {
49 '-'
50 }
51 })
52 .collect();
53 let is_hex = reference.bytes().all(|b| b.is_ascii_hexdigit());
54 let tail = if sanitized.is_empty() || is_hex {
55 commit.get(..12).unwrap_or(commit).to_owned()
56 } else {
57 sanitized
58 };
59 format!("{repo}-{tail}")
60}
61
62#[route(GET "/{owner}/{repo}/archive/{*rest}")]
63async fn archive(cx: &Cx) -> Result<Response> {
64 let owner = path_param::<Owner>(cx);
65 let name = path_param::<Repo>(cx);
66 let rest = path_param::<Rest>(cx).collect::<Vec<_>>().join("/");
67 let Some((reference, format)) = split_format(&rest) else {
68 return Err(not_found().into());
69 };
70
71 let ctx = load_repo(cx, owner, name).await?;
72 let state = app_context::<AppState>(cx);
73
74 let repo_dir = ctx.path.clone();
75 let rev = reference.to_owned();
76 let Some(commit) = blocking(move || browse::resolve_commit(&repo_dir, &rev)).await? else {
77 return Err(not_found().into());
78 };
79
80 let prefix = archive_prefix(&ctx.repo.name, reference, &commit);
81 let (content_type, extension) = match format {
82 ArchiveFormat::TarGz => ("application/gzip", "tar.gz"),
83 ArchiveFormat::Zip => ("application/zip", "zip"),
84 };
85 let mut command =
86 history::archive_command(&state.git, &ctx.path, &commit, format, &prefix)?.into_tokio();
87 command
88 .stdin(Stdio::null())
89 .stdout(Stdio::piped())
90 .stderr(Stdio::piped());
91 let mut child = command.spawn()?;
92 let Some(stdout) = child.stdout.take() else {
93 return Err(std::io::Error::other("git archive stdout missing").into());
94 };
95 tokio::spawn(reap(child));
96
97 let chunks = ReaderStream::new(stdout).map_ok(Frame::data);
98 let mut res = Response::new(Body::new(StreamBody::new(chunks)));
99 let headers = res.headers_mut();
100 headers.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
101 headers.insert(
102 CONTENT_DISPOSITION,
103 HeaderValue::from_str(&format!("attachment; filename=\"{prefix}.{extension}\""))?,
104 );
105 headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
106 headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
107 Ok(res)
108}
109
110/// Drains stderr into the log and waits for `git archive`, so no zombie is left behind.
111async fn reap(mut child: Child) {
112 let mut stderr = Vec::new();
113 if let Some(mut pipe) = child.stderr.take() {
114 let _ = pipe.read_to_end(&mut stderr).await;
115 }
116 match child.wait().await {
117 Ok(status) if status.success() => {}
118 Ok(status) => tracing::warn!(
119 %status,
120 stderr = %String::from_utf8_lossy(&stderr).trim(),
121 "git archive exited with failure"
122 ),
123 Err(e) => tracing::warn!(error = %e, "failed to wait for git archive"),
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use forge_core::Visibility;
130
131 use super::{archive_prefix, split_format};
132 use crate::pages::commits::test_support::{Fixture, OWNER, REPO, get};
133 use forge_git::history::ArchiveFormat;
134
135 #[test]
136 fn splits_ref_and_format() {
137 assert!(matches!(
138 split_format("feature/x.tar.gz"),
139 Some(("feature/x", ArchiveFormat::TarGz))
140 ));
141 assert!(matches!(
142 split_format("v1.zip"),
143 Some(("v1", ArchiveFormat::Zip))
144 ));
145 assert!(split_format("main.rar").is_none());
146 assert!(split_format(".zip").is_none());
147 }
148
149 #[test]
150 fn prefix_is_sanitized() {
151 assert_eq!(
152 archive_prefix("demo", "feature/x y", "f".repeat(40).as_str()),
153 "demo-feature-x-y"
154 );
155 let oid = "0123456789abcdef0123456789abcdef01234567";
156 assert_eq!(archive_prefix("demo", oid, oid), "demo-0123456789ab");
157 }
158
159 #[tokio::test]
160 async fn tar_gz_and_zip_responses() {
161 let fx = Fixture::new(Visibility::Public).await;
162
163 let (status, headers, body) =
164 get(&fx.router, &format!("/{OWNER}/{REPO}/archive/main.tar.gz")).await;
165 assert_eq!(status, 200);
166 assert_eq!(headers["content-type"], "application/gzip");
167 assert_eq!(
168 headers["content-disposition"],
169 "attachment; filename=\"demo-main.tar.gz\""
170 );
171 assert_eq!(headers["x-content-type-options"], "nosniff");
172 assert_eq!(&body[..2], &[0x1f, 0x8b]);
173
174 let (status, headers, body) =
175 get(&fx.router, &format!("/{OWNER}/{REPO}/archive/main.zip")).await;
176 assert_eq!(status, 200);
177 assert_eq!(headers["content-type"], "application/zip");
178 assert_eq!(&body[..2], b"PK");
179
180 let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/archive/nope.zip")).await;
181 assert_eq!(status, 404);
182 let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/archive/main.rar")).await;
183 assert_eq!(status, 404);
184 }
185
186 #[tokio::test]
187 async fn private_repo_is_404_for_anonymous() {
188 let fx = Fixture::new(Visibility::Private).await;
189 let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/archive/main.tar.gz")).await;
190 assert_eq!(status, 404);
191 }
192}