Jump to…
snowinitial commitqoxwzsukwmkx1mo
Matt W1//! Reference name validation (spec §9).
Matt W2//!
Matt W3//! > Validate against the Git ref format rules plus a stricter allowlist in the
Matt W4//! > pre-receive hook. Reject refs containing `..`, ending in `.lock`, starting
Matt W5//! > with `-`, or containing control characters or ANSI escapes. Ref names reach
Matt W6//! > terminals and log lines; escape sequences in them are an injection vector.
Matt W7//!
Matt W8//! Shared by the pre-receive hook and the repository settings form so a name
Matt W9//! that one accepts the other cannot reject.
Matt W10
Matt W11#[derive(Debug, Clone, PartialEq, Eq)]
Matt W12pub enum RefError {
Matt W13 Empty,
Matt W14 TooLong,
Matt W15 /// Control characters, DEL, or an ANSI escape.
Matt W16 Control,
Matt W17 /// `..`, a leading `-`, a trailing `.lock`, and friends.
Matt W18 Malformed(&'static str),
Matt W19 /// A character outside the permitted set.
Matt W20 Character(char),
Matt W21 /// Not under a namespace Dogfood accepts on push.
Matt W22 Namespace,
Matt W23}
Matt W24
Matt W25impl std::fmt::Display for RefError {
Matt W26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Matt W27 match self {
Matt W28 RefError::Empty => write!(f, "reference name is empty"),
Matt W29 RefError::TooLong => write!(f, "reference name is too long"),
Matt W30 RefError::Control => write!(
Matt W31 f,
Matt W32 "reference name contains control characters or escape sequences"
Matt W33 ),
Matt W34 RefError::Malformed(why) => write!(f, "reference name {why}"),
Matt W35 // The offending character is rendered by its Unicode escape rather
Matt W36 // than literally: this message is printed to the pusher's terminal,
Matt W37 // and echoing the raw byte is the injection we are rejecting.
Matt W38 RefError::Character(c) => {
Matt W39 write!(f, "reference name contains a forbidden character U+{:04X}", *c as u32)
Matt W40 }
Matt W41 RefError::Namespace => write!(
Matt W42 f,
Matt W43 "only refs/heads/* and refs/tags/* may be pushed to Dogfood"
Matt W44 ),
Matt W45 }
Matt W46 }
Matt W47}
Matt W48
Matt W49/// Longest ref we will accept. Git itself allows more; this is the point past
Matt W50/// which a name is not a branch but an attempt to exhaust something.
Matt W51const MAX_REF_LEN: usize = 255;
Matt W52
Matt W53/// Validate the short name of a bookmark (no `refs/heads/` prefix).
Matt W54pub fn validate_bookmark(name: &str) -> Result<(), RefError> {
Matt W55 if name.is_empty() {
Matt W56 return Err(RefError::Empty);
Matt W57 }
Matt W58 if name.len() > MAX_REF_LEN {
Matt W59 return Err(RefError::TooLong);
Matt W60 }
Matt W61
Matt W62 // Control characters, DEL, and anything that could carry an ANSI escape.
Matt W63 // Checked first, so later messages never quote a raw escape byte.
Matt W64 for c in name.chars() {
Matt W65 if c.is_control() || c == '\u{7f}' {
Matt W66 return Err(RefError::Control);
Matt W67 }
Matt W68 }
Matt W69
Matt W70 // Git's documented rules (git-check-ref-format), plus our own.
Matt W71 if name.contains("..") {
Matt W72 return Err(RefError::Malformed("contains `..`"));
Matt W73 }
Matt W74 if name.ends_with(".lock") {
Matt W75 return Err(RefError::Malformed("ends with `.lock`"));
Matt W76 }
Matt W77 if name.starts_with('-') {
Matt W78 // A leading hyphen is read as an option by every CLI that receives it.
Matt W79 return Err(RefError::Malformed("starts with `-`"));
Matt W80 }
Matt W81 if name.starts_with('/') || name.ends_with('/') || name.contains("//") {
Matt W82 return Err(RefError::Malformed("has an empty path component"));
Matt W83 }
Matt W84 if name.starts_with('.') || name.contains("/.") {
Matt W85 return Err(RefError::Malformed("has a component starting with `.`"));
Matt W86 }
Matt W87 if name.ends_with('.') {
Matt W88 return Err(RefError::Malformed("ends with `.`"));
Matt W89 }
Matt W90 if name.contains("@{") {
Matt W91 return Err(RefError::Malformed("contains `@{`"));
Matt W92 }
Matt W93 if name == "@" {
Matt W94 return Err(RefError::Malformed("is `@`"));
Matt W95 }
Matt W96 if name.contains(".lock/") {
Matt W97 return Err(RefError::Malformed("has a component ending in `.lock`"));
Matt W98 }
Matt W99
Matt W100 // Stricter than Git: an explicit allowlist. Git permits most bytes, which
Matt W101 // means a ref name can carry UTF-8 that renders as something else entirely
Matt W102 // in a terminal or a URL.
Matt W103 for c in name.chars() {
Matt W104 let ok = c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-' | '+');
Matt W105 if !ok {
Matt W106 return Err(RefError::Character(c));
Matt W107 }
Matt W108 }
Matt W109
Matt W110 Ok(())
Matt W111}
Matt W112
Matt W113/// Validate a fully-qualified ref as it arrives on the wire.
Matt W114///
Matt W115/// Only `refs/heads/*` and `refs/tags/*` may be pushed. Notably this rejects
Matt W116/// `refs/dogfood/*` and anything else a client might invent, so a push cannot
Matt W117/// create refs the UI never shows and GC never reasons about.
Matt W118pub fn validate_pushed_ref(full: &str) -> Result<(), RefError> {
Matt W119 if full.is_empty() {
Matt W120 return Err(RefError::Empty);
Matt W121 }
Matt W122 if full.len() > MAX_REF_LEN {
Matt W123 return Err(RefError::TooLong);
Matt W124 }
Matt W125 for c in full.chars() {
Matt W126 if c.is_control() || c == '\u{7f}' {
Matt W127 return Err(RefError::Control);
Matt W128 }
Matt W129 }
Matt W130
Matt W131 let short = full
Matt W132 .strip_prefix("refs/heads/")
Matt W133 .or_else(|| full.strip_prefix("refs/tags/"))
Matt W134 .ok_or(RefError::Namespace)?;
Matt W135
Matt W136 validate_bookmark(short)
Matt W137}
Matt W138
Matt W139#[cfg(test)]
Matt W140mod tests {
Matt W141 use super::*;
Matt W142
Matt W143 #[test]
Matt W144 fn accepts_ordinary_bookmark_names() {
Matt W145 for name in [
Matt W146 "main",
Matt W147 "feature/thing",
Matt W148 "release-1.2",
Matt W149 "v1.0.0",
Matt W150 "user/x_y",
Matt W151 "a+b",
Matt W152 "9lives",
Matt W153 ] {
Matt W154 assert_eq!(validate_bookmark(name), Ok(()), "should accept {name:?}");
Matt W155 }
Matt W156 }
Matt W157
Matt W158 // ─── the §9 list, verbatim ───────────────────────────────────────────────
Matt W159
Matt W160 #[test]
Matt W161 fn rejects_double_dot() {
Matt W162 assert!(matches!(
Matt W163 validate_bookmark("a..b"),
Matt W164 Err(RefError::Malformed(_))
Matt W165 ));
Matt W166 }
Matt W167
Matt W168 #[test]
Matt W169 fn rejects_dot_lock_suffix() {
Matt W170 assert!(matches!(
Matt W171 validate_bookmark("main.lock"),
Matt W172 Err(RefError::Malformed(_))
Matt W173 ));
Matt W174 assert!(matches!(
Matt W175 validate_bookmark("a.lock/b"),
Matt W176 Err(RefError::Malformed(_))
Matt W177 ));
Matt W178 }
Matt W179
Matt W180 #[test]
Matt W181 fn rejects_leading_hyphen() {
Matt W182 // Otherwise the name is read as an option by anything it is passed to.
Matt W183 assert!(matches!(
Matt W184 validate_bookmark("-rf"),
Matt W185 Err(RefError::Malformed(_))
Matt W186 ));
Matt W187 }
Matt W188
Matt W189 #[test]
Matt W190 fn rejects_control_characters_and_ansi_escapes() {
Matt W191 // "Ref names reach terminals and log lines; escape sequences in them
Matt W192 // are an injection vector."
Matt W193 for bad in [
Matt W194 "main\n",
Matt W195 "main\r",
Matt W196 "main\0",
Matt W197 "ma\tin",
Matt W198 "main\x1b[31mRED",
Matt W199 "main\x1b]0;title\x07",
Matt W200 "main\u{7f}",
Matt W201 ] {
Matt W202 assert_eq!(
Matt W203 validate_bookmark(bad),
Matt W204 Err(RefError::Control),
Matt W205 "must reject {bad:?}"
Matt W206 );
Matt W207 }
Matt W208 }
Matt W209
Matt W210 #[test]
Matt W211 fn the_error_message_never_echoes_the_offending_byte() {
Matt W212 // Printing the raw character back to the pusher's terminal would be the
Matt W213 // very injection this rejects.
Matt W214 let msg = RefError::Character('\u{202e}').to_string();
Matt W215 assert!(!msg.contains('\u{202e}'), "message echoed the byte: {msg:?}");
Matt W216 assert!(msg.contains("U+202E"), "got {msg}");
Matt W217 }
Matt W218
Matt W219 #[test]
Matt W220 fn rejects_git_format_violations() {
Matt W221 for bad in [
Matt W222 "/leading", "trailing/", "a//b", ".hidden", "a/.hidden", "trailing.",
Matt W223 "main@{1}", "@",
Matt W224 ] {
Matt W225 assert!(
Matt W226 matches!(validate_bookmark(bad), Err(RefError::Malformed(_))),
Matt W227 "must reject {bad:?}, got {:?}",
Matt W228 validate_bookmark(bad)
Matt W229 );
Matt W230 }
Matt W231 }
Matt W232
Matt W233 #[test]
Matt W234 fn rejects_characters_outside_the_allowlist() {
Matt W235 // Stricter than Git on purpose: these are all legal refs to Git.
Matt W236 for bad in ["main branch", "main~1", "main^", "main:x", "main?", "main*",
Matt W237 "main[1]", "main\\x", "café", "main'", "main\"", "main;rm -rf"] {
Matt W238 assert!(
Matt W239 matches!(
Matt W240 validate_bookmark(bad),
Matt W241 Err(RefError::Character(_)) | Err(RefError::Malformed(_))
Matt W242 ),
Matt W243 "must reject {bad:?}, got {:?}",
Matt W244 validate_bookmark(bad)
Matt W245 );
Matt W246 }
Matt W247 }
Matt W248
Matt W249 #[test]
Matt W250 fn rejects_a_homoglyph_or_bidi_override() {
Matt W251 // U+202E flips rendering order and can make a ref display as a
Matt W252 // different name than it is.
Matt W253 assert!(validate_bookmark("main\u{202e}drow").is_err());
Matt W254 }
Matt W255
Matt W256 #[test]
Matt W257 fn rejects_empty_and_overlong() {
Matt W258 assert_eq!(validate_bookmark(""), Err(RefError::Empty));
Matt W259 assert_eq!(validate_bookmark(&"a".repeat(256)), Err(RefError::TooLong));
Matt W260 assert_eq!(validate_bookmark(&"a".repeat(255)), Ok(()));
Matt W261 }
Matt W262
Matt W263 // ─── fully-qualified refs ────────────────────────────────────────────────
Matt W264
Matt W265 #[test]
Matt W266 fn accepts_heads_and_tags() {
Matt W267 assert_eq!(validate_pushed_ref("refs/heads/main"), Ok(()));
Matt W268 assert_eq!(validate_pushed_ref("refs/tags/v1.0"), Ok(()));
Matt W269 assert_eq!(validate_pushed_ref("refs/heads/feature/x"), Ok(()));
Matt W270 }
Matt W271
Matt W272 #[test]
Matt W273 fn rejects_other_namespaces() {
Matt W274 // A push must not be able to create refs the UI never shows.
Matt W275 for bad in [
Matt W276 "refs/dogfood/secret",
Matt W277 "refs/remotes/origin/main",
Matt W278 "refs/notes/commits",
Matt W279 "HEAD",
Matt W280 "main",
Matt W281 "refs/",
Matt W282 ] {
Matt W283 assert_eq!(
Matt W284 validate_pushed_ref(bad),
Matt W285 Err(RefError::Namespace),
Matt W286 "must reject namespace {bad:?}"
Matt W287 );
Matt W288 }
Matt W289 }
Matt W290
Matt W291 #[test]
Matt W292 fn qualified_refs_inherit_every_short_name_rule() {
Matt W293 assert!(validate_pushed_ref("refs/heads/a..b").is_err());
Matt W294 assert!(validate_pushed_ref("refs/heads/main.lock").is_err());
Matt W295 assert_eq!(
Matt W296 validate_pushed_ref("refs/heads/main\x1b[31m"),
Matt W297 Err(RefError::Control)
Matt W298 );
Matt W299 }
Matt W300}

300 lines · Rust