steid

@jamesgill /

26.9 KBCode·Blame·Raw
26d9828feat: password hashing behind a port1mo
1//! Service ports the application layer needs.
2//!
3//! Repository ports live in `domain::repository`; these are the non-persistence
4//! collaborators. Adapters live in `infrastructure`.
5
47db238feat: serve the git protocol through http-backend, behind a port8d
6use std::{path::PathBuf, pin::Pin};
7
8use tokio::io::AsyncRead;
02eb2e4feat: GitStorage port and DiskGitStorage24d
9
dce0bf3feat: browse a repository's files and history8d
10use crate::domain::{
2268309feat: a commit is a page, and two revisions can be compared17h
11 BranchRow, CommitDetail, CommitSummary, DomainError, GitRef, GrepHit, ObjectId, OrgName,
12 PasswordHash, RefName, RepoName, RepoPath, TagRow, TagSummary, TreeEntry,
dce0bf3feat: browse a repository's files and history8d
13};
26d9828feat: password hashing behind a port1mo
14
15/// Hashes and verifies passwords.
16///
17/// A port rather than a direct Argon2 call so tests can substitute a fast stub —
18/// Argon2 is deliberately slow, and a use case suite that hashes for real takes
19/// seconds per test.
20pub trait PasswordHasher: Send + Sync {
21 /// Hashes a plaintext password.
22 fn hash(&self, plaintext: &str) -> Result<PasswordHash, PasswordError>;
23
24 /// Checks a plaintext password against a stored hash.
25 ///
26 /// Returns `Ok(false)` for a wrong password and `Err` only when the hash itself
27 /// cannot be parsed — a corrupt stored hash is a different problem from a failed
28 /// login, and collapsing them hides real faults.
29 fn verify(&self, plaintext: &str, hash: &PasswordHash) -> Result<bool, PasswordError>;
30}
31
32/// A password could not be hashed or a stored hash could not be parsed.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct PasswordError(String);
35
36impl PasswordError {
37 pub fn new(message: impl Into<String>) -> Self {
38 Self(message.into())
39 }
40}
41
42impl std::fmt::Display for PasswordError {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 write!(f, "password hashing failed: {}", self.0)
45 }
46}
47
48impl std::error::Error for PasswordError {}
02eb2e4feat: GitStorage port and DiskGitStorage24d
49
50/// Where bare git repositories live on disk.
51///
52/// Laid out as `{data_dir}/{handle}/{name}.git`. Keyed by handle rather than
53/// [`OrgId`](crate::domain::OrgId) so the data directory is legible to anyone who has
54/// to debug it; the cost is that renaming a handle becomes a directory move rather
55/// than a row update.
56///
57/// Deliberately narrow. Serving the git protocol and browsing a tree are separate
58/// concerns with separate shapes — streaming and querying — and get their own ports as
59/// their use cases arrive, rather than accreting here. See
60/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md).
61pub trait GitStorage: Send + Sync {
62 /// Creates an empty bare repository.
63 ///
64 /// Empty means empty: no initial commit, no branch, matching what GitHub does for
65 /// a repository created without a README.
66 ///
67 /// Fails with [`GitStorageError::AlreadyExists`] rather than adopting whatever is
68 /// already there.
69 fn init_bare(
70 &self,
71 handle: &OrgName,
72 name: &RepoName,
73 ) -> impl Future<Output = Result<(), GitStorageError>> + Send;
74
75 /// Removes a bare repository, succeeding if there was nothing to remove.
76 ///
77 /// This exists to compensate a failed record insert — a repository row and its
78 /// directory cannot share a transaction — not as a user-facing delete. Deleting a
79 /// repository properly is a separate use case with its own authorization.
80 fn remove(
81 &self,
82 handle: &OrgName,
83 name: &RepoName,
84 ) -> impl Future<Output = Result<(), GitStorageError>> + Send;
85
86 /// Where a repository lives.
87 ///
88 /// Pure, and says nothing about whether anything exists there. Milestone 4 hands
89 /// this to `git http-backend`.
90 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf;
91}
92
93/// A repository could not be created or removed on disk.
94#[derive(Debug)]
95pub enum GitStorageError {
96 /// Something already occupies the repository's path.
97 ///
98 /// Kept distinct from a general failure because it is the one case a use case can
99 /// explain to a user, and because with no matching record it means an orphaned
100 /// directory left by a crashed create.
101 AlreadyExists,
102 /// The filesystem or the `git` binary failed.
103 Backend(Box<dyn std::error::Error + Send + Sync>),
104}
105
106impl GitStorageError {
107 pub fn backend(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
108 Self::Backend(error.into())
109 }
110}
111
112impl std::fmt::Display for GitStorageError {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 Self::AlreadyExists => f.write_str("a repository already exists at that path"),
116 Self::Backend(error) => write!(f, "git storage failure: {error}"),
117 }
118 }
119}
120
121impl std::error::Error for GitStorageError {
122 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
123 match self {
124 Self::AlreadyExists => None,
125 Self::Backend(error) => Some(&**error),
126 }
127 }
128}
47db238feat: serve the git protocol through http-backend, behind a port8d
129
130/// A stream of bytes, in either direction.
131///
132/// Pack data is arbitrarily large and must never be collected into memory — a clone of
133/// a large repository would otherwise be bounded by RAM rather than by disk. Boxed
134/// rather than generic so the port stays object-safe in shape and the adapter can hand
135/// back a subprocess's stdout directly.
136pub type ByteStream = Pin<Box<dyn AsyncRead + Send>>;
137
138/// Which HTTP method a git request uses.
139///
140/// Only these two exist in the smart protocol: the advertisement is a `GET`, the
141/// negotiation and pack transfer are `POST`s.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum GitMethod {
144 Get,
145 Post,
146}
147
148impl GitMethod {
149 pub fn as_str(self) -> &'static str {
150 match self {
151 Self::Get => "GET",
152 Self::Post => "POST",
153 }
154 }
155}
156
157/// A request to the git protocol, in the shape `git http-backend` wants it.
158///
159/// CGI-shaped rather than one method per operation, because the backend is a CGI —
160/// see the amendment to
161/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md). **Every field
162/// here is constructed by the use case**, not forwarded from a URL: `path_info` and
163/// `query` in particular are built from an already-validated handle and repository
164/// name, so the transport cannot ask for a path the use case did not authorize.
165pub struct GitRequest {
166 pub method: GitMethod,
167 /// The repository path relative to the project root, e.g. `/acme/steid.git/info/refs`.
168 pub path_info: String,
169 /// The CGI query string, e.g. `service=git-upload-pack`. Empty for an RPC POST.
170 pub query: String,
171 pub content_type: Option<String>,
172 /// The client's `Content-Encoding`. Real clients gzip this body once a repository
173 /// has more than a handful of refs, and the backend inflates it for us — but only
174 /// when it arrives as `HTTP_CONTENT_ENCODING`, which is the adapter's job.
175 pub content_encoding: Option<String>,
176 pub content_length: Option<String>,
177 /// The client's `Git-Protocol`, carrying `version=2` for any modern client.
178 /// Dropping it silently downgrades the exchange to v0 rather than failing.
179 pub git_protocol: Option<String>,
a814db5feat: push and clone private repositories with a token8d
180 /// Whether the backend may run `receive-pack` at all.
181 ///
182 /// `git http-backend` refuses pushes unless `http.receivepack` says otherwise, and
183 /// this is what sets it. The use case turns it on **only after** authorizing the
184 /// write, so git stays a second refusal behind Steid's own rather than being
185 /// switched off wholesale. If the authorization logic is ever wrong, this is what
186 /// still says no.
187 pub allow_receive_pack: bool,
47db238feat: serve the git protocol through http-backend, behind a port8d
188 pub body: ByteStream,
189}
190
191/// What the git protocol answered.
192///
193/// The status is CGI's, not the framework's: `git http-backend` reports failure with a
194/// `Status:` header and no status at all when it succeeded, so absence means 200 and
195/// the adapter translates it.
196pub struct GitResponse {
197 pub status: u16,
198 pub headers: Vec<(String, String)>,
199 pub body: ByteStream,
200}
201
8ac00bdfeat: decide who may reach the git protocol8d
202/// Written by hand because a body is a stream: it cannot be formatted without being
203/// consumed, and a `Debug` that drains the response would be a trap.
204impl std::fmt::Debug for GitRequest {
205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206 f.debug_struct("GitRequest")
207 .field("method", &self.method)
208 .field("path_info", &self.path_info)
209 .field("query", &self.query)
210 .field("content_type", &self.content_type)
211 .field("content_encoding", &self.content_encoding)
212 .field("content_length", &self.content_length)
213 .field("git_protocol", &self.git_protocol)
a814db5feat: push and clone private repositories with a token8d
214 .field("allow_receive_pack", &self.allow_receive_pack)
8ac00bdfeat: decide who may reach the git protocol8d
215 .finish_non_exhaustive()
216 }
217}
218
219impl std::fmt::Debug for GitResponse {
220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 f.debug_struct("GitResponse")
222 .field("status", &self.status)
223 .field("headers", &self.headers)
224 .finish_non_exhaustive()
225 }
226}
227
47db238feat: serve the git protocol through http-backend, behind a port8d
228/// Serves the git smart-HTTP protocol for one repository.
229///
230/// Says nothing about who may do this. Authorization is settled before a request ever
231/// reaches here — by the time the backend is running, refusing is no longer possible.
232pub trait GitProtocolServer: Send + Sync {
233 fn serve(
234 &self,
235 request: GitRequest,
236 ) -> impl Future<Output = Result<GitResponse, GitProtocolError>> + Send;
237}
238
239/// The git protocol could not be served.
240///
241/// Deliberately without a `NotFound`: a missing repository is something the backend
242/// reports in its own response, and the use case has already refused anything the
243/// viewer may not see.
244#[derive(Debug)]
245pub struct GitProtocolError(Box<dyn std::error::Error + Send + Sync>);
246
247impl GitProtocolError {
248 pub fn new(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
249 Self(error.into())
250 }
251}
252
253impl std::fmt::Display for GitProtocolError {
254 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255 write!(f, "git protocol failure: {}", self.0)
256 }
257}
258
259impl std::error::Error for GitProtocolError {
260 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
261 Some(&*self.0)
262 }
263}
dce0bf3feat: browse a repository's files and history8d
264
265/// A blob, as far as the port will carry it.
266///
267/// `content` is `None` when the blob is larger than the caller's limit: the size is
268/// still reported, so a page can say how big the thing it will not show is. Reading it
269/// anyway would let one URL pull an arbitrarily large file into memory.
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct Blob {
272 pub id: ObjectId,
273 pub size: u64,
274 pub content: Option<Vec<u8>>,
275}
276
277/// Reading what is inside a repository.
278///
279/// The third of the three git families named in
280/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md), arriving with
281/// its use case as that ADR requires. Every method takes a handle and a repository name
282/// rather than a path, matching [`GitStorage`] — where a repository lives is the
283/// adapter's business.
284///
285/// **Bytes are capped, never streamed.** Unlike the protocol, a browse request has a
286/// person waiting on a rendered page, so a bounded read is the right shape.
287///
288/// `Ok(None)` throughout means "no such thing in this repository" — an unknown
289/// revision, a path that is not there. It is not an authorization answer; that is
290/// settled before this port is reached.
291pub trait GitQuery: Send + Sync {
292 /// The repository's default branch, or `None` if it has no commits yet.
293 ///
294 /// Separate from resolving a revision because an empty repository has a `HEAD` that
295 /// names a branch which does not exist, and telling those apart is the difference
296 /// between "nothing pushed yet" and a 404.
297 fn default_branch(
298 &self,
299 handle: &OrgName,
300 name: &RepoName,
301 ) -> impl Future<Output = Result<Option<RefName>, GitQueryError>> + Send;
302
303 /// Resolves a revision to the commit it names.
304 fn resolve(
305 &self,
306 handle: &OrgName,
307 name: &RepoName,
308 rev: &RefName,
309 ) -> impl Future<Output = Result<Option<ObjectId>, GitQueryError>> + Send;
310
311 /// Lists a directory, unsorted — ordering is [`TreeEntry::ordering_key`]'s job.
312 ///
313 /// `Ok(None)` for a path that is not a directory in this revision, which includes a
314 /// path that is a file.
315 fn list_tree(
316 &self,
317 handle: &OrgName,
318 name: &RepoName,
319 rev: &RefName,
320 path: &RepoPath,
321 ) -> impl Future<Output = Result<Option<Vec<TreeEntry>>, GitQueryError>> + Send;
322
323 /// Reads a file, up to `max_bytes`.
324 ///
325 /// `Ok(None)` for a path that is not a file in this revision.
326 fn read_blob(
327 &self,
328 handle: &OrgName,
329 name: &RepoName,
330 rev: &RefName,
331 path: &RepoPath,
332 max_bytes: u64,
333 ) -> impl Future<Output = Result<Option<Blob>, GitQueryError>> + Send;
334
335 /// The most recent commits reachable from a revision, newest first.
336 fn log(
337 &self,
338 handle: &OrgName,
339 name: &RepoName,
340 rev: &RefName,
341 limit: usize,
342 ) -> impl Future<Output = Result<Vec<CommitSummary>, GitQueryError>> + Send;
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
343
344 /// Every branch and every tag, unordered.
345 ///
346 /// Ordering is the use case's decision, the same way it is for
347 /// [`list_tree`](Self::list_tree) — an adapter that sorted would have to be
348 /// re-taught the order every time it changed.
349 ///
350 /// **This costs a whole `git` process** — ~14ms, the most expensive of the read
351 /// commands measured for the Milestone 5 amendment to
352 /// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md), because
353 /// starting the process is the cost. A page that does not show a ref switcher must
354 /// not call it.
355 ///
356 /// An empty repository has no refs and answers with an empty list rather than an
357 /// error: nothing pushed yet is not a failure.
358 fn list_refs(
359 &self,
360 handle: &OrgName,
361 name: &RepoName,
362 ) -> impl Future<Output = Result<Vec<GitRef>, GitQueryError>> + Send;
dd5b600feat: the facts a repository page states about itself17h
363
364 /// How many commits are reachable from a revision.
365 ///
366 /// The number a repository page states about itself. A revision that resolves to
367 /// nothing — an empty repository, a branch that is not there — counts `0` rather
368 /// than failing, matching [`log`](Self::log): a page asking how big a repository is
369 /// does not want an error for the answer "nothing yet".
370 fn count_commits(
371 &self,
372 handle: &OrgName,
373 name: &RepoName,
374 rev: &RefName,
375 ) -> impl Future<Output = Result<u64, GitQueryError>> + Send;
376
377 /// The most recently created tag, or `None` if the repository has none.
378 ///
379 /// "Most recent" is by creation date, not by name: version numbers do not sort
380 /// chronologically once there is a `v1.10` beside a `v1.9`, and a repository's own
381 /// history is the only ordering that is always right.
382 ///
383 /// One `git` process, the same cost as [`list_refs`](Self::list_refs). Asked
384 /// separately rather than derived from that list because the ordering needs git's
385 /// `creatordate`, which the ref list deliberately does not carry.
386 fn latest_tag(
387 &self,
388 handle: &OrgName,
389 name: &RepoName,
390 ) -> impl Future<Output = Result<Option<TagSummary>, GitQueryError>> + Send;
3bbcd9ffeat: a branch and a tag are rows, not just names17h
391
2268309feat: a commit is a page, and two revisions can be compared17h
392 /// One commit, in full.
393 ///
394 /// `Ok(None)` for a revision that names nothing, and for one that names something
395 /// which is not a commit — a tree or a blob addressed by its own id. A commit page
396 /// has nothing to show for either, so both are the same 404.
397 ///
398 /// **Two `git` processes**: the revision is resolved before it is read, for the
399 /// reason [`count_commits`](Self::count_commits) resolves first. The resolved
400 /// [`ObjectId`] comes back on the answer, so a caller that goes on to ask for a
401 /// diff does not pay to resolve it again.
402 fn commit(
403 &self,
404 handle: &OrgName,
405 name: &RepoName,
406 rev: &RefName,
407 ) -> impl Future<Output = Result<Option<CommitDetail>, GitQueryError>> + Send;
408
409 /// The patch between two commits, as git writes it.
410 ///
411 /// Takes resolved ids rather than revisions: the caller has already resolved them
412 /// — that is what [`commit`](Self::commit) and [`resolve`](Self::resolve) hand
413 /// back — and re-resolving here would be a process per call for an answer already
414 /// in hand.
415 ///
416 /// `base` of `None` diffs against the empty tree, which is what a root commit needs
417 /// — a first commit has no parent to compare with, and showing it as an empty diff
418 /// would hide the whole of it.
419 ///
420 /// **Bytes are capped like every other read here**, and unlike the others the cap
421 /// is expected to bite: a merge of a vendored dependency is a legitimately enormous
422 /// patch. [`RawDiff::truncated`] says whether it did, and the per-file counts are
423 /// carried separately *precisely so that they survive it* — see [`RawDiff`].
424 ///
425 /// One `git` process.
426 fn diff(
427 &self,
428 handle: &OrgName,
429 name: &RepoName,
430 base: Option<&ObjectId>,
431 head: &ObjectId,
432 max_bytes: u64,
433 ) -> impl Future<Output = Result<RawDiff, GitQueryError>> + Send;
434
435 /// The best common ancestor of two commits, or `None` when they share none.
436 ///
437 /// `None` is a real answer, not a failure: two histories imported into one
438 /// repository have no merge base, and a compare page says so rather than 500ing.
439 ///
440 /// One `git` process.
441 fn merge_base(
442 &self,
443 handle: &OrgName,
444 name: &RepoName,
445 base: &ObjectId,
446 head: &ObjectId,
447 ) -> impl Future<Output = Result<Option<ObjectId>, GitQueryError>> + Send;
448
449 /// The commits reachable from `head` but not from `base`, newest first.
450 ///
451 /// `base` of `None` is every commit reachable from `head`, which makes this a
452 /// superset of [`log`](Self::log) — kept separate anyway, because `log` takes a
453 /// revision and this takes resolved ids, and collapsing them would put a URL's text
454 /// back in front of git's revision parser.
455 ///
456 /// One `git` process.
457 fn log_between(
458 &self,
459 handle: &OrgName,
460 name: &RepoName,
461 base: Option<&ObjectId>,
462 head: &ObjectId,
463 limit: usize,
464 ) -> impl Future<Output = Result<Vec<CommitSummary>, GitQueryError>> + Send;
465
3bbcd9ffeat: a branch and a tag are rows, not just names17h
466 /// Every branch, with what its tip commit says, newest commit first.
467 ///
468 /// Richer than [`list_refs`](Self::list_refs) and for a different page: the switcher
469 /// wants names, the branches page wants a row. Kept separate rather than widening
470 /// `list_refs`, because a switcher that paid for commit subjects it never shows
471 /// would make every tree page slower.
472 ///
473 /// **One `git` process for the whole page**, including which branch is the default
474 /// — git's own `%(HEAD)` marker carries that, so nothing has to ask `symbolic-ref`
475 /// as well.
476 ///
477 /// Ordered by committer date, newest first, because git sorts for free and the
478 /// alternative is re-sorting the whole list in Rust for the same answer. Pinning
479 /// the default branch to the top is a display decision and belongs to the use case.
480 ///
481 /// An empty repository has no branches and answers with an empty list.
482 fn branches(
483 &self,
484 handle: &OrgName,
485 name: &RepoName,
486 ) -> impl Future<Output = Result<Vec<BranchRow>, GitQueryError>> + Send;
487
488 /// Every tag, newest first by creation date.
489 ///
490 /// The counterpart to [`branches`](Self::branches), and one `git` process for the
491 /// same reason. Both kinds of tag are reported: the commit is peeled through an
492 /// annotated tag's object so a row always names something browsable.
493 fn tags(
494 &self,
495 handle: &OrgName,
496 name: &RepoName,
497 ) -> impl Future<Output = Result<Vec<TagRow>, GitQueryError>> + Send;
1b7587dfeat: finding a string in a repository, and landing on the line17h
498
499 /// Lines matching a fixed string, in the revision's tree.
500 ///
501 /// Fixed-string, not a regular expression: a search box that quietly reads `[` as
502 /// syntax is a search box that fails on real code. Binary files are skipped, so a
503 /// grep never reports a match nobody can read.
504 ///
505 /// `limit` caps the *hits carried back*, not what git does — `git grep` has no
506 /// portable maximum, so it runs to completion and the surplus is dropped here. Ask
507 /// for one more than is to be shown and the caller knows it truncated.
508 ///
509 /// An empty list is a real answer: no matches is not a failure, and neither is a
510 /// query with nothing in the repository to match.
511 fn grep(
512 &self,
513 handle: &OrgName,
514 name: &RepoName,
515 commit: &ObjectId,
516 query: &str,
517 limit: usize,
518 ) -> impl Future<Output = Result<Vec<GrepHit>, GitQueryError>> + Send;
dce0bf3feat: browse a repository's files and history8d
519}
2268309feat: a commit is a page, and two revisions can be compared17h
520
521/// A patch, plus the per-file counts that outlive truncating it.
522///
523/// The two arrive from **one** `git` process, because git will write a `--numstat`
524/// block and a `-p` patch in the same run and writes the counts *first*. That ordering
525/// is the whole reason for asking this way: a diff too large to render still has
526/// complete per-file statistics at the top of what was read, so the page can list every
527/// changed file with its `+a −b` and simply decline to draw the lines. Asking twice
528/// would cost a second process on every commit page to buy something only the rare
529/// oversized one needs.
530#[derive(Debug, Clone, Default, PartialEq, Eq)]
531pub struct RawDiff {
532 /// The `--numstat` block: `<added> TAB <removed> TAB <path>` per changed file, with
533 /// `-` for both counts when the file is binary. Always complete.
534 pub numstat: Vec<u8>,
535 /// The unified patch. Cut short — possibly mid-line — when
536 /// [`truncated`](Self::truncated) is set.
537 pub patch: Vec<u8>,
538 /// Whether the patch hit the byte cap and is therefore not the whole of it.
539 pub truncated: bool,
540}
dce0bf3feat: browse a repository's files and history8d
541/// A repository could not be read.
542#[derive(Debug)]
c974d80feat: every read-side git process is bounded, and a timeout is not a fault17h
543pub struct GitQueryError {
544 kind: GitQueryErrorKind,
545 source: Box<dyn std::error::Error + Send + Sync>,
546}
547
548/// Why a read failed — the one distinction a page needs to make.
549///
550/// A timeout is the caller asking too much of one request (blame on a huge file, a grep
551/// across a large tree), and the page should say so and offer less. Anything else is a
552/// fault in the repository or in git, and is a 500.
553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554pub enum GitQueryErrorKind {
555 Failed,
556 TimedOut,
557}
dce0bf3feat: browse a repository's files and history8d
558
559impl GitQueryError {
560 pub fn new(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
c974d80feat: every read-side git process is bounded, and a timeout is not a fault17h
561 Self {
562 kind: GitQueryErrorKind::Failed,
563 source: error.into(),
564 }
565 }
566
567 /// The git process was killed because it exceeded `limit`.
568 pub fn timed_out(limit: std::time::Duration) -> Self {
569 Self {
570 kind: GitQueryErrorKind::TimedOut,
571 source: format!("git did not finish within {limit:?}").into(),
572 }
573 }
574
575 pub fn kind(&self) -> GitQueryErrorKind {
576 self.kind
577 }
578
579 pub fn is_timeout(&self) -> bool {
580 self.kind == GitQueryErrorKind::TimedOut
dce0bf3feat: browse a repository's files and history8d
581 }
582}
583
584impl std::fmt::Display for GitQueryError {
585 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
c974d80feat: every read-side git process is bounded, and a timeout is not a fault17h
586 match self.kind {
587 GitQueryErrorKind::Failed => {
588 write!(f, "could not read the repository: {}", self.source)
589 }
590 GitQueryErrorKind::TimedOut => {
591 write!(f, "reading the repository took too long: {}", self.source)
592 }
593 }
dce0bf3feat: browse a repository's files and history8d
594 }
595}
596
597impl std::error::Error for GitQueryError {
598 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
c974d80feat: every read-side git process is bounded, and a timeout is not a fault17h
599 Some(&*self.source)
dce0bf3feat: browse a repository's files and history8d
600 }
601}
b7a57dbfeat: a repository can be taken away as a file17h
602
603/// What an archive is packed as.
604///
605/// Two formats because they are what people expect from a forge and what `git archive`
606/// produces without configuration: `tar.gz` everywhere, `zip` for Windows and for
607/// anyone who wants to look inside before extracting.
608#[derive(Debug, Clone, Copy, PartialEq, Eq)]
609pub enum ArchiveFormat {
610 TarGz,
611 Zip,
612}
613
614impl ArchiveFormat {
615 /// What `git archive --format=` is given. Also the file extension, which is not a
616 /// coincidence worth breaking apart into two constants.
617 pub fn as_str(self) -> &'static str {
618 match self {
619 Self::TarGz => "tar.gz",
620 Self::Zip => "zip",
621 }
622 }
623
624 /// What the response labels the bytes.
625 ///
626 /// Honest types, unlike the raw endpoint's deliberate `octet-stream`: neither of
627 /// these is a type a browser renders, so naming it costs nothing and lets a client
628 /// decompress without guessing.
629 pub fn content_type(self) -> &'static str {
630 match self {
631 Self::TarGz => "application/gzip",
632 Self::Zip => "application/zip",
633 }
634 }
635}
636
637/// `Result`, never `Option`, per the layer's rule: a silently-defaulted format would
638/// hand somebody a zip named `.tar.gz`.
639impl std::str::FromStr for ArchiveFormat {
640 type Err = DomainError;
641
642 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
643 match value {
644 "tar.gz" => Ok(Self::TarGz),
645 "zip" => Ok(Self::Zip),
646 other => Err(DomainError::validation(
647 "format",
648 format!("unknown archive format {other:?}"),
649 )),
650 }
651 }
652}
653
654/// One repository, packed for download.
655///
656/// Every field is built by the use case from values it has already resolved and
657/// authorized — the commit is an [`ObjectId`] git handed back, never a revision from a
658/// URL, and the prefix is derived from the repository's own name. Nothing here reaches
659/// git's revision parser.
660#[derive(Debug, Clone, PartialEq, Eq)]
661pub struct ArchiveRequest {
662 pub handle: OrgName,
663 pub name: RepoName,
664 pub format: ArchiveFormat,
665 pub commit: ObjectId,
666 /// The directory every entry is nested under, ending in `/`. Extracting an archive
667 /// that spills its contents into the current directory is a small hostility.
668 pub prefix: String,
669}
670
671/// Packing a repository into an archive.
672///
673/// The fourth narrow git port, and streaming rather than buffered for the same reason
674/// the protocol is: an archive is as large as the repository, so collecting it would
675/// bound downloads by RAM. That is the whole reason this is not a [`GitQuery`] method —
676/// that port's contract is capped bytes, and this one's is a stream.
677pub trait GitArchive: Send + Sync {
678 fn archive(
679 &self,
680 request: ArchiveRequest,
681 ) -> impl Future<Output = Result<ByteStream, GitArchiveError>> + Send;
682}
683
684/// A repository could not be packed.
685///
686/// No `NotFound`: the use case resolved the commit before asking, so anything failing
687/// here is git or the filesystem failing.
688#[derive(Debug)]
689pub struct GitArchiveError(Box<dyn std::error::Error + Send + Sync>);
690
691impl GitArchiveError {
692 pub fn new(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
693 Self(error.into())
694 }
695}
696
697impl std::fmt::Display for GitArchiveError {
698 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
699 write!(f, "could not archive the repository: {}", self.0)
700 }
701}
702
703impl std::error::Error for GitArchiveError {
704 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
705 Some(&*self.0)
706 }
707}