shuck-linter 0.0.43

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

pub struct UnanchoredSourcePath;

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

    fn message(&self) -> String {
        "source path is relative to the current directory; anchor it to the script directory"
            .to_owned()
    }
}

pub fn unanchored_source_path(checker: &mut Checker) {
    if checker.shell() != ShellDialect::Bash {
        return;
    }

    for source_ref in checker.semantic().source_refs() {
        if source_ref_has_unanchored_path(
            source_ref,
            checker.source(),
            &checker.rule_options().c160.allowed_anchors,
        ) {
            checker.report(UnanchoredSourcePath, source_ref.path_span);
        }
    }
}

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

    #[test]
    fn reports_literal_relative_source_paths() {
        let source = "\
#!/bin/bash
source ./lib.sh
. ../tools/env.sh
source lib/defaults.sh
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::UnanchoredSourcePath),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["./lib.sh", "../tools/env.sh", "lib/defaults.sh"]
        );
    }

    #[test]
    fn accepts_default_script_directory_anchors() {
        for source in [
            "#!/bin/bash\nsource \"${BASH_SOURCE[0]%/*}/lib.sh\"\n",
            "#!/bin/bash\nsource \"${BASH_SOURCE[0]%/*}\"/lib.sh\n",
            "#!/bin/bash\nsource ${BASH_SOURCE[0]%/*}\"/lib.sh\"\n",
            "#!/bin/bash\nsource ${BASH_SOURCE[0]%/*}'/lib.sh'\n",
            "#!/bin/bash\nsource \"$(dirname \"$0\")/lib.sh\"\n",
            "#!/bin/bash\nsource \"$(dirname \"$0\")\"/lib.sh\n",
            "#!/bin/bash\nsource \"$(dirname \"${BASH_SOURCE[0]}\")/lib.sh\"\n",
            "#!/bin/bash\nsource \"$(dirname \"${BASH_SOURCE[0]}\")\"/lib.sh\n",
        ] {
            let diagnostics = test_snippet(
                source,
                &LinterSettings::for_rule(Rule::UnanchoredSourcePath),
            );

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

    #[test]
    fn accepts_absolute_home_and_whole_command_substitution_paths() {
        for source in [
            "#!/bin/bash\nsource /etc/profile\n",
            "#!/bin/bash\nsource ~/.bashrc\n",
            "#!/bin/bash\nsource ~alice/lib.sh\n",
            "#!/bin/bash\nsource \"$(select_source_file)\"\n",
            "#!/bin/bash\nsource \"$(printf '%s' '/tmp)')\"\n",
            "#!/bin/bash\nsource \"$(printf \"%s\" \"/tmp)\")\"\n",
            "#!/bin/bash\nsource `select_source_file`\n",
        ] {
            let diagnostics = test_snippet(
                source,
                &LinterSettings::for_rule(Rule::UnanchoredSourcePath),
            );

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

    #[test]
    fn reports_dynamic_tilde_source_paths() {
        let source = "\
#!/bin/bash
source ~${USER}/lib.sh
source ~$USER/lib.sh
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::UnanchoredSourcePath),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["~${USER}/lib.sh", "~$USER/lib.sh"]
        );
    }

    #[test]
    fn reports_quoted_home_source_paths() {
        for source in [
            "#!/bin/bash\nsource \"~/.bashrc\"\n",
            "#!/bin/bash\nsource '~/.bashrc'\n",
        ] {
            let diagnostics = test_snippet(
                source,
                &LinterSettings::for_rule(Rule::UnanchoredSourcePath),
            );

            assert_eq!(
                diagnostics
                    .iter()
                    .map(|diagnostic| diagnostic.span.slice(source))
                    .collect::<Vec<_>>(),
                vec![source.lines().nth(1).unwrap().split_once(' ').unwrap().1]
            );
        }
    }

    #[test]
    fn reports_dynamic_path_prefixes_with_static_path_tails() {
        let source = "\
#!/bin/bash
source \"$SCRIPT_DIR/lib.sh\"
source \"$(pwd)/lib.sh\"
source '${BASH_SOURCE[0]%/*}/lib.sh'
source '${BASH_SOURCE[0]%/*}'/lib.sh
source \"${BASH_SOURCE[0]%/*}suffix/lib.sh\"
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::UnanchoredSourcePath),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "\"$SCRIPT_DIR/lib.sh\"",
                "\"$(pwd)/lib.sh\"",
                "'${BASH_SOURCE[0]%/*}/lib.sh'",
                "'${BASH_SOURCE[0]%/*}'/lib.sh",
                "\"${BASH_SOURCE[0]%/*}suffix/lib.sh\""
            ]
        );
    }

    #[test]
    fn reports_single_quoted_command_substitution_text() {
        let source = "#!/bin/bash\nsource '$(select_source_file)'\n";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::UnanchoredSourcePath),
        );

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.slice(source), "'$(select_source_file)'");
    }

    #[test]
    fn custom_allowed_anchors_accept_project_specific_prefixes() {
        let source =
            "#!/bin/bash\nsource \"$SCRIPT_DIR/lib.sh\"\nsource \"$SCRIPT_DIR\"/other.sh\n";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::UnanchoredSourcePath)
                .with_c160_allowed_anchors(["$SCRIPT_DIR"]),
        );

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

    #[test]
    fn custom_allowed_anchors_require_boundaries() {
        let source = "\
#!/bin/bash
source \"$SCRIPT_DIR/lib.sh\"
source \"$SCRIPT_DIRECTORY/lib.sh\"
source \"$SCRIPT_DIR\"suffix/lib.sh
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::UnanchoredSourcePath)
                .with_c160_allowed_anchors(["$SCRIPT_DIR"]),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "\"$SCRIPT_DIRECTORY/lib.sh\"",
                "\"$SCRIPT_DIR\"suffix/lib.sh"
            ]
        );
    }

    #[test]
    fn ignores_dynamic_paths_without_static_path_components() {
        let source = "#!/bin/bash\nsource \"$helper\"\n";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::UnanchoredSourcePath),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_dynamic_paths_without_static_components_even_with_source_directive() {
        let source = "\
#!/bin/bash
# shellcheck source=./helper.sh
source \"$helper\"
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::UnanchoredSourcePath),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn reports_single_quoted_dynamic_text_even_with_source_directive() {
        let source = "\
#!/bin/bash
# shellcheck source=./helper.sh
source '$(select_source_file)'
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::UnanchoredSourcePath),
        );

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.slice(source), "'$(select_source_file)'");
    }

    #[test]
    fn still_reports_static_runtime_paths_with_source_directives() {
        let source = "\
#!/bin/bash
# shellcheck source=./helper.sh
source ./runtime.sh
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::UnanchoredSourcePath),
        );

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.slice(source), "./runtime.sh");
    }

    #[test]
    fn still_reports_static_runtime_paths_with_empty_source_directives() {
        let source = "\
#!/bin/bash
# shellcheck source=
source ./runtime.sh
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::UnanchoredSourcePath),
        );

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.slice(source), "./runtime.sh");
    }

    #[test]
    fn still_reports_static_runtime_paths_with_dev_null_source_directives() {
        let source = "\
#!/bin/bash
# shellcheck source=/dev/null
source ./runtime.sh
. ../runtime.sh
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::UnanchoredSourcePath),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["./runtime.sh", "../runtime.sh"]
        );
    }

    #[test]
    fn ignores_pure_dynamic_paths_with_dev_null_source_directives() {
        let source = "\
#!/bin/bash
# shellcheck source=/dev/null
source \"$helper\"
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::UnanchoredSourcePath),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_non_bash_shells() {
        let source = "#!/bin/sh\n. ./lib.sh\n";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::UnanchoredSourcePath).with_shell(ShellDialect::Sh),
        );

        assert!(diagnostics.is_empty());
    }
}