steid

@jamesgill /

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
10use crate::domain::{OrgName, PasswordHash, RepoName};
26d9828feat: password hashing behind a port1mo
11
12/// Hashes and verifies passwords.
13///
14/// A port rather than a direct Argon2 call so tests can substitute a fast stub —
15/// Argon2 is deliberately slow, and a use case suite that hashes for real takes
16/// seconds per test.
17pub trait PasswordHasher: Send + Sync {
18 /// Hashes a plaintext password.
19 fn hash(&self, plaintext: &str) -> Result<PasswordHash, PasswordError>;
20
21 /// Checks a plaintext password against a stored hash.
22 ///
23 /// Returns `Ok(false)` for a wrong password and `Err` only when the hash itself
24 /// cannot be parsed — a corrupt stored hash is a different problem from a failed
25 /// login, and collapsing them hides real faults.
26 fn verify(&self, plaintext: &str, hash: &PasswordHash) -> Result<bool, PasswordError>;
27}
28
29/// A password could not be hashed or a stored hash could not be parsed.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct PasswordError(String);
32
33impl PasswordError {
34 pub fn new(message: impl Into<String>) -> Self {
35 Self(message.into())
36 }
37}
38
39impl std::fmt::Display for PasswordError {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 write!(f, "password hashing failed: {}", self.0)
42 }
43}
44
45impl std::error::Error for PasswordError {}
02eb2e4feat: GitStorage port and DiskGitStorage24d
46
47/// Where bare git repositories live on disk.
48///
49/// Laid out as `{data_dir}/{handle}/{name}.git`. Keyed by handle rather than
50/// [`OrgId`](crate::domain::OrgId) so the data directory is legible to anyone who has
51/// to debug it; the cost is that renaming a handle becomes a directory move rather
52/// than a row update.
53///
54/// Deliberately narrow. Serving the git protocol and browsing a tree are separate
55/// concerns with separate shapes — streaming and querying — and get their own ports as
56/// their use cases arrive, rather than accreting here. See
57/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md).
58pub trait GitStorage: Send + Sync {
59 /// Creates an empty bare repository.
60 ///
61 /// Empty means empty: no initial commit, no branch, matching what GitHub does for
62 /// a repository created without a README.
63 ///
64 /// Fails with [`GitStorageError::AlreadyExists`] rather than adopting whatever is
65 /// already there.
66 fn init_bare(
67 &self,
68 handle: &OrgName,
69 name: &RepoName,
70 ) -> impl Future<Output = Result<(), GitStorageError>> + Send;
71
72 /// Removes a bare repository, succeeding if there was nothing to remove.
73 ///
74 /// This exists to compensate a failed record insert — a repository row and its
75 /// directory cannot share a transaction — not as a user-facing delete. Deleting a
76 /// repository properly is a separate use case with its own authorization.
77 fn remove(
78 &self,
79 handle: &OrgName,
80 name: &RepoName,
81 ) -> impl Future<Output = Result<(), GitStorageError>> + Send;
82
83 /// Where a repository lives.
84 ///
85 /// Pure, and says nothing about whether anything exists there. Milestone 4 hands
86 /// this to `git http-backend`.
87 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf;
88}
89
90/// A repository could not be created or removed on disk.
91#[derive(Debug)]
92pub enum GitStorageError {
93 /// Something already occupies the repository's path.
94 ///
95 /// Kept distinct from a general failure because it is the one case a use case can
96 /// explain to a user, and because with no matching record it means an orphaned
97 /// directory left by a crashed create.
98 AlreadyExists,
99 /// The filesystem or the `git` binary failed.
100 Backend(Box<dyn std::error::Error + Send + Sync>),
101}
102
103impl GitStorageError {
104 pub fn backend(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
105 Self::Backend(error.into())
106 }
107}
108
109impl std::fmt::Display for GitStorageError {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 match self {
112 Self::AlreadyExists => f.write_str("a repository already exists at that path"),
113 Self::Backend(error) => write!(f, "git storage failure: {error}"),
114 }
115 }
116}
117
118impl std::error::Error for GitStorageError {
119 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
120 match self {
121 Self::AlreadyExists => None,
122 Self::Backend(error) => Some(&**error),
123 }
124 }
125}
47db238feat: serve the git protocol through http-backend, behind a port8d
126
127/// A stream of bytes, in either direction.
128///
129/// Pack data is arbitrarily large and must never be collected into memory — a clone of
130/// a large repository would otherwise be bounded by RAM rather than by disk. Boxed
131/// rather than generic so the port stays object-safe in shape and the adapter can hand
132/// back a subprocess's stdout directly.
133pub type ByteStream = Pin<Box<dyn AsyncRead + Send>>;
134
135/// Which HTTP method a git request uses.
136///
137/// Only these two exist in the smart protocol: the advertisement is a `GET`, the
138/// negotiation and pack transfer are `POST`s.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum GitMethod {
141 Get,
142 Post,
143}
144
145impl GitMethod {
146 pub fn as_str(self) -> &'static str {
147 match self {
148 Self::Get => "GET",
149 Self::Post => "POST",
150 }
151 }
152}
153
154/// A request to the git protocol, in the shape `git http-backend` wants it.
155///
156/// CGI-shaped rather than one method per operation, because the backend is a CGI —
157/// see the amendment to
158/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md). **Every field
159/// here is constructed by the use case**, not forwarded from a URL: `path_info` and
160/// `query` in particular are built from an already-validated handle and repository
161/// name, so the transport cannot ask for a path the use case did not authorize.
162pub struct GitRequest {
163 pub method: GitMethod,
164 /// The repository path relative to the project root, e.g. `/acme/steid.git/info/refs`.
165 pub path_info: String,
166 /// The CGI query string, e.g. `service=git-upload-pack`. Empty for an RPC POST.
167 pub query: String,
168 pub content_type: Option<String>,
169 /// The client's `Content-Encoding`. Real clients gzip this body once a repository
170 /// has more than a handful of refs, and the backend inflates it for us — but only
171 /// when it arrives as `HTTP_CONTENT_ENCODING`, which is the adapter's job.
172 pub content_encoding: Option<String>,
173 pub content_length: Option<String>,
174 /// The client's `Git-Protocol`, carrying `version=2` for any modern client.
175 /// Dropping it silently downgrades the exchange to v0 rather than failing.
176 pub git_protocol: Option<String>,
177 pub body: ByteStream,
178}
179
180/// What the git protocol answered.
181///
182/// The status is CGI's, not the framework's: `git http-backend` reports failure with a
183/// `Status:` header and no status at all when it succeeded, so absence means 200 and
184/// the adapter translates it.
185pub struct GitResponse {
186 pub status: u16,
187 pub headers: Vec<(String, String)>,
188 pub body: ByteStream,
189}
190
191/// Serves the git smart-HTTP protocol for one repository.
192///
193/// Says nothing about who may do this. Authorization is settled before a request ever
194/// reaches here — by the time the backend is running, refusing is no longer possible.
195pub trait GitProtocolServer: Send + Sync {
196 fn serve(
197 &self,
198 request: GitRequest,
199 ) -> impl Future<Output = Result<GitResponse, GitProtocolError>> + Send;
200}
201
202/// The git protocol could not be served.
203///
204/// Deliberately without a `NotFound`: a missing repository is something the backend
205/// reports in its own response, and the use case has already refused anything the
206/// viewer may not see.
207#[derive(Debug)]
208pub struct GitProtocolError(Box<dyn std::error::Error + Send + Sync>);
209
210impl GitProtocolError {
211 pub fn new(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
212 Self(error.into())
213 }
214}
215
216impl std::fmt::Display for GitProtocolError {
217 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218 write!(f, "git protocol failure: {}", self.0)
219 }
220}
221
222impl std::error::Error for GitProtocolError {
223 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
224 Some(&*self.0)
225 }
226}