@jpgilldev / steid

steid/src/domain/reference.rs
12.1 KBRaw
1//! How a place inside a repository is addressed: a revision, and a path within it.
2//!
3//! Both are user input that ends up as an argument to the `git` binary, so both are
4//! validated here rather than at the call site — the same reasoning that put traversal
5//! defence in [`RepoName`](super::RepoName) rather than in `DiskGitStorage`.
6
7use std::fmt;
8
9use super::DomainError;
10
11/// A revision: a branch, a tag, or an object id.
12///
13/// Named `RefName` because that is what
14/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md) called it, but it
15/// deliberately accepts an object id too — a URL carries whatever the visitor clicked,
16/// and deciding whether `a1b2c3` is a branch or a commit is git's job, not ours. What is
17/// enforced here is only that the value is *safe and well-formed*, never what it points
18/// at.
19///
20/// The rules are git's own `check-ref-format`, minus the parts that only apply to
21/// writing refs.
22#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
23pub struct RefName(String);
24
25impl RefName {
26 /// Long enough for any real branch name; short enough to bound a URL segment.
27 pub const MAX_LEN: usize = 255;
28
29 /// Characters git forbids in a ref, all of which mean something to its revision
30 /// parser: `~` and `^` walk ancestry, `:` separates a rev from a path, `?`, `*` and
31 /// `[` are globs, and `\` is an escape.
32 const FORBIDDEN: [char; 7] = ['~', '^', ':', '?', '*', '[', '\\'];
33
34 pub fn new(value: impl AsRef<str>) -> Result<Self, DomainError> {
35 let value = value.as_ref().trim();
36
37 if value.is_empty() {
38 return Err(invalid("a revision cannot be empty"));
39 }
40
41 if value.chars().count() > Self::MAX_LEN {
42 return Err(invalid(format!(
43 "a revision is at most {} characters",
44 Self::MAX_LEN
45 )));
46 }
47
48 // A leading hyphen would be read as a flag by the binary this is handed to.
49 // Refused here, at the boundary, rather than escaped at every call site.
50 if value.starts_with('-') {
51 return Err(invalid("a revision cannot start with '-'"));
52 }
53
54 if value.chars().any(|c| c.is_ascii_control() || c == ' ') {
55 return Err(invalid(
56 "a revision cannot contain spaces or control characters",
57 ));
58 }
59
60 if value.chars().any(|c| Self::FORBIDDEN.contains(&c)) {
61 return Err(invalid("a revision cannot contain ~ ^ : ? * [ or \\"));
62 }
63
64 // `..` is a range, and `@{` is a reflog lookup. Neither addresses a single
65 // revision, and both would silently mean something other than what was typed.
66 if value.contains("..") || value.contains("@{") {
67 return Err(invalid("a revision cannot contain '..' or '@{'"));
68 }
69
70 if value == "@" {
71 return Err(invalid("'@' is not a revision"));
72 }
73
74 if value.starts_with('/') || value.ends_with('/') || value.contains("//") {
75 return Err(invalid("a revision cannot have empty path components"));
76 }
77
78 if value.ends_with('.') {
79 return Err(invalid("a revision cannot end with '.'"));
80 }
81
82 // Per component, because `refs/heads/.hidden` and `refs/heads/x.lock` are both
83 // refused by git even though the whole string looks fine.
84 for component in value.split('/') {
85 if component.starts_with('.') || component.ends_with(".lock") {
86 return Err(invalid(
87 "no part of a revision may start with '.' or end with '.lock'",
88 ));
89 }
90 }
91
92 Ok(Self(value.to_owned()))
93 }
94
95 /// Wraps a revision already known to be well-formed.
96 pub fn from_trusted(value: impl Into<String>) -> Self {
97 Self(value.into())
98 }
99
100 pub fn as_str(&self) -> &str {
101 &self.0
102 }
103}
104
105impl fmt::Display for RefName {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 f.write_str(&self.0)
108 }
109}
110
111/// A path to something inside a repository, relative to its root.
112///
113/// The empty path is the root itself, which is what a bare `/tree/{ref}/-/` addresses.
114/// Never touches the filesystem directly — it is handed to git — but it is still user
115/// input arriving at a subprocess, so `..` is refused rather than normalised away.
116#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
117pub struct RepoPath(String);
118
119impl RepoPath {
120 /// Well past any real path, and a bound on what a URL can make git consider.
121 pub const MAX_LEN: usize = 4096;
122
123 pub fn new(value: impl AsRef<str>) -> Result<Self, DomainError> {
124 let value = value.as_ref().trim_matches('/');
125
126 if value.is_empty() {
127 return Ok(Self::root());
128 }
129
130 if value.len() > Self::MAX_LEN {
131 return Err(invalid_path(format!(
132 "a path is at most {} characters",
133 Self::MAX_LEN
134 )));
135 }
136
137 if value.starts_with('-') {
138 return Err(invalid_path("a path cannot start with '-'"));
139 }
140
141 if value.chars().any(|c| c.is_ascii_control()) {
142 return Err(invalid_path("a path cannot contain control characters"));
143 }
144
145 // `:` separates a revision from a path in git's own syntax, so a colon here
146 // would let a path smuggle in a second revision.
147 if value.contains(':') {
148 return Err(invalid_path("a path cannot contain ':'"));
149 }
150
151 for component in value.split('/') {
152 match component {
153 "" => return Err(invalid_path("a path cannot contain empty components")),
154 "." | ".." => {
155 return Err(invalid_path("a path cannot contain '.' or '..' components"));
156 }
157 _ => {}
158 }
159 }
160
161 Ok(Self(value.to_owned()))
162 }
163
164 /// The repository root.
165 pub fn root() -> Self {
166 Self(String::new())
167 }
168
169 pub fn from_trusted(value: impl Into<String>) -> Self {
170 Self(value.into())
171 }
172
173 pub fn is_root(&self) -> bool {
174 self.0.is_empty()
175 }
176
177 pub fn as_str(&self) -> &str {
178 &self.0
179 }
180
181 /// The path's components, for rendering breadcrumbs.
182 pub fn components(&self) -> impl Iterator<Item = &str> {
183 self.0.split('/').filter(|part| !part.is_empty())
184 }
185
186 /// The containing directory, or `None` at the root.
187 pub fn parent(&self) -> Option<Self> {
188 if self.is_root() {
189 return None;
190 }
191
192 Some(match self.0.rsplit_once('/') {
193 Some((parent, _)) => Self(parent.to_owned()),
194 None => Self::root(),
195 })
196 }
197
198 /// This path with one more component on the end.
199 pub fn join(&self, name: &str) -> Self {
200 if self.is_root() {
201 Self(name.to_owned())
202 } else {
203 Self(format!("{}/{name}", self.0))
204 }
205 }
206
207 /// The last component — a file or directory's own name.
208 pub fn file_name(&self) -> Option<&str> {
209 if self.is_root() {
210 return None;
211 }
212
213 Some(self.0.rsplit('/').next().unwrap_or(&self.0))
214 }
215}
216
217impl fmt::Display for RepoPath {
218 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219 f.write_str(&self.0)
220 }
221}
222
223fn invalid(reason: impl Into<String>) -> DomainError {
224 DomainError::validation("revision", reason)
225}
226
227fn invalid_path(reason: impl Into<String>) -> DomainError {
228 DomainError::validation("path", reason)
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 // --- RefName ------------------------------------------------------------------
236
237 #[test]
238 fn ordinary_revisions_are_accepted() {
239 for value in [
240 "main",
241 "feature/login",
242 "release/2026-08-29",
243 "v1.0.0",
244 "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
245 "HEAD",
246 ] {
247 assert!(RefName::new(value).is_ok(), "{value:?} should be valid");
248 }
249 }
250
251 #[test]
252 fn a_revision_that_would_be_read_as_a_flag_is_refused() {
253 // The value is handed to a subprocess. Refusing here beats escaping everywhere.
254 assert!(RefName::new("--upload-pack=evil").is_err());
255 assert!(RefName::new("-main").is_err());
256 }
257
258 #[test]
259 fn gits_own_forbidden_characters_are_refused() {
260 for value in [
261 "ma~in", "ma^in", "ma:in", "ma?in", "ma*in", "ma[in", "ma\\in",
262 ] {
263 assert!(RefName::new(value).is_err(), "{value:?} should be refused");
264 }
265 }
266
267 #[test]
268 fn range_and_reflog_syntax_are_refused() {
269 // Both address something other than a single revision.
270 assert!(RefName::new("main..other").is_err());
271 assert!(RefName::new("main@{yesterday}").is_err());
272 }
273
274 #[test]
275 fn empty_components_and_trailing_punctuation_are_refused() {
276 for value in ["/main", "main/", "feature//login", "main."] {
277 assert!(RefName::new(value).is_err(), "{value:?} should be refused");
278 }
279 }
280
281 #[test]
282 fn dot_prefixed_and_lock_suffixed_components_are_refused() {
283 assert!(RefName::new(".hidden").is_err());
284 assert!(RefName::new("refs/.hidden/x").is_err());
285 assert!(RefName::new("main.lock").is_err());
286 assert!(RefName::new("refs/heads/main.lock").is_err());
287 }
288
289 #[test]
290 fn spaces_control_characters_and_bare_at_are_refused() {
291 assert!(RefName::new("my branch").is_err());
292 assert!(RefName::new("main\nother").is_err());
293 assert!(RefName::new("@").is_err());
294 assert!(RefName::new("").is_err());
295 assert!(RefName::new(" ").is_err());
296 }
297
298 #[test]
299 fn a_revision_has_a_length_limit() {
300 assert!(RefName::new("a".repeat(RefName::MAX_LEN)).is_ok());
301 assert!(RefName::new("a".repeat(RefName::MAX_LEN + 1)).is_err());
302 }
303
304 // --- RepoPath -----------------------------------------------------------------
305
306 #[test]
307 fn the_empty_path_is_the_root() {
308 assert!(RepoPath::new("").expect("valid").is_root());
309 assert!(RepoPath::new("/").expect("valid").is_root());
310 assert!(RepoPath::root().is_root());
311 }
312
313 #[test]
314 fn ordinary_paths_are_accepted_and_normalised() {
315 let path = RepoPath::new("/src/domain/repo.rs/").expect("valid");
316
317 assert_eq!(path.as_str(), "src/domain/repo.rs");
318 assert!(!path.is_root());
319 }
320
321 #[test]
322 fn traversal_is_refused_rather_than_normalised() {
323 // Refusing beats cleaning: a normaliser that misses a case fails open.
324 for value in ["../etc/passwd", "src/../../etc", "src/./x", ".."] {
325 assert!(RepoPath::new(value).is_err(), "{value:?} should be refused");
326 }
327 }
328
329 #[test]
330 fn a_colon_is_refused_because_git_reads_it_as_a_revision_separator() {
331 assert!(RepoPath::new("src:main").is_err());
332 }
333
334 #[test]
335 fn a_path_that_would_be_read_as_a_flag_is_refused() {
336 assert!(RepoPath::new("-rf").is_err());
337 }
338
339 #[test]
340 fn empty_components_and_control_characters_are_refused() {
341 assert!(RepoPath::new("src//main.rs").is_err());
342 assert!(RepoPath::new("src/\0/x").is_err());
343 }
344
345 #[test]
346 fn a_path_walks_up_to_its_parent() {
347 let path = RepoPath::new("src/domain/repo.rs").expect("valid");
348
349 let parent = path.parent().expect("has a parent");
350 assert_eq!(parent.as_str(), "src/domain");
351
352 let grandparent = parent.parent().expect("has a parent");
353 assert_eq!(grandparent.as_str(), "src");
354
355 let root = grandparent.parent().expect("has a parent");
356 assert!(root.is_root());
357 assert_eq!(root.parent(), None, "the root has no parent");
358 }
359
360 #[test]
361 fn joining_builds_a_child_path() {
362 let root = RepoPath::root();
363 assert_eq!(root.join("src").as_str(), "src");
364 assert_eq!(root.join("src").join("main.rs").as_str(), "src/main.rs");
365 }
366
367 #[test]
368 fn components_drive_breadcrumbs() {
369 let path = RepoPath::new("src/domain/repo.rs").expect("valid");
370
371 assert_eq!(
372 path.components().collect::<Vec<_>>(),
373 vec!["src", "domain", "repo.rs"]
374 );
375 assert_eq!(path.file_name(), Some("repo.rs"));
376 assert_eq!(RepoPath::root().file_name(), None);
377 }
378}