steid

@jamesgill /

b7a57dbfeat: a repository can be taken away as a file18h
1//! Archive download — `/{handle}/repos/{name}/archive/{rev}.tar.gz` and `.zip`.
2//!
3//! A route rather than a page: the response is a file, and one that may be as large as
4//! the repository, so it streams out of `git archive` the same way pack data streams out
5//! of the protocol backend — see [`GitBody`].
6//!
7//! The extension is part of the last segment rather than a segment of its own, because
8//! it is part of the *filename* a browser saves: `steid-main.tar.gz` is what the URL
9//! promises and what the download is called. A revision containing a slash is
10//! percent-encoded whole, exactly as the tree routes encode it, so `release%2F2.0.zip`
11//! arrives here as one parameter.
12
13use topcoat::{
14 Result,
15 context::Cx,
16 router::{
17 Response, StatusCode,
18 error::{RouterErrorExt, not_found},
19 header::{CONTENT_DISPOSITION, CONTENT_TYPE, ETAG, IF_NONE_MATCH},
20 headers, path_param, route,
21 },
22};
23
24use crate::{
25 application::{archive_repo, open_archive, port::ArchiveFormat},
26 domain::RefName,
27};
28
29use super::{
30 context::{archives, current_actor, memberships, orgs, queries, repos, server_error},
31 git::GitBody,
32 repo::repo_for,
33};
34
35/// `{file}` from the path: the revision and its extension, e.g. `main.tar.gz`.
36#[path_param]
37struct File(str);
38
39/// Every extension served, longest first.
40///
41/// Order matters only in that `.tar.gz` must be tried before anything that is a suffix
42/// of it; today nothing is, and the list is written so that stays true by construction
43/// rather than by luck.
44const FORMATS: [(&str, ArchiveFormat); 2] = [
45 (".tar.gz", ArchiveFormat::TarGz),
46 (".zip", ArchiveFormat::Zip),
47];
48
49/// Splits `main.tar.gz` into the revision and the format it is asked for.
50///
51/// An unknown extension is a 404 rather than a bad request: `/archive/main.rar` is a
52/// URL Steid does not have, not a malformed one.
53fn requested(cx: &Cx) -> Result<(RefName, ArchiveFormat)> {
54 let file = path_param::<File>(cx);
55
56 let (rev, format) = FORMATS
57 .iter()
58 .find_map(|(extension, format)| Some((file.strip_suffix(extension)?, *format)))
59 .ok_or_else(not_found)?;
60
61 Ok((RefName::new(rev).map_err(|_| not_found())?, format))
62}
63
64/// A repository at a revision, as a `.tar.gz` or a `.zip`.
65///
66/// **Two `git` processes** — one resolving the revision, one packing — and the second is
67/// skipped entirely when the client already has this commit: the `ETag` *is* the
68/// resolved commit id, so `If-None-Match` can be answered before anything is spawned.
69/// That is what the use case's two-step shape buys.
70///
71/// Visibility is [`archive_repo`]'s answer, which is [`view_repo`]'s answer, which is
72/// the browse pages' answer: a private repository 404s for a stranger exactly as its
73/// page does. No `WWW-Authenticate` challenge here — this is a link on a page, reached
74/// with a session cookie, not a git client that can offer a credential.
75#[route(GET "/{handle}/repos/{name}/archive/{file}")]
76async fn archive_download(cx: &Cx) -> Result<Response<GitBody>> {
77 let (rev, format) = requested(cx)?;
78 let repo = repo_for(cx).await?;
79
80 let target = archive_repo(
81 &repo.handle,
82 &repo.name,
83 &rev,
84 format,
85 &current_actor(cx).await?,
86 &orgs(cx),
87 &memberships(cx),
88 &repos(cx),
89 &queries(cx),
90 )
91 .await
92 .map_err(server_error)?
93 .ok_or_not_found()?;
94
95 let etag = format!("\"{}\"", target.commit().as_str());
96
97 if presented_etag(cx).is_some_and(|presented| presented == etag) {
98 return not_modified(&etag);
99 }
100
101 let filename = target.filename().to_owned();
102 let body = open_archive(target, &archives(cx))
103 .await
104 .map_err(server_error)?;
105
106 Response::builder()
107 .header(CONTENT_TYPE, format.content_type())
108 // A tar.gz and a zip are both archives a browser saves rather than renders, so
109 // unlike the raw endpoint the honest type costs nothing. `nosniff` stays: the
110 // policy that the origin never lets a browser reconsider a type it was given is
111 // worth keeping uniform.
112 .header(NOSNIFF.0, NOSNIFF.1)
113 .header(CONTENT_DISPOSITION, disposition(&filename))
114 // The commit, so a repeat download of a tag is a 304 and a repeat download of a
115 // moving branch is not. Nothing weaker would do: `main` is a different archive
116 // every push.
117 .header(ETAG, etag)
118 .body(GitBody::new(body))
119 .map_err(server_error)
120}
121
122/// The header that stops a browser second-guessing a `Content-Type`.
123///
124/// The same pair [`browse`](super::browse) uses, written again rather than shared
125/// because it is two words and importing it would couple two modules over nothing.
126const NOSNIFF: (&str, &str) = ("x-content-type-options", "nosniff");
127
128/// The `If-None-Match` a client presented, if any.
129///
130/// Only the simple single-tag form is honoured. A list, or a `W/` weak tag, is treated
131/// as no match and the archive is packed — the cost of being wrong here is a download
132/// that was already going to happen.
133fn presented_etag(cx: &Cx) -> Option<String> {
134 Some(
135 headers(cx)
136 .get(IF_NONE_MATCH)?
137 .to_str()
138 .ok()?
139 .trim()
140 .to_owned(),
141 )
142}
143
144/// "You already have this one."
145///
146/// The `ETag` is repeated, per RFC 9110: a 304 must carry what the cached entry would
147/// have been validated against.
148fn not_modified(etag: &str) -> Result<Response<GitBody>> {
149 Response::builder()
150 .status(StatusCode::NOT_MODIFIED)
151 .header(ETAG, etag)
152 .body(GitBody::new(Box::pin(tokio::io::empty())))
153 .map_err(server_error)
154}
155
156/// The `Content-Disposition` for the download.
157///
158/// Simpler than [`browse`](super::browse)'s, and deliberately: that one carries a
159/// filename out of a repository, which is arbitrary bytes somebody else chose. This one
160/// is built by the use case from a repository name and a sanitised revision, both of
161/// which are already restricted to characters that cannot end the quoted string. The
162/// filter is kept anyway, because "the caller already validated it" is how header
163/// injection arrives later.
164fn disposition(filename: &str) -> String {
165 let safe: String = filename
166 .chars()
167 .map(|char| match char {
168 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' => char,
169 _ => '_',
170 })
171 .collect();
172
173 format!("attachment; filename=\"{safe}\"")
174}
175
176/// The URL an archive of a revision downloads from.
177///
178/// The revision is encoded whole, slashes included, so `release/2.0` stays one segment —
179/// the same rule [`tree_url`](super::browse::tree_url) follows, and the reason the
180/// extension can be part of that segment at all.
181pub(super) fn archive_url(handle: &str, name: &str, rev: &str, format: ArchiveFormat) -> String {
182 format!(
183 "/{handle}/repos/{name}/archive/{}.{}",
184 super::browse::encode(rev, false),
185 format.as_str()
186 )
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn an_archive_url_keeps_the_revision_in_one_segment() {
195 assert_eq!(
196 archive_url("ada", "steid", "main", ArchiveFormat::TarGz),
197 "/ada/repos/steid/archive/main.tar.gz"
198 );
199 assert_eq!(
200 archive_url("ada", "steid", "release/2.0", ArchiveFormat::Zip),
201 "/ada/repos/steid/archive/release%2F2.0.zip"
202 );
203 }
204
205 #[test]
206 fn a_filename_is_reduced_to_what_a_header_can_carry() {
207 assert_eq!(
208 disposition("steid-main.tar.gz"),
209 "attachment; filename=\"steid-main.tar.gz\""
210 );
211 assert_eq!(
212 disposition("a\"b\r\nc.zip"),
213 "attachment; filename=\"a_b__c.zip\""
214 );
215 }
216}