shuck-linter 0.0.43

Lint rule engine and checker for shell scripts
Documentation
use crate::facts::word_spans;
use shuck_ast::Span;

use crate::{
    Checker, Diagnostic, Edit, ExpansionContext, Fix, FixAvailability, Rule, Violation,
    WordFactHostKind,
};

pub struct SuspiciousBracketGlob;

impl Violation for SuspiciousBracketGlob {
    const FIX_AVAILABILITY: FixAvailability = FixAvailability::Always;

    fn rule() -> Rule {
        Rule::SuspiciousBracketGlob
    }

    fn message(&self) -> String {
        "bracket globs only match one character at a time; quote word-like ones".to_owned()
    }

    fn fix_title(&self) -> Option<String> {
        Some("quote the bracket-glob text".to_owned())
    }
}

pub fn suspicious_bracket_glob(checker: &mut Checker) {
    let source = checker.source();
    let diagnostics = checker
        .facts()
        .commands()
        .iter()
        .filter(|fact| {
            fact.body_name_word()
                .is_none_or(|word| !bare_bracket_test_name(word.span.slice(source)))
        })
        .flat_map(|fact| fact.suspicious_body_name_bracket_glob_spans(source))
        .chain(
            checker
                .facts()
                .command_facts()
                .case_items()
                .iter()
                .flat_map(|item| {
                    word_spans::case_item_suspicious_bracket_glob_spans(item.item(), source)
                }),
        )
        .chain(
            checker
                .facts()
                .commands()
                .iter()
                .filter_map(|fact| fact.conditional())
                .flat_map(|conditional| {
                    word_spans::conditional_suspicious_bracket_glob_spans(
                        conditional.expression(),
                        source,
                    )
                }),
        )
        .chain(
            checker
                .facts()
                .words()
                .word_facts()
                .iter()
                .filter(|fact| fact.host_kind() == WordFactHostKind::Direct)
                .filter(|fact| supports_suspicious_bracket_glob_context(fact.expansion_context()))
                .flat_map(|fact| fact.suspicious_bracket_glob_spans(source)),
        )
        .map(|span| {
            Diagnostic::new(SuspiciousBracketGlob, span)
                .with_fix(Fix::unsafe_edit(single_quote_span_edit(span, source)))
        })
        .collect::<Vec<_>>();

    for diagnostic in diagnostics {
        checker.report_diagnostic_dedup(diagnostic);
    }
}

fn single_quote_span_edit(span: Span, source: &str) -> Edit {
    Edit::replacement(shell_single_quoted_literal(span.slice(source)), span)
}

fn shell_single_quoted_literal(text: &str) -> String {
    let content = unescape_single_quote_shell_escapes(text);
    let mut quoted = String::with_capacity(content.len() + 2);
    quoted.push('\'');
    for ch in content.chars() {
        if ch == '\'' {
            quoted.push_str("'\\''");
        } else {
            quoted.push(ch);
        }
    }
    quoted.push('\'');
    quoted
}

fn unescape_single_quote_shell_escapes(text: &str) -> String {
    let mut unescaped = String::with_capacity(text.len());
    let mut chars = text.chars().peekable();
    while let Some(ch) = chars.next() {
        if ch == '\\' && chars.peek() == Some(&'\'') {
            unescaped.push('\'');
            chars.next();
        } else {
            unescaped.push(ch);
        }
    }
    unescaped
}

fn supports_suspicious_bracket_glob_context(context: Option<ExpansionContext>) -> bool {
    matches!(
        context,
        Some(
            ExpansionContext::CommandArgument
                | ExpansionContext::AssignmentValue
                | ExpansionContext::DeclarationAssignmentValue
                | ExpansionContext::RedirectTarget(_)
                | ExpansionContext::ForList
                | ExpansionContext::SelectList
                | ExpansionContext::CasePattern
                | ExpansionContext::ConditionalPattern
                | ExpansionContext::StringTestOperand
        )
    )
}

fn bare_bracket_test_name(text: &str) -> bool {
    text == "["
}

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

    #[test]
    fn reports_suspicious_bracket_globs_across_shell_contexts() {
        let source = "\
#!/bin/bash
[appname] arg
foo[appname] arg
echo [skipped]
printf '%s\\n' \"$dir\"/[appname]
ITEM=[0,-1,1,-10,-20]
cat <<EOF >/etc/systemd/system/[appname].service
EOF
for target in [appname]; do :; done
case $x in [appname]) : ;; esac
[ \"$x\" = foo[appname]bar ]
[[ $x = [appname] ]]
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::SuspiciousBracketGlob),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "[appname]",
                "[appname]",
                "[skipped]",
                "[appname]",
                "[0,-1,1,-10,-20]",
                "[appname]",
                "[appname]",
                "[appname]",
                "[appname]",
                "[appname]"
            ]
        );
    }

    #[test]
    fn applies_unsafe_fix_by_single_quoting_bracket_glob_text() {
        let source = "#!/bin/bash\necho [appname]\n";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::SuspiciousBracketGlob),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 1);
        assert_eq!(result.fixed_source, "#!/bin/bash\necho '[appname]'\n");
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn applies_unsafe_fix_with_escaped_apostrophe_in_bracket_glob_text() {
        let source = "#!/bin/bash\necho [a\\'a]\n";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::SuspiciousBracketGlob),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 1);
        assert_eq!(result.fixed_source, "#!/bin/bash\necho '[a'\\''a]'\n");
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn ignores_valid_sets_literal_text_and_parameter_patterns() {
        let source = "\
echo [ab]
echo [a-z]
echo [[:alpha:]]
echo foo[bar]baz
tr [:lower:] [:upper:]
sed -r s/[^a-zA-Z0-9]+/-/g
case \"$1\" in
  [0-9a-fA-F][0-9a-fA-F]) ;;
  *[!a-zA-Z_]*) return 1 ;;
esac
echo \"[appname]\"
echo \\[appname\\]
foo=${bar#[appname]}
foo=${bar%[appname]}
if [ \"$ARCH\" = \"x86_64\" ]; then :; fi
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::SuspiciousBracketGlob),
        );

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

    #[test]
    fn reports_zsh_brace_ccl_word_like_character_classes_from_facts() {
        let source = "\
setopt brace_ccl
{appname} arg
echo {appname}
opt=brace_ccl
setopt \"$opt\"
{dynamicc} arg
echo {dynamicc}
unsetopt brace_ccl
{appname} arg
echo {appname}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::SuspiciousBracketGlob).with_shell(ShellDialect::Zsh),
        );

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