steid

@jamesgill /

feat: repository domain model

RepoId, RepoName, Visibility, Repository. 17 tests.

RepoName is a security boundary, not a style choice: the value becomes a path
segment at {data_dir}/{handle}/{name}.git, so anything that could leave the
data directory has to be impossible here rather than sanitised at each call
site. One test enumerates the traversal shapes -- ".", "..", "../etc",
"foo/bar", backslashes, absolute paths, whitespace, newlines, NUL -- and
asserts every one is refused.

Allowing dots for names like .github and foo.js is what makes ".." reachable
in the first place, so a name of nothing but dots is rejected explicitly. The
character rule already excludes "/", which closes the rest of that shape.

Names ending in .git are refused: the repo would live at foo.git.git on disk
and make clone URLs ambiguous. ".git" inside a name is fine.

Reserved list is new, import, search -- only names directly under
/{handle}/repos/ can collide, since anything deeper is unambiguous. Reserved
before the first repo exists, because afterwards it is a breaking change for
whoever holds it.

Visibility defaults to Public, matching a portfolio-first product where the
ordinary case is showing your work. from_str returns Result: a silently
defaulted visibility would publish a private repository.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesPatrickGill authored 24 days agoparent1a58e00Browse filesef1e482ec5d305df54009e00a479640b0a444280

3 files changed+415 −1

src/domain/id.rs+5 −0View file
@@ -54,6 +54,11 @@ typed_id!(
5454 MembershipId
5555 );
5656
57+typed_id!(
58+ /// Identifies a [`Repository`](crate::domain::Repository).
59+ RepoId
60+);
61+
5762 #[cfg(test)]
5863 mod tests {
5964 use super::*;
src/domain/mod.rs+3 −1View file
@@ -10,6 +10,7 @@ pub mod id;
1010 pub mod membership;
1111 pub mod org;
1212 pub mod password;
13+pub mod repo;
1314 pub mod repository;
1415 pub mod session;
1516 pub mod setup_token;
@@ -18,10 +19,11 @@ pub mod user;
1819 pub use actor::Actor;
1920 pub use email::Email;
2021 pub use error::DomainError;
21pub use id::{MembershipId, OrgId, UserId};
22+pub use id::{MembershipId, OrgId, RepoId, UserId};
2223 pub use membership::{Membership, Role};
2324 pub use org::{OrgName, Organization};
2425 pub use password::PasswordHash;
26+pub use repo::{RepoName, Repository, Visibility};
2527 pub use session::{Session, SessionTokenHash};
2628 pub use setup_token::SetupToken;
2729 pub use user::User;
src/domain/repo.rs+407 −0View file
@@ -0,0 +1,407 @@
1+use std::{fmt, str::FromStr};
2+
3+use super::{DomainError, OrgId, RepoId};
4+
5+/// Repository names that would collide with a route under `/{handle}/repos/`.
6+///
7+/// Only names directly under `repos/` can collide — `/{handle}/repos/{name}/settings`
8+/// is deeper and safe. Reserve before the first repo exists; afterwards it is a
9+/// breaking change for whoever holds the name.
10+const RESERVED: &[&str] = &["import", "new", "search"];
11+
12+/// A repository name — the `{name}` in `/{handle}/repos/{name}`, and a path segment on
13+/// disk at `{data_dir}/{handle}/{name}.git`.
14+///
15+/// **This value becomes a filesystem path**, so the rules below are a security boundary
16+/// rather than a matter of taste. Anything that could escape the data directory has to
17+/// be impossible here, in one place, rather than sanitised at each call site.
18+///
19+/// Follows [`OrgName`](super::OrgName) and additionally allows `.` and `_`, which
20+/// repository names conventionally use (`.github`, `foo.js`, `my_repo`). Lowercased for
21+/// the same reason handles are: so lookups, URLs, and directories all agree.
22+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23+pub struct RepoName(String);
24+
25+impl RepoName {
26+ pub const MAX_LEN: usize = 100;
27+
28+ /// Validates and normalises user-supplied input.
29+ pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
30+ let value = value.into();
31+ let trimmed = value.trim();
32+
33+ let invalid = |reason: &str| DomainError::validation("repository name", reason);
34+
35+ if trimmed.is_empty() {
36+ return Err(invalid("must not be empty"));
37+ }
38+ if trimmed.chars().count() > Self::MAX_LEN {
39+ return Err(invalid("must be at most 100 characters"));
40+ }
41+ if !trimmed
42+ .chars()
43+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
44+ {
45+ return Err(invalid(
46+ "may only contain letters, digits, hyphens, underscores, and dots",
47+ ));
48+ }
49+ if trimmed.starts_with('-') || trimmed.ends_with('-') {
50+ return Err(invalid("must not start or end with a hyphen"));
51+ }
52+
53+ // `.` and `..` are directory references; a name of nothing but dots has no
54+ // meaning as a repository and every meaning as a path. The character rule above
55+ // already excludes `/`, so this closes the remaining traversal shape.
56+ if trimmed.chars().all(|c| c == '.') {
57+ return Err(invalid("must not consist only of dots"));
58+ }
59+
60+ let normalised = trimmed.to_lowercase();
61+
62+ // A repo named `foo.git` would live at `foo.git.git` and make clone URLs
63+ // ambiguous.
64+ if normalised.ends_with(".git") {
65+ return Err(invalid("must not end with .git"));
66+ }
67+ if RESERVED.contains(&normalised.as_str()) {
68+ return Err(invalid("is reserved"));
69+ }
70+
71+ Ok(Self(normalised))
72+ }
73+
74+ /// Wraps a value already validated on the way into the database.
75+ pub fn from_trusted(value: impl Into<String>) -> Self {
76+ Self(value.into())
77+ }
78+
79+ pub fn as_str(&self) -> &str {
80+ &self.0
81+ }
82+}
83+
84+impl fmt::Display for RepoName {
85+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86+ f.write_str(&self.0)
87+ }
88+}
89+
90+/// Who may see a repository.
91+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
92+pub enum Visibility {
93+ /// Anyone, signed in or not.
94+ ///
95+ /// The default: Steid is portfolio-first, so the ordinary case is showing your work.
96+ #[default]
97+ Public,
98+ /// Members of the owning organisation only.
99+ Private,
100+}
101+
102+impl Visibility {
103+ pub fn as_str(self) -> &'static str {
104+ match self {
105+ Self::Public => "public",
106+ Self::Private => "private",
107+ }
108+ }
109+
110+ pub fn is_public(self) -> bool {
111+ self == Self::Public
112+ }
113+}
114+
115+impl fmt::Display for Visibility {
116+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117+ f.write_str(self.as_str())
118+ }
119+}
120+
121+/// Parsing returns `Result`, never `Option`. A silently-defaulted visibility would
122+/// publish a private repository.
123+impl FromStr for Visibility {
124+ type Err = DomainError;
125+
126+ fn from_str(value: &str) -> Result<Self, Self::Err> {
127+ match value {
128+ "public" => Ok(Self::Public),
129+ "private" => Ok(Self::Private),
130+ other => Err(DomainError::validation(
131+ "visibility",
132+ format!("unknown visibility {other:?}"),
133+ )),
134+ }
135+ }
136+}
137+
138+/// A repository. Owned by an organisation, never directly by a user.
139+#[derive(Debug, Clone, PartialEq, Eq)]
140+pub struct Repository {
141+ pub id: RepoId,
142+ pub org_id: OrgId,
143+ pub name: RepoName,
144+ pub description: Option<String>,
145+ pub visibility: Visibility,
146+}
147+
148+impl Repository {
149+ /// Longest permitted description. A sentence, not a README.
150+ pub const MAX_DESCRIPTION_LEN: usize = 300;
151+
152+ /// Creates a repository from user-supplied input.
153+ pub fn new(
154+ id: RepoId,
155+ org_id: OrgId,
156+ name: impl Into<String>,
157+ description: Option<String>,
158+ visibility: Visibility,
159+ ) -> Result<Self, DomainError> {
160+ let description = normalise_optional(description);
161+
162+ if let Some(description) = &description
163+ && description.chars().count() > Self::MAX_DESCRIPTION_LEN
164+ {
165+ return Err(DomainError::validation(
166+ "description",
167+ format!("must be at most {} characters", Self::MAX_DESCRIPTION_LEN),
168+ ));
169+ }
170+
171+ Ok(Self {
172+ id,
173+ org_id,
174+ name: RepoName::new(name)?,
175+ description,
176+ visibility,
177+ })
178+ }
179+
180+ /// Reassembles a repository from storage, skipping validation.
181+ pub fn from_trusted(
182+ id: RepoId,
183+ org_id: OrgId,
184+ name: RepoName,
185+ description: Option<String>,
186+ visibility: Visibility,
187+ ) -> Self {
188+ Self {
189+ id,
190+ org_id,
191+ name,
192+ description,
193+ visibility,
194+ }
195+ }
196+}
197+
198+/// Trims, and treats blank as absent.
199+fn normalise_optional(value: Option<String>) -> Option<String> {
200+ value
201+ .map(|value| value.trim().to_owned())
202+ .filter(|value| !value.is_empty())
203+}
204+
205+#[cfg(test)]
206+mod tests {
207+ use super::*;
208+
209+ #[test]
210+ fn accepts_ordinary_repository_names() {
211+ for input in [
212+ "steid",
213+ "my-repo",
214+ "my_repo",
215+ "foo.js",
216+ ".github",
217+ "v2",
218+ "a",
219+ "dot.separated.name",
220+ "trailing.",
221+ ] {
222+ assert!(
223+ RepoName::new(input).is_ok(),
224+ "expected {input:?} to be accepted"
225+ );
226+ }
227+ }
228+
229+ #[test]
230+ fn normalises_case_and_whitespace() {
231+ let name = RepoName::new(" MyRepo ").expect("should be valid");
232+
233+ assert_eq!(name.as_str(), "myrepo");
234+ }
235+
236+ /// The security boundary. Each of these, if allowed through, becomes a path segment
237+ /// that can leave `{data_dir}/{handle}/`.
238+ #[test]
239+ fn rejects_anything_that_could_escape_the_data_directory() {
240+ for input in [
241+ ".",
242+ "..",
243+ "...",
244+ "../etc",
245+ "..%2F..%2Fetc",
246+ "foo/bar",
247+ "foo\\bar",
248+ "/absolute",
249+ "~root",
250+ "with space",
251+ "new\nline",
252+ "nul\0byte",
253+ ] {
254+ assert!(
255+ RepoName::new(input).is_err(),
256+ "expected {input:?} to be rejected: it could escape the data directory"
257+ );
258+ }
259+ }
260+
261+ #[test]
262+ fn rejects_malformed_names() {
263+ for input in [
264+ "",
265+ " ",
266+ "-leading",
267+ "trailing-",
268+ "emoji🦀",
269+ &"a".repeat(101),
270+ ] {
271+ assert!(
272+ RepoName::new(input).is_err(),
273+ "expected {input:?} to be rejected"
274+ );
275+ }
276+ }
277+
278+ #[test]
279+ fn rejects_names_ending_in_dot_git() {
280+ // Would live at `foo.git.git` on disk and make clone URLs ambiguous.
281+ assert!(RepoName::new("foo.git").is_err());
282+ assert!(RepoName::new("FOO.GIT").is_err());
283+ // But `.git` inside the name is harmless.
284+ assert!(RepoName::new("foo.github").is_ok());
285+ }
286+
287+ #[test]
288+ fn rejects_reserved_names() {
289+ for reserved in RESERVED {
290+ assert!(
291+ RepoName::new(*reserved).is_err(),
292+ "expected {reserved:?} to be reserved"
293+ );
294+ }
295+ // Case-insensitively, since names normalise to lowercase.
296+ assert!(RepoName::new("NEW").is_err());
297+ }
298+
299+ #[test]
300+ fn reservation_does_not_leak_into_substrings() {
301+ for allowed in ["newton", "renew", "imports", "research"] {
302+ assert!(
303+ RepoName::new(allowed).is_ok(),
304+ "expected {allowed:?} to be allowed"
305+ );
306+ }
307+ }
308+
309+ #[test]
310+ fn the_reserved_list_is_sorted_and_unique() {
311+ let mut sorted = RESERVED.to_vec();
312+ sorted.sort_unstable();
313+ sorted.dedup();
314+
315+ assert_eq!(
316+ sorted.as_slice(),
317+ RESERVED,
318+ "keep RESERVED sorted and unique"
319+ );
320+ }
321+
322+ #[test]
323+ fn accepts_a_name_at_the_length_limit() {
324+ assert!(RepoName::new("a".repeat(RepoName::MAX_LEN)).is_ok());
325+ }
326+
327+ #[test]
328+ fn repositories_default_to_public() {
329+ assert_eq!(Visibility::default(), Visibility::Public);
330+ assert!(Visibility::default().is_public());
331+ }
332+
333+ #[test]
334+ fn visibility_round_trips_through_strings() {
335+ for visibility in [Visibility::Public, Visibility::Private] {
336+ assert_eq!(
337+ visibility
338+ .as_str()
339+ .parse::<Visibility>()
340+ .expect("round trip"),
341+ visibility
342+ );
343+ }
344+ }
345+
346+ #[test]
347+ fn unknown_visibility_is_an_error_not_a_default() {
348+ // Defaulting here would publish a private repository.
349+ assert!("internal".parse::<Visibility>().is_err());
350+ assert!("Public".parse::<Visibility>().is_err());
351+ }
352+
353+ fn repo(description: Option<&str>) -> Result<Repository, DomainError> {
354+ Repository::new(
355+ RepoId::generate(),
356+ OrgId::generate(),
357+ "steid",
358+ description.map(str::to_owned),
359+ Visibility::Public,
360+ )
361+ }
362+
363+ #[test]
364+ fn a_repository_keeps_its_description() {
365+ let repo = repo(Some("A personal-first gitforge.")).expect("valid");
366+
367+ assert_eq!(
368+ repo.description.as_deref(),
369+ Some("A personal-first gitforge.")
370+ );
371+ }
372+
373+ #[test]
374+ fn a_blank_description_is_treated_as_unset() {
375+ assert_eq!(repo(Some(" ")).expect("valid").description, None);
376+ }
377+
378+ #[test]
379+ fn rejects_an_over_long_description() {
380+ let long = "a".repeat(Repository::MAX_DESCRIPTION_LEN + 1);
381+
382+ let error = repo(Some(&long)).expect_err("should reject");
383+
384+ assert!(matches!(error, DomainError::Validation { .. }));
385+ }
386+
387+ #[test]
388+ fn description_length_counts_characters_not_bytes() {
389+ let accented = "é".repeat(Repository::MAX_DESCRIPTION_LEN);
390+
391+ assert!(repo(Some(&accented)).is_ok());
392+ }
393+
394+ #[test]
395+ fn an_invalid_name_rejects_the_whole_repository() {
396+ let error = Repository::new(
397+ RepoId::generate(),
398+ OrgId::generate(),
399+ "../escape",
400+ None,
401+ Visibility::Public,
402+ )
403+ .expect_err("should reject");
404+
405+ assert!(matches!(error, DomainError::Validation { .. }));
406+ }
407+}