| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | use forge_git::GitService; |
| 8 | |
| 9 | #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] |
| 10 | pub enum ParseError { |
| 11 | #[error("unsupported command")] |
| 12 | UnsupportedCommand, |
| 13 | #[error("malformed repository path")] |
| 14 | MalformedPath, |
| 15 | } |
| 16 | |
| 17 | |
| 18 | |
| 19 | const PREFIXES: &[(&str, GitService)] = &[ |
| 20 | ("git-upload-pack ", GitService::UploadPack), |
| 21 | ("git-receive-pack ", GitService::ReceivePack), |
| 22 | ("git upload-pack ", GitService::UploadPack), |
| 23 | ("git receive-pack ", GitService::ReceivePack), |
| 24 | ]; |
| 25 | |
| 26 | |
| 27 | |
| 28 | pub fn parse_exec(cmd: &str) -> Result<(GitService, String), ParseError> { |
| 29 | let (service, rest) = split_service(cmd)?; |
| 30 | let raw = unquote_argument(rest)?; |
| 31 | let path = normalize_path(raw)?; |
| 32 | Ok((service, path)) |
| 33 | } |
| 34 | |
| 35 | |
| 36 | pub(crate) const LFS_AUTHENTICATE_PREFIX: &str = "git-lfs-authenticate "; |
| 37 | |
| 38 | |
| 39 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 40 | pub enum LfsOperation { |
| 41 | Download, |
| 42 | Upload, |
| 43 | } |
| 44 | |
| 45 | |
| 46 | |
| 47 | pub fn parse_lfs_authenticate(cmd: &str) -> Result<(String, LfsOperation), ParseError> { |
| 48 | let rest = cmd |
| 49 | .strip_prefix(LFS_AUTHENTICATE_PREFIX) |
| 50 | .ok_or(ParseError::UnsupportedCommand)?; |
| 51 | |
| 52 | let end = if let Some(quoted) = rest.strip_prefix('\'') { |
| 53 | quoted.find('\'').ok_or(ParseError::MalformedPath)? + 2 |
| 54 | } else { |
| 55 | rest.find(' ').ok_or(ParseError::MalformedPath)? |
| 56 | }; |
| 57 | let (arg, tail) = rest.split_at(end); |
| 58 | let operation = match tail { |
| 59 | " download" => LfsOperation::Download, |
| 60 | " upload" => LfsOperation::Upload, |
| 61 | _ => return Err(ParseError::MalformedPath), |
| 62 | }; |
| 63 | let path = normalize_path(unquote_argument(arg)?)?; |
| 64 | Ok((path, operation)) |
| 65 | } |
| 66 | |
| 67 | fn split_service(cmd: &str) -> Result<(GitService, &str), ParseError> { |
| 68 | PREFIXES |
| 69 | .iter() |
| 70 | .find_map(|(prefix, service)| cmd.strip_prefix(prefix).map(|rest| (*service, rest))) |
| 71 | .ok_or(ParseError::UnsupportedCommand) |
| 72 | } |
| 73 | |
| 74 | |
| 75 | |
| 76 | |
| 77 | fn unquote_argument(rest: &str) -> Result<&str, ParseError> { |
| 78 | if let Some(quoted) = rest.strip_prefix('\'') { |
| 79 | let inner = quoted.strip_suffix('\'').ok_or(ParseError::MalformedPath)?; |
| 80 | if inner |
| 81 | .chars() |
| 82 | .any(|c| matches!(c, '\'' | '\\') || c.is_control()) |
| 83 | { |
| 84 | return Err(ParseError::MalformedPath); |
| 85 | } |
| 86 | return Ok(inner); |
| 87 | } |
| 88 | if rest.is_empty() |
| 89 | || rest |
| 90 | .chars() |
| 91 | .any(|c| c.is_whitespace() || matches!(c, '\'' | '"' | '\\') || c.is_control()) |
| 92 | { |
| 93 | return Err(ParseError::MalformedPath); |
| 94 | } |
| 95 | Ok(rest) |
| 96 | } |
| 97 | |
| 98 | |
| 99 | |
| 100 | fn normalize_path(raw: &str) -> Result<String, ParseError> { |
| 101 | let path = raw.strip_prefix('/').unwrap_or(raw); |
| 102 | let path = path.strip_prefix("~/").unwrap_or(path); |
| 103 | |
| 104 | let segments: Vec<&str> = path.split('/').collect(); |
| 105 | let [owner, repo] = segments.as_slice() else { |
| 106 | return Err(ParseError::MalformedPath); |
| 107 | }; |
| 108 | for segment in [owner, repo] { |
| 109 | if segment.is_empty() |
| 110 | || *segment == ".." |
| 111 | || segment.starts_with('-') |
| 112 | || segment.starts_with('.') |
| 113 | || !segment |
| 114 | .bytes() |
| 115 | .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) |
| 116 | { |
| 117 | return Err(ParseError::MalformedPath); |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | let repo = repo.strip_suffix(".git").unwrap_or(repo); |
| 122 | if repo.is_empty() { |
| 123 | return Err(ParseError::MalformedPath); |
| 124 | } |
| 125 | Ok(format!("{owner}/{repo}")) |
| 126 | } |
| 127 | |
| 128 | #[cfg(test)] |
| 129 | mod tests { |
| 130 | use super::*; |
| 131 | |
| 132 | fn ok(cmd: &str) -> (GitService, String) { |
| 133 | match parse_exec(cmd) { |
| 134 | Ok(v) => v, |
| 135 | Err(e) => panic!("expected {cmd:?} to parse, got {e:?}"), |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | fn err(cmd: &str) -> ParseError { |
| 140 | match parse_exec(cmd) { |
| 141 | Ok(v) => panic!("expected {cmd:?} to be rejected, got {v:?}"), |
| 142 | Err(e) => e, |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | #[test] |
| 147 | fn accepts_upload_and_receive_pack() { |
| 148 | assert_eq!( |
| 149 | ok("git-upload-pack '/alice/proj.git'"), |
| 150 | (GitService::UploadPack, "alice/proj".into()) |
| 151 | ); |
| 152 | assert_eq!( |
| 153 | ok("git-receive-pack '/alice/proj.git'"), |
| 154 | (GitService::ReceivePack, "alice/proj".into()) |
| 155 | ); |
| 156 | } |
| 157 | |
| 158 | #[test] |
| 159 | fn wiki_paths_keep_the_wiki_suffix() { |
| 160 | assert_eq!( |
| 161 | ok("git-receive-pack '/alice/proj.wiki.git'"), |
| 162 | (GitService::ReceivePack, "alice/proj.wiki".to_owned()) |
| 163 | ); |
| 164 | assert_eq!( |
| 165 | ok("git-upload-pack 'alice/proj.wiki'"), |
| 166 | (GitService::UploadPack, "alice/proj.wiki".to_owned()) |
| 167 | ); |
| 168 | } |
| 169 | |
| 170 | #[test] |
| 171 | fn accepts_spaced_form() { |
| 172 | assert_eq!( |
| 173 | ok("git upload-pack '/alice/proj'"), |
| 174 | (GitService::UploadPack, "alice/proj".into()) |
| 175 | ); |
| 176 | assert_eq!( |
| 177 | ok("git receive-pack 'alice/proj.git'"), |
| 178 | (GitService::ReceivePack, "alice/proj".into()) |
| 179 | ); |
| 180 | } |
| 181 | |
| 182 | #[test] |
| 183 | fn strips_leading_slash_and_tilde() { |
| 184 | assert_eq!(ok("git-upload-pack '~/alice/proj'").1, "alice/proj"); |
| 185 | assert_eq!(ok("git-upload-pack '/~/alice/proj'").1, "alice/proj"); |
| 186 | assert_eq!(ok("git-upload-pack 'alice/proj'").1, "alice/proj"); |
| 187 | |
| 188 | assert_eq!( |
| 189 | err("git-upload-pack '//alice/proj'"), |
| 190 | ParseError::MalformedPath |
| 191 | ); |
| 192 | } |
| 193 | |
| 194 | #[test] |
| 195 | fn unquoted_path_without_spaces_is_accepted() { |
| 196 | assert_eq!( |
| 197 | ok("git-upload-pack /alice/proj.git"), |
| 198 | (GitService::UploadPack, "alice/proj".into()) |
| 199 | ); |
| 200 | assert_eq!( |
| 201 | ok("git-receive-pack alice/proj"), |
| 202 | (GitService::ReceivePack, "alice/proj".into()) |
| 203 | ); |
| 204 | } |
| 205 | |
| 206 | #[test] |
| 207 | fn unquoted_path_with_quote_or_space_is_rejected() { |
| 208 | assert_eq!(err("git-upload-pack a/b c"), ParseError::MalformedPath); |
| 209 | assert_eq!(err("git-upload-pack a/b'"), ParseError::MalformedPath); |
| 210 | assert_eq!(err("git-upload-pack a\"b/c"), ParseError::MalformedPath); |
| 211 | assert_eq!(err("git-upload-pack a\\b/c"), ParseError::MalformedPath); |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn rejects_option_injection() { |
| 216 | assert_eq!(err("git-upload-pack '--help'"), ParseError::MalformedPath); |
| 217 | assert_eq!( |
| 218 | err("git-upload-pack '--upload-pack=touch /x'"), |
| 219 | ParseError::MalformedPath |
| 220 | ); |
| 221 | assert_eq!(err("git-upload-pack '-x/y'"), ParseError::MalformedPath); |
| 222 | assert_eq!(err("git-upload-pack 'a/-b'"), ParseError::MalformedPath); |
| 223 | } |
| 224 | |
| 225 | #[test] |
| 226 | fn rejects_shell_injection() { |
| 227 | assert_eq!( |
| 228 | err("git-upload-pack 'a/b'; rm -rf /"), |
| 229 | ParseError::MalformedPath |
| 230 | ); |
| 231 | assert_eq!( |
| 232 | err("git-upload-pack 'a/b' 'c/d'"), |
| 233 | ParseError::MalformedPath |
| 234 | ); |
| 235 | assert_eq!(err("git-upload-pack 'a/b$(id)'"), ParseError::MalformedPath); |
| 236 | assert_eq!(err("git-upload-pack 'a/b;c'"), ParseError::MalformedPath); |
| 237 | assert_eq!(err("git-upload-pack 'a/b|c'"), ParseError::MalformedPath); |
| 238 | assert_eq!(err("git-upload-pack 'a/b&c'"), ParseError::MalformedPath); |
| 239 | assert_eq!(err("git-upload-pack 'a/b%20c'"), ParseError::MalformedPath); |
| 240 | assert_eq!(err("git-upload-pack 'a/é'"), ParseError::MalformedPath); |
| 241 | assert_eq!(err("git-upload-pack 'a/b`id`'"), ParseError::MalformedPath); |
| 242 | assert_eq!( |
| 243 | err("git-upload-pack 'a/b' && id"), |
| 244 | ParseError::MalformedPath |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | #[test] |
| 249 | fn rejects_quote_backslash_newline_nul_and_control_chars_inside_quotes() { |
| 250 | assert_eq!(err("git-upload-pack 'a/b\\c'"), ParseError::MalformedPath); |
| 251 | assert_eq!(err("git-upload-pack 'a/b\nc'"), ParseError::MalformedPath); |
| 252 | assert_eq!(err("git-upload-pack 'a/b\rc'"), ParseError::MalformedPath); |
| 253 | assert_eq!(err("git-upload-pack 'a/b\0c'"), ParseError::MalformedPath); |
| 254 | assert_eq!(err("git-upload-pack 'a/b\x07c'"), ParseError::MalformedPath); |
| 255 | assert_eq!(err("git-upload-pack 'a/b\x7fc'"), ParseError::MalformedPath); |
| 256 | assert_eq!(err("git-upload-pack 'a/b'c'"), ParseError::MalformedPath); |
| 257 | } |
| 258 | |
| 259 | #[test] |
| 260 | fn rejects_traversal() { |
| 261 | assert_eq!(err("git-upload-pack '../x/y'"), ParseError::MalformedPath); |
| 262 | assert_eq!(err("git-upload-pack 'a/..'"), ParseError::MalformedPath); |
| 263 | assert_eq!(err("git-upload-pack '/a/../b'"), ParseError::MalformedPath); |
| 264 | assert_eq!( |
| 265 | err("git-upload-pack '.hidden/repo'"), |
| 266 | ParseError::MalformedPath |
| 267 | ); |
| 268 | assert_eq!( |
| 269 | err("git-upload-pack 'owner/.git'"), |
| 270 | ParseError::MalformedPath |
| 271 | ); |
| 272 | } |
| 273 | |
| 274 | #[test] |
| 275 | fn rejects_wrong_segment_count() { |
| 276 | assert_eq!(err("git-upload-pack 'a/b/c'"), ParseError::MalformedPath); |
| 277 | assert_eq!(err("git-upload-pack 'a'"), ParseError::MalformedPath); |
| 278 | assert_eq!(err("git-upload-pack 'a/'"), ParseError::MalformedPath); |
| 279 | assert_eq!(err("git-upload-pack '/a/b/'"), ParseError::MalformedPath); |
| 280 | assert_eq!(err("git-upload-pack '//'"), ParseError::MalformedPath); |
| 281 | } |
| 282 | |
| 283 | #[test] |
| 284 | fn rejects_empty_values() { |
| 285 | assert_eq!(err("git-upload-pack ''"), ParseError::MalformedPath); |
| 286 | assert_eq!(err("git-upload-pack '/'"), ParseError::MalformedPath); |
| 287 | assert_eq!(err("git-upload-pack '~/'"), ParseError::MalformedPath); |
| 288 | assert_eq!(err("git-upload-pack '.git'"), ParseError::MalformedPath); |
| 289 | assert_eq!(err("git-upload-pack "), ParseError::MalformedPath); |
| 290 | } |
| 291 | |
| 292 | #[test] |
| 293 | fn rejects_unknown_commands() { |
| 294 | assert_eq!( |
| 295 | err("git-upload-archive '/a/b'"), |
| 296 | ParseError::UnsupportedCommand |
| 297 | ); |
| 298 | assert_eq!( |
| 299 | err("git upload-archive '/a/b'"), |
| 300 | ParseError::UnsupportedCommand |
| 301 | ); |
| 302 | assert_eq!(err("ls -la"), ParseError::UnsupportedCommand); |
| 303 | assert_eq!(err(""), ParseError::UnsupportedCommand); |
| 304 | assert_eq!( |
| 305 | err("git-upload-packx '/a/b'"), |
| 306 | ParseError::UnsupportedCommand |
| 307 | ); |
| 308 | assert_eq!(err("git-upload-pack'/a/b'"), ParseError::UnsupportedCommand); |
| 309 | assert_eq!(err("git-upload-pack"), ParseError::UnsupportedCommand); |
| 310 | assert_eq!( |
| 311 | err("GIT-UPLOAD-PACK '/a/b'"), |
| 312 | ParseError::UnsupportedCommand |
| 313 | ); |
| 314 | assert_eq!( |
| 315 | err(" git-upload-pack '/a/b'"), |
| 316 | ParseError::UnsupportedCommand |
| 317 | ); |
| 318 | } |
| 319 | |
| 320 | #[test] |
| 321 | fn rejects_extra_spaces_and_trailing_garbage() { |
| 322 | assert_eq!(err("git-upload-pack '/a/b'"), ParseError::MalformedPath); |
| 323 | assert_eq!(err("git-upload-pack '/a/b' "), ParseError::MalformedPath); |
| 324 | assert_eq!(err("git-upload-pack '/a/b"), ParseError::MalformedPath); |
| 325 | } |
| 326 | |
| 327 | fn lfs_ok(cmd: &str) -> (String, LfsOperation) { |
| 328 | match parse_lfs_authenticate(cmd) { |
| 329 | Ok(v) => v, |
| 330 | Err(e) => panic!("expected {cmd:?} to parse, got {e:?}"), |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | fn lfs_err(cmd: &str) -> ParseError { |
| 335 | match parse_lfs_authenticate(cmd) { |
| 336 | Ok(v) => panic!("expected {cmd:?} to be rejected, got {v:?}"), |
| 337 | Err(e) => e, |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | #[test] |
| 342 | fn lfs_accepts_quoted_and_bare_paths() { |
| 343 | assert_eq!( |
| 344 | lfs_ok("git-lfs-authenticate '/alice/proj.git' download"), |
| 345 | ("alice/proj".into(), LfsOperation::Download) |
| 346 | ); |
| 347 | assert_eq!( |
| 348 | lfs_ok("git-lfs-authenticate 'alice/proj' upload"), |
| 349 | ("alice/proj".into(), LfsOperation::Upload) |
| 350 | ); |
| 351 | assert_eq!( |
| 352 | lfs_ok("git-lfs-authenticate /alice/proj.git upload"), |
| 353 | ("alice/proj".into(), LfsOperation::Upload) |
| 354 | ); |
| 355 | } |
| 356 | |
| 357 | #[test] |
| 358 | fn lfs_rejects_bad_operation() { |
| 359 | assert_eq!( |
| 360 | lfs_err("git-lfs-authenticate 'a/b' push"), |
| 361 | ParseError::MalformedPath |
| 362 | ); |
| 363 | assert_eq!( |
| 364 | lfs_err("git-lfs-authenticate 'a/b' "), |
| 365 | ParseError::MalformedPath |
| 366 | ); |
| 367 | assert_eq!( |
| 368 | lfs_err("git-lfs-authenticate 'a/b' download extra"), |
| 369 | ParseError::MalformedPath |
| 370 | ); |
| 371 | assert_eq!( |
| 372 | lfs_err("git-lfs-authenticate 'a/b'download"), |
| 373 | ParseError::MalformedPath |
| 374 | ); |
| 375 | assert_eq!( |
| 376 | lfs_err("git-lfs-authenticate 'a/b' Download"), |
| 377 | ParseError::MalformedPath |
| 378 | ); |
| 379 | } |
| 380 | |
| 381 | #[test] |
| 382 | fn lfs_rejects_bad_paths() { |
| 383 | assert_eq!( |
| 384 | lfs_err("git-lfs-authenticate '--help' download"), |
| 385 | ParseError::MalformedPath |
| 386 | ); |
| 387 | assert_eq!( |
| 388 | lfs_err("git-lfs-authenticate '../x/y' upload"), |
| 389 | ParseError::MalformedPath |
| 390 | ); |
| 391 | assert_eq!( |
| 392 | lfs_err("git-lfs-authenticate 'a/b'; id download"), |
| 393 | ParseError::MalformedPath |
| 394 | ); |
| 395 | assert_eq!( |
| 396 | lfs_err("git-lfs-authenticate 'a/b' 'c' download"), |
| 397 | ParseError::MalformedPath |
| 398 | ); |
| 399 | assert_eq!( |
| 400 | lfs_err("git-lfs-authenticate 'a/b download"), |
| 401 | ParseError::MalformedPath |
| 402 | ); |
| 403 | assert_eq!( |
| 404 | lfs_err("git-lfs-authenticate a/b c download"), |
| 405 | ParseError::MalformedPath |
| 406 | ); |
| 407 | assert_eq!( |
| 408 | lfs_err("git-lfs-authenticate 'a/b/c' download"), |
| 409 | ParseError::MalformedPath |
| 410 | ); |
| 411 | assert_eq!( |
| 412 | lfs_err("git-lfs-authenticate ''"), |
| 413 | ParseError::MalformedPath |
| 414 | ); |
| 415 | } |
| 416 | |
| 417 | #[test] |
| 418 | fn lfs_rejects_other_commands() { |
| 419 | assert_eq!( |
| 420 | lfs_err("git-upload-pack 'a/b' download"), |
| 421 | ParseError::UnsupportedCommand |
| 422 | ); |
| 423 | assert_eq!( |
| 424 | lfs_err("git-lfs-authenticate-x 'a/b' download"), |
| 425 | ParseError::UnsupportedCommand |
| 426 | ); |
| 427 | } |
| 428 | } |