checkleft 0.1.0-alpha.8

Experimental repository convention checker; API and behavior may change without notice
Documentation
use std::path::Path;
use std::sync::Arc;

use anyhow::{Context, Result};
use async_trait::async_trait;
use serde_yaml::{Mapping, Value};

use crate::check::{Check, ConfiguredCheck};
use crate::input::{ChangeKind, ChangeSet, SourceTree};
use crate::output::{CheckResult, Finding, Location, Severity};

const STRICT_MODE_PREFIX: &str = "set -euo pipefail";

#[derive(Debug, Default)]
pub struct WorkflowShellStrictCheck;

#[async_trait]
impl Check for WorkflowShellStrictCheck {
    fn id(&self) -> &str {
        "workflow-shell-strict"
    }

    fn description(&self) -> &str {
        "requires GitHub Actions run scripts to start with strict shell mode"
    }

    fn configure(&self, _config: &toml::Value) -> Result<Arc<dyn ConfiguredCheck>> {
        Ok(Arc::new(Self))
    }
}

#[async_trait]
impl ConfiguredCheck for WorkflowShellStrictCheck {
    async fn run(&self, changeset: &ChangeSet, tree: &dyn SourceTree) -> Result<CheckResult> {
        let mut findings = Vec::new();

        for changed_file in &changeset.changed_files {
            if matches!(changed_file.kind, ChangeKind::Deleted) {
                continue;
            }
            if !is_github_workflow_file(&changed_file.path) {
                continue;
            }

            let Ok(contents) = tree.read_file(&changed_file.path) else {
                continue;
            };
            let Ok(contents) = String::from_utf8(contents) else {
                continue;
            };

            let workflow = match parse_workflow(&contents) {
                Ok(workflow) => workflow,
                Err(error) => {
                    findings.push(Finding {
                        severity: Severity::Error,
                        message: format!(
                            "failed to parse workflow YAML while enforcing strict shell mode: {error}"
                        ),
                        location: Some(Location {
                            path: changed_file.path.clone(),
                            line: None,
                            column: None,
                        }),
                        remediation: Some(
                            "Fix YAML syntax so checks can validate `run:` script blocks."
                                .to_owned(),
                        ),
                        suggested_fix: None,
                    });
                    continue;
                }
            };

            for violation in find_non_strict_run_scripts(&workflow) {
                findings.push(Finding {
                    severity: Severity::Error,
                    message: format!(
                        "GitHub Actions run script in job `{}` step {} must start with `set -euo pipefail`.",
                        violation.job_name, violation.step_index
                    ),
                    location: Some(Location {
                        path: changed_file.path.clone(),
                        line: None,
                        column: None,
                    }),
                    remediation: Some(
                        "Add `set -euo pipefail` as the first non-comment line in each `run:` script block."
                            .to_owned(),
                    ),
                    suggested_fix: None,
                });
            }
        }

        Ok(CheckResult {
            check_id: self.id().to_owned(),
            findings,
        })
    }
}

#[derive(Debug)]
struct RunScriptViolation {
    job_name: String,
    step_index: usize,
}

fn is_github_workflow_file(path: &Path) -> bool {
    if !path.starts_with(Path::new(".github/workflows")) {
        return false;
    }

    match path.extension().and_then(|ext| ext.to_str()) {
        Some("yml") | Some("yaml") => true,
        _ => false,
    }
}

fn parse_workflow(contents: &str) -> Result<Value> {
    serde_yaml::from_str(contents).context("invalid YAML document")
}

fn find_non_strict_run_scripts(workflow: &Value) -> Vec<RunScriptViolation> {
    let mut violations = Vec::new();
    let Some(root) = workflow.as_mapping() else {
        return violations;
    };
    let Some(jobs) = mapping_get(root, "jobs").and_then(Value::as_mapping) else {
        return violations;
    };

    for (job_key, job_value) in jobs {
        let Some(job) = job_value.as_mapping() else {
            continue;
        };
        let Some(steps) = mapping_get(job, "steps").and_then(Value::as_sequence) else {
            continue;
        };

        let job_name = job_key.as_str().unwrap_or("<unknown-job>").to_owned();
        for (index, step) in steps.iter().enumerate() {
            let Some(step_map) = step.as_mapping() else {
                continue;
            };
            let Some(run_script) = mapping_get(step_map, "run").and_then(Value::as_str) else {
                continue;
            };
            if !is_multiline_script(run_script) {
                continue;
            }
            if !starts_with_strict_mode(run_script) {
                violations.push(RunScriptViolation {
                    job_name: job_name.clone(),
                    step_index: index + 1,
                });
            }
        }
    }

    violations
}

fn mapping_get<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a Value> {
    mapping.get(Value::String(key.to_owned()))
}

fn is_multiline_script(script: &str) -> bool {
    script.contains('\n')
}

fn starts_with_strict_mode(script: &str) -> bool {
    let first_command = script
        .lines()
        .map(str::trim_start)
        .find(|line| !line.is_empty() && !line.starts_with('#'));

    first_command
        .map(|line| line.starts_with(STRICT_MODE_PREFIX))
        .unwrap_or(true)
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::Path;

    use tempfile::tempdir;

    use crate::check::Check;
    use crate::input::{ChangeKind, ChangeSet, ChangedFile};
    use crate::source_tree::LocalSourceTree;

    use super::WorkflowShellStrictCheck;

    #[tokio::test]
    async fn flags_missing_strict_mode_in_workflow_run_block() {
        let temp = tempdir().expect("create temp dir");
        fs::create_dir_all(temp.path().join(".github/workflows")).expect("create workflows dir");
        fs::write(
            temp.path().join(".github/workflows/ci.yaml"),
            r#"jobs:
  test:
    steps:
      - run: |
          echo "hello"
"#,
        )
        .expect("write workflow");

        let check = WorkflowShellStrictCheck;
        let tree = LocalSourceTree::new(temp.path()).expect("create tree");
        let result = check
            .run(
                &ChangeSet::new(vec![ChangedFile {
                    path: Path::new(".github/workflows/ci.yaml").to_path_buf(),
                    kind: ChangeKind::Modified,
                    old_path: None,
                }]),
                &tree,
                &toml::Value::Table(Default::default()),
            )
            .await
            .expect("run check");

        assert_eq!(result.findings.len(), 1);
        assert!(
            result.findings[0]
                .message
                .contains("job `test` step 1 must start with `set -euo pipefail`")
        );
    }

    #[tokio::test]
    async fn accepts_strict_mode_after_comments_and_blank_lines() {
        let temp = tempdir().expect("create temp dir");
        fs::create_dir_all(temp.path().join(".github/workflows")).expect("create workflows dir");
        fs::write(
            temp.path().join(".github/workflows/ci.yml"),
            r#"jobs:
  test:
    steps:
      - run: |

          # strict shell mode
          set -euo pipefail
          echo "hello"
"#,
        )
        .expect("write workflow");

        let check = WorkflowShellStrictCheck;
        let tree = LocalSourceTree::new(temp.path()).expect("create tree");
        let result = check
            .run(
                &ChangeSet::new(vec![ChangedFile {
                    path: Path::new(".github/workflows/ci.yml").to_path_buf(),
                    kind: ChangeKind::Modified,
                    old_path: None,
                }]),
                &tree,
                &toml::Value::Table(Default::default()),
            )
            .await
            .expect("run check");

        assert!(result.findings.is_empty());
    }

    #[tokio::test]
    async fn ignores_non_workflow_files() {
        let temp = tempdir().expect("create temp dir");
        fs::create_dir_all(temp.path().join("docs")).expect("create docs dir");
        fs::write(
            temp.path().join("docs/example.yaml"),
            r#"run: |
  echo "hello"
"#,
        )
        .expect("write yaml");

        let check = WorkflowShellStrictCheck;
        let tree = LocalSourceTree::new(temp.path()).expect("create tree");
        let result = check
            .run(
                &ChangeSet::new(vec![ChangedFile {
                    path: Path::new("docs/example.yaml").to_path_buf(),
                    kind: ChangeKind::Modified,
                    old_path: None,
                }]),
                &tree,
                &toml::Value::Table(Default::default()),
            )
            .await
            .expect("run check");

        assert!(result.findings.is_empty());
    }

    #[tokio::test]
    async fn ignores_single_line_run_entries() {
        let temp = tempdir().expect("create temp dir");
        fs::create_dir_all(temp.path().join(".github/workflows")).expect("create workflows dir");
        fs::write(
            temp.path().join(".github/workflows/ci.yaml"),
            r#"jobs:
  test:
    steps:
      - run: echo "hello"
"#,
        )
        .expect("write workflow");

        let check = WorkflowShellStrictCheck;
        let tree = LocalSourceTree::new(temp.path()).expect("create tree");
        let result = check
            .run(
                &ChangeSet::new(vec![ChangedFile {
                    path: Path::new(".github/workflows/ci.yaml").to_path_buf(),
                    kind: ChangeKind::Modified,
                    old_path: None,
                }]),
                &tree,
                &toml::Value::Table(Default::default()),
            )
            .await
            .expect("run check");

        assert!(result.findings.is_empty());
    }

    #[tokio::test]
    async fn reports_yaml_parse_failures() {
        let temp = tempdir().expect("create temp dir");
        fs::create_dir_all(temp.path().join(".github/workflows")).expect("create workflows dir");
        fs::write(
            temp.path().join(".github/workflows/ci.yaml"),
            r#"jobs:
  test:
    steps:
      - run: |
          echo "hello"
      - bad: [unclosed
"#,
        )
        .expect("write workflow");

        let check = WorkflowShellStrictCheck;
        let tree = LocalSourceTree::new(temp.path()).expect("create tree");
        let result = check
            .run(
                &ChangeSet::new(vec![ChangedFile {
                    path: Path::new(".github/workflows/ci.yaml").to_path_buf(),
                    kind: ChangeKind::Modified,
                    old_path: None,
                }]),
                &tree,
                &toml::Value::Table(Default::default()),
            )
            .await
            .expect("run check");

        assert_eq!(result.findings.len(), 1);
        assert!(
            result.findings[0]
                .message
                .contains("failed to parse workflow YAML")
        );
    }
}