bashrs 6.66.0

Rust-to-Shell transpiler for deterministic bootstrap scripts
//! SEC007: Running Commands as Root Without Validation
//!
//! **Rule**: Detect sudo/root operations without input validation
//!
//! **Why this matters**:
//! Unvalidated root operations can destroy entire systems if variables are
//! empty or contain dangerous values like "/".
//!
//! **Auto-fix**: Manual review required (context-dependent)
//!
//! ## Examples
//!
//! ❌ **UNSAFE ROOT OPERATIONS**:
//! ```bash
//! sudo rm -rf $DIR
//! sudo chmod 777 $FILE
//! sudo chown $USER $PATH
//! ```
//!
//! ✅ **ADD VALIDATION**:
//! ```bash
//! if [ -z "$DIR" ] || [ "$DIR" = "/" ]; then
//!     echo "Error: Invalid directory"
//!     exit 1
//! fi
//! sudo rm -rf "${DIR}"
//! ```

use crate::linter::{Diagnostic, LintResult, Severity, Span};

/// Dangerous commands that should never be run with sudo + unquoted vars
const DANGEROUS_SUDO_COMMANDS: &[&str] = &["rm -rf", "chmod 777", "chmod -R", "chown -R"];

/// Check a single line for unsafe sudo + dangerous command + unquoted variable
fn check_sudo_line(line: &str, line_num: usize, result: &mut LintResult) {
    if !line.contains("sudo") {
        return;
    }
    for cmd in DANGEROUS_SUDO_COMMANDS {
        if line.contains(cmd) && line.contains(" $") {
            if let Some(col) = line.find("sudo") {
                let span = Span::new(line_num + 1, col + 1, line_num + 1, col + 5);
                let diag = Diagnostic::new(
                    "SEC007",
                    Severity::Warning,
                    format!(
                        "Unsafe root operation: sudo {} with unquoted variable - add validation",
                        cmd
                    ),
                    span,
                );
                result.add(diag);
                break;
            }
        }
    }
}

/// Check for unsafe sudo operations
pub fn check(source: &str) -> LintResult {
    if source.is_empty() { return LintResult::new(); }
    // Contract: safety-classifier-v1.yaml precondition (pv codegen)
    contract_pre_classify_filesystem!(source);
    let mut result = LintResult::new();
    for (line_num, line) in source.lines().enumerate() {
        check_sudo_line(line, line_num, &mut result);
    }
    result
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_SEC007_detects_sudo_rm_rf() {
        let script = "sudo rm -rf $DIR";
        let result = check(script);

        assert_eq!(result.diagnostics.len(), 1);
        let diag = &result.diagnostics[0];
        assert_eq!(diag.code, "SEC007");
        assert_eq!(diag.severity, Severity::Warning);
    }

    #[test]
    fn test_SEC007_detects_sudo_chmod_777() {
        let script = "sudo chmod 777 $FILE";
        let result = check(script);

        assert_eq!(result.diagnostics.len(), 1);
    }

    #[test]
    fn test_SEC007_no_warning_with_quotes() {
        let script = "sudo rm -rf \"${DIR}\"";
        let result = check(script);

        // Still warns because even quoted vars need validation
        // But this is a simpler pattern matcher
        assert_eq!(result.diagnostics.len(), 0);
    }

    #[test]
    fn test_SEC007_no_warning_safe_command() {
        let script = "sudo systemctl restart nginx";
        let result = check(script);

        assert_eq!(result.diagnostics.len(), 0);
    }

    #[test]
    fn test_SEC007_no_auto_fix() {
        let script = "sudo rm -rf $TMPDIR";
        let result = check(script);

        assert_eq!(result.diagnostics.len(), 1);
        let diag = &result.diagnostics[0];
        assert!(diag.fix.is_none(), "SEC007 should not provide auto-fix");
    }

    // ===== Mutation Coverage Tests - Following SEC001 pattern (100% kill rate) =====

    #[test]
    fn test_mutation_sec007_sudo_start_col_exact() {
        // MUTATION: Line 47:33 - replace + with * in line_num + 1
        // MUTATION: Line 48:33 - replace + with * in col + 1
        let bash_code = "sudo rm -rf $DIR";
        let result = check(bash_code);
        assert_eq!(result.diagnostics.len(), 1);
        let span = result.diagnostics[0].span;
        // "sudo" starts at column 1 (0-indexed)
        assert_eq!(
            span.start_col, 1,
            "Start column must use col + 1, not col * 1"
        );
    }

    #[test]
    fn test_mutation_sec007_sudo_end_col_exact() {
        // MUTATION: Line 50:33 - replace + with * in col + 5
        // MUTATION: Line 50:33 - replace + with - in col + 5
        let bash_code = "sudo rm -rf $DIR";
        let result = check(bash_code);
        assert_eq!(result.diagnostics.len(), 1);
        let span = result.diagnostics[0].span;
        // "sudo" is 4 chars, ends at col + 5
        assert_eq!(
            span.end_col, 5,
            "End column must be col + 5, not col * 5 or col - 5"
        );
    }

    #[test]
    fn test_mutation_sec007_line_num_calculation() {
        // MUTATION: Line 47:33 - replace + with * in line_num + 1
        // Tests line number calculation for multiline input
        let bash_code = "# comment\nsudo chmod 777 $FILE";
        let result = check(bash_code);
        assert_eq!(result.diagnostics.len(), 1);
        // With +1: line 2
        // With *1: line 0
        assert_eq!(
            result.diagnostics[0].span.start_line, 2,
            "Line number must use +1, not *1"
        );
    }

    #[test]
    fn test_mutation_sec007_column_with_leading_whitespace() {
        // Tests column calculations with offset
        let bash_code = "    sudo chown -R $USER";
        let result = check(bash_code);
        assert_eq!(result.diagnostics.len(), 1);
        let span = result.diagnostics[0].span;
        // "sudo" starts after leading whitespace (4 spaces + 1)
        assert_eq!(span.start_col, 5, "Must account for leading whitespace");
        assert_eq!(span.end_col, 9, "End must be start + 4 (sudo length)");
    }
}