steid

@jamesgill /

22.8 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::{
1b7587dfeat: finding a string in a repository, and landing on the line18h
11 BranchRow, CommitSummary, DomainError, GitRef, GrepHit, ObjectId, OrgName, PasswordHash,
12 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 itself19h
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 names19h
391
392 /// Every branch, with what its tip commit says, newest commit first.
393 ///
394 /// Richer than [`list_refs`](Self::list_refs) and for a different page: the switcher
395 /// wants names, the branches page wants a row. Kept separate rather than widening
396 /// `list_refs`, because a switcher that paid for commit subjects it never shows
397 /// would make every tree page slower.
398 ///
399 /// **One `git` process for the whole page**, including which branch is the default
400 /// — git's own `%(HEAD)` marker carries that, so nothing has to ask `symbolic-ref`
401 /// as well.
402 ///
403 /// Ordered by committer date, newest first, because git sorts for free and the
404 /// alternative is re-sorting the whole list in Rust for the same answer. Pinning
405 /// the default branch to the top is a display decision and belongs to the use case.
406 ///
407 /// An empty repository has no branches and answers with an empty list.
408 fn branches(
409 &self,
410 handle: &OrgName,
411 name: &RepoName,
412 ) -> impl Future<Output = Result<Vec<BranchRow>, GitQueryError>> + Send;
413
414 /// Every tag, newest first by creation date.
415 ///
416 /// The counterpart to [`branches`](Self::branches), and one `git` process for the
417 /// same reason. Both kinds of tag are reported: the commit is peeled through an
418 /// annotated tag's object so a row always names something browsable.
419 fn tags(
420 &self,
421 handle: &OrgName,
422 name: &RepoName,
423 ) -> impl Future<Output = Result<Vec<TagRow>, GitQueryError>> + Send;
1b7587dfeat: finding a string in a repository, and landing on the line18h
424
425 /// Lines matching a fixed string, in the revision's tree.
426 ///
427 /// Fixed-string, not a regular expression: a search box that quietly reads `[` as
428 /// syntax is a search box that fails on real code. Binary files are skipped, so a
429 /// grep never reports a match nobody can read.
430 ///
431 /// `limit` caps the *hits carried back*, not what git does — `git grep` has no
432 /// portable maximum, so it runs to completion and the surplus is dropped here. Ask
433 /// for one more than is to be shown and the caller knows it truncated.
434 ///
435 /// An empty list is a real answer: no matches is not a failure, and neither is a
436 /// query with nothing in the repository to match.
437 fn grep(
438 &self,
439 handle: &OrgName,
440 name: &RepoName,
441 commit: &ObjectId,
442 query: &str,
443 limit: usize,
444 ) -> impl Future<Output = Result<Vec<GrepHit>, GitQueryError>> + Send;
dce0bf3feat: browse a repository's files and history8d
445}
446/// A repository could not be read.
447#[derive(Debug)]
c974d80feat: every read-side git process is bounded, and a timeout is not a fault19h
448pub struct GitQueryError {
449 kind: GitQueryErrorKind,
450 source: Box<dyn std::error::Error + Send + Sync>,
451}
452
453/// Why a read failed — the one distinction a page needs to make.
454///
455/// A timeout is the caller asking too much of one request (blame on a huge file, a grep
456/// across a large tree), and the page should say so and offer less. Anything else is a
457/// fault in the repository or in git, and is a 500.
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub enum GitQueryErrorKind {
460 Failed,
461 TimedOut,
462}
dce0bf3feat: browse a repository's files and history8d
463
464impl GitQueryError {
465 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 fault19h
466 Self {
467 kind: GitQueryErrorKind::Failed,
468 source: error.into(),
469 }
470 }
471
472 /// The git process was killed because it exceeded `limit`.
473 pub fn timed_out(limit: std::time::Duration) -> Self {
474 Self {
475 kind: GitQueryErrorKind::TimedOut,
476 source: format!("git did not finish within {limit:?}").into(),
477 }
478 }
479
480 pub fn kind(&self) -> GitQueryErrorKind {
481 self.kind
482 }
483
484 pub fn is_timeout(&self) -> bool {
485 self.kind == GitQueryErrorKind::TimedOut
dce0bf3feat: browse a repository's files and history8d
486 }
487}
488
489impl std::fmt::Display for GitQueryError {
490 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 fault19h
491 match self.kind {
492 GitQueryErrorKind::Failed => {
493 write!(f, "could not read the repository: {}", self.source)
494 }
495 GitQueryErrorKind::TimedOut => {
496 write!(f, "reading the repository took too long: {}", self.source)
497 }
498 }
dce0bf3feat: browse a repository's files and history8d
499 }
500}
501
502impl std::error::Error for GitQueryError {
503 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
c974d80feat: every read-side git process is bounded, and a timeout is not a fault19h
504 Some(&*self.source)
dce0bf3feat: browse a repository's files and history8d
505 }
506}
b7a57dbfeat: a repository can be taken away as a file19h
507
508/// What an archive is packed as.
509///
510/// Two formats because they are what people expect from a forge and what `git archive`
511/// produces without configuration: `tar.gz` everywhere, `zip` for Windows and for
512/// anyone who wants to look inside before extracting.
513#[derive(Debug, Clone, Copy, PartialEq, Eq)]
514pub enum ArchiveFormat {
515 TarGz,
516 Zip,
517}
518
519impl ArchiveFormat {
520 /// What `git archive --format=` is given. Also the file extension, which is not a
521 /// coincidence worth breaking apart into two constants.
522 pub fn as_str(self) -> &'static str {
523 match self {
524 Self::TarGz => "tar.gz",
525 Self::Zip => "zip",
526 }
527 }
528
529 /// What the response labels the bytes.
530 ///
531 /// Honest types, unlike the raw endpoint's deliberate `octet-stream`: neither of
532 /// these is a type a browser renders, so naming it costs nothing and lets a client
533 /// decompress without guessing.
534 pub fn content_type(self) -> &'static str {
535 match self {
536 Self::TarGz => "application/gzip",
537 Self::Zip => "application/zip",
538 }
539 }
540}
541
542/// `Result`, never `Option`, per the layer's rule: a silently-defaulted format would
543/// hand somebody a zip named `.tar.gz`.
544impl std::str::FromStr for ArchiveFormat {
545 type Err = DomainError;
546
547 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
548 match value {
549 "tar.gz" => Ok(Self::TarGz),
550 "zip" => Ok(Self::Zip),
551 other => Err(DomainError::validation(
552 "format",
553 format!("unknown archive format {other:?}"),
554 )),
555 }
556 }
557}
558
559/// One repository, packed for download.
560///
561/// Every field is built by the use case from values it has already resolved and
562/// authorized — the commit is an [`ObjectId`] git handed back, never a revision from a
563/// URL, and the prefix is derived from the repository's own name. Nothing here reaches
564/// git's revision parser.
565#[derive(Debug, Clone, PartialEq, Eq)]
566pub struct ArchiveRequest {
567 pub handle: OrgName,
568 pub name: RepoName,
569 pub format: ArchiveFormat,
570 pub commit: ObjectId,
571 /// The directory every entry is nested under, ending in `/`. Extracting an archive
572 /// that spills its contents into the current directory is a small hostility.
573 pub prefix: String,
574}
575
576/// Packing a repository into an archive.
577///
578/// The fourth narrow git port, and streaming rather than buffered for the same reason
579/// the protocol is: an archive is as large as the repository, so collecting it would
580/// bound downloads by RAM. That is the whole reason this is not a [`GitQuery`] method —
581/// that port's contract is capped bytes, and this one's is a stream.
582pub trait GitArchive: Send + Sync {
583 fn archive(
584 &self,
585 request: ArchiveRequest,
586 ) -> impl Future<Output = Result<ByteStream, GitArchiveError>> + Send;
587}
588
589/// A repository could not be packed.
590///
591/// No `NotFound`: the use case resolved the commit before asking, so anything failing
592/// here is git or the filesystem failing.
593#[derive(Debug)]
594pub struct GitArchiveError(Box<dyn std::error::Error + Send + Sync>);
595
596impl GitArchiveError {
597 pub fn new(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
598 Self(error.into())
599 }
600}
601
602impl std::fmt::Display for GitArchiveError {
603 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
604 write!(f, "could not archive the repository: {}", self.0)
605 }
606}
607
608impl std::error::Error for GitArchiveError {
609 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
610 Some(&*self.0)
611 }
612}