steid

@jamesgill /

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