1use std::collections::HashMap;
29
30use chrono::{DateTime, Local};
31use uuid::Uuid;
32
33use crate::error::NikaError;
34
35const FORBIDDEN_VAR_CHARS: &[char] = &['/', '\\', '\0'];
38
39const FORBIDDEN_VAR_PATTERNS: &[&str] = &["..", "~"];
41
42fn validate_var_value(key: &str, value: &str) -> Result<(), NikaError> {
46 if value.is_empty() {
48 return Ok(());
49 }
50
51 for c in FORBIDDEN_VAR_CHARS {
53 if value.contains(*c) {
54 return Err(NikaError::TemplateError {
55 template: format!("{{{{{}}}}}", key),
56 reason: format!(
57 "Variable value contains forbidden character '{}': path traversal risk",
58 c
59 ),
60 });
61 }
62 }
63
64 for pattern in FORBIDDEN_VAR_PATTERNS {
66 if value.contains(pattern) {
67 return Err(NikaError::TemplateError {
68 template: format!("{{{{{}}}}}", key),
69 reason: format!(
70 "Variable value contains forbidden pattern '{}': path traversal risk",
71 pattern
72 ),
73 });
74 }
75 }
76
77 Ok(())
78}
79
80#[derive(Debug)]
82pub struct TemplateResolver {
83 task_id: String,
85 workflow_name: String,
87 timestamp: DateTime<Local>,
89 custom_vars: HashMap<String, String>,
91}
92
93impl TemplateResolver {
94 pub fn new(task_id: impl Into<String>, workflow_name: impl Into<String>) -> Self {
96 Self {
97 task_id: task_id.into(),
98 workflow_name: workflow_name.into(),
99 timestamp: Local::now(),
100 custom_vars: HashMap::new(),
101 }
102 }
103
104 #[cfg(test)]
111 pub fn with_var(
112 mut self,
113 key: impl Into<String>,
114 value: impl Into<String>,
115 ) -> Result<Self, NikaError> {
116 let key = key.into();
117 let value = value.into();
118
119 if key.is_empty() {
121 return Err(NikaError::TemplateError {
122 template: "{{}}".to_string(),
123 reason: "Variable name cannot be empty".to_string(),
124 });
125 }
126
127 validate_var_value(&key, &value)?;
129
130 self.custom_vars.insert(key, value);
131 Ok(self)
132 }
133
134 pub fn with_vars(mut self, vars: HashMap<String, String>) -> Result<Self, NikaError> {
141 for (key, value) in &vars {
142 if key.is_empty() {
143 return Err(NikaError::TemplateError {
144 template: "{{}}".to_string(),
145 reason: "Variable name cannot be empty".to_string(),
146 });
147 }
148 validate_var_value(key, value)?;
149 }
150 self.custom_vars.extend(vars);
151 Ok(self)
152 }
153
154 #[cfg(test)]
156 pub fn with_timestamp(mut self, timestamp: DateTime<Local>) -> Self {
157 self.timestamp = timestamp;
158 self
159 }
160
161 pub fn resolve(&self, template: &str) -> Result<String, NikaError> {
175 let mut result = template.to_string();
176 let mut pos = 0;
177
178 while let Some(start) = result[pos..].find("{{") {
179 let start = pos + start;
180 let Some(end) = result[start..].find("}}") else {
181 break;
183 };
184 let end = start + end + 2;
185
186 let var_name = &result[start + 2..end - 2].trim();
187 let value = self.resolve_variable(var_name)?;
188
189 result.replace_range(start..end, &value);
190 pos = start + value.len();
191 }
192
193 Ok(result)
194 }
195
196 fn resolve_variable(&self, var_name: &str) -> Result<String, NikaError> {
198 if let Some(format) = var_name.strip_prefix("date.") {
200 return Ok(self.format_date(format));
201 }
202
203 if let Some(format) = var_name.strip_prefix("time.") {
205 return Ok(self.format_time(format));
206 }
207
208 match var_name {
210 "task_id" => Ok(self.task_id.clone()),
211 "workflow_name" | "workflow" => Ok(self.workflow_name.clone()),
213 "date" => Ok(self.timestamp.format("%Y-%m-%d").to_string()),
214 "time" => Ok(self.timestamp.format("%H-%M-%S").to_string()),
215 "timestamp" => Ok(self.timestamp.timestamp().to_string()),
216 "uuid" => Ok(Uuid::new_v4().to_string()),
217 _ => {
218 if let Some(value) = self.custom_vars.get(var_name) {
220 return Ok(value.clone());
221 }
222
223 Err(NikaError::TemplateError {
224 template: format!("{{{{{}}}}}", var_name),
225 reason: format!("Unknown template variable: {}", var_name),
226 })
227 }
228 }
229 }
230
231 fn format_date(&self, format: &str) -> String {
238 let mut result = format.to_string();
239 result = result.replace("YYYY", &self.timestamp.format("%Y").to_string());
240 result = result.replace("MM", &self.timestamp.format("%m").to_string());
241 result = result.replace("DD", &self.timestamp.format("%d").to_string());
242 result
243 }
244
245 fn format_time(&self, format: &str) -> String {
252 let mut result = format.to_string();
253 result = result.replace("HH", &self.timestamp.format("%H").to_string());
254 result = result.replace("mm", &self.timestamp.format("%M").to_string());
255 result = result.replace("ss", &self.timestamp.format("%S").to_string());
256 result
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use chrono::TimeZone;
264
265 fn fixed_resolver() -> TemplateResolver {
266 let ts = Local.with_ymd_and_hms(2024, 1, 15, 14, 30, 45).unwrap();
267 TemplateResolver::new("test_task", "test_workflow").with_timestamp(ts)
268 }
269
270 #[test]
271 fn test_resolve_task_id() {
272 let resolver = fixed_resolver();
273 let result = resolver.resolve("{{task_id}}/output.json").unwrap();
274 assert_eq!(result, "test_task/output.json");
275 }
276
277 #[test]
278 fn test_resolve_workflow_name() {
279 let resolver = fixed_resolver();
280 let result = resolver
281 .resolve("{{workflow_name}}/{{task_id}}.json")
282 .unwrap();
283 assert_eq!(result, "test_workflow/test_task.json");
284 }
285
286 #[test]
287 fn test_resolve_date() {
288 let resolver = fixed_resolver();
289 let result = resolver.resolve("{{date}}/output.json").unwrap();
290 assert_eq!(result, "2024-01-15/output.json");
291 }
292
293 #[test]
294 fn test_resolve_time() {
295 let resolver = fixed_resolver();
296 let result = resolver.resolve("{{time}}.json").unwrap();
297 assert_eq!(result, "14-30-45.json");
298 }
299
300 #[test]
301 fn test_resolve_timestamp() {
302 let resolver = fixed_resolver();
303 let result = resolver.resolve("{{timestamp}}.json").unwrap();
304 assert!(result.ends_with(".json"));
306 let ts_str = result.strip_suffix(".json").unwrap();
307 assert!(ts_str.parse::<i64>().is_ok());
308 }
309
310 #[test]
311 fn test_resolve_uuid() {
312 let resolver = fixed_resolver();
313 let result = resolver.resolve("{{uuid}}.json").unwrap();
314 assert!(result.ends_with(".json"));
316 let uuid_str = result.strip_suffix(".json").unwrap();
317 assert!(Uuid::parse_str(uuid_str).is_ok());
318 }
319
320 #[test]
321 fn test_resolve_date_format() {
322 let resolver = fixed_resolver();
323 let result = resolver.resolve("{{date.YYYY-MM-DD}}.json").unwrap();
324 assert_eq!(result, "2024-01-15.json");
325 }
326
327 #[test]
328 fn test_resolve_date_format_custom() {
329 let resolver = fixed_resolver();
330 let result = resolver.resolve("{{date.YYYY/MM/DD}}.json").unwrap();
331 assert_eq!(result, "2024/01/15.json");
332 }
333
334 #[test]
335 fn test_resolve_time_format() {
336 let resolver = fixed_resolver();
337 let result = resolver.resolve("{{time.HH-mm-ss}}.json").unwrap();
338 assert_eq!(result, "14-30-45.json");
339 }
340
341 #[test]
342 fn test_resolve_custom_var() {
343 let resolver = fixed_resolver().with_var("entity", "qr-code").unwrap();
344 let result = resolver.resolve("{{entity}}/{{task_id}}.json").unwrap();
345 assert_eq!(result, "qr-code/test_task.json");
346 }
347
348 #[test]
349 fn test_resolve_multiple_vars() {
350 let mut vars = HashMap::new();
351 vars.insert("locale".to_string(), "fr-FR".to_string());
352 vars.insert("version".to_string(), "v1".to_string());
353
354 let resolver = fixed_resolver().with_vars(vars).unwrap();
355 let result = resolver
356 .resolve("{{locale}}/{{version}}/{{task_id}}.json")
357 .unwrap();
358 assert_eq!(result, "fr-FR/v1/test_task.json");
359 }
360
361 #[test]
362 fn test_var_path_traversal_rejected() {
363 let result = fixed_resolver().with_var("entity", "../escape");
364 assert!(result.is_err());
365 let err = result.unwrap_err();
366 if let NikaError::TemplateError { reason, .. } = err {
367 assert!(reason.contains("path traversal"));
368 } else {
369 panic!("Expected TemplateError");
370 }
371 }
372
373 #[test]
374 fn test_var_slash_rejected() {
375 let result = fixed_resolver().with_var("path", "a/b/c");
376 assert!(result.is_err());
377 let err = result.unwrap_err();
378 if let NikaError::TemplateError { reason, .. } = err {
379 assert!(reason.contains("forbidden character"));
380 } else {
381 panic!("Expected TemplateError");
382 }
383 }
384
385 #[test]
386 fn test_empty_var_name_rejected() {
387 let result = fixed_resolver().with_var("", "value");
388 assert!(result.is_err());
389 let err = result.unwrap_err();
390 if let NikaError::TemplateError { reason, .. } = err {
391 assert!(reason.contains("empty"));
392 } else {
393 panic!("Expected TemplateError");
394 }
395 }
396
397 #[test]
398 fn test_empty_var_value_allowed() {
399 let resolver = fixed_resolver().with_var("empty", "").unwrap();
400 let result = resolver.resolve("prefix{{empty}}suffix").unwrap();
401 assert_eq!(result, "prefixsuffix");
402 }
403
404 #[test]
405 fn test_resolve_unknown_var() {
406 let resolver = fixed_resolver();
407 let result = resolver.resolve("{{unknown}}/output.json");
408 assert!(result.is_err());
409 let err = result.unwrap_err();
410 assert!(matches!(err, NikaError::TemplateError { .. }));
411 }
412
413 #[test]
414 fn test_resolve_unclosed_template() {
415 let resolver = fixed_resolver();
416 let result = resolver.resolve("{{task_id/output.json").unwrap();
418 assert_eq!(result, "{{task_id/output.json");
419 }
420
421 #[test]
422 fn test_resolve_no_templates() {
423 let resolver = fixed_resolver();
424 let result = resolver.resolve("simple/path/output.json").unwrap();
425 assert_eq!(result, "simple/path/output.json");
426 }
427
428 #[test]
429 fn test_resolve_whitespace_in_var() {
430 let resolver = fixed_resolver();
431 let result = resolver.resolve("{{ task_id }}/output.json").unwrap();
432 assert_eq!(result, "test_task/output.json");
433 }
434
435 #[test]
436 fn test_resolve_complex_path() {
437 let resolver = fixed_resolver().with_var("locale", "es-MX").unwrap();
438 let result = resolver
439 .resolve("{{workflow_name}}/{{date}}/{{locale}}/{{task_id}}_{{time}}.json")
440 .unwrap();
441 assert_eq!(
442 result,
443 "test_workflow/2024-01-15/es-MX/test_task_14-30-45.json"
444 );
445 }
446
447 #[test]
448 fn test_resolve_workflow_alias() {
449 let resolver = fixed_resolver();
451 let result = resolver.resolve("{{workflow}}/{{task_id}}.json").unwrap();
452 assert_eq!(result, "test_workflow/test_task.json");
453 }
454}