Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! Reference name validation (spec §9).
2//!
3//! > Validate against the Git ref format rules plus a stricter allowlist in the
4//! > pre-receive hook. Reject refs containing `..`, ending in `.lock`, starting
5//! > with `-`, or containing control characters or ANSI escapes. Ref names reach
6//! > terminals and log lines; escape sequences in them are an injection vector.
7//!
8//! Shared by the pre-receive hook and the repository settings form so a name
9//! that one accepts the other cannot reject.
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum RefError {
13 Empty,
14 TooLong,
15 /// Control characters, DEL, or an ANSI escape.
16 Control,
17 /// `..`, a leading `-`, a trailing `.lock`, and friends.
18 Malformed(&'static str),
19 /// A character outside the permitted set.
20 Character(char),
21 /// Not under a namespace Dogfood accepts on push.
22 Namespace,
23}
24
25impl std::fmt::Display for RefError {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 match self {
28 RefError::Empty => write!(f, "reference name is empty"),
29 RefError::TooLong => write!(f, "reference name is too long"),
30 RefError::Control => write!(
31 f,
32 "reference name contains control characters or escape sequences"
33 ),
34 RefError::Malformed(why) => write!(f, "reference name {why}"),
35 // The offending character is rendered by its Unicode escape rather
36 // than literally: this message is printed to the pusher's terminal,
37 // and echoing the raw byte is the injection we are rejecting.
38 RefError::Character(c) => {
39 write!(f, "reference name contains a forbidden character U+{:04X}", *c as u32)
40 }
41 RefError::Namespace => write!(
42 f,
43 "only refs/heads/* and refs/tags/* may be pushed to Dogfood"
44 ),
45 }
46 }
47}
48
49/// Longest ref we will accept. Git itself allows more; this is the point past
50/// which a name is not a branch but an attempt to exhaust something.
51const MAX_REF_LEN: usize = 255;
52
53/// Validate the short name of a bookmark (no `refs/heads/` prefix).
54pub fn validate_bookmark(name: &str) -> Result<(), RefError> {
55 if name.is_empty() {
56 return Err(RefError::Empty);
57 }
58 if name.len() > MAX_REF_LEN {
59 return Err(RefError::TooLong);
60 }
61
62 // Control characters, DEL, and anything that could carry an ANSI escape.
63 // Checked first, so later messages never quote a raw escape byte.
64 for c in name.chars() {
65 if c.is_control() || c == '\u{7f}' {
66 return Err(RefError::Control);
67 }
68 }
69
70 // Git's documented rules (git-check-ref-format), plus our own.
71 if name.contains("..") {
72 return Err(RefError::Malformed("contains `..`"));
73 }
74 if name.ends_with(".lock") {
75 return Err(RefError::Malformed("ends with `.lock`"));
76 }
77 if name.starts_with('-') {
78 // A leading hyphen is read as an option by every CLI that receives it.
79 return Err(RefError::Malformed("starts with `-`"));
80 }
81 if name.starts_with('/') || name.ends_with('/') || name.contains("//") {
82 return Err(RefError::Malformed("has an empty path component"));
83 }
84 if name.starts_with('.') || name.contains("/.") {
85 return Err(RefError::Malformed("has a component starting with `.`"));
86 }
87 if name.ends_with('.') {
88 return Err(RefError::Malformed("ends with `.`"));
89 }
90 if name.contains("@{") {
91 return Err(RefError::Malformed("contains `@{`"));
92 }
93 if name == "@" {
94 return Err(RefError::Malformed("is `@`"));
95 }
96 if name.contains(".lock/") {
97 return Err(RefError::Malformed("has a component ending in `.lock`"));
98 }
99
100 // Stricter than Git: an explicit allowlist. Git permits most bytes, which
101 // means a ref name can carry UTF-8 that renders as something else entirely
102 // in a terminal or a URL.
103 for c in name.chars() {
104 let ok = c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-' | '+');
105 if !ok {
106 return Err(RefError::Character(c));
107 }
108 }
109
110 Ok(())
111}
112
113/// Validate a fully-qualified ref as it arrives on the wire.
114///
115/// Only `refs/heads/*` and `refs/tags/*` may be pushed. Notably this rejects
116/// `refs/dogfood/*` and anything else a client might invent, so a push cannot
117/// create refs the UI never shows and GC never reasons about.
118pub fn validate_pushed_ref(full: &str) -> Result<(), RefError> {
119 if full.is_empty() {
120 return Err(RefError::Empty);
121 }
122 if full.len() > MAX_REF_LEN {
123 return Err(RefError::TooLong);
124 }
125 for c in full.chars() {
126 if c.is_control() || c == '\u{7f}' {
127 return Err(RefError::Control);
128 }
129 }
130
131 let short = full
132 .strip_prefix("refs/heads/")
133 .or_else(|| full.strip_prefix("refs/tags/"))
134 .ok_or(RefError::Namespace)?;
135
136 validate_bookmark(short)
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn accepts_ordinary_bookmark_names() {
145 for name in [
146 "main",
147 "feature/thing",
148 "release-1.2",
149 "v1.0.0",
150 "user/x_y",
151 "a+b",
152 "9lives",
153 ] {
154 assert_eq!(validate_bookmark(name), Ok(()), "should accept {name:?}");
155 }
156 }
157
158 // ─── the §9 list, verbatim ───────────────────────────────────────────────
159
160 #[test]
161 fn rejects_double_dot() {
162 assert!(matches!(
163 validate_bookmark("a..b"),
164 Err(RefError::Malformed(_))
165 ));
166 }
167
168 #[test]
169 fn rejects_dot_lock_suffix() {
170 assert!(matches!(
171 validate_bookmark("main.lock"),
172 Err(RefError::Malformed(_))
173 ));
174 assert!(matches!(
175 validate_bookmark("a.lock/b"),
176 Err(RefError::Malformed(_))
177 ));
178 }
179
180 #[test]
181 fn rejects_leading_hyphen() {
182 // Otherwise the name is read as an option by anything it is passed to.
183 assert!(matches!(
184 validate_bookmark("-rf"),
185 Err(RefError::Malformed(_))
186 ));
187 }
188
189 #[test]
190 fn rejects_control_characters_and_ansi_escapes() {
191 // "Ref names reach terminals and log lines; escape sequences in them
192 // are an injection vector."
193 for bad in [
194 "main\n",
195 "main\r",
196 "main\0",
197 "ma\tin",
198 "main\x1b[31mRED",
199 "main\x1b]0;title\x07",
200 "main\u{7f}",
201 ] {
202 assert_eq!(
203 validate_bookmark(bad),
204 Err(RefError::Control),
205 "must reject {bad:?}"
206 );
207 }
208 }
209
210 #[test]
211 fn the_error_message_never_echoes_the_offending_byte() {
212 // Printing the raw character back to the pusher's terminal would be the
213 // very injection this rejects.
214 let msg = RefError::Character('\u{202e}').to_string();
215 assert!(!msg.contains('\u{202e}'), "message echoed the byte: {msg:?}");
216 assert!(msg.contains("U+202E"), "got {msg}");
217 }
218
219 #[test]
220 fn rejects_git_format_violations() {
221 for bad in [
222 "/leading", "trailing/", "a//b", ".hidden", "a/.hidden", "trailing.",
223 "main@{1}", "@",
224 ] {
225 assert!(
226 matches!(validate_bookmark(bad), Err(RefError::Malformed(_))),
227 "must reject {bad:?}, got {:?}",
228 validate_bookmark(bad)
229 );
230 }
231 }
232
233 #[test]
234 fn rejects_characters_outside_the_allowlist() {
235 // Stricter than Git on purpose: these are all legal refs to Git.
236 for bad in ["main branch", "main~1", "main^", "main:x", "main?", "main*",
237 "main[1]", "main\\x", "café", "main'", "main\"", "main;rm -rf"] {
238 assert!(
239 matches!(
240 validate_bookmark(bad),
241 Err(RefError::Character(_)) | Err(RefError::Malformed(_))
242 ),
243 "must reject {bad:?}, got {:?}",
244 validate_bookmark(bad)
245 );
246 }
247 }
248
249 #[test]
250 fn rejects_a_homoglyph_or_bidi_override() {
251 // U+202E flips rendering order and can make a ref display as a
252 // different name than it is.
253 assert!(validate_bookmark("main\u{202e}drow").is_err());
254 }
255
256 #[test]
257 fn rejects_empty_and_overlong() {
258 assert_eq!(validate_bookmark(""), Err(RefError::Empty));
259 assert_eq!(validate_bookmark(&"a".repeat(256)), Err(RefError::TooLong));
260 assert_eq!(validate_bookmark(&"a".repeat(255)), Ok(()));
261 }
262
263 // ─── fully-qualified refs ────────────────────────────────────────────────
264
265 #[test]
266 fn accepts_heads_and_tags() {
267 assert_eq!(validate_pushed_ref("refs/heads/main"), Ok(()));
268 assert_eq!(validate_pushed_ref("refs/tags/v1.0"), Ok(()));
269 assert_eq!(validate_pushed_ref("refs/heads/feature/x"), Ok(()));
270 }
271
272 #[test]
273 fn rejects_other_namespaces() {
274 // A push must not be able to create refs the UI never shows.
275 for bad in [
276 "refs/dogfood/secret",
277 "refs/remotes/origin/main",
278 "refs/notes/commits",
279 "HEAD",
280 "main",
281 "refs/",
282 ] {
283 assert_eq!(
284 validate_pushed_ref(bad),
285 Err(RefError::Namespace),
286 "must reject namespace {bad:?}"
287 );
288 }
289 }
290
291 #[test]
292 fn qualified_refs_inherit_every_short_name_rule() {
293 assert!(validate_pushed_ref("refs/heads/a..b").is_err());
294 assert!(validate_pushed_ref("refs/heads/main.lock").is_err());
295 assert_eq!(
296 validate_pushed_ref("refs/heads/main\x1b[31m"),
297 Err(RefError::Control)
298 );
299 }
300}

300 lines · Rust