steid

@jamesgill /

steid/src/application/archive.rs
10.4 KBCode·Blame·Raw
1//! Packing a repository at a revision into a downloadable archive.
2//!
3//! Two steps rather than one, and the split is the point: [`archive_repo`] decides
4//! *whether* — visibility, and whether the revision exists — and [`open_archive`] then
5//! runs git. Nothing but [`archive_repo`] can build an [`ArchiveTarget`], so the
6//! expensive half cannot be reached without the authorized half having happened. It
7//! also lets a caller answer a conditional request from the resolved commit without
8//! spawning anything, which is what the `ETag` is for.
9//!
10//! Authorization is not re-implemented here: like everything in [`browse`](super::browse)
11//! it goes through [`view_repo`](super::repo::view_repo), so a repository invisible on
12//! its page has no archive either.
13
14use crate::domain::{
15 Actor, ObjectId, OrgName, RefName, RepoName,
16 repository::{MembershipRepository, OrgRepository, RepoRepository},
17};
18
19use super::{
20 error::Result,
21 port::{ArchiveFormat, ArchiveRequest, ByteStream, GitArchive, GitQuery},
22 repo::view_repo,
23};
24
25/// An archive that may be produced: authorized, and resolved to a commit.
26///
27/// Fields are private and there is no constructor, so the only way to hold one is to
28/// have been through [`archive_repo`].
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct ArchiveTarget {
31 handle: OrgName,
32 name: RepoName,
33 format: ArchiveFormat,
34 commit: ObjectId,
35 prefix: String,
36 filename: String,
37}
38
39impl ArchiveTarget {
40 /// The commit the revision resolved to.
41 ///
42 /// What the response's `ETag` is: two requests for `main` a week apart are
43 /// different archives, and two requests for a tag are the same one forever.
44 pub fn commit(&self) -> &ObjectId {
45 &self.commit
46 }
47
48 /// What the download is called, e.g. `steid-v0.2.0.tar.gz`.
49 pub fn filename(&self) -> &str {
50 &self.filename
51 }
52
53 pub fn format(&self) -> ArchiveFormat {
54 self.format
55 }
56}
57
58/// Settles whether this actor may download this revision of this repository, and what
59/// the archive would be called.
60///
61/// **One `git` process** — resolving the revision — and no packing. `Ok(None)` covers
62/// an invisible repository, an absent one, an unknown revision, and a repository with
63/// no commits: all of them are one answer for the reason
64/// [`view_repo`](super::repo::view_repo) gives, and none of them is a thing to download.
65#[allow(clippy::too_many_arguments)]
66pub async fn archive_repo(
67 handle: &OrgName,
68 name: &RepoName,
69 rev: &RefName,
70 format: ArchiveFormat,
71 actor: &Actor,
72 orgs: &impl OrgRepository,
73 memberships: &impl MembershipRepository,
74 repos: &impl RepoRepository,
75 queries: &impl GitQuery,
76) -> Result<Option<ArchiveTarget>> {
77 if view_repo(handle, name, actor, orgs, memberships, repos)
78 .await?
79 .is_none()
80 {
81 return Ok(None);
82 }
83
84 // Resolved before anything is packed, for the module rule `git_query` sets out: a
85 // revision that is not there must be a 404 rather than a fatal `git archive`.
86 let Some(commit) = queries.resolve(handle, name, rev).await? else {
87 return Ok(None);
88 };
89
90 let stem = format!("{}-{}", name.as_str(), safe_component(rev.as_str()));
91
92 Ok(Some(ArchiveTarget {
93 handle: handle.clone(),
94 name: name.clone(),
95 format,
96 commit,
97 prefix: format!("{stem}/"),
98 filename: format!("{stem}.{}", format.as_str()),
99 }))
100}
101
102/// Runs the pack, handing back its bytes as they arrive.
103///
104/// Takes the target by value: an archive is a side effect large enough that producing
105/// one twice from the same decision should be a deliberate act, not an accident.
106pub async fn open_archive(target: ArchiveTarget, archives: &impl GitArchive) -> Result<ByteStream> {
107 Ok(archives
108 .archive(ArchiveRequest {
109 handle: target.handle,
110 name: target.name,
111 format: target.format,
112 commit: target.commit,
113 prefix: target.prefix,
114 })
115 .await?)
116}
117
118/// A revision reduced to something that can be a filename and a directory name.
119///
120/// A revision may contain slashes — `release/2.0` is an ordinary branch — and a
121/// `Content-Disposition` naming `steid-release/2.0.zip` is at best ignored and at worst
122/// a path. Everything outside the unreserved set becomes a hyphen, which is what every
123/// forge does and what people expect a downloaded branch archive to look like.
124///
125/// A leading dot is dropped as well: a hidden archive in the downloads folder is not a
126/// security problem, just a file nobody can find.
127fn safe_component(rev: &str) -> String {
128 let mut safe: String = rev
129 .chars()
130 .map(|char| match char {
131 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' => char,
132 _ => '-',
133 })
134 .collect();
135
136 while safe.starts_with('.') {
137 safe.remove(0);
138 }
139
140 // A revision written entirely in a script this cannot carry reduces to a row of
141 // hyphens, which is a worse filename than saying nothing — the same rule
142 // `browse::disposition` applies to a file's own name. The commit is in the `ETag`;
143 // the name only has to be usable.
144 if !safe.chars().any(|char| char.is_ascii_alphanumeric()) {
145 safe = "archive".to_owned();
146 }
147
148 safe
149}
150
151#[cfg(test)]
152mod tests {
153 use std::time::SystemTime;
154
155 use super::*;
156 use crate::{
157 domain::{
158 Membership, MembershipId, OrgId, Organization, RepoId, Repository, UserId, Visibility,
159 },
160 infrastructure::{
161 git::{InMemoryGitArchive, InMemoryGitQuery},
162 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
163 },
164 };
165
166 struct Fixture {
167 orgs: InMemoryOrgRepo,
168 memberships: InMemoryMembershipRepo,
169 repos: InMemoryRepoRepo,
170 handle: OrgName,
171 owner: Actor,
172 stranger: Actor,
173 }
174
175 async fn fixture(visibility: Visibility) -> Fixture {
176 let orgs = InMemoryOrgRepo::new();
177 let memberships = InMemoryMembershipRepo::new();
178 let repos = InMemoryRepoRepo::new();
179
180 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
181 orgs.save(&org).await.expect("save org");
182
183 let owner = UserId::generate();
184 memberships
185 .save(&Membership::new(
186 MembershipId::generate(),
187 org.id.clone(),
188 owner.clone(),
189 crate::domain::Role::Owner,
190 ))
191 .await
192 .expect("save membership");
193
194 repos
195 .save(
196 &Repository::new(
197 RepoId::generate(),
198 org.id.clone(),
199 "steid",
200 None,
201 visibility,
202 SystemTime::now(),
203 )
204 .expect("valid repository"),
205 )
206 .await
207 .expect("save repo");
208
209 Fixture {
210 orgs,
211 memberships,
212 repos,
213 handle: org.name,
214 owner: Actor::User(owner),
215 stranger: Actor::Anonymous,
216 }
217 }
218
219 fn repo_name() -> RepoName {
220 RepoName::new("steid").expect("valid repository name")
221 }
222
223 fn rev(value: &str) -> RefName {
224 RefName::new(value).expect("valid revision")
225 }
226
227 impl Fixture {
228 async fn target(
229 &self,
230 actor: &Actor,
231 rev: &RefName,
232 queries: &InMemoryGitQuery,
233 ) -> Result<Option<ArchiveTarget>> {
234 archive_repo(
235 &self.handle,
236 &repo_name(),
237 rev,
238 ArchiveFormat::TarGz,
239 actor,
240 &self.orgs,
241 &self.memberships,
242 &self.repos,
243 queries,
244 )
245 .await
246 }
247 }
248
249 #[tokio::test]
250 async fn a_public_repository_archives_under_its_own_name() {
251 let f = fixture(Visibility::Public).await;
252
253 let target = f
254 .target(&f.stranger, &rev("main"), &InMemoryGitQuery::new())
255 .await
256 .expect("should read")
257 .expect("archivable");
258
259 assert_eq!(target.filename(), "steid-main.tar.gz");
260 assert_eq!(target.prefix, "steid-main/");
261 }
262
263 #[tokio::test]
264 async fn a_private_repository_has_no_archive_for_a_stranger() {
265 // The same answer the page gives: absent and invisible are one thing.
266 let f = fixture(Visibility::Private).await;
267 let queries = InMemoryGitQuery::new();
268
269 assert!(
270 f.target(&f.stranger, &rev("main"), &queries)
271 .await
272 .expect("should read")
273 .is_none()
274 );
275 assert!(
276 f.target(&f.owner, &rev("main"), &queries)
277 .await
278 .expect("should read")
279 .is_some()
280 );
281 }
282
283 #[tokio::test]
284 async fn a_revision_that_does_not_resolve_has_nothing_to_pack() {
285 // An empty repository resolves nothing, which is the same non-answer as a
286 // branch that was never pushed.
287 let f = fixture(Visibility::Public).await;
288
289 assert!(
290 f.target(&f.owner, &rev("main"), &InMemoryGitQuery::empty())
291 .await
292 .expect("should read")
293 .is_none()
294 );
295 }
296
297 #[tokio::test]
298 async fn the_pack_is_asked_for_exactly_what_was_resolved() {
299 let f = fixture(Visibility::Public).await;
300 let archives = InMemoryGitArchive::new();
301
302 let target = f
303 .target(&f.owner, &rev("release/2.0"), &InMemoryGitQuery::new())
304 .await
305 .expect("should read")
306 .expect("archivable");
307 let commit = target.commit().clone();
308
309 open_archive(target, &archives).await.expect("should pack");
310
311 let requested = archives.requests().pop().expect("one request");
312 assert_eq!(requested.commit, commit);
313 // A slash in a branch name is not a directory in the archive.
314 assert_eq!(requested.prefix, "steid-release-2.0/");
315 }
316
317 #[test]
318 fn a_revision_becomes_something_a_filesystem_will_take() {
319 assert_eq!(safe_component("main"), "main");
320 assert_eq!(safe_component("v0.2.0"), "v0.2.0");
321 assert_eq!(safe_component("release/2.0"), "release-2.0");
322 assert_eq!(safe_component("../etc/passwd"), "-etc-passwd");
323 assert_eq!(safe_component("..."), "archive");
324 assert_eq!(safe_component("日本語"), "archive");
325 }
326}