Skip to main content

santa/
script_generator.rs

1//! Safe script generation for package manager operations.
2//!
3//! This module provides the core script generation functionality that makes Santa
4//! secure by default. Instead of directly executing potentially dangerous commands,
5//! Santa generates platform-specific scripts that can be reviewed before execution.
6//!
7//! # Architecture
8//!
9//! - [`ScriptGenerator`]: MiniJinja-based template engine for script generation
10//! - [`ExecutionMode`]: Safe (script generation) vs Execute (direct execution)
11//! - [`ScriptFormat`]: Platform-specific script formats (Shell, PowerShell, Batch)
12//!
13//! # Security
14//!
15//! All user inputs are sanitized using:
16//! - Shell escaping via `shell-escape` crate
17//! - PowerShell escaping with custom filters
18//! - Package name validation
19//! - Template-based command construction
20//!
21//! # Examples
22//!
23//! ```rust,no_run
24//! use santa::script_generator::{ScriptGenerator, ScriptFormat};
25//!
26//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
27//! let generator = ScriptGenerator::new()?;
28//! let packages = vec!["git".to_string(), "rust".to_string()];
29//!
30//! // Generate a safe shell script
31//! let script = generator.generate_install_script(
32//!     &packages,
33//!     "brew",
34//!     ScriptFormat::Shell,
35//!     "homebrew"
36//! )?;
37//!
38//! // Script can now be reviewed and executed manually
39//! println!("{}", script);
40//! # Ok(())
41//! # }
42//! ```
43
44use crate::errors::{Result, SantaError};
45use chrono::Utc;
46use minijinja::Environment;
47use serde::{Deserialize, Serialize};
48use shell_escape::escape;
49
50/// Execution modes for Santa - determines whether to execute directly or generate scripts.
51///
52/// The default mode is [`ExecutionMode::Safe`], which generates scripts that can be
53/// reviewed before execution. [`ExecutionMode::Execute`] directly runs commands and
54/// requires explicit opt-in
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
56pub enum ExecutionMode {
57    /// Generate scripts only (safe default mode)
58    #[default]
59    Safe,
60    /// Execute commands directly (dangerous mode, requires opt-in)
61    Execute,
62}
63
64/// Script formats for different platforms and shells.
65///
66/// Santa automatically detects the appropriate format based on the current
67/// platform, but users can explicitly specify a format if needed.
68///
69/// # Examples
70///
71/// ```rust
72/// use santa::script_generator::ScriptFormat;
73///
74/// // Auto-detect based on platform
75/// let format = ScriptFormat::auto_detect();
76///
77/// // Get file extension
78/// assert_eq!(format.extension(), if cfg!(windows) { "ps1" } else { "sh" });
79/// ```
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub enum ScriptFormat {
82    /// Unix shell script (.sh) for Linux and macOS
83    Shell,
84    /// Windows PowerShell script (.ps1) - modern Windows default
85    PowerShell,
86    /// Windows Batch file (.bat) - legacy Windows fallback
87    Batch,
88}
89
90impl ScriptFormat {
91    /// Choose appropriate format based on current platform
92    pub fn auto_detect() -> Self {
93        if cfg!(windows) {
94            ScriptFormat::PowerShell
95        } else {
96            ScriptFormat::Shell
97        }
98    }
99
100    /// Get file extension for this script format
101    pub fn extension(&self) -> &'static str {
102        match self {
103            ScriptFormat::Shell => "sh",
104            ScriptFormat::PowerShell => "ps1",
105            ScriptFormat::Batch => "bat",
106        }
107    }
108
109    /// Get template name for this script format
110    pub fn install_template_name(&self) -> &'static str {
111        match self {
112            ScriptFormat::Shell => "install.sh",
113            ScriptFormat::PowerShell => "install.ps1",
114            ScriptFormat::Batch => "install.bat",
115        }
116    }
117
118    /// Get check template name for this script format
119    pub fn check_template_name(&self) -> &'static str {
120        match self {
121            ScriptFormat::Shell => "check.sh",
122            ScriptFormat::PowerShell => "check.ps1",
123            ScriptFormat::Batch => "check.bat",
124        }
125    }
126}
127
128/// Script generator using MiniJinja templates for safe script generation.
129///
130/// The generator uses embedded MiniJinja templates to create platform-specific
131/// scripts with proper escaping and validation. This design prevents command
132/// injection attacks and allows users to review generated scripts before execution.
133///
134/// # Security Features
135///
136/// - Shell escaping for Unix commands
137/// - PowerShell escaping for Windows commands
138/// - Package name validation
139/// - Template-based construction (no string interpolation)
140///
141/// # Examples
142///
143/// ```rust,no_run
144/// use santa::script_generator::{ScriptGenerator, ScriptFormat};
145///
146/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
147/// let generator = ScriptGenerator::new()?;
148///
149/// // Generate installation script
150/// let packages = vec!["git".to_string(), "curl".to_string()];
151/// let script = generator.generate_install_script(
152///     &packages,
153///     "apt-get",
154///     ScriptFormat::Shell,
155///     "apt"
156/// )?;
157///
158/// // Write script to file or execute
159/// std::fs::write("install.sh", script)?;
160/// # Ok(())
161/// # }
162/// ```
163pub struct ScriptGenerator {
164    env: Environment<'static>,
165}
166
167impl ScriptGenerator {
168    /// Create new script generator with built-in templates.
169    ///
170    /// Initializes the MiniJinja template engine with embedded templates for
171    /// Shell, PowerShell, and Batch formats, and registers custom filters
172    /// for secure escaping.
173    ///
174    /// # Returns
175    ///
176    /// Returns a new [`ScriptGenerator`] or a [`SantaError::Template`] if
177    /// template initialization fails.
178    pub fn new() -> Result<Self> {
179        let mut env = Environment::new();
180
181        // Add built-in templates for different script formats
182        env.add_template("install.sh", include_str!("../templates/install.sh.tera"))
183            .map_err(|e| SantaError::Template(e.to_string()))?;
184
185        env.add_template("install.ps1", include_str!("../templates/install.ps1.tera"))
186            .map_err(|e| SantaError::Template(e.to_string()))?;
187
188        env.add_template("check.sh", include_str!("../templates/check.sh.tera"))
189            .map_err(|e| SantaError::Template(e.to_string()))?;
190
191        env.add_template("check.ps1", include_str!("../templates/check.ps1.tera"))
192            .map_err(|e| SantaError::Template(e.to_string()))?;
193
194        // Register custom filters for safe escaping
195        env.add_filter("shell_escape", shell_escape_filter);
196        env.add_filter("powershell_escape", powershell_escape_filter);
197        env.add_filter("validate_package", validate_package_filter);
198
199        Ok(Self { env })
200    }
201
202    /// Generate installation script for given packages and manager
203    pub fn generate_install_script(
204        &self,
205        packages: &[String],
206        manager: &str,
207        format: ScriptFormat,
208        source_name: &str,
209    ) -> Result<String> {
210        let template_name = format.install_template_name();
211        let template = self
212            .env
213            .get_template(template_name)
214            .map_err(|e| SantaError::Template(e.to_string()))?;
215
216        let context = minijinja::context! {
217            packages => packages,
218            manager => manager,
219            source_name => source_name,
220            timestamp => Utc::now().to_rfc3339(),
221            version => env!("CARGO_PKG_VERSION"),
222            package_count => packages.len(),
223        };
224
225        template.render(context).map_err(|e| {
226            SantaError::Template(format!(
227                "Failed to render {} template: {}",
228                template_name, e
229            ))
230        })
231    }
232
233    /// Generate check script for listing installed packages
234    pub fn generate_check_script(
235        &self,
236        manager: &str,
237        check_command: &str,
238        format: ScriptFormat,
239        source_name: &str,
240    ) -> Result<String> {
241        let template_name = format.check_template_name();
242        let template = self
243            .env
244            .get_template(template_name)
245            .map_err(|e| SantaError::Template(e.to_string()))?;
246
247        let context = minijinja::context! {
248            manager => manager,
249            check_command => check_command,
250            source_name => source_name,
251            timestamp => Utc::now().to_rfc3339(),
252            version => env!("CARGO_PKG_VERSION"),
253        };
254
255        template.render(context).map_err(|e| {
256            SantaError::Template(format!(
257                "Failed to render {} template: {}",
258                template_name, e
259            ))
260        })
261    }
262
263    /// Generate script filename with timestamp
264    pub fn generate_filename(prefix: &str, format: &ScriptFormat) -> String {
265        let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
266        format!("{}_{}.{}", prefix, timestamp, format.extension())
267    }
268}
269
270impl Default for ScriptGenerator {
271    fn default() -> Self {
272        Self::new().expect("Failed to initialize script generator with built-in templates")
273    }
274}
275
276/// MiniJinja filter for shell escaping using shell-escape crate
277fn shell_escape_filter(value: String) -> String {
278    escape(value.into()).into_owned()
279}
280
281/// MiniJinja filter for PowerShell argument escaping
282fn powershell_escape_filter(value: String) -> String {
283    escape_powershell_arg(&value)
284}
285
286/// MiniJinja filter for package name validation
287fn validate_package_filter(value: String) -> std::result::Result<String, minijinja::Error> {
288    if is_safe_package_name(&value) {
289        Ok(value)
290    } else {
291        Err(minijinja::Error::new(
292            minijinja::ErrorKind::InvalidOperation,
293            format!("Package name contains dangerous characters: {}", value),
294        ))
295    }
296}
297
298/// Escape PowerShell arguments safely
299fn escape_powershell_arg(arg: &str) -> String {
300    // PowerShell single quotes prevent most variable expansion
301    // Escape single quotes by doubling them
302    format!("'{}'", arg.replace("'", "''"))
303}
304
305/// Check if a package name is safe (basic validation)
306fn is_safe_package_name(name: &str) -> bool {
307    // Reject obviously dangerous patterns
308    let dangerous_patterns = &[
309        "$(", "`", ">&", "|", ";", "&&", "||", "../", "..\\", "/dev/", "C:\\", "\\\\", "curl",
310        "wget", "rm -rf", "del /s",
311    ];
312
313    for pattern in dangerous_patterns {
314        if name.contains(pattern) {
315            return false;
316        }
317    }
318
319    // Additional checks: no null bytes, control characters
320    !name.chars().any(|c| c.is_control() || c == '\0')
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_script_format_auto_detect() {
329        let format = ScriptFormat::auto_detect();
330        if cfg!(windows) {
331            assert_eq!(format, ScriptFormat::PowerShell);
332        } else {
333            assert_eq!(format, ScriptFormat::Shell);
334        }
335    }
336
337    #[test]
338    fn test_script_format_extensions() {
339        assert_eq!(ScriptFormat::Shell.extension(), "sh");
340        assert_eq!(ScriptFormat::PowerShell.extension(), "ps1");
341        assert_eq!(ScriptFormat::Batch.extension(), "bat");
342    }
343
344    #[test]
345    fn test_execution_mode_default() {
346        assert_eq!(ExecutionMode::default(), ExecutionMode::Safe);
347    }
348
349    #[test]
350    fn test_powershell_escaping() {
351        assert_eq!(escape_powershell_arg("simple"), "'simple'");
352        assert_eq!(escape_powershell_arg("with'quote"), "'with''quote'");
353        assert_eq!(
354            escape_powershell_arg("complex'test'case"),
355            "'complex''test''case'"
356        );
357    }
358
359    #[test]
360    fn test_package_name_validation() {
361        // Safe package names
362        assert!(is_safe_package_name("git"));
363        assert!(is_safe_package_name("node-sass"));
364        assert!(is_safe_package_name("package_with_underscores"));
365        assert!(is_safe_package_name("package-with-dashes"));
366
367        // Dangerous package names
368        assert!(!is_safe_package_name("package; rm -rf /"));
369        assert!(!is_safe_package_name("$(evil_command)"));
370        assert!(!is_safe_package_name("package`with`backticks"));
371        assert!(!is_safe_package_name("../../../etc/passwd"));
372        assert!(!is_safe_package_name("curl evil.com"));
373    }
374
375    #[test]
376    fn test_script_generator_creation() {
377        let generator = ScriptGenerator::new();
378        assert!(
379            generator.is_ok(),
380            "Script generator should initialize successfully"
381        );
382    }
383
384    #[test]
385    fn test_filename_generation() {
386        let filename = ScriptGenerator::generate_filename("santa_install", &ScriptFormat::Shell);
387        assert!(filename.starts_with("santa_install_"));
388        assert!(filename.ends_with(".sh"));
389
390        let ps_filename =
391            ScriptGenerator::generate_filename("santa_check", &ScriptFormat::PowerShell);
392        assert!(ps_filename.starts_with("santa_check_"));
393        assert!(ps_filename.ends_with(".ps1"));
394    }
395
396    #[test]
397    fn test_script_format_template_names() {
398        assert_eq!(ScriptFormat::Shell.install_template_name(), "install.sh");
399        assert_eq!(
400            ScriptFormat::PowerShell.install_template_name(),
401            "install.ps1"
402        );
403        assert_eq!(ScriptFormat::Batch.install_template_name(), "install.bat");
404
405        assert_eq!(ScriptFormat::Shell.check_template_name(), "check.sh");
406        assert_eq!(ScriptFormat::PowerShell.check_template_name(), "check.ps1");
407        assert_eq!(ScriptFormat::Batch.check_template_name(), "check.bat");
408    }
409
410    #[test]
411    fn test_generate_install_script_shell() {
412        let generator = ScriptGenerator::new().unwrap();
413        let packages = vec!["git".to_string(), "curl".to_string()];
414        let script = generator
415            .generate_install_script(&packages, "brew", ScriptFormat::Shell, "homebrew")
416            .unwrap();
417
418        assert!(
419            script.contains("brew"),
420            "Script should contain manager name"
421        );
422        assert!(script.contains("git"), "Script should contain package name");
423        assert!(
424            script.contains("curl"),
425            "Script should contain package name"
426        );
427    }
428
429    #[test]
430    fn test_generate_install_script_powershell() {
431        let generator = ScriptGenerator::new().unwrap();
432        let packages = vec!["git".to_string()];
433        let script = generator
434            .generate_install_script(&packages, "choco", ScriptFormat::PowerShell, "chocolatey")
435            .unwrap();
436
437        assert!(
438            script.contains("choco"),
439            "Script should contain manager name"
440        );
441        assert!(script.contains("git"), "Script should contain package name");
442    }
443
444    #[test]
445    fn test_generate_check_script_shell() {
446        let generator = ScriptGenerator::new().unwrap();
447        let script = generator
448            .generate_check_script("brew", "brew list", ScriptFormat::Shell, "homebrew")
449            .unwrap();
450
451        assert!(
452            script.contains("brew list"),
453            "Script should contain check command"
454        );
455    }
456
457    #[test]
458    fn test_generate_check_script_powershell() {
459        let generator = ScriptGenerator::new().unwrap();
460        let script = generator
461            .generate_check_script(
462                "choco",
463                "choco list",
464                ScriptFormat::PowerShell,
465                "chocolatey",
466            )
467            .unwrap();
468
469        assert!(
470            script.contains("choco list"),
471            "Script should contain check command"
472        );
473    }
474
475    #[test]
476    fn test_shell_escape_filter() {
477        // Test that shell escaping works correctly
478        let result = shell_escape_filter("simple".to_string());
479        assert!(!result.is_empty());
480
481        let result_space = shell_escape_filter("with space".to_string());
482        assert!(result_space.contains("with space"));
483    }
484
485    #[test]
486    fn test_powershell_escape_filter() {
487        let result = powershell_escape_filter("simple".to_string());
488        assert_eq!(result, "'simple'");
489
490        let result_quote = powershell_escape_filter("with'quote".to_string());
491        assert_eq!(result_quote, "'with''quote'");
492    }
493
494    #[test]
495    fn test_validate_package_filter_valid() {
496        let result = validate_package_filter("git".to_string());
497        assert!(result.is_ok());
498        assert_eq!(result.unwrap(), "git");
499    }
500
501    #[test]
502    fn test_validate_package_filter_invalid() {
503        let result = validate_package_filter("$(evil)".to_string());
504        assert!(result.is_err());
505    }
506
507    #[test]
508    fn test_script_generator_default() {
509        let generator = ScriptGenerator::default();
510        let packages = vec!["test".to_string()];
511        // Should not panic and should produce valid output
512        let result =
513            generator.generate_install_script(&packages, "brew", ScriptFormat::Shell, "test");
514        assert!(result.is_ok());
515    }
516
517    #[test]
518    fn test_generate_install_script_empty_packages() {
519        let generator = ScriptGenerator::new().unwrap();
520        let packages: Vec<String> = vec![];
521        let script = generator
522            .generate_install_script(&packages, "brew", ScriptFormat::Shell, "homebrew")
523            .unwrap();
524
525        // Should still generate a valid script structure
526        assert!(!script.is_empty());
527    }
528
529    #[test]
530    fn test_generate_install_script_includes_metadata() {
531        let generator = ScriptGenerator::new().unwrap();
532        let packages = vec!["git".to_string()];
533        let script = generator
534            .generate_install_script(&packages, "brew", ScriptFormat::Shell, "homebrew")
535            .unwrap();
536
537        // Script should include source name and version info
538        assert!(
539            script.contains("homebrew"),
540            "Script should contain source name"
541        );
542    }
543}