bashrs 6.66.0

Rust-to-Shell transpiler for deterministic bootstrap scripts
//! CONFIG-002: Quote Variable Expansions
//!
//! Detects and fixes unquoted variable expansions that can lead to:
//! - Word splitting
//! - Glob expansion
//! - Injection vulnerabilities
//!
//! Transforms:
//! - `export DIR=$HOME/my projects` → `export DIR="${HOME}/my projects"`
//! - `cd $PROJECT_DIR` → `cd "${PROJECT_DIR}"`
//! - `FILES=$(ls *.txt)` → `FILES="$(ls *.txt)"`

use super::{ConfigIssue, Severity};
use regex::Regex;
use std::collections::HashMap;

/// Pattern to match unquoted variable expansions
/// Matches: $VAR or ${VAR} not already inside quotes
fn create_unquoted_var_pattern() -> Regex {
    // Match variable patterns: $VAR, ${VAR}, $1, etc.
    // But NOT when already inside double quotes
    Regex::new(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?").unwrap()
}

/// Analyze source for unquoted variable expansions
pub fn analyze_unquoted_variables(source: &str) -> Vec<UnquotedVariable> {
    let mut variables = Vec::new();
    let var_pattern = create_unquoted_var_pattern();

    for (line_num, line) in source.lines().enumerate() {
        let line_num = line_num + 1;

        // Skip comments
        if line.trim().starts_with('#') {
            continue;
        }

        // Check if line has variable expansions
        if !line.contains('$') {
            continue;
        }

        // Find all variable references in the line
        for cap in var_pattern.captures_iter(line) {
            let var_match = cap.get(0).unwrap();
            let var_name = var_match.as_str();
            let start = var_match.start();

            // Check if already quoted
            if is_already_quoted(line, start) {
                continue;
            }

            // Check if in special contexts where quoting not needed
            if is_special_context(line, start) {
                continue;
            }

            variables.push(UnquotedVariable {
                line: line_num,
                column: start,
                variable: var_name.to_string(),
                context: line.to_string(),
            });
        }
    }

    variables
}

/// Check if a variable at position is already quoted
fn is_already_quoted(line: &str, pos: usize) -> bool {
    // Check for double quotes before variable
    let before = &line[..pos];

    // Count quotes before this position
    let quote_count = before.matches('"').count();

    // If odd number of quotes, we're inside quotes
    if quote_count % 2 == 1 {
        return true;
    }

    // Check if immediately preceded by quote
    if pos > 0 && line.chars().nth(pos - 1) == Some('"') {
        return true;
    }

    false
}

/// Check if variable is in a special context where quoting is not needed
fn is_special_context(line: &str, pos: usize) -> bool {
    let line_trimmed = line.trim();

    // In arithmetic context: $(( )) or (( ))
    if line_trimmed.contains("$((") || line_trimmed.contains("((") {
        return true;
    }

    // In array index: arr[$i]
    if pos > 0 && line.chars().nth(pos - 1) == Some('[') {
        return true;
    }

    // After 'export' without assignment (just exporting, not assigning)
    if line_trimmed.starts_with("export ") && !line.contains('=') {
        return true;
    }

    false
}

/// Represents an unquoted variable found in the source
#[derive(Debug, Clone, PartialEq)]
pub struct UnquotedVariable {
    pub line: usize,
    pub column: usize,
    pub variable: String,
    pub context: String,
}

/// Generate CONFIG-002 issues for unquoted variables
pub fn detect_unquoted_variables(variables: &[UnquotedVariable]) -> Vec<ConfigIssue> {
    variables
        .iter()
        .map(|var| ConfigIssue {
            rule_id: "CONFIG-002".to_string(),
            severity: Severity::Warning,
            message: format!(
                "Unquoted variable expansion: '{}' can cause word splitting and glob expansion",
                var.variable
            ),
            line: var.line,
            column: var.column,
            suggestion: Some(format!("Quote the variable: \"{}\"", var.variable)),
        })
        .collect()
}

/// Quote all unquoted variables in source
pub fn quote_variables(source: &str) -> String {
    let variables = analyze_unquoted_variables(source);

    if variables.is_empty() {
        return source.to_string();
    }

    // Build a map of line numbers to variables on that line
    let mut lines_to_fix: HashMap<usize, Vec<&UnquotedVariable>> = HashMap::new();
    for var in &variables {
        lines_to_fix.entry(var.line).or_default().push(var);
    }

    let mut result = Vec::new();

    for (line_num, line) in source.lines().enumerate() {
        let line_num = line_num + 1;

        if let Some(_vars_on_line) = lines_to_fix.get(&line_num) {
            // Check if this is an export/assignment line
            if line.contains('=')
                && (line.trim().starts_with("export ")
                    || line.trim().starts_with("local ")
                    || !line.trim().starts_with("if "))
            {
                // For assignment lines, quote the entire RHS value
                let fixed_line = quote_assignment_line(line);
                result.push(fixed_line);
            } else {
                // For command lines, quote individual variables
                let fixed_line = quote_command_line(line);
                result.push(fixed_line);
            }
        } else {
            result.push(line.to_string());
        }
    }

    result.join("\n")
}

/// Quote the RHS of an assignment line
fn quote_assignment_line(line: &str) -> String {
    // Find the = sign
    if let Some(eq_pos) = line.find('=') {
        let lhs = &line[..=eq_pos];
        let rhs = &line[eq_pos + 1..];

        // If RHS already starts and ends with quotes, keep it
        if (rhs.starts_with('"') && rhs.ends_with('"'))
            || (rhs.starts_with('\'') && rhs.ends_with('\''))
        {
            return line.to_string();
        }

        // Convert $VAR to ${VAR} in the RHS before quoting
        let rhs_with_braces = add_braces_to_variables(rhs);

        // Quote the entire RHS
        format!("{}\"{}\"", lhs, rhs_with_braces)
    } else {
        line.to_string()
    }
}

/// Convert $VAR to ${VAR} (add braces if missing)
fn add_braces_to_variables(text: &str) -> String {
    let var_pattern = Regex::new(r"\$([A-Za-z_][A-Za-z0-9_]*)").unwrap();
    // In replacement strings, $ is special, so $$ = literal $, and $1 = capture group 1
    var_pattern.replace_all(text, "$${$1}").to_string()
}

/// Quote variables in a command line
fn quote_command_line(line: &str) -> String {
    let var_pattern = create_unquoted_var_pattern();
    let mut result = line.to_string();

    // Find all variables and quote them
    let matches: Vec<_> = var_pattern.find_iter(line).collect();

    // Replace from right to left to maintain positions
    for mat in matches.iter().rev() {
        let var = mat.as_str();
        let start = mat.start();
        let end = mat.end();

        // Skip if already quoted
        if is_already_quoted(line, start) {
            continue;
        }

        // Create quoted version
        let quoted = if var.starts_with("${") {
            format!("\"{}\"", var)
        } else {
            let var_name = var.trim_start_matches('$');
            format!("\"${{{}}}\"", var_name)
        };

        // Replace
        let before = &result[..start];
        let after = &result[end..];
        result = format!("{}{}{}", before, quoted, after);
    }

    result
}

#[cfg(test)]
#[path = "quoter_tests_config_002.rs"]
mod tests_extracted;