1use lazy_static::lazy_static;
2use regex::Regex;
3use serde_yaml::Value;
4use sha2::{Digest, Sha256};
5use std::collections::HashMap;
6use std::path::Path;
7
8lazy_static! {
9 static ref MATRIX_PATTERN: Regex =
10 Regex::new(r"\$\{\{\s*matrix\.([a-zA-Z0-9_]+)\s*\}\}").unwrap();
11 static ref HASH_FILES_PATTERN: Regex =
12 Regex::new(r"\$\{\{\s*hashFiles\(([^)]+)\)\s*\}\}").unwrap();
13 static ref EXPRESSION_PATTERN: Regex =
16 Regex::new(r"\$\{\{(?:[^}]|\}[^}])*\}\}").unwrap();
17}
18
19pub fn preprocess_command(command: &str, matrix_values: &HashMap<String, Value>) -> String {
22 let result = MATRIX_PATTERN.replace_all(command, |caps: ®ex::Captures| {
24 let var_name = &caps[1];
25
26 if let Some(value) = matrix_values.get(var_name) {
28 match value {
30 Value::String(s) => s.clone(),
31 Value::Number(n) => n.to_string(),
32 Value::Bool(b) => b.to_string(),
33 _ => format!("\\${{{{ matrix.{} }}}}", var_name), }
35 } else {
36 format!("\\${{{{ matrix.{} }}}}", var_name)
38 }
39 });
40
41 result.into_owned()
42}
43
44pub fn process_step_run(run: &str, matrix_combination: &Option<HashMap<String, Value>>) -> String {
46 if let Some(matrix) = matrix_combination {
47 preprocess_command(run, matrix)
48 } else {
49 MATRIX_PATTERN
51 .replace_all(run, |caps: ®ex::Captures| {
52 let var_name = &caps[1];
53 format!("\\${{{{ matrix.{} }}}}", var_name)
54 })
55 .to_string()
56 }
57}
58
59pub fn preprocess_hash_files(text: &str, workspace: &Path) -> Result<String, String> {
67 let mut error: Option<String> = None;
68 let result = HASH_FILES_PATTERN
69 .replace_all(text, |caps: ®ex::Captures| {
70 if error.is_some() {
71 return String::new();
72 }
73 let args_raw = &caps[1];
74 match compute_hash_files(args_raw, workspace) {
75 Ok(hash) => hash,
76 Err(e) => {
77 error = Some(e);
78 String::new()
79 }
80 }
81 })
82 .into_owned();
83 match error {
84 Some(e) => Err(e),
85 None => Ok(result),
86 }
87}
88
89fn compute_hash_files(args_raw: &str, workspace: &Path) -> Result<String, String> {
96 let patterns: Vec<&str> = args_raw
98 .split(',')
99 .map(|s| s.trim().trim_matches('\'').trim_matches('"'))
100 .filter(|s| !s.is_empty())
101 .collect();
102
103 if patterns.is_empty() {
104 return Ok(String::new());
105 }
106
107 for pattern in &patterns {
109 if pattern.split('/').any(|seg| seg == "..") {
110 return Err(format!(
111 "hashFiles: pattern '{}' contains '..' path traversal",
112 pattern
113 ));
114 }
115 }
116
117 let canonical_workspace = workspace
119 .canonicalize()
120 .map_err(|e| format!("hashFiles: cannot canonicalize workspace: {}", e))?;
121 let mut matched_files = Vec::new();
122 for pattern in &patterns {
123 let full_pattern = workspace.join(pattern).to_string_lossy().to_string();
124 if let Ok(entries) = glob::glob(&full_pattern) {
125 for entry in entries.flatten() {
126 if entry.is_file() && !entry.is_symlink() {
127 if let Ok(canonical) = entry.canonicalize() {
130 if canonical.starts_with(&canonical_workspace) {
131 matched_files.push(entry);
132 }
133 }
134 }
135 }
136 }
137 }
138
139 if matched_files.is_empty() {
140 return Ok(format!("{:x}", Sha256::new().finalize()));
142 }
143
144 matched_files.sort();
146 matched_files.dedup();
147
148 let mut hasher = Sha256::new();
150 for path in &matched_files {
151 let mut file = std::fs::File::open(path)
152 .map_err(|e| format!("hashFiles: could not read '{}': {}", path.display(), e))?;
153 std::io::copy(&mut file, &mut hasher)
154 .map_err(|e| format!("hashFiles: could not read '{}': {}", path.display(), e))?;
155 }
156
157 Ok(format!("{:x}", hasher.finalize()))
158}
159
160pub fn preprocess_expressions(
172 text: &str,
173 workspace: &Path,
174 ctx: &crate::expression::ExpressionContext<'_>,
175) -> Result<String, String> {
176 use crate::expression::evaluate;
177
178 let result = preprocess_hash_files(text, workspace)?;
181
182 let result = EXPRESSION_PATTERN
184 .replace_all(&result, |caps: ®ex::Captures| {
185 let full_match = &caps[0];
186 let inner = &full_match[3..full_match.len() - 2];
188 match evaluate(inner, ctx) {
189 Ok(val) => val.to_output_string(),
190 Err(e) => {
191 wrkflw_logging::debug(&format!(
192 "Expression evaluation failed for '{}': {} — substituting empty string",
193 inner.trim(),
194 e
195 ));
196 String::new()
197 }
198 }
199 })
200 .into_owned();
201
202 Ok(result)
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::expression::ExpressionContext;
209 use std::fs;
210 use tempfile::tempdir;
211
212 fn make_ctx<'a>(
215 matrix: &'a Option<HashMap<String, Value>>,
216 step_outputs: &'a HashMap<String, HashMap<String, String>>,
217 env: &'a HashMap<String, String>,
218 ) -> ExpressionContext<'a> {
219 ExpressionContext {
220 env_context: env,
221 user_env: env,
222 step_outputs,
223 matrix_combination: matrix,
224 step_statuses: &EMPTY_STATUSES,
225 job_status: "success",
226 secrets_context: &EMPTY_SECRETS,
227 needs_context: &EMPTY_NEEDS,
228 needs_results: &EMPTY_NEEDS_RESULTS,
229 }
230 }
231
232 lazy_static::lazy_static! {
233 static ref EMPTY_STATUSES: HashMap<String, (String, String)> = HashMap::new();
234 static ref EMPTY_SECRETS: HashMap<String, String> = HashMap::new();
235 static ref EMPTY_NEEDS: HashMap<String, HashMap<String, String>> = HashMap::new();
236 static ref EMPTY_NEEDS_RESULTS: HashMap<String, String> = HashMap::new();
237 }
238
239 #[test]
240 fn test_preprocess_simple_matrix_vars() {
241 let mut matrix = HashMap::new();
242 matrix.insert("os".to_string(), Value::String("ubuntu-latest".to_string()));
243 matrix.insert(
244 "node".to_string(),
245 Value::Number(serde_yaml::Number::from(14)),
246 );
247
248 let cmd = "echo \"Running on ${{ matrix.os }} with Node ${{ matrix.node }}\"";
249 let processed = preprocess_command(cmd, &matrix);
250
251 assert_eq!(processed, "echo \"Running on ubuntu-latest with Node 14\"");
252 }
253
254 #[test]
255 fn test_preprocess_with_missing_vars() {
256 let mut matrix = HashMap::new();
257 matrix.insert("os".to_string(), Value::String("ubuntu-latest".to_string()));
258
259 let cmd = "echo \"Running on ${{ matrix.os }} with Node ${{ matrix.node }}\"";
260 let processed = preprocess_command(cmd, &matrix);
261
262 assert_eq!(
264 processed,
265 "echo \"Running on ubuntu-latest with Node \\${{ matrix.node }}\""
266 );
267 }
268
269 #[test]
270 fn test_preprocess_preserves_other_text() {
271 let mut matrix = HashMap::new();
272 matrix.insert("os".to_string(), Value::String("ubuntu-latest".to_string()));
273
274 let cmd = "echo \"Starting job\" && echo \"OS: ${{ matrix.os }}\" && echo \"Done!\"";
275 let processed = preprocess_command(cmd, &matrix);
276
277 assert_eq!(
278 processed,
279 "echo \"Starting job\" && echo \"OS: ubuntu-latest\" && echo \"Done!\""
280 );
281 }
282
283 #[test]
284 fn test_process_without_matrix() {
285 let cmd = "echo \"Value: ${{ matrix.value }}\"";
286 let processed = process_step_run(cmd, &None);
287
288 assert_eq!(processed, "echo \"Value: \\${{ matrix.value }}\"");
289 }
290
291 #[test]
292 fn hash_files_single_pattern() {
293 let dir = tempdir().unwrap();
294 fs::write(dir.path().join("package-lock.json"), "lock-content").unwrap();
295 fs::write(dir.path().join("other.txt"), "other").unwrap();
296
297 let text = "${{ hashFiles('package-lock.json') }}";
298 let result = preprocess_hash_files(text, dir.path()).unwrap();
299
300 assert!(!result.is_empty());
301 assert!(!result.contains("hashFiles"));
302 assert_eq!(result.len(), 64);
304 }
305
306 #[test]
307 fn hash_files_multiple_patterns() {
308 let dir = tempdir().unwrap();
309 fs::write(dir.path().join("a.lock"), "aaa").unwrap();
310 fs::write(dir.path().join("b.json"), "bbb").unwrap();
311
312 let text = "${{ hashFiles('*.lock', '*.json') }}";
313 let result = preprocess_hash_files(text, dir.path()).unwrap();
314
315 assert_eq!(result.len(), 64);
316 }
317
318 #[test]
319 fn hash_files_no_matches_returns_hash_of_empty() {
320 let dir = tempdir().unwrap();
321
322 let text = "${{ hashFiles('nonexistent-*.xyz') }}";
323 let result = preprocess_hash_files(text, dir.path()).unwrap();
324
325 let expected = format!("{:x}", Sha256::new().finalize());
327 assert_eq!(result, expected);
328 assert_eq!(result.len(), 64);
329 }
330
331 #[test]
332 fn hash_files_deterministic() {
333 let dir = tempdir().unwrap();
334 fs::write(dir.path().join("a.txt"), "hello").unwrap();
335 fs::write(dir.path().join("b.txt"), "world").unwrap();
336
337 let text = "${{ hashFiles('*.txt') }}";
338 let r1 = preprocess_hash_files(text, dir.path()).unwrap();
339 let r2 = preprocess_hash_files(text, dir.path()).unwrap();
340
341 assert_eq!(r1, r2);
342 }
343
344 #[test]
345 fn hash_files_glob_recursive() {
346 let dir = tempdir().unwrap();
347 let sub = dir.path().join("sub");
348 fs::create_dir(&sub).unwrap();
349 fs::write(sub.join("deep.lock"), "deep-content").unwrap();
350
351 let text = "${{ hashFiles('**/deep.lock') }}";
352 let result = preprocess_hash_files(text, dir.path()).unwrap();
353
354 assert_eq!(result.len(), 64);
355 }
356
357 #[test]
358 fn hash_files_inline_with_other_text() {
359 let dir = tempdir().unwrap();
360 fs::write(dir.path().join("Cargo.lock"), "lockfile").unwrap();
361
362 let text = "cache-key-${{ hashFiles('Cargo.lock') }}-suffix";
363 let result = preprocess_hash_files(text, dir.path()).unwrap();
364
365 assert!(result.starts_with("cache-key-"));
366 assert!(result.ends_with("-suffix"));
367 assert!(!result.contains("hashFiles"));
368 }
369
370 #[test]
371 fn preprocess_expressions_combines_hash_and_matrix() {
372 let dir = tempdir().unwrap();
373 fs::write(dir.path().join("Cargo.lock"), "lockfile").unwrap();
374
375 let mut matrix = HashMap::new();
376 matrix.insert("os".to_string(), Value::String("ubuntu".to_string()));
377
378 let text = "${{ matrix.os }}-${{ hashFiles('Cargo.lock') }}";
379 let matrix = Some(matrix);
380 let empty_steps = HashMap::new();
381 let empty_env = HashMap::new();
382 let ctx = make_ctx(&matrix, &empty_steps, &empty_env);
383 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
384
385 assert!(result.starts_with("ubuntu-"));
386 assert!(!result.contains("hashFiles"));
387 assert!(!result.contains("matrix"));
388 }
389
390 #[test]
391 fn hash_files_rejects_path_traversal() {
392 let dir = tempdir().unwrap();
393 fs::write(dir.path().join("legit.txt"), "content").unwrap();
394
395 let text = "${{ hashFiles('../../etc/passwd') }}";
396 let result = preprocess_hash_files(text, dir.path());
397 assert!(result.is_err());
398 assert!(result.unwrap_err().contains("path traversal"));
399 }
400
401 #[test]
402 fn hash_files_rejects_mid_path_traversal() {
403 let dir = tempdir().unwrap();
404
405 let result = compute_hash_files("'subdir/../../etc/passwd'", dir.path());
406 assert!(result.is_err());
407 assert!(result.unwrap_err().contains("path traversal"));
408 }
409
410 #[test]
413 fn step_output_substitution() {
414 let dir = tempdir().unwrap();
415 let mut step_outputs = HashMap::new();
416 let mut build_outputs = HashMap::new();
417 build_outputs.insert("version".to_string(), "1.2.3".to_string());
418 step_outputs.insert("build".to_string(), build_outputs);
419
420 let text = "Version is ${{ steps.build.outputs.version }}";
421 let empty_env = HashMap::new();
422 let ctx = make_ctx(&None, &step_outputs, &empty_env);
423 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
424 assert_eq!(result, "Version is 1.2.3");
425 }
426
427 #[test]
428 fn step_output_missing_returns_empty() {
429 let dir = tempdir().unwrap();
430
431 let text = "Value: ${{ steps.unknown.outputs.key }}";
432 let empty_steps = HashMap::new();
433 let empty_env = HashMap::new();
434 let ctx = make_ctx(&None, &empty_steps, &empty_env);
435 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
436 assert_eq!(result, "Value: ");
437 }
438
439 #[test]
440 fn step_output_missing_key_returns_empty() {
441 let dir = tempdir().unwrap();
442 let mut step_outputs = HashMap::new();
443 step_outputs.insert("build".to_string(), HashMap::new());
444
445 let text = "${{ steps.build.outputs.missing }}";
446 let empty_env = HashMap::new();
447 let ctx = make_ctx(&None, &step_outputs, &empty_env);
448 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
449 assert_eq!(result, "");
450 }
451
452 #[test]
455 fn env_context_substitution() {
456 let dir = tempdir().unwrap();
457 let mut env = HashMap::new();
458 env.insert("MY_VAR".to_string(), "hello".to_string());
459
460 let text = "Value: ${{ env.MY_VAR }}";
461 let empty_steps = HashMap::new();
462 let ctx = make_ctx(&None, &empty_steps, &env);
463 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
464 assert_eq!(result, "Value: hello");
465 }
466
467 #[test]
468 fn env_context_missing_returns_empty() {
469 let dir = tempdir().unwrap();
470
471 let text = "${{ env.MISSING }}";
472 let empty_steps = HashMap::new();
473 let empty_env = HashMap::new();
474 let ctx = make_ctx(&None, &empty_steps, &empty_env);
475 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
476 assert_eq!(result, "");
477 }
478
479 #[test]
482 fn combined_substitutions() {
483 let dir = tempdir().unwrap();
484 fs::write(dir.path().join("lock"), "content").unwrap();
485
486 let mut matrix = HashMap::new();
487 matrix.insert("os".to_string(), Value::String("ubuntu".to_string()));
488
489 let mut step_outputs = HashMap::new();
490 let mut build_out = HashMap::new();
491 build_out.insert("tag".to_string(), "v1".to_string());
492 step_outputs.insert("build".to_string(), build_out);
493
494 let mut env = HashMap::new();
495 env.insert("CI".to_string(), "true".to_string());
496
497 let text = "${{ matrix.os }}-${{ steps.build.outputs.tag }}-${{ env.CI }}";
498 let matrix = Some(matrix);
499 let ctx = make_ctx(&matrix, &step_outputs, &env);
500 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
501 assert_eq!(result, "ubuntu-v1-true");
502 }
503
504 #[test]
507 fn inputs_context_substitution() {
508 let dir = tempdir().unwrap();
509 let mut env = HashMap::new();
510 env.insert("INPUT_TOOLCHAIN".to_string(), "stable".to_string());
511 env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string());
512
513 let text =
514 "rustup toolchain install ${{ inputs.toolchain }} --component ${{ inputs.components }}";
515 let empty_steps = HashMap::new();
516 let ctx = make_ctx(&None, &empty_steps, &env);
517 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
518 assert_eq!(
519 result,
520 "rustup toolchain install stable --component rustfmt"
521 );
522 }
523
524 #[test]
525 fn inputs_context_hyphenated_name() {
526 let dir = tempdir().unwrap();
527 let mut env = HashMap::new();
528 env.insert("INPUT_NODE_VERSION".to_string(), "18".to_string());
529
530 let text = "${{ inputs.node-version }}";
531 let empty_steps = HashMap::new();
532 let ctx = make_ctx(&None, &empty_steps, &env);
533 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
534 assert_eq!(result, "18");
535 }
536
537 #[test]
538 fn inputs_context_missing_returns_empty() {
539 let dir = tempdir().unwrap();
540
541 let text = "${{ inputs.missing }}";
542 let empty_steps = HashMap::new();
543 let empty_env = HashMap::new();
544 let ctx = make_ctx(&None, &empty_steps, &empty_env);
545 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
546 assert_eq!(result, "");
547 }
548
549 #[test]
552 fn github_context_substitution() {
553 let dir = tempdir().unwrap();
554 let mut env = HashMap::new();
555 env.insert("GITHUB_REPOSITORY".to_string(), "owner/repo".to_string());
556 env.insert("GITHUB_REF_NAME".to_string(), "main".to_string());
557
558 let text = "${{ github.repository }}/${{ github.ref_name }}";
559 let empty_steps = HashMap::new();
560 let ctx = make_ctx(&None, &empty_steps, &env);
561 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
562 assert_eq!(result, "owner/repo/main");
563 }
564
565 #[test]
566 fn github_context_missing_returns_empty() {
567 let dir = tempdir().unwrap();
568
569 let text = "${{ github.token }}";
570 let empty_steps = HashMap::new();
571 let empty_env = HashMap::new();
572 let ctx = make_ctx(&None, &empty_steps, &empty_env);
573 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
574 assert_eq!(result, "");
575 }
576
577 #[test]
580 fn runner_context_substitution() {
581 let dir = tempdir().unwrap();
582 let mut env = HashMap::new();
583 env.insert("RUNNER_OS".to_string(), "Linux".to_string());
584 env.insert("RUNNER_TEMP".to_string(), "/tmp/runner".to_string());
585
586 let text = "${{ runner.os }} ${{ runner.temp }}";
587 let empty_steps = HashMap::new();
588 let ctx = make_ctx(&None, &empty_steps, &env);
589 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
590 assert_eq!(result, "Linux /tmp/runner");
591 }
592
593 #[test]
594 fn runner_context_missing_returns_empty() {
595 let dir = tempdir().unwrap();
596
597 let text = "${{ runner.arch }}";
598 let empty_steps = HashMap::new();
599 let empty_env = HashMap::new();
600 let ctx = make_ctx(&None, &empty_steps, &empty_env);
601 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
602 assert_eq!(result, "");
603 }
604
605 #[test]
608 fn preprocess_expressions_includes_inputs_and_github() {
609 let dir = tempdir().unwrap();
610
611 let mut env = HashMap::new();
612 env.insert("INPUT_TOOLCHAIN".to_string(), "nightly".to_string());
613 env.insert("GITHUB_REPOSITORY".to_string(), "foo/bar".to_string());
614 env.insert("RUNNER_OS".to_string(), "Linux".to_string());
615
616 let text = "${{ inputs.toolchain }}-${{ github.repository }}-${{ runner.os }}";
617 let empty_steps = HashMap::new();
618 let ctx = make_ctx(&None, &empty_steps, &env);
619 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
620 assert_eq!(result, "nightly-foo/bar-Linux");
621 }
622
623 #[test]
624 fn preprocess_expressions_unknown_context_returns_empty() {
625 let dir = tempdir().unwrap();
626
627 let text = "echo ${{ unknown_context.value }}";
628 let empty_steps = HashMap::new();
629 let empty_env = HashMap::new();
630 let ctx = make_ctx(&None, &empty_steps, &empty_env);
631 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
632 assert_eq!(result, "echo ");
633 }
634
635 #[test]
638 fn preprocess_expressions_evaluates_complex_expression() {
639 let dir = tempdir().unwrap();
640 let mut env = HashMap::new();
641 env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string());
642
643 let mut step_outputs = HashMap::new();
644 let mut parse_out = HashMap::new();
645 parse_out.insert("toolchain".to_string(), "nightly".to_string());
646 step_outputs.insert("parse".to_string(), parse_out);
647
648 let text = "rustup toolchain install nightly${{ steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || '' }}";
650 let ctx = make_ctx(&None, &step_outputs, &env);
651 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
652 assert_eq!(result, "rustup toolchain install nightly --allow-downgrade");
653 }
654
655 #[test]
656 fn preprocess_expressions_evaluates_comparison_to_empty() {
657 let dir = tempdir().unwrap();
658 let mut env = HashMap::new();
659 env.insert("INPUT_COMPONENTS".to_string(), "rustfmt".to_string());
660
661 let mut step_outputs = HashMap::new();
662 let mut parse_out = HashMap::new();
663 parse_out.insert("toolchain".to_string(), "stable".to_string());
664 step_outputs.insert("parse".to_string(), parse_out);
665
666 let text = "rustup toolchain install stable${{ steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || '' }}";
667 let ctx = make_ctx(&None, &step_outputs, &env);
668 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
669 assert_eq!(result, "rustup toolchain install stable");
671 }
672
673 #[test]
674 fn preprocess_expressions_no_spaces() {
675 let dir = tempdir().unwrap();
676 let mut env = HashMap::new();
677 env.insert("RUNNER_OS".to_string(), "Linux".to_string());
678
679 let text = "if [[ ${{runner.os}} == macOS ]]; then echo mac; fi";
681 let empty_steps = HashMap::new();
682 let ctx = make_ctx(&None, &empty_steps, &env);
683 let result = preprocess_expressions(text, dir.path(), &ctx).unwrap();
684 assert_eq!(result, "if [[ Linux == macOS ]]; then echo mac; fi");
685 }
686}