1use crate::errors::{Result, SantaError};
45use chrono::Utc;
46use minijinja::Environment;
47use serde::{Deserialize, Serialize};
48use shell_escape::escape;
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
56pub enum ExecutionMode {
57 #[default]
59 Safe,
60 Execute,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub enum ScriptFormat {
82 Shell,
84 PowerShell,
86 Batch,
88}
89
90impl ScriptFormat {
91 pub fn auto_detect() -> Self {
93 if cfg!(windows) {
94 ScriptFormat::PowerShell
95 } else {
96 ScriptFormat::Shell
97 }
98 }
99
100 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 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 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
128pub struct ScriptGenerator {
164 env: Environment<'static>,
165}
166
167impl ScriptGenerator {
168 pub fn new() -> Result<Self> {
179 let mut env = Environment::new();
180
181 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 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 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 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 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
276fn shell_escape_filter(value: String) -> String {
278 escape(value.into()).into_owned()
279}
280
281fn powershell_escape_filter(value: String) -> String {
283 escape_powershell_arg(&value)
284}
285
286fn 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
298fn escape_powershell_arg(arg: &str) -> String {
300 format!("'{}'", arg.replace("'", "''"))
303}
304
305fn is_safe_package_name(name: &str) -> bool {
307 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 !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 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 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 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 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 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 assert!(
539 script.contains("homebrew"),
540 "Script should contain source name"
541 );
542 }
543}