rsnip/infrastructure/
minijinja.rs

1// infrastructure/minijinja.rs
2use crate::domain::{
3    content::SnippetContent,
4    template::{
5        errors::TemplateError,
6        interface::{ShellCommandExecutor, TemplateEngine},
7    },
8};
9use chrono::{DateTime, Utc};
10use minijinja::{Environment, Error, ErrorKind, Value};
11use std::collections::HashMap;
12use std::process::Command;
13use tracing::{info};
14
15pub struct MiniJinjaEngine {
16    env: Environment<'static>,
17}
18
19impl MiniJinjaEngine {
20    pub fn new(shell_executor: Box<dyn ShellCommandExecutor>) -> Self {
21        let mut env = Environment::new();
22
23        // Register template filters
24        env.add_filter("strftime", date_format);
25        env.add_filter("subtract_days", subtract_days);
26        env.add_filter("add_days", add_days);
27
28        // Create shell filter with captured executor
29        let shell_executor_clone = shell_executor.box_clone();
30        env.add_filter("shell", move |value: Value| {
31            let cmd = value.as_str().ok_or_else(|| {
32                Error::new(ErrorKind::InvalidOperation, "Expected string command")
33            })?;
34
35            match shell_executor_clone.execute(cmd) {
36                Ok(result) => Ok(Value::from(result)),
37                Err(e) => Err(Error::new(ErrorKind::InvalidOperation, e.to_string())),
38            }
39        });
40
41        Self {
42            env,
43        }
44    }
45
46    fn create_context(&self) -> HashMap<String, Value> {
47        let mut context = HashMap::new();
48
49        // Add current date/time
50        context.insert(
51            "current_date".to_string(),
52            Value::from(Utc::now().to_rfc3339()),
53        );
54
55        // Add environment variables
56        for (key, value) in std::env::vars() {
57            context.insert(format!("env_{}", key), Value::from(value));
58        }
59
60        context
61    }
62}
63
64impl TemplateEngine for MiniJinjaEngine {
65    fn render(&self, content: &SnippetContent) -> Result<String, TemplateError> {
66        match content {
67            SnippetContent::Static(s) => Ok(s.clone()),
68            SnippetContent::Template { source, .. } => {
69                let template = self
70                    .env
71                    .template_from_str(source)
72                    .map_err(|e| TemplateError::Syntax(e.to_string()))?;
73
74                let context = self.create_context();
75
76                template
77                    .render(context)
78                    .map_err(|e| TemplateError::Rendering(e.to_string()))
79            }
80        }
81    }
82}
83
84// Safe shell executor implementation
85#[derive(Clone, Debug)]
86pub struct SafeShellExecutor;
87
88impl SafeShellExecutor {
89    pub fn new() -> Self {
90        Self
91    }
92
93    fn is_command_safe(&self, cmd: &str) -> bool {
94        let dangerous_patterns = [
95            ";", "|", "&", ">", "<", "`", "$", "(", ")", "{", "}", "[", "]", "sudo", "rm", "mv",
96            "cp", "dd", "mkfs", "fork", "kill",
97        ];
98
99        !dangerous_patterns
100            .iter()
101            .any(|pattern| cmd.contains(pattern))
102    }
103}
104
105impl ShellCommandExecutor for SafeShellExecutor {
106    fn execute(&self, cmd: &str) -> Result<String, TemplateError> {
107        info!("Executing shell command: {}", cmd);
108
109        if !self.is_command_safe(cmd) {
110            return Err(TemplateError::Shell(
111                "Command contains forbidden patterns".to_string(),
112            ));
113        }
114
115        let output = Command::new("sh")
116            .arg("-c")
117            .arg(cmd)
118            .output()
119            .map_err(|e| TemplateError::Shell(format!("Failed to execute command: {}", e)))?;
120
121        if !output.status.success() {
122            let error = String::from_utf8_lossy(&output.stderr);
123            return Err(TemplateError::Shell(format!("Command failed: {}", error)));
124        }
125
126        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
127    }
128    fn box_clone(&self) -> Box<dyn ShellCommandExecutor> {
129        Box::new(self.clone())
130    }
131}
132
133// Filter implementations
134fn date_format(value: Value, args: &[Value]) -> Result<Value, Error> {
135    let date_str = value
136        .as_str()
137        .ok_or_else(|| Error::new(ErrorKind::InvalidOperation, "Expected string date"))?;
138    let format = args.first().and_then(|v| v.as_str()).unwrap_or("%Y-%m-%d");
139
140    let date = DateTime::parse_from_rfc3339(date_str)
141        .map_err(|e| Error::new(ErrorKind::InvalidOperation, format!("Invalid date: {}", e)))?
142        .with_timezone(&Utc);
143
144    Ok(Value::from(date.format(format).to_string()))
145}
146
147fn subtract_days(value: Value, args: &[Value]) -> Result<Value, Error> {
148    let date_str = value
149        .as_str()
150        .ok_or_else(|| Error::new(ErrorKind::InvalidOperation, "Expected date string"))?;
151
152    let date = DateTime::parse_from_rfc3339(date_str)
153        .map_err(|e| Error::new(ErrorKind::InvalidOperation, format!("Invalid date: {}", e)))?;
154
155    let days = args.first().and_then(|v| v.as_i64()).unwrap_or(0);
156    let new_date = date - chrono::Duration::days(days);
157
158    Ok(Value::from(new_date.to_rfc3339()))
159}
160
161fn add_days(value: Value, args: &[Value]) -> Result<Value, Error> {
162    let date_str = value
163        .as_str()
164        .ok_or_else(|| Error::new(ErrorKind::InvalidOperation, "Expected date string"))?;
165
166    let date = DateTime::parse_from_rfc3339(date_str)
167        .map_err(|e| Error::new(ErrorKind::InvalidOperation, format!("Invalid date: {}", e)))?;
168
169    let days = args.first().and_then(|v| v.as_i64()).unwrap_or(0);
170    let new_date = date + chrono::Duration::days(days);
171
172    Ok(Value::from(new_date.to_rfc3339()))
173}
174