@jpgilldev / steid

steid/src/domain/repo.rs
13.2 KBRaw
1use std::{fmt, str::FromStr, time::SystemTime};
2
3use 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.
10const 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)]
23pub struct RepoName(String);
24
25impl 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
84impl 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)]
92pub 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
102impl 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
115impl 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.
123impl 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)]
140pub 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 /// When code last landed here — set at creation and moved when a push is
147 /// authorized. The profile orders by it, so it means "recently worked on", not
148 /// "row last written": editing a description does not move it.
149 pub updated_at: SystemTime,
150 /// Whether this is the one repository the owner leads their profile with.
151 ///
152 /// At most one per owner. Not a constructor argument: a repository becomes the lead
153 /// by a later, deliberate choice, never by being created.
154 pub pinned: bool,
155}
156
157impl Repository {
158 /// Longest permitted description. A sentence, not a README.
159 pub const MAX_DESCRIPTION_LEN: usize = 300;
160
161 /// Creates a repository from user-supplied input.
162 ///
163 /// `now` is passed in rather than read here, following
164 /// [`PersonalAccessToken::new`](super::PersonalAccessToken::new): a domain type that
165 /// reads the clock cannot be tested against a fixed time.
166 pub fn new(
167 id: RepoId,
168 org_id: OrgId,
169 name: impl Into<String>,
170 description: Option<String>,
171 visibility: Visibility,
172 now: SystemTime,
173 ) -> Result<Self, DomainError> {
174 let description = normalise_optional(description);
175
176 if let Some(description) = &description
177 && description.chars().count() > Self::MAX_DESCRIPTION_LEN
178 {
179 return Err(DomainError::validation(
180 "description",
181 format!("must be at most {} characters", Self::MAX_DESCRIPTION_LEN),
182 ));
183 }
184
185 Ok(Self {
186 id,
187 org_id,
188 name: RepoName::new(name)?,
189 description,
190 visibility,
191 updated_at: now,
192 pinned: false,
193 })
194 }
195
196 /// Reassembles a repository from storage, skipping validation.
197 #[allow(clippy::too_many_arguments)]
198 pub fn from_trusted(
199 id: RepoId,
200 org_id: OrgId,
201 name: RepoName,
202 description: Option<String>,
203 visibility: Visibility,
204 updated_at: SystemTime,
205 pinned: bool,
206 ) -> Self {
207 Self {
208 id,
209 org_id,
210 name,
211 description,
212 visibility,
213 updated_at,
214 pinned,
215 }
216 }
217}
218
219/// Trims, and treats blank as absent.
220fn normalise_optional(value: Option<String>) -> Option<String> {
221 value
222 .map(|value| value.trim().to_owned())
223 .filter(|value| !value.is_empty())
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn accepts_ordinary_repository_names() {
232 for input in [
233 "steid",
234 "my-repo",
235 "my_repo",
236 "foo.js",
237 ".github",
238 "v2",
239 "a",
240 "dot.separated.name",
241 "trailing.",
242 ] {
243 assert!(
244 RepoName::new(input).is_ok(),
245 "expected {input:?} to be accepted"
246 );
247 }
248 }
249
250 #[test]
251 fn normalises_case_and_whitespace() {
252 let name = RepoName::new(" MyRepo ").expect("should be valid");
253
254 assert_eq!(name.as_str(), "myrepo");
255 }
256
257 /// The security boundary. Each of these, if allowed through, becomes a path segment
258 /// that can leave `{data_dir}/{handle}/`.
259 #[test]
260 fn rejects_anything_that_could_escape_the_data_directory() {
261 for input in [
262 ".",
263 "..",
264 "...",
265 "../etc",
266 "..%2F..%2Fetc",
267 "foo/bar",
268 "foo\\bar",
269 "/absolute",
270 "~root",
271 "with space",
272 "new\nline",
273 "nul\0byte",
274 ] {
275 assert!(
276 RepoName::new(input).is_err(),
277 "expected {input:?} to be rejected: it could escape the data directory"
278 );
279 }
280 }
281
282 #[test]
283 fn rejects_malformed_names() {
284 for input in [
285 "",
286 " ",
287 "-leading",
288 "trailing-",
289 "emoji🦀",
290 &"a".repeat(101),
291 ] {
292 assert!(
293 RepoName::new(input).is_err(),
294 "expected {input:?} to be rejected"
295 );
296 }
297 }
298
299 #[test]
300 fn rejects_names_ending_in_dot_git() {
301 // Would live at `foo.git.git` on disk and make clone URLs ambiguous.
302 assert!(RepoName::new("foo.git").is_err());
303 assert!(RepoName::new("FOO.GIT").is_err());
304 // But `.git` inside the name is harmless.
305 assert!(RepoName::new("foo.github").is_ok());
306 }
307
308 #[test]
309 fn rejects_reserved_names() {
310 for reserved in RESERVED {
311 assert!(
312 RepoName::new(*reserved).is_err(),
313 "expected {reserved:?} to be reserved"
314 );
315 }
316 // Case-insensitively, since names normalise to lowercase.
317 assert!(RepoName::new("NEW").is_err());
318 }
319
320 #[test]
321 fn reservation_does_not_leak_into_substrings() {
322 for allowed in ["newton", "renew", "imports", "research"] {
323 assert!(
324 RepoName::new(allowed).is_ok(),
325 "expected {allowed:?} to be allowed"
326 );
327 }
328 }
329
330 #[test]
331 fn the_reserved_list_is_sorted_and_unique() {
332 let mut sorted = RESERVED.to_vec();
333 sorted.sort_unstable();
334 sorted.dedup();
335
336 assert_eq!(
337 sorted.as_slice(),
338 RESERVED,
339 "keep RESERVED sorted and unique"
340 );
341 }
342
343 #[test]
344 fn accepts_a_name_at_the_length_limit() {
345 assert!(RepoName::new("a".repeat(RepoName::MAX_LEN)).is_ok());
346 }
347
348 #[test]
349 fn repositories_default_to_public() {
350 assert_eq!(Visibility::default(), Visibility::Public);
351 assert!(Visibility::default().is_public());
352 }
353
354 #[test]
355 fn visibility_round_trips_through_strings() {
356 for visibility in [Visibility::Public, Visibility::Private] {
357 assert_eq!(
358 visibility
359 .as_str()
360 .parse::<Visibility>()
361 .expect("round trip"),
362 visibility
363 );
364 }
365 }
366
367 #[test]
368 fn unknown_visibility_is_an_error_not_a_default() {
369 // Defaulting here would publish a private repository.
370 assert!("internal".parse::<Visibility>().is_err());
371 assert!("Public".parse::<Visibility>().is_err());
372 }
373
374 fn repo(description: Option<&str>) -> Result<Repository, DomainError> {
375 Repository::new(
376 RepoId::generate(),
377 OrgId::generate(),
378 "steid",
379 description.map(str::to_owned),
380 Visibility::Public,
381 at(1_000),
382 )
383 }
384
385 fn at(seconds: u64) -> SystemTime {
386 SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(seconds)
387 }
388
389 #[test]
390 fn a_new_repository_is_stamped_with_the_time_it_was_created() {
391 let repo = Repository::new(
392 RepoId::generate(),
393 OrgId::generate(),
394 "steid",
395 None,
396 Visibility::Public,
397 at(1_700),
398 )
399 .expect("valid");
400
401 assert_eq!(repo.updated_at, at(1_700));
402 }
403
404 #[test]
405 fn a_new_repository_is_not_pinned() {
406 // Pinning is a later, deliberate choice; creating a repository is not a claim
407 // that it should lead the profile.
408 assert!(!repo(None).expect("valid").pinned);
409 }
410
411 #[test]
412 fn a_repository_keeps_its_description() {
413 let repo = repo(Some("A personal-first gitforge.")).expect("valid");
414
415 assert_eq!(
416 repo.description.as_deref(),
417 Some("A personal-first gitforge.")
418 );
419 }
420
421 #[test]
422 fn a_blank_description_is_treated_as_unset() {
423 assert_eq!(repo(Some(" ")).expect("valid").description, None);
424 }
425
426 #[test]
427 fn rejects_an_over_long_description() {
428 let long = "a".repeat(Repository::MAX_DESCRIPTION_LEN + 1);
429
430 let error = repo(Some(&long)).expect_err("should reject");
431
432 assert!(matches!(error, DomainError::Validation { .. }));
433 }
434
435 #[test]
436 fn description_length_counts_characters_not_bytes() {
437 let accented = "é".repeat(Repository::MAX_DESCRIPTION_LEN);
438
439 assert!(repo(Some(&accented)).is_ok());
440 }
441
442 #[test]
443 fn an_invalid_name_rejects_the_whole_repository() {
444 let error = Repository::new(
445 RepoId::generate(),
446 OrgId::generate(),
447 "../escape",
448 None,
449 Visibility::Public,
450 at(1_000),
451 )
452 .expect_err("should reject");
453
454 assert!(matches!(error, DomainError::Validation { .. }));
455 }
456}