steid

@jamesgill /

steid/src/domain/repo.rs
11.5 KBCode·Blame·Raw
1use std::{fmt, str::FromStr};
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}
147
148impl 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.
199fn 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)]
206mod 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}