Skip to main content

ggen_utils/
safe_command.rs

1//! Safe command execution with validation to prevent command injection attacks
2//!
3//! This module provides the `SafeCommand` type that wraps shell commands with validation
4//! to prevent common command injection vulnerabilities. All commands are validated on
5//! construction to ensure they:
6//!
7//! - Are in the whitelist of allowed commands
8//! - Do not contain shell metacharacters in arguments (;|&><$`\n)
9//! - Do not exceed maximum command length of 4096 characters
10//! - Use validated paths for path arguments
11//!
12//! ## Security Model
13//!
14//! This module uses a multi-layered defense approach:
15//!
16//! 1. **Command Whitelist**: Only explicitly allowed commands can be executed
17//! 2. **Argument Sanitization**: Shell metacharacters are blocked in arguments
18//! 3. **Type-State Pattern**: Commands must be validated before execution
19//! 4. **Path Integration**: Path arguments use SafePath validation
20//! 5. **Length Limits**: Maximum 4096 characters to prevent buffer overflow
21//!
22//! ## Examples
23//!
24//! ```rust
25//! use ggen_utils::safe_command::{SafeCommand, CommandName, CommandArg};
26//!
27//! // Valid command
28//! let cmd = SafeCommand::new("cargo")
29//!     .unwrap()
30//!     .arg("build")
31//!     .unwrap()
32//!     .arg("--release")
33//!     .unwrap()
34//!     .validate()
35//!     .unwrap();
36//!
37//! // Invalid command (not in whitelist)
38//! assert!(SafeCommand::new("rm").is_err());
39//!
40//! // Invalid argument (shell metacharacter)
41//! let result = SafeCommand::new("cargo")
42//!     .unwrap()
43//!     .arg("build; rm -rf /")
44//!     .unwrap_or_else(|e| panic!("Should fail: {}", e));
45//! ```
46
47use crate::error::{Error, Result};
48use crate::safe_path::SafePath;
49use std::convert::TryFrom;
50use std::fmt;
51use std::process::Command;
52
53/// Maximum allowed command length (including all arguments)
54const MAX_COMMAND_LENGTH: usize = 4096;
55
56/// Shell metacharacters that are blocked in command arguments
57const SHELL_METACHARACTERS: &[char] = &[';', '|', '&', '>', '<', '$', '`', '\n', '\r'];
58
59/// Whitelist of allowed commands
60const ALLOWED_COMMANDS: &[&str] = &[
61    "cargo",
62    "git",
63    "npm",
64    "rustc",
65    "rustfmt",
66    "clippy-driver",
67    "timeout",
68    "make",
69    "cmake",
70    "sh",   // Only when explicitly needed with validated scripts
71    "bash", // Only when explicitly needed with validated scripts
72    "ggen", // Our own CLI
73];
74
75/// A validated command name from the whitelist
76///
77/// This newtype ensures that only whitelisted commands can be constructed.
78#[derive(Debug, Clone, PartialEq, Eq, Hash)]
79pub struct CommandName {
80    /// Inner command name - private to prevent direct mutation
81    inner: String,
82}
83
84impl CommandName {
85    /// Create a new CommandName from a string
86    ///
87    /// # Validation Rules
88    ///
89    /// - Command must be in the whitelist
90    /// - Command name cannot be empty
91    /// - Command name cannot contain whitespace
92    ///
93    /// # Examples
94    ///
95    /// ```rust
96    /// use ggen_utils::safe_command::CommandName;
97    ///
98    /// // Valid commands
99    /// assert!(CommandName::new("cargo").is_ok());
100    /// assert!(CommandName::new("git").is_ok());
101    ///
102    /// // Invalid commands
103    /// assert!(CommandName::new("rm").is_err());
104    /// assert!(CommandName::new("").is_err());
105    /// ```
106    pub fn new<S: AsRef<str>>(name: S) -> Result<Self> {
107        let name = name.as_ref();
108
109        // Check for empty
110        if name.is_empty() {
111            return Err(Error::invalid_input("Command name cannot be empty"));
112        }
113
114        // Check for whitespace
115        if name.contains(char::is_whitespace) {
116            return Err(Error::invalid_input(
117                "Command name cannot contain whitespace",
118            ));
119        }
120
121        // Check whitelist
122        if !ALLOWED_COMMANDS.contains(&name) {
123            return Err(Error::invalid_input(format!(
124                "Command '{}' is not in whitelist. Allowed commands: {:?}",
125                name, ALLOWED_COMMANDS
126            )));
127        }
128
129        Ok(Self {
130            inner: name.to_string(),
131        })
132    }
133
134    /// Get the command name as a string slice
135    #[must_use]
136    pub fn as_str(&self) -> &str {
137        &self.inner
138    }
139
140    /// Convert into the inner String
141    #[must_use]
142    pub fn into_string(self) -> String {
143        self.inner
144    }
145}
146
147impl TryFrom<&str> for CommandName {
148    type Error = Error;
149
150    fn try_from(value: &str) -> Result<Self> {
151        Self::new(value)
152    }
153}
154
155impl TryFrom<String> for CommandName {
156    type Error = Error;
157
158    fn try_from(value: String) -> Result<Self> {
159        Self::new(value)
160    }
161}
162
163impl fmt::Display for CommandName {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        write!(f, "{}", self.inner)
166    }
167}
168
169/// A validated command argument with sanitization
170///
171/// This newtype ensures that command arguments do not contain shell metacharacters
172/// that could lead to command injection attacks.
173#[derive(Debug, Clone, PartialEq, Eq, Hash)]
174pub struct CommandArg {
175    /// Inner argument - private to prevent direct mutation
176    inner: String,
177}
178
179impl CommandArg {
180    /// Create a new CommandArg from a string
181    ///
182    /// # Validation Rules
183    ///
184    /// - Argument cannot contain shell metacharacters (;|&><$`\n\r)
185    /// - Argument can be empty (for flags like --release)
186    ///
187    /// # Examples
188    ///
189    /// ```rust
190    /// use ggen_utils::safe_command::CommandArg;
191    ///
192    /// // Valid arguments
193    /// assert!(CommandArg::new("build").is_ok());
194    /// assert!(CommandArg::new("--release").is_ok());
195    /// assert!(CommandArg::new("").is_ok());
196    ///
197    /// // Invalid arguments (shell metacharacters)
198    /// assert!(CommandArg::new("build; rm -rf /").is_err());
199    /// assert!(CommandArg::new("build | tee").is_err());
200    /// assert!(CommandArg::new("$(whoami)").is_err());
201    /// ```
202    pub fn new<S: AsRef<str>>(arg: S) -> Result<Self> {
203        let arg = arg.as_ref();
204
205        // Check for shell metacharacters
206        for ch in SHELL_METACHARACTERS {
207            if arg.contains(*ch) {
208                return Err(Error::invalid_input(format!(
209                    "Argument contains shell metacharacter '{}': {}",
210                    ch, arg
211                )));
212            }
213        }
214
215        Ok(Self {
216            inner: arg.to_string(),
217        })
218    }
219
220    /// Create a CommandArg from a SafePath
221    ///
222    /// This ensures that path arguments are validated through SafePath.
223    ///
224    /// # Examples
225    ///
226    /// ```rust
227    /// use ggen_utils::safe_command::CommandArg;
228    /// use ggen_utils::safe_path::SafePath;
229    ///
230    /// let path = SafePath::new("src/generated").unwrap();
231    /// let arg = CommandArg::from_path(&path);
232    /// assert_eq!(arg.as_str(), "src/generated");
233    /// ```
234    #[must_use]
235    pub fn from_path(path: &SafePath) -> Self {
236        // SafePath already validated, so we can safely convert
237        // We know it doesn't contain shell metacharacters
238        Self {
239            inner: path.as_path().display().to_string(),
240        }
241    }
242
243    /// Get the argument as a string slice
244    #[must_use]
245    pub fn as_str(&self) -> &str {
246        &self.inner
247    }
248
249    /// Convert into the inner String
250    #[must_use]
251    pub fn into_string(self) -> String {
252        self.inner
253    }
254}
255
256impl TryFrom<&str> for CommandArg {
257    type Error = Error;
258
259    fn try_from(value: &str) -> Result<Self> {
260        Self::new(value)
261    }
262}
263
264impl TryFrom<String> for CommandArg {
265    type Error = Error;
266
267    fn try_from(value: String) -> Result<Self> {
268        Self::new(value)
269    }
270}
271
272impl fmt::Display for CommandArg {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        write!(f, "{}", self.inner)
275    }
276}
277
278/// Type-state marker for unvalidated commands
279#[derive(Debug)]
280pub struct Unvalidated;
281
282/// Type-state marker for validated commands
283#[derive(Debug, Clone)]
284pub struct Validated;
285
286/// A safe command builder with type-state pattern
287///
288/// Commands must be validated before they can be executed. The type-state pattern
289/// ensures this at compile time.
290///
291/// # Type States
292///
293/// - `SafeCommand<Unvalidated>`: Command is being built, not yet validated
294/// - `SafeCommand<Validated>`: Command has been validated and can be executed
295///
296/// # Examples
297///
298/// ```rust
299/// use ggen_utils::safe_command::SafeCommand;
300///
301/// // Build and validate command
302/// let cmd = SafeCommand::new("cargo")
303///     .unwrap()
304///     .arg("build")
305///     .unwrap()
306///     .arg("--release")
307///     .unwrap()
308///     .validate()
309///     .unwrap();
310///
311/// // Now cmd can be converted to std::process::Command
312/// let process_cmd = cmd.into_command();
313/// ```
314#[derive(Debug, Clone)]
315pub struct SafeCommand<State = Unvalidated> {
316    /// Validated command name
317    command: CommandName,
318    /// List of validated arguments
319    args: Vec<CommandArg>,
320    /// Type-state marker
321    _state: std::marker::PhantomData<State>,
322}
323
324impl SafeCommand<Unvalidated> {
325    /// Create a new SafeCommand with a validated command name
326    ///
327    /// # Examples
328    ///
329    /// ```rust
330    /// use ggen_utils::safe_command::SafeCommand;
331    ///
332    /// let cmd = SafeCommand::new("cargo").unwrap();
333    /// assert!(SafeCommand::new("rm").is_err());
334    /// ```
335    pub fn new<S: AsRef<str>>(command: S) -> Result<Self> {
336        let command = CommandName::new(command)?;
337        Ok(Self {
338            command,
339            args: Vec::new(),
340            _state: std::marker::PhantomData,
341        })
342    }
343
344    /// Add an argument to the command
345    ///
346    /// # Examples
347    ///
348    /// ```rust
349    /// use ggen_utils::safe_command::SafeCommand;
350    ///
351    /// let cmd = SafeCommand::new("cargo")
352    ///     .unwrap()
353    ///     .arg("build")
354    ///     .unwrap()
355    ///     .arg("--release")
356    ///     .unwrap();
357    /// ```
358    pub fn arg<S: AsRef<str>>(mut self, arg: S) -> Result<Self> {
359        let arg = CommandArg::new(arg)?;
360        self.args.push(arg);
361        Ok(self)
362    }
363
364    /// Add a path argument to the command
365    ///
366    /// This ensures path arguments are validated through SafePath.
367    ///
368    /// # Examples
369    ///
370    /// ```rust
371    /// use ggen_utils::safe_command::SafeCommand;
372    /// use ggen_utils::safe_path::SafePath;
373    ///
374    /// let path = SafePath::new("src/generated").unwrap();
375    /// let cmd = SafeCommand::new("cargo")
376    ///     .unwrap()
377    ///     .arg("build")
378    ///     .unwrap()
379    ///     .arg_path(&path);
380    /// ```
381    #[must_use]
382    pub fn arg_path(mut self, path: &SafePath) -> Self {
383        let arg = CommandArg::from_path(path);
384        self.args.push(arg);
385        self
386    }
387
388    /// Add multiple arguments at once
389    ///
390    /// # Examples
391    ///
392    /// ```rust
393    /// use ggen_utils::safe_command::SafeCommand;
394    ///
395    /// let cmd = SafeCommand::new("cargo")
396    ///     .unwrap()
397    ///     .args(&["build", "--release"])
398    ///     .unwrap();
399    /// ```
400    pub fn args<I, S>(mut self, args: I) -> Result<Self>
401    where
402        I: IntoIterator<Item = S>,
403        S: AsRef<str>,
404    {
405        for arg in args {
406            let validated_arg = CommandArg::new(arg)?;
407            self.args.push(validated_arg);
408        }
409        Ok(self)
410    }
411
412    /// Validate the command and transition to Validated state
413    ///
414    /// This performs final validation including total command length check.
415    ///
416    /// # Examples
417    ///
418    /// ```rust
419    /// use ggen_utils::safe_command::SafeCommand;
420    ///
421    /// let cmd = SafeCommand::new("cargo")
422    ///     .unwrap()
423    ///     .arg("build")
424    ///     .unwrap()
425    ///     .validate()
426    ///     .unwrap();
427    /// ```
428    pub fn validate(self) -> Result<SafeCommand<Validated>> {
429        // Calculate total command length
430        let total_length = self.total_length();
431
432        if total_length > MAX_COMMAND_LENGTH {
433            return Err(Error::invalid_input(format!(
434                "Command length {} exceeds maximum allowed length of {}",
435                total_length, MAX_COMMAND_LENGTH
436            )));
437        }
438
439        Ok(SafeCommand {
440            command: self.command,
441            args: self.args,
442            _state: std::marker::PhantomData,
443        })
444    }
445
446    /// Calculate total command length (command + args + spaces)
447    fn total_length(&self) -> usize {
448        let mut length = self.command.as_str().len();
449
450        for arg in &self.args {
451            length += 1; // space
452            length += arg.as_str().len();
453        }
454
455        length
456    }
457}
458
459impl SafeCommand<Validated> {
460    /// Convert this SafeCommand into a std::process::Command
461    ///
462    /// This is only available for validated commands.
463    ///
464    /// # Examples
465    ///
466    /// ```rust
467    /// use ggen_utils::safe_command::SafeCommand;
468    ///
469    /// let safe_cmd = SafeCommand::new("cargo")
470    ///     .unwrap()
471    ///     .arg("--version")
472    ///     .unwrap()
473    ///     .validate()
474    ///     .unwrap();
475    ///
476    /// let mut cmd = safe_cmd.into_command();
477    /// // Now you can execute cmd with std::process::Command
478    /// ```
479    #[must_use]
480    pub fn into_command(self) -> Command {
481        let mut cmd = Command::new(self.command.as_str());
482
483        for arg in self.args {
484            cmd.arg(arg.as_str());
485        }
486
487        cmd
488    }
489
490    /// Get a reference to the command name
491    #[must_use]
492    pub fn command(&self) -> &CommandName {
493        &self.command
494    }
495
496    /// Get a reference to the arguments
497    #[must_use]
498    pub fn args(&self) -> &[CommandArg] {
499        &self.args
500    }
501
502    /// Get the command as a string for display purposes
503    ///
504    /// This does NOT execute the command, it just formats it as a string.
505    #[must_use]
506    pub fn to_string_debug(&self) -> String {
507        let mut result = self.command.as_str().to_string();
508
509        for arg in &self.args {
510            result.push(' ');
511            result.push_str(arg.as_str());
512        }
513
514        result
515    }
516}
517
518impl fmt::Display for SafeCommand<Validated> {
519    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
520        write!(f, "{}", self.to_string_debug())
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    // ============================================================================
529    // CommandName Tests
530    // ============================================================================
531
532    #[test]
533    fn test_command_name_valid() {
534        // Arrange
535        let name = "cargo";
536
537        // Act
538        let result = CommandName::new(name);
539
540        // Assert
541        assert!(result.is_ok());
542        assert_eq!(result.unwrap().as_str(), "cargo");
543    }
544
545    #[test]
546    fn test_command_name_all_whitelisted() {
547        // Arrange & Act & Assert
548        for &cmd in ALLOWED_COMMANDS {
549            let result = CommandName::new(cmd);
550            assert!(result.is_ok(), "Should allow whitelisted command: {}", cmd);
551        }
552    }
553
554    #[test]
555    fn test_command_name_not_whitelisted() {
556        // Arrange
557        let dangerous_commands = vec!["rm", "mv", "dd", "mkfs", "kill"];
558
559        // Act & Assert
560        for cmd in dangerous_commands {
561            let result = CommandName::new(cmd);
562            assert!(
563                result.is_err(),
564                "Should block non-whitelisted command: {}",
565                cmd
566            );
567            assert!(
568                result.unwrap_err().to_string().contains("not in whitelist"),
569                "Error should mention whitelist"
570            );
571        }
572    }
573
574    #[test]
575    fn test_command_name_empty() {
576        // Arrange
577        let name = "";
578
579        // Act
580        let result = CommandName::new(name);
581
582        // Assert
583        assert!(result.is_err());
584        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
585    }
586
587    #[test]
588    fn test_command_name_with_whitespace() {
589        // Arrange
590        let name = "cargo build";
591
592        // Act
593        let result = CommandName::new(name);
594
595        // Assert
596        assert!(result.is_err());
597        assert!(result.unwrap_err().to_string().contains("whitespace"));
598    }
599
600    #[test]
601    fn test_command_name_try_from_str() {
602        // Arrange
603        let name = "git";
604
605        // Act
606        let result = CommandName::try_from(name);
607
608        // Assert
609        assert!(result.is_ok());
610        assert_eq!(result.unwrap().as_str(), "git");
611    }
612
613    #[test]
614    fn test_command_name_try_from_string() {
615        // Arrange
616        let name = String::from("npm");
617
618        // Act
619        let result = CommandName::try_from(name);
620
621        // Assert
622        assert!(result.is_ok());
623        assert_eq!(result.unwrap().as_str(), "npm");
624    }
625
626    #[test]
627    fn test_command_name_display() {
628        // Arrange
629        let name = CommandName::new("cargo").unwrap();
630
631        // Act
632        let display = format!("{}", name);
633
634        // Assert
635        assert_eq!(display, "cargo");
636    }
637
638    // ============================================================================
639    // CommandArg Tests
640    // ============================================================================
641
642    #[test]
643    fn test_command_arg_valid() {
644        // Arrange
645        let arg = "build";
646
647        // Act
648        let result = CommandArg::new(arg);
649
650        // Assert
651        assert!(result.is_ok());
652        assert_eq!(result.unwrap().as_str(), "build");
653    }
654
655    #[test]
656    fn test_command_arg_with_dashes() {
657        // Arrange
658        let args = vec!["--release", "-v", "--all-features"];
659
660        // Act & Assert
661        for arg in args {
662            let result = CommandArg::new(arg);
663            assert!(result.is_ok(), "Should allow arg with dashes: {}", arg);
664        }
665    }
666
667    #[test]
668    fn test_command_arg_empty() {
669        // Arrange
670        let arg = "";
671
672        // Act
673        let result = CommandArg::new(arg);
674
675        // Assert - empty args are allowed (for compatibility)
676        assert!(result.is_ok());
677    }
678
679    #[test]
680    fn test_command_arg_shell_metacharacters() {
681        // Arrange
682        let attacks = vec![
683            ("build; rm -rf /", ';'),
684            ("build | tee output", '|'),
685            ("build && rm -rf /", '&'),
686            ("build > /dev/null", '>'),
687            ("build < input", '<'),
688            ("$(whoami)", '$'),
689            ("`whoami`", '`'),
690            ("build\nrm -rf /", '\n'),
691            ("build\rrm -rf /", '\r'),
692        ];
693
694        // Act & Assert
695        for (attack, metachar) in attacks {
696            let result = CommandArg::new(attack);
697            assert!(
698                result.is_err(),
699                "Should block shell metacharacter: {}",
700                metachar
701            );
702            assert!(
703                result.unwrap_err().to_string().contains("metacharacter"),
704                "Error should mention metacharacter"
705            );
706        }
707    }
708
709    #[test]
710    fn test_command_arg_from_path() {
711        // Arrange
712        let path = SafePath::new("src/generated").unwrap();
713
714        // Act
715        let arg = CommandArg::from_path(&path);
716
717        // Assert
718        assert_eq!(arg.as_str(), "src/generated");
719    }
720
721    #[test]
722    fn test_command_arg_try_from() {
723        // Arrange
724        let arg_str = "--release";
725
726        // Act
727        let result = CommandArg::try_from(arg_str);
728
729        // Assert
730        assert!(result.is_ok());
731        assert_eq!(result.unwrap().as_str(), "--release");
732    }
733
734    // ============================================================================
735    // SafeCommand Basic Tests
736    // ============================================================================
737
738    #[test]
739    fn test_safe_command_new() {
740        // Arrange
741        let cmd = "cargo";
742
743        // Act
744        let result = SafeCommand::new(cmd);
745
746        // Assert
747        assert!(result.is_ok());
748    }
749
750    #[test]
751    fn test_safe_command_new_invalid() {
752        // Arrange
753        let cmd = "rm";
754
755        // Act
756        let result = SafeCommand::new(cmd);
757
758        // Assert
759        assert!(result.is_err());
760        assert!(result.unwrap_err().to_string().contains("not in whitelist"));
761    }
762
763    #[test]
764    fn test_safe_command_single_arg() {
765        // Arrange & Act
766        let result = SafeCommand::new("cargo").unwrap().arg("build");
767
768        // Assert
769        assert!(result.is_ok());
770    }
771
772    #[test]
773    fn test_safe_command_multiple_args() {
774        // Arrange & Act
775        let result = SafeCommand::new("cargo")
776            .unwrap()
777            .arg("build")
778            .unwrap()
779            .arg("--release")
780            .unwrap()
781            .arg("--all-features");
782
783        // Assert
784        assert!(result.is_ok());
785    }
786
787    #[test]
788    fn test_safe_command_args_bulk() {
789        // Arrange & Act
790        let result =
791            SafeCommand::new("cargo")
792                .unwrap()
793                .args(["build", "--release", "--all-features"]);
794
795        // Assert
796        assert!(result.is_ok());
797    }
798
799    #[test]
800    fn test_safe_command_arg_path() {
801        // Arrange
802        let path = SafePath::new("src/generated").unwrap();
803
804        // Act
805        let cmd = SafeCommand::new("cargo")
806            .unwrap()
807            .arg("build")
808            .unwrap()
809            .arg_path(&path);
810
811        // Assert - arg_path is infallible (uses already-validated SafePath)
812        let validated = cmd.validate();
813        assert!(validated.is_ok());
814    }
815
816    #[test]
817    fn test_safe_command_validate_success() {
818        // Arrange
819        let cmd = SafeCommand::new("cargo")
820            .unwrap()
821            .arg("build")
822            .unwrap()
823            .arg("--release")
824            .unwrap();
825
826        // Act
827        let result = cmd.validate();
828
829        // Assert
830        assert!(result.is_ok());
831    }
832
833    #[test]
834    fn test_safe_command_into_command() {
835        // Arrange
836        let safe_cmd = SafeCommand::new("cargo")
837            .unwrap()
838            .arg("--version")
839            .unwrap()
840            .validate()
841            .unwrap();
842
843        // Act
844        let process_cmd = safe_cmd.into_command();
845
846        // Assert - verify it creates a valid Command
847        // We can't easily test execution here, but we can verify it compiles
848        let _ = process_cmd;
849    }
850
851    #[test]
852    fn test_safe_command_to_string_debug() {
853        // Arrange
854        let cmd = SafeCommand::new("cargo")
855            .unwrap()
856            .arg("build")
857            .unwrap()
858            .arg("--release")
859            .unwrap()
860            .validate()
861            .unwrap();
862
863        // Act
864        let debug_string = cmd.to_string_debug();
865
866        // Assert
867        assert_eq!(debug_string, "cargo build --release");
868    }
869
870    #[test]
871    fn test_safe_command_display() {
872        // Arrange
873        let cmd = SafeCommand::new("git")
874            .unwrap()
875            .arg("status")
876            .unwrap()
877            .validate()
878            .unwrap();
879
880        // Act
881        let display = format!("{}", cmd);
882
883        // Assert
884        assert_eq!(display, "git status");
885    }
886
887    // ============================================================================
888    // SafeCommand Security Tests
889    // ============================================================================
890
891    #[test]
892    fn test_safe_command_injection_in_arg() {
893        // Arrange & Act
894        let result = SafeCommand::new("cargo").unwrap().arg("build; rm -rf /");
895
896        // Assert
897        assert!(result.is_err());
898        assert!(result.unwrap_err().to_string().contains("metacharacter"));
899    }
900
901    #[test]
902    fn test_safe_command_injection_in_multiple_args() {
903        // Arrange & Act
904        let result = SafeCommand::new("cargo")
905            .unwrap()
906            .arg("build")
907            .unwrap()
908            .arg("--release && rm -rf /");
909
910        // Assert
911        assert!(result.is_err());
912    }
913
914    #[test]
915    fn test_safe_command_max_length() {
916        // Arrange - create a command that exceeds MAX_COMMAND_LENGTH
917        let long_arg = "a".repeat(MAX_COMMAND_LENGTH);
918
919        // Act
920        let result = SafeCommand::new("cargo")
921            .unwrap()
922            .arg(&long_arg)
923            .unwrap()
924            .validate();
925
926        // Assert
927        assert!(result.is_err());
928        assert!(result.unwrap_err().to_string().contains("exceeds maximum"));
929    }
930
931    #[test]
932    fn test_safe_command_max_length_boundary() {
933        // Arrange - create a command at exactly MAX_COMMAND_LENGTH
934        // "cargo" = 5 chars
935        // " " = 1 char
936        // So we need MAX_COMMAND_LENGTH - 6 chars for the arg
937        let arg_length = MAX_COMMAND_LENGTH - 6;
938        let exact_arg = "a".repeat(arg_length);
939
940        // Act
941        let result = SafeCommand::new("cargo")
942            .unwrap()
943            .arg(&exact_arg)
944            .unwrap()
945            .validate();
946
947        // Assert - should succeed at exact boundary
948        assert!(result.is_ok());
949    }
950
951    #[test]
952    fn test_safe_command_command_and_args_accessors() {
953        // Arrange
954        let cmd = SafeCommand::new("cargo")
955            .unwrap()
956            .arg("build")
957            .unwrap()
958            .arg("--release")
959            .unwrap()
960            .validate()
961            .unwrap();
962
963        // Act
964        let command = cmd.command();
965        let args = cmd.args();
966
967        // Assert
968        assert_eq!(command.as_str(), "cargo");
969        assert_eq!(args.len(), 2);
970        assert_eq!(args[0].as_str(), "build");
971        assert_eq!(args[1].as_str(), "--release");
972    }
973
974    // ============================================================================
975    // Integration with SafePath Tests
976    // ============================================================================
977
978    #[test]
979    fn test_safe_command_with_safe_path() {
980        // Arrange
981        let path = SafePath::new("src/generated/output.rs").unwrap();
982
983        // Act
984        let cmd = SafeCommand::new("rustfmt")
985            .unwrap()
986            .arg_path(&path)
987            .validate()
988            .unwrap();
989
990        // Assert
991        let debug_string = cmd.to_string_debug();
992        assert!(debug_string.contains("src/generated/output.rs"));
993    }
994
995    #[test]
996    fn test_safe_command_multiple_paths() {
997        // Arrange
998        let path1 = SafePath::new("src/main.rs").unwrap();
999        let path2 = SafePath::new("src/lib.rs").unwrap();
1000
1001        // Act
1002        let cmd = SafeCommand::new("rustfmt")
1003            .unwrap()
1004            .arg_path(&path1)
1005            .arg_path(&path2)
1006            .validate()
1007            .unwrap();
1008
1009        // Assert
1010        let debug_string = cmd.to_string_debug();
1011        assert!(debug_string.contains("src/main.rs"));
1012        assert!(debug_string.contains("src/lib.rs"));
1013    }
1014
1015    // ============================================================================
1016    // Edge Cases
1017    // ============================================================================
1018
1019    #[test]
1020    fn test_safe_command_no_args() {
1021        // Arrange & Act
1022        let result = SafeCommand::new("git").unwrap().validate();
1023
1024        // Assert - command with no args should be valid
1025        assert!(result.is_ok());
1026    }
1027
1028    #[test]
1029    fn test_safe_command_many_small_args() {
1030        // Arrange - create many small args
1031        let mut cmd = SafeCommand::new("cargo").unwrap();
1032
1033        for i in 0..100 {
1034            cmd = cmd.arg(format!("arg{}", i)).unwrap();
1035        }
1036
1037        // Act
1038        let result = cmd.validate();
1039
1040        // Assert - should succeed if under max length
1041        assert!(result.is_ok());
1042    }
1043
1044    #[test]
1045    fn test_command_name_clone() {
1046        // Arrange
1047        let name = CommandName::new("cargo").unwrap();
1048
1049        // Act
1050        let cloned = name.clone();
1051
1052        // Assert
1053        assert_eq!(name, cloned);
1054    }
1055
1056    #[test]
1057    fn test_command_arg_clone() {
1058        // Arrange
1059        let arg = CommandArg::new("build").unwrap();
1060
1061        // Act
1062        let cloned = arg.clone();
1063
1064        // Assert
1065        assert_eq!(arg, cloned);
1066    }
1067
1068    #[test]
1069    fn test_safe_command_clone() {
1070        // Arrange
1071        let cmd = SafeCommand::new("cargo")
1072            .unwrap()
1073            .arg("build")
1074            .unwrap()
1075            .validate()
1076            .unwrap();
1077
1078        // Act
1079        let cloned = cmd.clone();
1080
1081        // Assert
1082        assert_eq!(cmd.to_string_debug(), cloned.to_string_debug());
1083    }
1084}