use crate::linter::{Diagnostic, LintResult, Severity, Span};
use regex::Regex;
static SSH_WITH_VAR: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"ssh\s+[^\s]+\s+[^']*\$[a-zA-Z_][a-zA-Z0-9_]*").unwrap()
});
fn should_skip_match(matched: &str, line: &str) -> bool {
if matched.contains("'$") {
return true;
}
if matched.contains("\\$") {
return true;
}
let pos = line.find(matched).unwrap_or(0);
let before = &line[..pos];
let single_quote_count = before.matches('\'').count();
single_quote_count % 2 == 1 }
fn create_diagnostic(matched: &str, line: &str, line_num: usize) -> Diagnostic {
let pos = line.find(matched).unwrap_or(0);
let start_col = pos + 1;
let end_col = start_col + matched.len();
Diagnostic::new(
"SC2029",
Severity::Info,
"Note that, unescaped, this expands on the client side. Use single quotes or escape $ for remote expansion".to_string(),
Span::new(line_num, start_col, line_num, end_col),
)
}
fn is_ssh_line_with_vars(line: &str) -> bool {
!line.trim_start().starts_with('#') && line.contains("ssh ") && line.contains('$')
}
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
for (line_num, line) in source.lines().enumerate() {
let line_num = line_num + 1;
if !is_ssh_line_with_vars(line) {
continue;
}
for m in SSH_WITH_VAR.find_iter(line) {
let matched = m.as_str();
if should_skip_match(matched, line) {
continue;
}
let diagnostic = create_diagnostic(matched, line, line_num);
result.add(diagnostic);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sc2029_ssh_unquoted_var() {
let code = r#"ssh user@host echo $PATH"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "SC2029");
assert_eq!(result.diagnostics[0].severity, Severity::Info);
assert!(result.diagnostics[0].message.contains("client side"));
}
#[test]
fn test_sc2029_ssh_home() {
let code = r#"ssh server ls $HOME"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2029_ssh_double_quotes() {
let code = r#"ssh remote "echo $USER""#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2029_ssh_single_quotes_ok() {
let code = r#"ssh user@host 'echo $PATH'"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2029_ssh_escaped_ok() {
let code = r#"ssh remote "echo \$USER""#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2029_ssh_no_var_ok() {
let code = r#"ssh host echo hello"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2029_local_command_ok() {
let code = r#"echo $PATH"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2029_multiple_issues() {
let code = r#"
ssh host1 echo $VAR1
ssh host2 echo $VAR2
"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 2);
}
#[test]
fn test_sc2029_ssh_with_flags() {
let code = r#"ssh -t user@host echo $PATH"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2029_scp_ok() {
let code = r#"scp file user@host:$HOME/"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}