shuck-linter 0.0.43

Lint rule engine and checker for shell scripts
Documentation
use crate::facts::ShebangInvocationForm;
use crate::{Checker, Rule, Violation};

pub struct ShebangFormPolicy;

impl Violation for ShebangFormPolicy {
    fn rule() -> Rule {
        Rule::ShebangFormPolicy
    }

    fn message(&self) -> String {
        "shebang invocation form is outside the configured policy".to_owned()
    }
}

pub fn shebang_form_policy(checker: &mut Checker) {
    let Some(shebang) = checker.facts().source_facts().shebang_invocation() else {
        return;
    };

    let options = &checker.rule_options().s079;
    if !allowed_path_matches(shebang.text(), &options.allowed_paths)
        && !allowed_form_matches(shebang.form(), &options.allowed_forms)
    {
        checker.report(ShebangFormPolicy, shebang.span());
    }
}

fn allowed_path_matches(text: &str, allowed_paths: &[String]) -> bool {
    let text = text.trim();
    allowed_paths.iter().any(|allowed| allowed.trim() == text)
}

fn allowed_form_matches(form: ShebangInvocationForm, allowed_forms: &[String]) -> bool {
    allowed_forms
        .iter()
        .any(|allowed| allowed_form_name_matches(form, allowed))
}

fn allowed_form_name_matches(form: ShebangInvocationForm, allowed: &str) -> bool {
    match allowed.trim().to_ascii_lowercase().as_str() {
        "absolute-path" => form == ShebangInvocationForm::AbsolutePath,
        "env-lookup" => form == ShebangInvocationForm::EnvLookup,
        _ => false,
    }
}

#[cfg(test)]
mod tests {
    use crate::test::test_snippet;
    use crate::{LinterSettings, Rule};

    #[test]
    fn reports_disallowed_absolute_path_forms() {
        let source = "#!/usr/local/bin/bash\necho hello\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::ShebangFormPolicy));

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["#!/usr/local/bin/bash"]
        );
    }

    #[test]
    fn reports_indented_shebangs_outside_the_allowed_form_policy() {
        let source = "  #!/usr/local/bin/bash\necho hello\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::ShebangFormPolicy));

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["  #!/usr/local/bin/bash"]
        );
    }

    #[test]
    fn accepts_default_env_form_and_default_exact_paths() {
        for source in [
            "#!/usr/bin/env bash\necho hello\n",
            "#!/bin/bash\necho hello\n",
        ] {
            let diagnostics =
                test_snippet(source, &LinterSettings::for_rule(Rule::ShebangFormPolicy));

            assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
        }
    }

    #[test]
    fn accepts_configured_absolute_path_forms() {
        let source = "#!/usr/local/bin/bash\necho hello\n";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ShebangFormPolicy)
                .with_s079_allowed_forms(["absolute-path"])
                .with_s079_allowed_paths(std::iter::empty::<&str>()),
        );

        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    }

    #[test]
    fn accepts_configured_exact_paths_without_matching_form() {
        let source = "#!/opt/project/bash\necho hello\n";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ShebangFormPolicy)
                .with_s079_allowed_forms(["env-lookup"])
                .with_s079_allowed_paths(["/opt/project/bash"]),
        );

        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    }

    #[test]
    fn ignores_files_without_a_parseable_shebang_invocation() {
        for source in ["echo hello\n", "# !/usr/local/bin/bash\necho hello\n"] {
            let diagnostics =
                test_snippet(source, &LinterSettings::for_rule(Rule::ShebangFormPolicy));

            assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
        }
    }
}