1use std::fmt;
28use std::path::Path;
29
30pub const MAX_REF_LEN: usize = 255;
33pub const MAX_REMOTE_LEN: usize = 255;
34pub const MAX_PATH_LEN: usize = 4096;
35pub const MAX_MESSAGE_LEN: usize = 16_384;
36pub const MAX_AUTHOR_LEN: usize = 320;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum GitArgError {
45 Empty(&'static str),
46 TooLong(&'static str, usize),
47 InvalidCharacter(&'static str, String),
48 LeadingDash(&'static str, String),
49 PathTraversal(&'static str, String),
50 ForceDenied,
51 NotARepo(String),
52 NotAbsolute(String),
53}
54
55impl fmt::Display for GitArgError {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 match self {
58 Self::Empty(field) => write!(f, "{field} must not be empty"),
59 Self::TooLong(field, max) => {
60 write!(f, "{field} exceeds the maximum length of {max}")
61 }
62 Self::InvalidCharacter(field, value) => write!(
63 f,
64 "{field} {value:?} contains characters outside the allowed set"
65 ),
66 Self::LeadingDash(field, value) => write!(
67 f,
68 "{field} {value:?} must not start with '-' (rejected: could be parsed as a flag)"
69 ),
70 Self::PathTraversal(field, value) => {
71 write!(f, "{field} {value:?} must not contain a '..' segment")
72 }
73 Self::ForceDenied => {
74 write!(
75 f,
76 "force-push is never permitted through this verb (ADR-108)"
77 )
78 }
79 Self::NotARepo(path) => write!(f, "repo {path:?} does not contain a .git entry"),
80 Self::NotAbsolute(path) => write!(f, "repo {path:?} must be an absolute path"),
81 }
82 }
83}
84
85impl std::error::Error for GitArgError {}
86
87pub fn validate_ref_name(field: &'static str, value: &str) -> Result<(), GitArgError> {
93 if value.is_empty() {
94 return Err(GitArgError::Empty(field));
95 }
96 if value.len() > MAX_REF_LEN {
97 return Err(GitArgError::TooLong(field, MAX_REF_LEN));
98 }
99 if value.starts_with('-') {
100 return Err(GitArgError::LeadingDash(field, value.to_string()));
101 }
102 if value.contains("..") {
103 return Err(GitArgError::PathTraversal(field, value.to_string()));
104 }
105 let charset_ok = value
106 .chars()
107 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'));
108 if !charset_ok {
109 return Err(GitArgError::InvalidCharacter(field, value.to_string()));
110 }
111 if value.starts_with('/')
112 || value.ends_with('/')
113 || value.contains("//")
114 || value.starts_with('.')
115 || value.ends_with('.')
116 || value.ends_with(".lock")
117 || value.contains("@{")
118 {
119 return Err(GitArgError::InvalidCharacter(field, value.to_string()));
120 }
121 Ok(())
122}
123
124pub fn validate_remote_name(value: &str) -> Result<(), GitArgError> {
127 const FIELD: &str = "remote";
128 if value.is_empty() {
129 return Err(GitArgError::Empty(FIELD));
130 }
131 if value.len() > MAX_REMOTE_LEN {
132 return Err(GitArgError::TooLong(FIELD, MAX_REMOTE_LEN));
133 }
134 if value.starts_with('-') {
135 return Err(GitArgError::LeadingDash(FIELD, value.to_string()));
136 }
137 if value.contains("..") {
138 return Err(GitArgError::PathTraversal(FIELD, value.to_string()));
139 }
140 let charset_ok = value
141 .chars()
142 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
143 if !charset_ok {
144 return Err(GitArgError::InvalidCharacter(FIELD, value.to_string()));
145 }
146 Ok(())
147}
148
149pub fn validate_commit_path(value: &str) -> Result<(), GitArgError> {
157 const FIELD: &str = "paths[]";
158 if value.is_empty() {
159 return Err(GitArgError::Empty(FIELD));
160 }
161 if value.len() > MAX_PATH_LEN {
162 return Err(GitArgError::TooLong(FIELD, MAX_PATH_LEN));
163 }
164 if Path::new(value).is_absolute() {
165 return Err(GitArgError::InvalidCharacter(FIELD, value.to_string()));
166 }
167 if value.as_bytes().contains(&0) {
168 return Err(GitArgError::InvalidCharacter(FIELD, value.to_string()));
169 }
170 if value.split('/').any(|seg| seg == "..") {
171 return Err(GitArgError::PathTraversal(FIELD, value.to_string()));
172 }
173 Ok(())
174}
175
176fn literal_pathspec(value: &str) -> String {
177 format!(":(literal){value}")
178}
179
180pub fn validate_message(value: &str) -> Result<(), GitArgError> {
186 const FIELD: &str = "message";
187 if value.trim().is_empty() {
188 return Err(GitArgError::Empty(FIELD));
189 }
190 if value.len() > MAX_MESSAGE_LEN {
191 return Err(GitArgError::TooLong(FIELD, MAX_MESSAGE_LEN));
192 }
193 if value.as_bytes().contains(&0) {
194 return Err(GitArgError::InvalidCharacter(
195 FIELD,
196 "<contains NUL byte>".to_string(),
197 ));
198 }
199 Ok(())
200}
201
202pub fn validate_author(value: &str) -> Result<(), GitArgError> {
208 const FIELD: &str = "author";
209 if value.trim().is_empty() {
210 return Err(GitArgError::Empty(FIELD));
211 }
212 if value.len() > MAX_AUTHOR_LEN {
213 return Err(GitArgError::TooLong(FIELD, MAX_AUTHOR_LEN));
214 }
215 if value.starts_with('-') {
216 return Err(GitArgError::LeadingDash(FIELD, value.to_string()));
217 }
218 if value.bytes().any(|b| b == 0 || b == b'\n' || b == b'\r') {
219 return Err(GitArgError::InvalidCharacter(
220 FIELD,
221 "<contains control character>".to_string(),
222 ));
223 }
224 Ok(())
225}
226
227pub fn reject_force(force: Option<bool>) -> Result<(), GitArgError> {
232 if force == Some(true) {
233 return Err(GitArgError::ForceDenied);
234 }
235 Ok(())
236}
237
238pub fn validate_repo_path(path: &Path) -> Result<(), GitArgError> {
242 if !path.is_absolute() {
243 return Err(GitArgError::NotAbsolute(path.display().to_string()));
244 }
245 if !path.join(".git").exists() {
246 return Err(GitArgError::NotARepo(path.display().to_string()));
247 }
248 Ok(())
249}
250
251pub fn build_add_argv(paths: &[String]) -> Result<Vec<String>, GitArgError> {
254 for p in paths {
255 validate_commit_path(p)?;
256 }
257 let mut argv = vec!["add".to_string(), "--".to_string()];
258 argv.extend(paths.iter().map(|path| literal_pathspec(path)));
259 Ok(argv)
260}
261
262pub fn build_commit_argv(
272 message: &str,
273 paths: &[String],
274 author: Option<&str>,
275) -> Result<Vec<String>, GitArgError> {
276 validate_message(message)?;
277 for p in paths {
278 validate_commit_path(p)?;
279 }
280 if let Some(a) = author {
281 validate_author(a)?;
282 }
283
284 let mut argv = vec!["commit".to_string()];
285 if paths.is_empty() {
286 argv.push("-a".to_string());
287 }
288 if let Some(a) = author {
289 argv.push(format!("--author={a}"));
290 }
291 argv.push("-m".to_string());
292 argv.push(message.to_string());
293 if !paths.is_empty() {
294 argv.push("--".to_string());
295 argv.extend(paths.iter().map(|path| literal_pathspec(path)));
296 }
297 Ok(argv)
298}
299
300pub fn build_branch_argv(name: &str, from: Option<&str>) -> Result<Vec<String>, GitArgError> {
303 validate_ref_name("name", name)?;
304 if let Some(f) = from {
305 validate_ref_name("from", f)?;
306 }
307 let mut argv = vec!["branch".to_string(), "--".to_string(), name.to_string()];
308 if let Some(f) = from {
309 argv.push(f.to_string());
310 }
311 Ok(argv)
312}
313
314pub fn build_push_argv(remote: &str, branch: &str) -> Result<Vec<String>, GitArgError> {
318 validate_remote_name(remote)?;
319 validate_ref_name("branch", branch)?;
320 Ok(vec![
321 "push".to_string(),
322 "--".to_string(),
323 remote.to_string(),
324 branch.to_string(),
325 ])
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
335 fn ref_name_accepts_ordinary_branch_names() {
336 assert!(validate_ref_name("name", "feat/adr108-git-write-verbs").is_ok());
337 assert!(validate_ref_name("name", "main").is_ok());
338 assert!(validate_ref_name("name", "release-1.2.3").is_ok());
339 }
340
341 #[test]
342 fn ref_name_rejects_empty() {
343 assert_eq!(
344 validate_ref_name("name", ""),
345 Err(GitArgError::Empty("name"))
346 );
347 }
348
349 #[test]
350 fn ref_name_rejects_leading_dash() {
351 for injected in ["-f", "--upload-pack=evil", "-- ", "-x"] {
352 let err = validate_ref_name("name", injected).unwrap_err();
353 assert!(
354 matches!(err, GitArgError::LeadingDash(..)),
355 "{injected}: {err}"
356 );
357 }
358 }
359
360 #[test]
361 fn ref_name_rejects_whitespace() {
362 let err = validate_ref_name("name", "feat branch").unwrap_err();
363 assert!(matches!(err, GitArgError::InvalidCharacter(..)));
364 }
365
366 #[test]
367 fn ref_name_rejects_semicolon_injection() {
368 let err = validate_ref_name("name", "main;rm -rf /").unwrap_err();
369 assert!(matches!(err, GitArgError::InvalidCharacter(..)));
370 }
371
372 #[test]
373 fn ref_name_rejects_path_traversal() {
374 let err = validate_ref_name("name", "../../etc/passwd").unwrap_err();
375 assert!(matches!(err, GitArgError::PathTraversal(..)));
376 }
377
378 #[test]
379 fn ref_name_rejects_double_dot_anywhere() {
380 let err = validate_ref_name("name", "feat/foo..bar").unwrap_err();
381 assert!(matches!(err, GitArgError::PathTraversal(..)));
382 }
383
384 #[test]
385 fn ref_name_rejects_leading_and_trailing_slash() {
386 assert!(validate_ref_name("name", "/main").is_err());
387 assert!(validate_ref_name("name", "main/").is_err());
388 assert!(validate_ref_name("name", "a//b").is_err());
389 }
390
391 #[test]
392 fn ref_name_rejects_leading_dot_and_lock_suffix() {
393 assert!(validate_ref_name("name", ".hidden").is_err());
394 assert!(validate_ref_name("name", "main.lock").is_err());
395 assert!(validate_ref_name("name", "main.").is_err());
396 }
397
398 #[test]
399 fn ref_name_rejects_at_brace() {
400 let err = validate_ref_name("name", "main@{upstream}").unwrap_err();
401 assert!(matches!(err, GitArgError::InvalidCharacter(..)));
402 }
403
404 #[test]
405 fn ref_name_rejects_too_long() {
406 let long = "a".repeat(MAX_REF_LEN + 1);
407 assert!(matches!(
408 validate_ref_name("name", &long),
409 Err(GitArgError::TooLong(..))
410 ));
411 }
412
413 #[test]
414 fn ref_name_rejects_null_byte() {
415 let err = validate_ref_name("name", "main\0evil").unwrap_err();
416 assert!(matches!(err, GitArgError::InvalidCharacter(..)));
417 }
418
419 #[test]
422 fn remote_name_accepts_ordinary_names() {
423 assert!(validate_remote_name("origin").is_ok());
424 assert!(validate_remote_name("upstream-2").is_ok());
425 }
426
427 #[test]
428 fn remote_name_rejects_slash() {
429 let err = validate_remote_name("org/repo").unwrap_err();
430 assert!(matches!(err, GitArgError::InvalidCharacter(..)));
431 }
432
433 #[test]
434 fn remote_name_rejects_leading_dash_flag_shapes() {
435 for injected in ["--upload-pack=/bin/sh", "-o", "--exec=evil"] {
436 let err = validate_remote_name(injected).unwrap_err();
437 assert!(
438 matches!(err, GitArgError::LeadingDash(..)),
439 "{injected}: {err}"
440 );
441 }
442 }
443
444 #[test]
445 fn remote_name_rejects_whitespace_and_semicolons() {
446 assert!(validate_remote_name("origin; rm -rf /").is_err());
447 assert!(validate_remote_name("evil host").is_err());
448 }
449
450 #[test]
453 fn commit_path_accepts_ordinary_relative_paths() {
454 assert!(validate_commit_path("src/main.rs").is_ok());
455 assert!(validate_commit_path("README.md").is_ok());
456 }
457
458 #[test]
459 fn commit_path_accepts_names_that_are_literalized_before_git() {
460 for literal in [
461 "--upload-pack=x",
462 "a\nb",
463 ":(top)a.txt",
464 "src/:(top)evil",
465 "a\tb",
466 "a\u{1b}b",
467 "src/m\u{0430}in.rs",
468 ":!x",
469 "*.rs",
470 "?.rs",
471 "[ab].rs",
472 ] {
473 assert!(validate_commit_path(literal).is_ok(), "{literal:?}");
474 }
475 }
476
477 #[test]
478 fn commit_path_rejects_absolute_path() {
479 let err = validate_commit_path("/etc/passwd").unwrap_err();
480 assert!(matches!(err, GitArgError::InvalidCharacter(..)));
481 }
482
483 #[test]
484 fn commit_path_rejects_traversal_segment() {
485 for injected in ["../../etc/passwd", "a/../../b", ".."] {
486 let err = validate_commit_path(injected).unwrap_err();
487 assert!(
488 matches!(err, GitArgError::PathTraversal(..)),
489 "{injected}: {err}"
490 );
491 }
492 }
493
494 #[test]
495 fn commit_path_rejects_nul() {
496 let err = validate_commit_path("a\0b").unwrap_err();
497 assert!(matches!(err, GitArgError::InvalidCharacter(..)));
498 }
499
500 #[test]
503 fn message_accepts_multiline_text() {
504 assert!(validate_message("subject\n\nbody line\nwith more text").is_ok());
505 }
506
507 #[test]
508 fn message_accepts_leading_dash_since_it_is_never_a_bare_argv_entry() {
509 assert!(validate_message("--upload-pack=evil").is_ok());
511 }
512
513 #[test]
514 fn message_rejects_empty_or_whitespace_only() {
515 assert!(validate_message("").is_err());
516 assert!(validate_message(" \n ").is_err());
517 }
518
519 #[test]
520 fn message_rejects_null_byte() {
521 let err = validate_message("hello\0world").unwrap_err();
522 assert!(matches!(err, GitArgError::InvalidCharacter(..)));
523 }
524
525 #[test]
526 fn message_rejects_too_long() {
527 let long = "a".repeat(MAX_MESSAGE_LEN + 1);
528 assert!(matches!(
529 validate_message(&long),
530 Err(GitArgError::TooLong(..))
531 ));
532 }
533
534 #[test]
537 fn author_accepts_name_and_email() {
538 assert!(validate_author("Test User <test@example.com>").is_ok());
539 }
540
541 #[test]
542 fn author_rejects_leading_dash() {
543 let err = validate_author("--upload-pack=evil").unwrap_err();
544 assert!(matches!(err, GitArgError::LeadingDash(..)));
545 }
546
547 #[test]
548 fn author_rejects_embedded_newline() {
549 let err = validate_author("Test User\n<test@example.com>").unwrap_err();
550 assert!(matches!(err, GitArgError::InvalidCharacter(..)));
551 }
552
553 #[test]
556 fn reject_force_denies_explicit_true() {
557 assert_eq!(reject_force(Some(true)), Err(GitArgError::ForceDenied));
558 }
559
560 #[test]
561 fn reject_force_allows_false_or_absent() {
562 assert!(reject_force(Some(false)).is_ok());
563 assert!(reject_force(None).is_ok());
564 }
565
566 #[test]
569 fn repo_path_rejects_relative() {
570 let err = validate_repo_path(Path::new("relative/repo")).unwrap_err();
571 assert!(matches!(err, GitArgError::NotAbsolute(_)));
572 }
573
574 #[test]
575 fn repo_path_rejects_missing_git_dir() {
576 let dir = tempfile::tempdir().expect("tempdir");
577 let err = validate_repo_path(dir.path()).unwrap_err();
578 assert!(matches!(err, GitArgError::NotARepo(_)));
579 }
580
581 #[test]
582 fn repo_path_accepts_dir_with_git_entry() {
583 let dir = tempfile::tempdir().expect("tempdir");
584 std::fs::create_dir_all(dir.path().join(".git")).unwrap();
585 assert!(validate_repo_path(dir.path()).is_ok());
586 }
587
588 #[test]
591 fn build_add_argv_fixed_shape() {
592 let argv = build_add_argv(&["a.rs".to_string(), "b/c.rs".to_string()]).unwrap();
593 assert_eq!(
594 argv,
595 vec!["add", "--", ":(literal)a.rs", ":(literal)b/c.rs"]
596 );
597 }
598
599 #[test]
600 fn build_add_argv_literalizes_caller_paths() {
601 let argv = build_add_argv(&[":(top)".to_string(), "*.rs".to_string()]).unwrap();
602 assert_eq!(
603 argv,
604 vec!["add", "--", ":(literal):(top)", ":(literal)*.rs"]
605 );
606 }
607
608 #[test]
611 fn build_commit_argv_no_paths_uses_dash_a() {
612 let argv = build_commit_argv("fix: thing", &[], None).unwrap();
613 assert_eq!(argv, vec!["commit", "-a", "-m", "fix: thing"]);
614 }
615
616 #[test]
617 fn build_commit_argv_with_paths_scopes_and_appends_dashdash() {
618 let argv = build_commit_argv("fix: thing", &["src/lib.rs".to_string()], None).unwrap();
619 assert_eq!(
620 argv,
621 vec!["commit", "-m", "fix: thing", "--", ":(literal)src/lib.rs"]
622 );
623 }
624
625 #[test]
626 fn build_commit_argv_with_author() {
627 let argv = build_commit_argv("msg", &[], Some("Test User <test@example.com>")).unwrap();
628 assert_eq!(
629 argv,
630 vec![
631 "commit",
632 "-a",
633 "--author=Test User <test@example.com>",
634 "-m",
635 "msg"
636 ]
637 );
638 }
639
640 #[test]
641 fn build_commit_argv_rejects_bad_message() {
642 assert!(build_commit_argv("", &[], None).is_err());
643 }
644
645 #[test]
646 fn build_commit_argv_literalizes_caller_paths() {
647 let argv = build_commit_argv("msg", &[":(glob)*".to_string()], None).unwrap();
648 assert_eq!(
649 argv,
650 vec!["commit", "-m", "msg", "--", ":(literal):(glob)*"]
651 );
652 }
653
654 #[test]
657 fn build_branch_argv_name_only() {
658 let argv = build_branch_argv("feat/x", None).unwrap();
659 assert_eq!(argv, vec!["branch", "--", "feat/x"]);
660 }
661
662 #[test]
663 fn build_branch_argv_with_from() {
664 let argv = build_branch_argv("feat/x", Some("main")).unwrap();
665 assert_eq!(argv, vec!["branch", "--", "feat/x", "main"]);
666 }
667
668 #[test]
669 fn build_branch_argv_rejects_injection_shaped_name() {
670 for injected in ["-D", "--upload-pack=evil", "main;rm -rf /", "a b"] {
671 assert!(build_branch_argv(injected, None).is_err(), "{injected}");
672 }
673 }
674
675 #[test]
676 fn build_branch_argv_rejects_injection_shaped_from() {
677 assert!(build_branch_argv("feat/x", Some("--upload-pack=evil")).is_err());
678 }
679
680 #[test]
683 fn build_push_argv_fixed_shape() {
684 let argv = build_push_argv("origin", "feat/x").unwrap();
685 assert_eq!(argv, vec!["push", "--", "origin", "feat/x"]);
686 }
687
688 #[test]
689 fn build_push_argv_never_contains_force_flag() {
690 let argv = build_push_argv("origin", "feat/x").unwrap();
694 assert!(!argv.iter().any(|a| a.contains("force") || a == "-f"));
695 }
696
697 #[test]
698 fn build_push_argv_rejects_injection_shaped_remote() {
699 for injected in ["--upload-pack=evil", "-o", "ext::sh -c evil"] {
700 assert!(build_push_argv(injected, "feat/x").is_err(), "{injected}");
701 }
702 }
703
704 #[test]
705 fn build_push_argv_rejects_injection_shaped_branch() {
706 for injected in ["-D", "--upload-pack=evil", "a;b"] {
707 assert!(build_push_argv("origin", injected).is_err(), "{injected}");
708 }
709 }
710}