Skip to main content

khive_pack_git/
write_argv.rs

1//! Hardened argv construction for the git write verbs (ADR-108).
2//!
3//! Every caller-supplied value that can reach `std::process::Command::args`
4//! for `git.commit` / `git.branch` / `git.push` passes through this module
5//! first. Nothing here ever touches a shell: `Command::new("git")` spawns the
6//! binary directly and `.args([...])` passes each element as one literal,
7//! unparsed argv entry — there is no string interpolation anywhere in this
8//! crate's write path for a caller-supplied value to escape.
9//!
10//! This module owns exactly two responsibilities, both binding conditions of
11//! ADR-108's Fork (b) resolution:
12//!
13//! 1. Validation on every caller-supplied string before it can reach an argv
14//!    array. Commit paths use Git's internally-added `:(literal)` pathspec
15//!    magic so valid filesystem names are not mistaken for caller-controlled
16//!    pathspec syntax.
17//! 2. A fixed subcommand + flag allowlist — the `build_*_argv` functions
18//!    below are the only argv shapes the write handlers ever construct.
19//!
20//! Force-push is rejected unconditionally by [`reject_force`]: no code path
21//! in this module can produce `--force`, `-f`, or `--force-with-lease` in a
22//! push argv, regardless of caller input or Gate policy (ADR-108 hard rule
23//! 1). This module is scoped for isolated, dedicated adversarial review per
24//! ADR-108's binding requirement — keep new git-write argv construction here,
25//! not inlined into the handlers.
26
27use std::fmt;
28use std::path::Path;
29
30/// Maximum lengths, chosen generously above any real git identifier while
31/// still bounding argv/memory size against a malicious caller.
32pub 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/// Everything that can go wrong constructing a git write argv. Every variant
39/// carries enough context to build a caller-facing error without ever
40/// echoing back characters that were rejected specifically because they were
41/// suspicious (the raw value is still included for legitimate debugging --
42/// none of the rejected classes here are secret-shaped).
43#[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
87/// Validates a branch/ref-shaped identifier: `name` (git.branch), `from`
88/// (git.branch's optional start point), and `branch` (git.push's target
89/// ref). Deliberately more restrictive than git's own `check-ref-format` --
90/// this only needs to admit the identifiers a legitimate caller would ever
91/// pass, not the full ref grammar.
92pub 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
124/// Validates the `remote` argument (git.push). Narrower than a ref name --
125/// remote names never contain `/`.
126pub 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
149/// Validates one entry of `git.commit`'s `paths` argument as a bounded,
150/// repository-relative filename. Git parses path arguments as pathspecs even
151/// after `--`, so [`build_add_argv`] and [`build_commit_argv`] prepend the
152/// fixed, internally-constructed `:(literal)` signature after validation.
153/// Caller text such as `:(top)`, `*`, `?`, brackets, leading dashes, control
154/// characters, and Unicode therefore remains filename text rather than Git
155/// syntax. NUL is rejected because operating-system argv cannot represent it.
156pub 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
180/// Validates the commit message. Passed to git as a single argv element
181/// bound to `-m`'s value slot, so leading `-` is not an injection vector
182/// here (unlike ref/remote/path names, which can appear as bare positional
183/// argv entries) -- only NUL bytes (illegal in a process argv on every
184/// target platform) and an unreasonable length are rejected.
185pub 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
202/// Validates the `author` argument. Bound into a single `--author=<value>`
203/// argv token (see [`build_commit_argv`]), so a leading dash inside `value`
204/// cannot itself become a new flag -- rejected anyway, defensively, since a
205/// concatenated `--author=--upload-pack=x` value has no legitimate reason to
206/// start with `-`.
207pub 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
227/// ADR-108 hard rule 1: force-push is always denied, unconditionally, at the
228/// handler -- not left to Gate policy. `force: Some(true)` (an explicit,
229/// loud request) is rejected; absent or `Some(false)` proceeds normally.
230/// There is no argv path anywhere in this module that can add a force flag.
231pub fn reject_force(force: Option<bool>) -> Result<(), GitArgError> {
232    if force == Some(true) {
233        return Err(GitArgError::ForceDenied);
234    }
235    Ok(())
236}
237
238/// Validates the `repo` argument shared by all three write verbs: must be an
239/// absolute local path containing a `.git` entry (mirrors `git.digest`'s
240/// local-source validation in `src/source.rs`).
241pub 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
251/// Builds the argv for the `git add` pre-stage step of `git.commit` when
252/// `paths` is non-empty. Fixed shape: `["add", "--", literal-pathspecs...]`.
253pub 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
262/// Builds the argv for `git commit`.
263///
264/// - `paths` empty: `["commit", "-a", "-m", message]` (or with `--author=`)
265///   -- commits everything currently staged/modified in tracked files, never
266///   auto-adds new untracked files.
267/// - `paths` non-empty: `["commit", "-m", message, "--", paths...]` (or with
268///   `--author=`) -- scoped to exactly the given paths, which the caller
269///   must have already staged via the paired `git add` step
270///   ([`build_add_argv`]).
271pub 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
300/// Builds the argv for `git branch`: `["branch", "--", name]` or
301/// `["branch", "--", name, from]`.
302pub 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
314/// Builds the argv for `git push`: `["push", "--", remote, branch]`. Never
315/// carries a force flag -- see [`reject_force`], which handlers must call
316/// before reaching this function.
317pub 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    // -- validate_ref_name --------------------------------------------------
333
334    #[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    // -- validate_remote_name ------------------------------------------------
420
421    #[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    // -- validate_commit_path -------------------------------------------------
451
452    #[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    // -- validate_message ------------------------------------------------------
501
502    #[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        // "-m" binds this as a value, not a new flag -- see doc comment.
510        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    // -- validate_author ---------------------------------------------------
535
536    #[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    // -- reject_force --------------------------------------------------------
554
555    #[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    // -- validate_repo_path ----------------------------------------------------
567
568    #[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    // -- build_add_argv ------------------------------------------------------
589
590    #[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    // -- build_commit_argv -----------------------------------------------------
609
610    #[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    // -- build_branch_argv -----------------------------------------------------
655
656    #[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    // -- build_push_argv -----------------------------------------------------
681
682    #[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        // No caller input can produce a force flag -- build_push_argv has no
691        // parameter for it at all. This test pins that invariant against a
692        // future edit that might add one.
693        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}