@jpgilldev / steid

14.2 KBRaw
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
6use std::{path::PathBuf, pin::Pin};
7
8use tokio::io::AsyncRead;
9
10use crate::domain::{
11 CommitSummary, GitRef, ObjectId, OrgName, PasswordHash, RefName, RepoName, RepoPath, TreeEntry,
12};
13
14/// Hashes and verifies passwords.
15///
16/// A port rather than a direct Argon2 call so tests can substitute a fast stub —
17/// Argon2 is deliberately slow, and a use case suite that hashes for real takes
18/// seconds per test.
19pub trait PasswordHasher: Send + Sync {
20 /// Hashes a plaintext password.
21 fn hash(&self, plaintext: &str) -> Result<PasswordHash, PasswordError>;
22
23 /// Checks a plaintext password against a stored hash.
24 ///
25 /// Returns `Ok(false)` for a wrong password and `Err` only when the hash itself
26 /// cannot be parsed — a corrupt stored hash is a different problem from a failed
27 /// login, and collapsing them hides real faults.
28 fn verify(&self, plaintext: &str, hash: &PasswordHash) -> Result<bool, PasswordError>;
29}
30
31/// A password could not be hashed or a stored hash could not be parsed.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct PasswordError(String);
34
35impl PasswordError {
36 pub fn new(message: impl Into<String>) -> Self {
37 Self(message.into())
38 }
39}
40
41impl std::fmt::Display for PasswordError {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 write!(f, "password hashing failed: {}", self.0)
44 }
45}
46
47impl std::error::Error for PasswordError {}
48
49/// Where bare git repositories live on disk.
50///
51/// Laid out as `{data_dir}/{handle}/{name}.git`. Keyed by handle rather than
52/// [`OrgId`](crate::domain::OrgId) so the data directory is legible to anyone who has
53/// to debug it; the cost is that renaming a handle becomes a directory move rather
54/// than a row update.
55///
56/// Deliberately narrow. Serving the git protocol and browsing a tree are separate
57/// concerns with separate shapes — streaming and querying — and get their own ports as
58/// their use cases arrive, rather than accreting here. See
59/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md).
60pub trait GitStorage: Send + Sync {
61 /// Creates an empty bare repository.
62 ///
63 /// Empty means empty: no initial commit, no branch, matching what GitHub does for
64 /// a repository created without a README.
65 ///
66 /// Fails with [`GitStorageError::AlreadyExists`] rather than adopting whatever is
67 /// already there.
68 fn init_bare(
69 &self,
70 handle: &OrgName,
71 name: &RepoName,
72 ) -> impl Future<Output = Result<(), GitStorageError>> + Send;
73
74 /// Removes a bare repository, succeeding if there was nothing to remove.
75 ///
76 /// This exists to compensate a failed record insert — a repository row and its
77 /// directory cannot share a transaction — not as a user-facing delete. Deleting a
78 /// repository properly is a separate use case with its own authorization.
79 fn remove(
80 &self,
81 handle: &OrgName,
82 name: &RepoName,
83 ) -> impl Future<Output = Result<(), GitStorageError>> + Send;
84
85 /// Where a repository lives.
86 ///
87 /// Pure, and says nothing about whether anything exists there. Milestone 4 hands
88 /// this to `git http-backend`.
89 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf;
90}
91
92/// A repository could not be created or removed on disk.
93#[derive(Debug)]
94pub enum GitStorageError {
95 /// Something already occupies the repository's path.
96 ///
97 /// Kept distinct from a general failure because it is the one case a use case can
98 /// explain to a user, and because with no matching record it means an orphaned
99 /// directory left by a crashed create.
100 AlreadyExists,
101 /// The filesystem or the `git` binary failed.
102 Backend(Box<dyn std::error::Error + Send + Sync>),
103}
104
105impl GitStorageError {
106 pub fn backend(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
107 Self::Backend(error.into())
108 }
109}
110
111impl std::fmt::Display for GitStorageError {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 match self {
114 Self::AlreadyExists => f.write_str("a repository already exists at that path"),
115 Self::Backend(error) => write!(f, "git storage failure: {error}"),
116 }
117 }
118}
119
120impl std::error::Error for GitStorageError {
121 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
122 match self {
123 Self::AlreadyExists => None,
124 Self::Backend(error) => Some(&**error),
125 }
126 }
127}
128
129/// A stream of bytes, in either direction.
130///
131/// Pack data is arbitrarily large and must never be collected into memory — a clone of
132/// a large repository would otherwise be bounded by RAM rather than by disk. Boxed
133/// rather than generic so the port stays object-safe in shape and the adapter can hand
134/// back a subprocess's stdout directly.
135pub type ByteStream = Pin<Box<dyn AsyncRead + Send>>;
136
137/// Which HTTP method a git request uses.
138///
139/// Only these two exist in the smart protocol: the advertisement is a `GET`, the
140/// negotiation and pack transfer are `POST`s.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum GitMethod {
143 Get,
144 Post,
145}
146
147impl GitMethod {
148 pub fn as_str(self) -> &'static str {
149 match self {
150 Self::Get => "GET",
151 Self::Post => "POST",
152 }
153 }
154}
155
156/// A request to the git protocol, in the shape `git http-backend` wants it.
157///
158/// CGI-shaped rather than one method per operation, because the backend is a CGI —
159/// see the amendment to
160/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md). **Every field
161/// here is constructed by the use case**, not forwarded from a URL: `path_info` and
162/// `query` in particular are built from an already-validated handle and repository
163/// name, so the transport cannot ask for a path the use case did not authorize.
164pub struct GitRequest {
165 pub method: GitMethod,
166 /// The repository path relative to the project root, e.g. `/acme/steid.git/info/refs`.
167 pub path_info: String,
168 /// The CGI query string, e.g. `service=git-upload-pack`. Empty for an RPC POST.
169 pub query: String,
170 pub content_type: Option<String>,
171 /// The client's `Content-Encoding`. Real clients gzip this body once a repository
172 /// has more than a handful of refs, and the backend inflates it for us — but only
173 /// when it arrives as `HTTP_CONTENT_ENCODING`, which is the adapter's job.
174 pub content_encoding: Option<String>,
175 pub content_length: Option<String>,
176 /// The client's `Git-Protocol`, carrying `version=2` for any modern client.
177 /// Dropping it silently downgrades the exchange to v0 rather than failing.
178 pub git_protocol: Option<String>,
179 /// Whether the backend may run `receive-pack` at all.
180 ///
181 /// `git http-backend` refuses pushes unless `http.receivepack` says otherwise, and
182 /// this is what sets it. The use case turns it on **only after** authorizing the
183 /// write, so git stays a second refusal behind Steid's own rather than being
184 /// switched off wholesale. If the authorization logic is ever wrong, this is what
185 /// still says no.
186 pub allow_receive_pack: bool,
187 pub body: ByteStream,
188}
189
190/// What the git protocol answered.
191///
192/// The status is CGI's, not the framework's: `git http-backend` reports failure with a
193/// `Status:` header and no status at all when it succeeded, so absence means 200 and
194/// the adapter translates it.
195pub struct GitResponse {
196 pub status: u16,
197 pub headers: Vec<(String, String)>,
198 pub body: ByteStream,
199}
200
201/// Written by hand because a body is a stream: it cannot be formatted without being
202/// consumed, and a `Debug` that drains the response would be a trap.
203impl std::fmt::Debug for GitRequest {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 f.debug_struct("GitRequest")
206 .field("method", &self.method)
207 .field("path_info", &self.path_info)
208 .field("query", &self.query)
209 .field("content_type", &self.content_type)
210 .field("content_encoding", &self.content_encoding)
211 .field("content_length", &self.content_length)
212 .field("git_protocol", &self.git_protocol)
213 .field("allow_receive_pack", &self.allow_receive_pack)
214 .finish_non_exhaustive()
215 }
216}
217
218impl std::fmt::Debug for GitResponse {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 f.debug_struct("GitResponse")
221 .field("status", &self.status)
222 .field("headers", &self.headers)
223 .finish_non_exhaustive()
224 }
225}
226
227/// Serves the git smart-HTTP protocol for one repository.
228///
229/// Says nothing about who may do this. Authorization is settled before a request ever
230/// reaches here — by the time the backend is running, refusing is no longer possible.
231pub trait GitProtocolServer: Send + Sync {
232 fn serve(
233 &self,
234 request: GitRequest,
235 ) -> impl Future<Output = Result<GitResponse, GitProtocolError>> + Send;
236}
237
238/// The git protocol could not be served.
239///
240/// Deliberately without a `NotFound`: a missing repository is something the backend
241/// reports in its own response, and the use case has already refused anything the
242/// viewer may not see.
243#[derive(Debug)]
244pub struct GitProtocolError(Box<dyn std::error::Error + Send + Sync>);
245
246impl GitProtocolError {
247 pub fn new(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
248 Self(error.into())
249 }
250}
251
252impl std::fmt::Display for GitProtocolError {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 write!(f, "git protocol failure: {}", self.0)
255 }
256}
257
258impl std::error::Error for GitProtocolError {
259 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
260 Some(&*self.0)
261 }
262}
263
264/// A blob, as far as the port will carry it.
265///
266/// `content` is `None` when the blob is larger than the caller's limit: the size is
267/// still reported, so a page can say how big the thing it will not show is. Reading it
268/// anyway would let one URL pull an arbitrarily large file into memory.
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct Blob {
271 pub id: ObjectId,
272 pub size: u64,
273 pub content: Option<Vec<u8>>,
274}
275
276/// Reading what is inside a repository.
277///
278/// The third of the three git families named in
279/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md), arriving with
280/// its use case as that ADR requires. Every method takes a handle and a repository name
281/// rather than a path, matching [`GitStorage`] — where a repository lives is the
282/// adapter's business.
283///
284/// **Bytes are capped, never streamed.** Unlike the protocol, a browse request has a
285/// person waiting on a rendered page, so a bounded read is the right shape.
286///
287/// `Ok(None)` throughout means "no such thing in this repository" — an unknown
288/// revision, a path that is not there. It is not an authorization answer; that is
289/// settled before this port is reached.
290pub trait GitQuery: Send + Sync {
291 /// The repository's default branch, or `None` if it has no commits yet.
292 ///
293 /// Separate from resolving a revision because an empty repository has a `HEAD` that
294 /// names a branch which does not exist, and telling those apart is the difference
295 /// between "nothing pushed yet" and a 404.
296 fn default_branch(
297 &self,
298 handle: &OrgName,
299 name: &RepoName,
300 ) -> impl Future<Output = Result<Option<RefName>, GitQueryError>> + Send;
301
302 /// Resolves a revision to the commit it names.
303 fn resolve(
304 &self,
305 handle: &OrgName,
306 name: &RepoName,
307 rev: &RefName,
308 ) -> impl Future<Output = Result<Option<ObjectId>, GitQueryError>> + Send;
309
310 /// Lists a directory, unsorted — ordering is [`TreeEntry::ordering_key`]'s job.
311 ///
312 /// `Ok(None)` for a path that is not a directory in this revision, which includes a
313 /// path that is a file.
314 fn list_tree(
315 &self,
316 handle: &OrgName,
317 name: &RepoName,
318 rev: &RefName,
319 path: &RepoPath,
320 ) -> impl Future<Output = Result<Option<Vec<TreeEntry>>, GitQueryError>> + Send;
321
322 /// Reads a file, up to `max_bytes`.
323 ///
324 /// `Ok(None)` for a path that is not a file in this revision.
325 fn read_blob(
326 &self,
327 handle: &OrgName,
328 name: &RepoName,
329 rev: &RefName,
330 path: &RepoPath,
331 max_bytes: u64,
332 ) -> impl Future<Output = Result<Option<Blob>, GitQueryError>> + Send;
333
334 /// The most recent commits reachable from a revision, newest first.
335 fn log(
336 &self,
337 handle: &OrgName,
338 name: &RepoName,
339 rev: &RefName,
340 limit: usize,
341 ) -> impl Future<Output = Result<Vec<CommitSummary>, GitQueryError>> + Send;
342
343 /// Every branch and every tag, unordered.
344 ///
345 /// Ordering is the use case's decision, the same way it is for
346 /// [`list_tree`](Self::list_tree) — an adapter that sorted would have to be
347 /// re-taught the order every time it changed.
348 ///
349 /// **This costs a whole `git` process** — ~14ms, the most expensive of the read
350 /// commands measured for the Milestone 5 amendment to
351 /// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md), because
352 /// starting the process is the cost. A page that does not show a ref switcher must
353 /// not call it.
354 ///
355 /// An empty repository has no refs and answers with an empty list rather than an
356 /// error: nothing pushed yet is not a failure.
357 fn list_refs(
358 &self,
359 handle: &OrgName,
360 name: &RepoName,
361 ) -> impl Future<Output = Result<Vec<GitRef>, GitQueryError>> + Send;
362}
363
364/// A repository could not be read.
365#[derive(Debug)]
366pub struct GitQueryError(Box<dyn std::error::Error + Send + Sync>);
367
368impl GitQueryError {
369 pub fn new(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
370 Self(error.into())
371 }
372}
373
374impl std::fmt::Display for GitQueryError {
375 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
376 write!(f, "could not read the repository: {}", self.0)
377 }
378}
379
380impl std::error::Error for GitQueryError {
381 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
382 Some(&*self.0)
383 }
384}