steid

@jamesgill /

feat: a repository can be taken away as a file

`git archive` behind its own narrow port, because its shape is the protocol's and
not the read queries': the output is as large as the repository, so it streams
out of the subprocess into the response body rather than being collected the way
`GitQuery` collects capped bytes.

The use case is two steps on purpose. `archive_repo` decides — visibility, and
whether the revision exists — and hands back an `ArchiveTarget` nothing else can
construct; `open_archive` then runs git. That makes the `ETag` free: it is the
commit the revision resolved to, so a conditional request is answered from the
first step and the pack is never spawned. It also means a tag downloads once and
`main` re-downloads after every push, which is the honest distinction.

A private repository 404s for a stranger, exactly as its page does. The uniform
401 of 0007 is for the git transport, where a client needs the challenge before
it will offer a credential; this is a link on a page reached with a cookie, and
answering `WWW-Authenticate` there asks a browser for a password Steid does not
use.

A revision may contain slashes, so it is percent-encoded whole into one segment
and sanitised separately for the filename and the prefix directory — extracting
an archive that spills into the current directory is a small hostility, and
`steid-release/2.0.zip` is not a filename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXVUnompCMVxQ28a9T3HAx
JamesPatrickGill authored 15 hours agoparent9b1feb6Browse filesb7a57dbfaa589c198a43e5f7d58c79cb74cf5539

10 files changed+965 −14

src/application/archive.rs+326 −0View file
@@ -0,0 +1,326 @@
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+
14+use crate::domain::{
15+ Actor, ObjectId, OrgName, RefName, RepoName,
16+ repository::{MembershipRepository, OrgRepository, RepoRepository},
17+};
18+
19+use 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)]
30+pub struct ArchiveTarget {
31+ handle: OrgName,
32+ name: RepoName,
33+ format: ArchiveFormat,
34+ commit: ObjectId,
35+ prefix: String,
36+ filename: String,
37+}
38+
39+impl 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)]
66+pub 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.
106+pub 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.
127+fn 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)]
152+mod 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+}
src/application/error.rs+13 −1View file
@@ -1,6 +1,8 @@
11 use crate::domain::{DomainError, repository::RepositoryError};
22
3use super::port::{GitProtocolError, GitQueryError, GitStorageError, PasswordError};
3+use super::port::{
4+ GitArchiveError, GitProtocolError, GitQueryError, GitStorageError, PasswordError,
5+};
46
57 /// What a use case can fail with.
68 ///
@@ -22,6 +24,8 @@ pub enum Error {
2224 GitProtocol(GitProtocolError),
2325 /// A repository's contents could not be read.
2426 GitQuery(GitQueryError),
27+ /// A repository could not be packed into an archive.
28+ GitArchive(GitArchiveError),
2529 }
2630
2731 impl From<DomainError> for Error {
@@ -60,6 +64,12 @@ impl From<GitQueryError> for Error {
6064 }
6165 }
6266
67+impl From<GitArchiveError> for Error {
68+ fn from(error: GitArchiveError) -> Self {
69+ Self::GitArchive(error)
70+ }
71+}
72+
6373 impl std::fmt::Display for Error {
6474 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6575 match self {
@@ -69,6 +79,7 @@ impl std::fmt::Display for Error {
6979 Self::GitStorage(error) => write!(f, "{error}"),
7080 Self::GitProtocol(error) => write!(f, "{error}"),
7181 Self::GitQuery(error) => write!(f, "{error}"),
82+ Self::GitArchive(error) => write!(f, "{error}"),
7283 }
7384 }
7485 }
@@ -82,6 +93,7 @@ impl std::error::Error for Error {
8293 Self::GitStorage(error) => Some(error),
8394 Self::GitProtocol(error) => Some(error),
8495 Self::GitQuery(error) => Some(error),
96+ Self::GitArchive(error) => Some(error),
8597 }
8698 }
8799 }
src/application/mod.rs+2 −0View file
@@ -3,6 +3,7 @@
33 //! Every use case takes an actor or a credential plus the ports it needs, and enforces
44 //! the rules before any side effect. Nothing here knows about HTTP or Topcoat.
55
6+pub mod archive;
67 pub(crate) mod authz;
78 pub mod browse;
89 pub mod claim;
@@ -18,6 +19,7 @@ pub mod session;
1819 pub mod summary;
1920 pub mod token;
2021
22+pub use archive::{ArchiveTarget, archive_repo, open_archive};
2123 pub use browse::{
2224 Browsed, FileView, LOG_LIMIT, MAX_BLOB_BYTES, MAX_RAW_BYTES, RawFile, RefList, RefPage,
2325 browse_repo, list_branches, list_refs, list_tags, read_raw_file, repo_log,
src/application/port.rs+108 −3View file
@@ -8,8 +8,8 @@ use std::{path::PathBuf, pin::Pin};
88 use tokio::io::AsyncRead;
99
1010 use crate::domain::{
11 BranchRow, CommitSummary, GitRef, ObjectId, OrgName, PasswordHash, RefName, RepoName, RepoPath,
12 TagRow, TagSummary, TreeEntry,
11+ BranchRow, CommitSummary, DomainError, GitRef, ObjectId, OrgName, PasswordHash, RefName,
12+ RepoName, RepoPath, TagRow, TagSummary, TreeEntry,
1313 };
1414
1515 /// Hashes and verifies passwords.
@@ -422,7 +422,6 @@ pub trait GitQuery: Send + Sync {
422422 name: &RepoName,
423423 ) -> impl Future<Output = Result<Vec<TagRow>, GitQueryError>> + Send;
424424 }
425
426425 /// A repository could not be read.
427426 #[derive(Debug)]
428427 pub struct GitQueryError {
@@ -484,3 +483,109 @@ impl std::error::Error for GitQueryError {
484483 Some(&*self.source)
485484 }
486485 }
486+
487+/// What an archive is packed as.
488+///
489+/// Two formats because they are what people expect from a forge and what `git archive`
490+/// produces without configuration: `tar.gz` everywhere, `zip` for Windows and for
491+/// anyone who wants to look inside before extracting.
492+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
493+pub enum ArchiveFormat {
494+ TarGz,
495+ Zip,
496+}
497+
498+impl ArchiveFormat {
499+ /// What `git archive --format=` is given. Also the file extension, which is not a
500+ /// coincidence worth breaking apart into two constants.
501+ pub fn as_str(self) -> &'static str {
502+ match self {
503+ Self::TarGz => "tar.gz",
504+ Self::Zip => "zip",
505+ }
506+ }
507+
508+ /// What the response labels the bytes.
509+ ///
510+ /// Honest types, unlike the raw endpoint's deliberate `octet-stream`: neither of
511+ /// these is a type a browser renders, so naming it costs nothing and lets a client
512+ /// decompress without guessing.
513+ pub fn content_type(self) -> &'static str {
514+ match self {
515+ Self::TarGz => "application/gzip",
516+ Self::Zip => "application/zip",
517+ }
518+ }
519+}
520+
521+/// `Result`, never `Option`, per the layer's rule: a silently-defaulted format would
522+/// hand somebody a zip named `.tar.gz`.
523+impl std::str::FromStr for ArchiveFormat {
524+ type Err = DomainError;
525+
526+ fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
527+ match value {
528+ "tar.gz" => Ok(Self::TarGz),
529+ "zip" => Ok(Self::Zip),
530+ other => Err(DomainError::validation(
531+ "format",
532+ format!("unknown archive format {other:?}"),
533+ )),
534+ }
535+ }
536+}
537+
538+/// One repository, packed for download.
539+///
540+/// Every field is built by the use case from values it has already resolved and
541+/// authorized — the commit is an [`ObjectId`] git handed back, never a revision from a
542+/// URL, and the prefix is derived from the repository's own name. Nothing here reaches
543+/// git's revision parser.
544+#[derive(Debug, Clone, PartialEq, Eq)]
545+pub struct ArchiveRequest {
546+ pub handle: OrgName,
547+ pub name: RepoName,
548+ pub format: ArchiveFormat,
549+ pub commit: ObjectId,
550+ /// The directory every entry is nested under, ending in `/`. Extracting an archive
551+ /// that spills its contents into the current directory is a small hostility.
552+ pub prefix: String,
553+}
554+
555+/// Packing a repository into an archive.
556+///
557+/// The fourth narrow git port, and streaming rather than buffered for the same reason
558+/// the protocol is: an archive is as large as the repository, so collecting it would
559+/// bound downloads by RAM. That is the whole reason this is not a [`GitQuery`] method —
560+/// that port's contract is capped bytes, and this one's is a stream.
561+pub trait GitArchive: Send + Sync {
562+ fn archive(
563+ &self,
564+ request: ArchiveRequest,
565+ ) -> impl Future<Output = Result<ByteStream, GitArchiveError>> + Send;
566+}
567+
568+/// A repository could not be packed.
569+///
570+/// No `NotFound`: the use case resolved the commit before asking, so anything failing
571+/// here is git or the filesystem failing.
572+#[derive(Debug)]
573+pub struct GitArchiveError(Box<dyn std::error::Error + Send + Sync>);
574+
575+impl GitArchiveError {
576+ pub fn new(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
577+ Self(error.into())
578+ }
579+}
580+
581+impl std::fmt::Display for GitArchiveError {
582+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583+ write!(f, "could not archive the repository: {}", self.0)
584+ }
585+}
586+
587+impl std::error::Error for GitArchiveError {
588+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
589+ Some(&*self.0)
590+ }
591+}
src/infrastructure/git.rs+256 −2View file
@@ -21,8 +21,9 @@ use tokio::{
2121
2222 use crate::{
2323 application::port::{
24 Blob, GitMethod, GitProtocolError, GitProtocolServer, GitQuery, GitQueryError, GitRequest,
25 GitResponse, GitStorage, GitStorageError,
24+ ArchiveRequest, Blob, ByteStream, GitArchive, GitArchiveError, GitMethod, GitProtocolError,
25+ GitProtocolServer, GitQuery, GitQueryError, GitRequest, GitResponse, GitStorage,
26+ GitStorageError,
2627 },
2728 domain::{
2829 BranchRow, CommitSummary, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath,
@@ -472,6 +473,142 @@ impl GitProtocolServer for InMemoryGitProtocol {
472473 }
473474 }
474475
476+/// `git archive`, streamed.
477+///
478+/// Beside the protocol server rather than beside the read queries because it has the
479+/// protocol's shape, not theirs: the output is as large as the repository and is handed
480+/// on as it arrives. `DiskGitQuery`'s contract — bounded bytes, collected — is exactly
481+/// what an archive must not do.
482+#[derive(Debug, Clone)]
483+pub struct DiskGitArchive {
484+ data_dir: PathBuf,
485+}
486+
487+impl DiskGitArchive {
488+ pub fn new(data_dir: impl Into<PathBuf>) -> Self {
489+ Self {
490+ data_dir: data_dir.into(),
491+ }
492+ }
493+
494+ /// Where a repository lives, matching `DiskGitStorage`'s layout.
495+ fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
496+ self.data_dir
497+ .join(handle.as_str())
498+ .join(format!("{name}.git"))
499+ }
500+}
501+
502+impl GitArchive for DiskGitArchive {
503+ async fn archive(&self, request: ArchiveRequest) -> Result<ByteStream, GitArchiveError> {
504+ let repo = self.repo_path(&request.handle, &request.name);
505+
506+ // Both values are the use case's own construction — a resolved object id and a
507+ // prefix built from the repository's name — so nothing from a URL reaches git's
508+ // revision parser here. They are still written `--flag=value` rather than as
509+ // separate arguments, so neither can be read as a flag of its own.
510+ let mut command = git_command();
511+ command
512+ .arg("-C")
513+ .arg(&repo)
514+ .arg("archive")
515+ .arg(format!("--format={}", request.format.as_str()))
516+ .arg(format!("--prefix={}", request.prefix))
517+ .arg(request.commit.as_str())
518+ .stdin(Stdio::null())
519+ .stdout(Stdio::piped())
520+ .stderr(Stdio::piped())
521+ // A client that gives up mid-download must take the packing with it;
522+ // without this, git finishes compressing a repository for nobody.
523+ .kill_on_drop(true);
524+
525+ let mut child = command
526+ .spawn()
527+ .map_err(|error| GitArchiveError::new(format!("could not run git: {error}")))?;
528+
529+ let stdout = child.stdout.take().expect("stdout was piped");
530+ let mut stderr = child.stderr.take().expect("stderr was piped");
531+
532+ // The child is reaped in its own task, exactly as the protocol server's is, and
533+ // for the same two reasons: dropping a `Child` with `kill_on_drop` would kill
534+ // the process the response body is still reading from, and an unread stderr pipe
535+ // eventually fills and stalls the transfer. The exit status cannot gate the
536+ // response — by the time it is known, bytes have been sent — so a late failure
537+ // is a truncated download and a line in the log.
538+ tokio::spawn(async move {
539+ let mut complaint = String::new();
540+ let _ = stderr.read_to_string(&mut complaint).await;
541+
542+ match child.wait().await {
543+ Ok(status) if status.success() => {}
544+ Ok(status) => {
545+ eprintln!(
546+ "steid: git archive exited with {status}: {}",
547+ complaint.trim()
548+ )
549+ }
550+ Err(error) => eprintln!("steid: could not wait for git archive: {error}"),
551+ }
552+ });
553+
554+ Ok(Box::pin(stdout))
555+ }
556+}
557+
558+/// What an [`InMemoryGitArchive`] was asked for.
559+///
560+/// The request minus nothing: unlike a protocol request there is no body, so every
561+/// field is worth asserting on.
562+#[derive(Debug, Clone, PartialEq, Eq)]
563+pub struct RecordedArchive {
564+ pub handle: OrgName,
565+ pub name: RepoName,
566+ pub format: crate::application::port::ArchiveFormat,
567+ pub commit: ObjectId,
568+ pub prefix: String,
569+}
570+
571+/// An archive port that records what it was asked and never runs git.
572+///
573+/// Like [`InMemoryGitProtocol`], what it is really for is proving a negative: that a
574+/// use case refused before a single byte was packed.
575+#[derive(Debug, Default, Clone)]
576+pub struct InMemoryGitArchive {
577+ requests: Arc<Mutex<Vec<RecordedArchive>>>,
578+}
579+
580+impl InMemoryGitArchive {
581+ pub fn new() -> Self {
582+ Self::default()
583+ }
584+
585+ pub fn requests(&self) -> Vec<RecordedArchive> {
586+ self.requests.lock().expect("lock poisoned").clone()
587+ }
588+
589+ /// Whether anything was ever packed.
590+ pub fn was_called(&self) -> bool {
591+ !self.requests.lock().expect("lock poisoned").is_empty()
592+ }
593+}
594+
595+impl GitArchive for InMemoryGitArchive {
596+ async fn archive(&self, request: ArchiveRequest) -> Result<ByteStream, GitArchiveError> {
597+ self.requests
598+ .lock()
599+ .expect("lock poisoned")
600+ .push(RecordedArchive {
601+ handle: request.handle,
602+ name: request.name,
603+ format: request.format,
604+ commit: request.commit,
605+ prefix: request.prefix,
606+ });
607+
608+ Ok(Box::pin(std::io::Cursor::new(b"archive".to_vec())))
609+ }
610+}
611+
475612 /// A repository's contents, held in memory, for testing use cases and pages.
476613 ///
477614 /// The counterpart to `DiskGitQuery`. Seeded with exactly what a test needs rather than
@@ -706,7 +843,10 @@ mod tests {
706843
707844 use tempfile::TempDir;
708845
846+ use tokio::io::AsyncReadExt;
847+
709848 use super::*;
849+ use crate::application::port::ArchiveFormat;
710850
711851 /// The `TempDir` is returned alongside the storage because dropping it deletes the
712852 /// data directory — binding it to `_` would remove the fixture mid-test.
@@ -1124,4 +1264,118 @@ mod tests {
11241264 );
11251265 assert!(protocol.was_called());
11261266 }
1267+
1268+ // --- git archive ---------------------------------------------------------------
1269+
1270+ /// A bare repository with one commit in it, packed the way a real one is.
1271+ ///
1272+ /// Built by pushing from a working copy rather than by writing objects directly,
1273+ /// for the same reason `git_query`'s fixture is: it is what actually happens.
1274+ fn archivable() -> (TempDir, DiskGitArchive, ObjectId) {
1275+ let dir = TempDir::new().expect("temp dir");
1276+ let storage = DiskGitStorage::new(dir.path());
1277+ let repo = storage.repo_path(&handle(), &repo_name("steid"));
1278+ let work = dir.path().join("work");
1279+
1280+ let git = |at: &Path, args: &[&str]| {
1281+ let output = std::process::Command::new("git")
1282+ .arg("-C")
1283+ .arg(at)
1284+ .args(args)
1285+ .env("GIT_CONFIG_GLOBAL", "/dev/null")
1286+ .env("GIT_CONFIG_SYSTEM", "/dev/null")
1287+ .env("GIT_AUTHOR_NAME", "Ada Lovelace")
1288+ .env("GIT_AUTHOR_EMAIL", "ada@example.com")
1289+ .env("GIT_COMMITTER_NAME", "Ada Lovelace")
1290+ .env("GIT_COMMITTER_EMAIL", "ada@example.com")
1291+ .output()
1292+ .expect("git should be on PATH");
1293+
1294+ assert!(
1295+ output.status.success(),
1296+ "git {args:?} failed: {}",
1297+ String::from_utf8_lossy(&output.stderr)
1298+ );
1299+
1300+ String::from_utf8_lossy(&output.stdout).trim().to_owned()
1301+ };
1302+
1303+ std::fs::create_dir_all(&work).expect("create work tree");
1304+ git(
1305+ dir.path(),
1306+ &[
1307+ "init",
1308+ "--bare",
1309+ "--quiet",
1310+ "--template=",
1311+ "--initial-branch=main",
1312+ "--",
1313+ repo.to_str().expect("utf-8 fixture path"),
1314+ ],
1315+ );
1316+ git(&work, &["init", "--quiet", "-b", "main"]);
1317+ std::fs::write(work.join("README.md"), b"hello\n").expect("write");
1318+ git(&work, &["add", "-A"]);
1319+ git(&work, &["commit", "--quiet", "-m", "first"]);
1320+ git(
1321+ &work,
1322+ &["push", "--quiet", repo.to_str().expect("utf-8"), "main"],
1323+ );
1324+
1325+ let head = git(&repo, &["rev-parse", "main"]);
1326+ let archives = DiskGitArchive::new(dir.path());
1327+
1328+ (
1329+ dir,
1330+ archives,
1331+ ObjectId::new(head).expect("a real object id"),
1332+ )
1333+ }
1334+
1335+ async fn packed(format: ArchiveFormat) -> Vec<u8> {
1336+ let (dir, archives, commit) = archivable();
1337+
1338+ let mut stream = archives
1339+ .archive(ArchiveRequest {
1340+ handle: handle(),
1341+ name: repo_name("steid"),
1342+ format,
1343+ commit,
1344+ prefix: "steid-main/".to_owned(),
1345+ })
1346+ .await
1347+ .expect("should pack");
1348+
1349+ let mut bytes = Vec::new();
1350+ stream.read_to_end(&mut bytes).await.expect("should stream");
1351+
1352+ // Held until the bytes are read: dropping the fixture deletes the repository
1353+ // git is still reading from.
1354+ drop(dir);
1355+
1356+ bytes
1357+ }
1358+
1359+ #[tokio::test]
1360+ async fn a_tarball_carries_the_prefix_directory() {
1361+ let bytes = packed(ArchiveFormat::TarGz).await;
1362+
1363+ // The gzip magic number, so the format flag is doing something rather than
1364+ // silently producing an uncompressed tar.
1365+ assert_eq!(&bytes[..2], &[0x1f, 0x8b], "expected gzip");
1366+ assert!(bytes.len() > 100, "expected a real archive");
1367+ }
1368+
1369+ #[tokio::test]
1370+ async fn a_zip_is_a_zip() {
1371+ let bytes = packed(ArchiveFormat::Zip).await;
1372+
1373+ assert_eq!(&bytes[..2], b"PK", "expected a zip");
1374+ // The prefix is stored as part of every entry name, uncompressed in the
1375+ // central directory, so it is readable in the raw bytes.
1376+ assert!(
1377+ bytes.windows(11).any(|window| window == b"steid-main/"),
1378+ "expected the prefix directory in the archive"
1379+ );
1380+ }
11271381 }
src/infrastructure/web/archive.rs+216 −0View file
@@ -0,0 +1,216 @@
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+
13+use 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+
24+use crate::{
25+ application::{archive_repo, open_archive, port::ArchiveFormat},
26+ domain::RefName,
27+};
28+
29+use 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]
37+struct 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.
44+const 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.
53+fn 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}")]
76+async 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.
126+const 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.
133+fn 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.
148+fn 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.
164+fn 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.
181+pub(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)]
190+mod 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+}
src/infrastructure/web/context.rs+6 −1View file
@@ -23,7 +23,7 @@ use crate::{
2323 application::{AppConfig, Identity, describe_identity, is_claimed, resolve_actor},
2424 domain::{Actor, SessionTokenHash},
2525 infrastructure::{
26 git::{DiskGitStorage, GitHttpBackend},
26+ git::{DiskGitArchive, DiskGitStorage, GitHttpBackend},
2727 git_query::DiskGitQuery,
2828 repository::{
2929 SqliteMembershipRepo, SqliteOrgRepo, SqliteRepoRepo, SqliteSessionRepo,
@@ -81,6 +81,11 @@ pub fn queries(cx: &Cx) -> DiskGitQuery {
8181 DiskGitQuery::new(app_context::<AppConfig>(cx).data_dir.clone())
8282 }
8383
84+/// `git archive`, rooted at the same data directory as [`storage`].
85+pub fn archives(cx: &Cx) -> DiskGitArchive {
86+ DiskGitArchive::new(app_context::<AppConfig>(cx).data_dir.clone())
87+}
88+
8489 /// The git smart-HTTP protocol, rooted at the same data directory as [`storage`].
8590 pub fn protocol(cx: &Cx) -> GitHttpBackend {
8691 GitHttpBackend::new(app_context::<AppConfig>(cx).data_dir.clone())
src/infrastructure/web/git.rs+3 −1View file
@@ -258,7 +258,9 @@ pub struct GitBody {
258258 }
259259
260260 impl GitBody {
261 fn new(reader: ByteStream) -> Self {
261+ /// Also used by the archive route, which streams out of git in exactly the same
262+ /// shape — one subprocess's stdout becoming a response body.
263+ pub(super) fn new(reader: ByteStream) -> Self {
262264 Self { reader }
263265 }
264266 }
src/infrastructure/web/mod.rs+1 −0View file
@@ -1,6 +1,7 @@
11 //! The web surface: pages, forms, and the request-scoped helpers they use.
22
33 pub mod api;
4+pub mod archive;
45 pub mod browse;
56 pub mod context;
67 pub mod git;
src/infrastructure/web/repo.rs+34 −6View file
@@ -22,8 +22,8 @@ use topcoat::{
2222
2323 use crate::{
2424 application::{
25 Browsed, Error, FileView, NewRepo, RepoFacts, RepoView, create_repo, repo_summary,
26 view_repo,
25+ Browsed, Error, FileView, NewRepo, RepoFacts, RepoView, create_repo, port::ArchiveFormat,
26+ repo_summary, view_repo,
2727 },
2828 components::{
2929 badge::{BadgeVariant, badge},
@@ -41,6 +41,7 @@ use crate::{
4141 };
4242
4343 use super::{
44+ archive::archive_url,
4445 browse::{
4546 ago, blob, browsed_at, browsed_rev, directory, empty_repo, log_url, repo_toolbar, tree_url,
4647 },
@@ -472,7 +473,12 @@ async fn repo_about(repo: &RepoView, rev: &str, facts: Option<&RepoFacts>, clone
472473 </dl>,
473474 }
474475
475 clone_block(url: clone)
476+ clone_block(
477+ handle: handle,
478+ name: name,
479+ rev: rev,
480+ url: clone,
481+ )
476482 }
477483 }
478484
@@ -540,16 +546,38 @@ async fn fact(term: &str, #[default] child: View) -> Result {
540546 /// The URL alone rather than `git clone <url>`: at sidebar width the command wraps or
541547 /// scrolls, and the address is the part being copied. It wraps rather than scrolls —
542548 /// a horizontally scrolled URL looks like a truncated one, and the part cut off is the
543/// repository's own name. The two small download links — `.tar.gz` and `.zip` — belong
544/// under it once an archive endpoint exists.
549+/// repository's own name.
550+///
551+/// The two download links sit under it, for **the revision being viewed** rather than
552+/// always the default branch: someone reading a tag wants that tag's tarball. An empty
553+/// repository has no revision and gets no links — there is nothing to pack, and the
554+/// clone URL above is the thing that is actually useful there.
545555 #[component]
546async fn clone_block(url: &str) -> Result {
556+async fn clone_block(handle: &str, name: &str, rev: &str, url: &str) -> Result {
547557 view! {
548558 <div class="mt-4 border-t border-border pt-4">
549559 <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
550560 "Clone"
551561 </p>
552562 <pre class="mt-2 rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs break-all whitespace-pre-wrap">(url)</pre>
563+
564+ if !rev.is_empty() {
565+ <p class="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
566+ icon(data: iconify_icon!("feather:download"), attrs: attributes! {
567+ class="size-3.5"
568+ })
569+ "Download"
570+ <a
571+ href=(archive_url(handle, name, rev, ArchiveFormat::Zip))
572+ class="font-mono hover:text-foreground hover:underline"
573+ >"zip"</a>
574+ "·"
575+ <a
576+ href=(archive_url(handle, name, rev, ArchiveFormat::TarGz))
577+ class="font-mono hover:text-foreground hover:underline"
578+ >"tar.gz"</a>
579+ </p>
580+ }
553581 </div>
554582 }
555583 }