1use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result};
15
16pub const MAX_GLUE_LINES: usize = 12;
19
20const LOGIC_KEYWORDS: [(&str, &str); 5] = [
22 ("for", "for loop"),
23 ("while", "while loop"),
24 ("until", "until loop"),
25 ("select", "select loop"),
26 ("case", "case dispatch"),
27];
28
29const MUNGING_COMMANDS: [&str; 2] = ["awk", "sed"];
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Finding {
35 pub file: PathBuf,
36 pub line: usize,
38 pub step: String,
40 pub kind: &'static str,
42 pub reasons: Vec<String>,
44}
45
46pub fn flag_reasons(body: &str) -> Vec<String> {
48 let mut reasons: Vec<String> = Vec::new();
49 for (keyword, name) in LOGIC_KEYWORDS {
50 if body.lines().any(|line| starts_command(line, keyword)) {
51 reasons.push(name.to_string());
52 }
53 }
54 for command in MUNGING_COMMANDS {
55 if body.lines().any(|line| runs_command(line, command)) {
56 reasons.push(command.to_string());
57 }
58 }
59 let count = significant_lines(body);
60 if count > MAX_GLUE_LINES {
61 reasons.push(format!("{count} lines (> {MAX_GLUE_LINES})"));
62 }
63 reasons
64}
65
66fn significant_lines(body: &str) -> usize {
69 body.lines()
70 .map(str::trim)
71 .filter(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with("set -"))
72 .count()
73}
74
75fn starts_command(line: &str, keyword: &str) -> bool {
78 line.split_whitespace().next() == Some(keyword)
79}
80
81fn runs_command(line: &str, command: &str) -> bool {
84 command_segments(line).any(|segment| segment.split_whitespace().next() == Some(command))
85}
86
87fn command_segments(line: &str) -> impl Iterator<Item = &str> {
91 line.split(['|', ';', '&', '(', ')', '`', '{', '}'])
92}
93
94pub fn find_violations(yaml_text: &str, file: impl AsRef<Path>) -> Vec<Finding> {
102 let file = file.as_ref();
103 let root = match marked_yaml::parse_yaml(0, yaml_text) {
104 Ok(marked_yaml::types::Node::Mapping(root)) => root,
105 _ => return Vec::new(),
106 };
107
108 let mut findings = Vec::new();
109 for step in steps(&root) {
110 let label = step_label(step);
111 for (kind, body, line) in bodies(step) {
112 let reasons = flag_reasons(&body);
113 if !reasons.is_empty() {
114 findings.push(Finding {
115 file: file.to_path_buf(),
116 line,
117 step: label.clone(),
118 kind,
119 reasons,
120 });
121 }
122 }
123 }
124 findings
125}
126
127fn steps(
130 root: &marked_yaml::types::MarkedMappingNode,
131) -> Vec<&marked_yaml::types::MarkedMappingNode> {
132 let mut out = Vec::new();
133 if let Some(jobs) = root.get_mapping("jobs") {
134 for (_, job) in jobs.iter() {
135 if let Some(job) = job.as_mapping() {
136 push_steps(job, &mut out);
137 }
138 }
139 }
140 if let Some(runs) = root.get_mapping("runs") {
141 push_steps(runs, &mut out);
142 }
143 out
144}
145
146fn push_steps<'a>(
148 owner: &'a marked_yaml::types::MarkedMappingNode,
149 out: &mut Vec<&'a marked_yaml::types::MarkedMappingNode>,
150) {
151 let Some(steps) = owner.get_sequence("steps") else {
152 return;
153 };
154 out.extend(steps.iter().filter_map(|step| step.as_mapping()));
155}
156
157fn step_label(step: &marked_yaml::types::MarkedMappingNode) -> String {
159 step.get_scalar("name")
160 .or_else(|| step.get_scalar("uses"))
161 .map(|node| node.as_str().to_string())
162 .unwrap_or_else(|| "<unnamed step>".to_string())
163}
164
165fn bodies(step: &marked_yaml::types::MarkedMappingNode) -> Vec<(&'static str, String, usize)> {
167 let mut out = Vec::new();
168 if let Some(run) = step.get_scalar("run") {
169 out.push(("run", run.as_str().to_string(), line_of(run.span())));
170 }
171 out.extend(github_script_body(step));
172 out
173}
174
175fn github_script_body(
178 step: &marked_yaml::types::MarkedMappingNode,
179) -> Option<(&'static str, String, usize)> {
180 let uses = step.get_scalar("uses")?;
181 if !uses.as_str().starts_with("actions/github-script") {
182 return None;
183 }
184 let script = step.get_mapping("with")?.get_scalar("script")?;
185 Some((
186 "github-script",
187 script.as_str().to_string(),
188 line_of(script.span()),
189 ))
190}
191
192fn line_of(span: &marked_yaml::Span) -> usize {
194 span.start().map(|marker| marker.line()).unwrap_or(0)
195}
196
197pub fn scan(path: impl AsRef<Path>) -> Result<Vec<Finding>> {
203 let path = path.as_ref();
204 let mut files = Vec::new();
205 if path.exists() {
206 collect_ci_files(path, &mut files)?;
207 }
208 files.sort();
209
210 let mut findings = Vec::new();
211 for file in files {
212 let text = std::fs::read_to_string(&file)
213 .with_context(|| format!("reading workflow `{}`", file.display()))?;
214 findings.extend(find_violations(&text, &file));
215 }
216 Ok(findings)
217}
218
219fn collect_ci_files(path: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
223 if path.is_file() {
224 out.push(path.to_path_buf());
225 return Ok(());
226 }
227 let entries = std::fs::read_dir(path)
228 .with_context(|| format!("reading directory `{}`", path.display()))?;
229 for entry in entries {
230 let child = crate::walk::dir_entry(entry, path)?.path();
231 if child.is_dir() {
232 collect_ci_files(&child, out)?;
233 } else if is_ci_file(&child) {
234 out.push(child);
235 }
236 }
237 Ok(())
238}
239
240fn is_ci_file(path: &Path) -> bool {
246 if !matches!(
247 path.extension().and_then(|e| e.to_str()),
248 Some("yml" | "yaml")
249 ) {
250 return false;
251 }
252 let name = path.file_name().and_then(|n| n.to_str());
253 if matches!(name, Some("action.yml" | "action.yaml")) {
254 return true;
255 }
256 path.parent()
257 .and_then(Path::file_name)
258 .and_then(|n| n.to_str())
259 == Some("workflows")
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn straight_line_glue_is_not_logic() {
268 assert_eq!(
269 flag_reasons(
270 "pkg=@x/linux\nmkdir -p \"$pkg/bin\"\ninstall -m 755 bin/x \"$pkg/bin/x\""
271 ),
272 Vec::<String>::new()
273 );
274 }
275
276 #[test]
277 fn a_lone_guard_around_an_exit_is_glue() {
278 let body = "set -euo pipefail\n\
279 private=$(gh api repos/x --jq .private)\n\
280 if [ \"$private\" = \"true\" ]; then\n\
281 echo \"::error::private\"\n\
282 exit 1\n\
283 fi\n\
284 echo ok\n";
285 assert_eq!(flag_reasons(body), Vec::<String>::new());
286 }
287
288 #[test]
289 fn each_loop_and_dispatch_keyword_is_named() {
290 assert_eq!(
291 flag_reasons("for name in $NAMES; do\n npm publish \"$name\"\ndone"),
292 vec!["for loop"]
293 );
294 assert_eq!(
295 flag_reasons("while read -r l; do\n echo $l\ndone"),
296 vec!["while loop"]
297 );
298 assert_eq!(
299 flag_reasons("until ok; do\n sleep 1\ndone"),
300 vec!["until loop"]
301 );
302 assert_eq!(
303 flag_reasons("select x in a b; do\n echo $x\ndone"),
304 vec!["select loop"]
305 );
306 assert_eq!(
307 flag_reasons("case \"$1\" in\n a) echo a ;;\nesac"),
308 vec!["case dispatch"]
309 );
310 }
311
312 #[test]
313 fn a_keyword_is_only_a_marker_at_a_command_position() {
314 assert_eq!(
316 flag_reasons("before_hook\nformat_output\ncasements=3"),
317 Vec::<String>::new()
318 );
319 assert_eq!(flag_reasons("echo for while case"), Vec::<String>::new());
321 }
322
323 #[test]
324 fn text_munging_counts_wherever_the_command_runs() {
325 assert_eq!(flag_reasons("awk -F/ '{print $2}' f"), vec!["awk"]);
326 assert_eq!(flag_reasons("echo x | awk -F/ '{print $2}'"), vec!["awk"]);
327 assert_eq!(flag_reasons("cat f | sed 's/a/b/'"), vec!["sed"]);
328 assert_eq!(flag_reasons("v=$(sed -n 1p f)"), vec!["sed"]);
329 assert_eq!(flag_reasons("a; sed -i s/x/y/ f"), vec!["sed"]);
330 assert_eq!(flag_reasons("ok && awk 'END{print NR}' f"), vec!["awk"]);
331 }
332
333 #[test]
334 fn a_munging_command_named_as_an_argument_is_not_running() {
335 assert_eq!(
336 flag_reasons("echo 'use awk for this'"),
337 Vec::<String>::new()
338 );
339 assert_eq!(flag_reasons("apt-get install -y sed"), Vec::<String>::new());
340 }
341
342 #[test]
343 fn a_long_straight_line_body_is_a_script_by_length() {
344 let body = (0..MAX_GLUE_LINES + 3)
345 .map(|i| format!("cmd{i}"))
346 .collect::<Vec<_>>()
347 .join("\n");
348 assert_eq!(flag_reasons(&body), vec!["15 lines (> 12)"]);
349 }
350
351 #[test]
352 fn the_length_count_ignores_bookkeeping() {
353 let body = (0..MAX_GLUE_LINES)
355 .map(|i| format!("cmd{i}"))
356 .collect::<Vec<_>>()
357 .join("\n");
358 assert_eq!(flag_reasons(&body), Vec::<String>::new());
359 assert_eq!(
360 flag_reasons("set -euo pipefail\n# a note\n\necho hi"),
361 Vec::<String>::new()
362 );
363 }
364
365 #[test]
366 fn every_reason_a_body_earns_is_reported() {
367 let body = "for f in *; do\n case $f in\n a) sed -i s/x/y/ $f ;;\n esac\ndone";
368 assert_eq!(flag_reasons(body), vec!["for loop", "case dispatch", "sed"]);
369 }
370
371 const LOOP_WORKFLOW: &str = "jobs:\n publish:\n steps:\n - name: Publish stubs\n run: |\n for name in $NAMES; do\n npm publish \"$name\"\n done\n";
372
373 #[test]
374 fn a_run_step_reports_its_file_name_and_line() {
375 let findings = find_violations(LOOP_WORKFLOW, "w.yml");
376 assert_eq!(findings.len(), 1);
377 assert_eq!(findings[0].step, "Publish stubs");
378 assert_eq!(findings[0].kind, "run");
379 assert_eq!(findings[0].reasons, vec!["for loop"]);
380 assert_eq!(findings[0].file, PathBuf::from("w.yml"));
381 assert_eq!(findings[0].line, 6, "the body starts on line 6");
382 }
383
384 #[test]
385 fn a_github_script_body_is_judged_too() {
386 let yaml = "jobs:\n label:\n steps:\n - uses: actions/github-script@v7\n with:\n script: |\n for (const i of items) {\n core.info(i)\n }\n";
387 let findings = find_violations(yaml, "w.yml");
388 assert_eq!(findings.len(), 1);
389 assert_eq!(findings[0].kind, "github-script");
390 assert_eq!(findings[0].step, "actions/github-script@v7");
391 assert_eq!(findings[0].reasons, vec!["for loop"]);
392 }
393
394 #[test]
395 fn another_actions_script_input_is_not_a_github_script_body() {
396 let yaml = "jobs:\n j:\n steps:\n - uses: some/other@v1\n with:\n script: |\n for x in 1 2; do echo $x; done\n";
397 assert_eq!(find_violations(yaml, "w.yml"), Vec::new());
398 }
399
400 #[test]
401 fn a_composite_action_is_scanned_through_runs_steps() {
402 let yaml = "runs:\n using: composite\n steps:\n - name: Munge\n shell: bash\n run: cat f | sed 's/a/b/'\n";
403 let findings = find_violations(yaml, "action.yml");
404 assert_eq!(findings.len(), 1);
405 assert_eq!(findings[0].step, "Munge");
406 assert_eq!(findings[0].reasons, vec!["sed"]);
407 }
408
409 #[test]
410 fn a_clean_workflow_yields_nothing() {
411 let yaml = "jobs:\n build:\n steps:\n - uses: actions/checkout@v4\n - run: pnpm install --frozen-lockfile\n - name: Test\n run: pnpm test\n";
412 assert_eq!(find_violations(yaml, "w.yml"), Vec::new());
413 }
414
415 #[test]
416 fn a_step_without_a_name_falls_back_to_its_uses_then_a_placeholder() {
417 let named_by_uses = "jobs:\n j:\n steps:\n - uses: actions/setup-node@v4\n run: for x in 1; do echo $x; done\n";
418 assert_eq!(
419 find_violations(named_by_uses, "w.yml")[0].step,
420 "actions/setup-node@v4"
421 );
422
423 let unnamed = "jobs:\n j:\n steps:\n - run: for x in 1; do echo $x; done\n";
424 assert_eq!(find_violations(unnamed, "w.yml")[0].step, "<unnamed step>");
425 }
426
427 #[test]
428 fn unparseable_yaml_is_actionlints_to_report_not_ours() {
429 assert_eq!(find_violations("jobs: [unclosed\n", "w.yml"), Vec::new());
430 }
431
432 #[test]
433 fn a_document_with_no_steps_anywhere_yields_nothing() {
434 assert_eq!(find_violations("name: CI\non: push\n", "w.yml"), Vec::new());
435 assert_eq!(find_violations("- a\n- b\n", "w.yml"), Vec::new());
436 assert_eq!(find_violations("", "w.yml"), Vec::new());
437 assert_eq!(
439 find_violations("jobs:\n a:\n runs-on: ubuntu-latest\n", "w.yml"),
440 Vec::new()
441 );
442 assert_eq!(
443 find_violations("runs:\n using: node20\n main: index.js\n", "action.yml"),
444 Vec::new()
445 );
446 }
447
448 #[test]
449 fn a_job_that_is_not_a_mapping_is_skipped() {
450 assert_eq!(find_violations("jobs:\n a: 3\n", "w.yml"), Vec::new());
452 }
453
454 #[test]
455 fn a_github_script_step_carrying_no_script_has_no_body() {
456 let yaml = "jobs:\n j:\n steps:\n - uses: actions/github-script@v7\n";
457 assert_eq!(find_violations(yaml, "w.yml"), Vec::new());
458 }
459
460 #[test]
461 fn discovery_follows_githubs_own_rules() {
462 assert!(is_ci_file(Path::new(".github/workflows/ci.yml")));
463 assert!(is_ci_file(Path::new(".github/workflows/ci.yaml")));
464 assert!(is_ci_file(Path::new(".github/actions/detect/action.yml")));
465 assert!(is_ci_file(Path::new("tools/thing/action.yaml")));
466
467 assert!(!is_ci_file(Path::new(
470 ".github/selftest/clean/pnpm-lock.yaml"
471 )));
472 assert!(!is_ci_file(Path::new(
473 ".github/workflows/nested/deep/ci.yml"
474 )));
475 assert!(!is_ci_file(Path::new(".github/workflows/notes.md")));
476 }
477
478 #[test]
479 fn an_unreadable_directory_names_itself() {
480 let err = collect_ci_files(
481 Path::new("tc-workflow-lint-no-such-directory"),
482 &mut Vec::new(),
483 )
484 .unwrap_err();
485 assert!(
486 format!("{err:#}").contains("reading directory"),
487 "got: {err:#}"
488 );
489 }
490
491 #[test]
492 fn every_job_in_a_workflow_is_scanned() {
493 let yaml = "jobs:\n a:\n steps:\n - run: for x in 1; do :; done\n b:\n steps:\n - run: cat f | sed s/a/b/\n";
494 let findings = find_violations(yaml, "w.yml");
495 assert_eq!(findings.len(), 2);
496 }
497}