Skip to main content

browser_automation_cli/
sg_local.rs

1//! One-shot structural lint / rewrite for product-forbidden patterns (ยง5AC / GAP-A011).
2//!
3//! Scans Rust sources under given roots for patterns that violate agent-first / one-shot
4//! product rules (remote telemetry strings, product secret env reads, naked `unwrap()` in
5//! non-test production modules). Default rewrite is dry-run; `--apply` writes in place.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use ignore::WalkBuilder;
11use regex::Regex;
12use serde_json::{json, Value};
13
14use crate::error::{CliError, ErrorKind};
15
16/// A single finding.
17#[derive(Debug, Clone)]
18struct Finding {
19    path: String,
20    line: usize,
21    rule: &'static str,
22    snippet: String,
23}
24
25/// Scan roots for forbidden structural patterns (one-shot).
26pub fn sg_scan(roots: &[PathBuf], limit: usize) -> Result<Value, CliError> {
27    let roots = if roots.is_empty() {
28        vec![PathBuf::from(".")]
29    } else {
30        roots.to_vec()
31    };
32    let rules = compile_rules();
33    let mut findings = Vec::new();
34    for root in &roots {
35        let mut builder = WalkBuilder::new(root);
36        builder.hidden(false);
37        builder.git_ignore(true);
38        for entry in builder.build() {
39            let entry = match entry {
40                Ok(e) => e,
41                Err(_) => continue,
42            };
43            let path = entry.path();
44            if path
45                .extension()
46                .and_then(|e| e.to_str())
47                .is_none_or(|e| e != "rs")
48            {
49                continue;
50            }
51            // Skip tests and generated/target.
52            let s = path.to_string_lossy();
53            if s.contains("/target/") || s.contains("\\target\\") {
54                continue;
55            }
56            let Ok(text) = fs::read_to_string(path) else {
57                continue;
58            };
59            for (lineno, line) in text.lines().enumerate() {
60                let lineno = lineno + 1;
61                // Skip pure test modules lightly: lines under #[cfg(test)] blocks still scanned
62                // but test files under tests/ are informational only for unwrap.
63                for (rule, re) in &rules {
64                    if re.is_match(line) {
65                        if *rule == "unwrap_prod"
66                            && (s.contains("/tests/")
67                                || s.contains("\\tests\\")
68                                || path.ends_with("test_utils.rs"))
69                        {
70                            continue;
71                        }
72                        findings.push(Finding {
73                            path: path.display().to_string(),
74                            line: lineno,
75                            rule,
76                            snippet: line.trim().chars().take(160).collect(),
77                        });
78                        if limit > 0 && findings.len() >= limit {
79                            return Ok(findings_to_json(&findings, false));
80                        }
81                    }
82                }
83            }
84        }
85    }
86    Ok(findings_to_json(&findings, false))
87}
88
89/// Dry-run or apply safe rewrites (GAP-A011). Only applies trivial safe fixes.
90pub fn sg_rewrite(roots: &[PathBuf], apply: bool) -> Result<Value, CliError> {
91    let roots = if roots.is_empty() {
92        vec![PathBuf::from(".")]
93    } else {
94        roots.to_vec()
95    };
96    // Safe rewrite: strip `RUST_LOG` product-env comments that reintroduce env secrets guidance.
97    // Real rewrites of unwrap are intentionally NOT automatic (would be blind rewrite โ€” forbidden).
98    let re_env_hint = Regex::new(r#"(?i)export\s+RUST_LOG\s*="#)
99        .map_err(|e| CliError::new(ErrorKind::Software, format!("regex: {e}")))?;
100    let mut changed = Vec::new();
101    let mut planned = 0usize;
102    for root in &roots {
103        let mut builder = WalkBuilder::new(root);
104        builder.git_ignore(true);
105        for entry in builder.build() {
106            let entry = match entry {
107                Ok(e) => e,
108                Err(_) => continue,
109            };
110            let path = entry.path();
111            if path
112                .extension()
113                .and_then(|e| e.to_str())
114                .is_none_or(|e| e != "md" && e != "txt" && e != "rs")
115            {
116                continue;
117            }
118            let s = path.to_string_lossy();
119            if s.contains("/target/") {
120                continue;
121            }
122            let Ok(text) = fs::read_to_string(path) else {
123                continue;
124            };
125            if !re_env_hint.is_match(&text) {
126                continue;
127            }
128            planned += 1;
129            if apply {
130                let new_text = re_env_hint.replace_all(
131                    &text,
132                    "# (removed product RUST_LOG export โ€” use XDG log_level) ",
133                );
134                atomic_write(path, new_text.as_ref())?;
135                changed.push(path.display().to_string());
136            } else {
137                changed.push(path.display().to_string());
138            }
139        }
140    }
141    Ok(json!({
142        "ok": true,
143        "apply": apply,
144        "planned": planned,
145        "changed": changed,
146        "note": "Blind AST rewrite is forbidden; only safe RUST_LOG export hints are rewritten",
147        "chrome": false,
148    }))
149}
150
151fn compile_rules() -> Vec<(&'static str, Regex)> {
152    vec![
153        (
154            "telemetry_string",
155            Regex::new(r"(?i)\b(opentelemetry|sentry\.io|telemetry\.|posthog|datadog)\b").unwrap(),
156        ),
157        (
158            "product_env_secret",
159            Regex::new(r#"std::env::var\(\s*"(API_KEY|OPENAI_API_KEY|SECRET|TOKEN|PASSWORD)""#)
160                .unwrap(),
161        ),
162        ("unwrap_prod", Regex::new(r"\.unwrap\(\)").unwrap()),
163        ("dotenv", Regex::new(r"(?i)\bdotenv\b|\.env\b").unwrap()),
164    ]
165}
166
167fn findings_to_json(findings: &[Finding], apply: bool) -> Value {
168    let items: Vec<Value> = findings
169        .iter()
170        .map(|f| {
171            json!({
172                "path": f.path,
173                "line": f.line,
174                "rule": f.rule,
175                "snippet": f.snippet,
176            })
177        })
178        .collect();
179    json!({
180        "ok": true,
181        "count": items.len(),
182        "findings": items,
183        "apply": apply,
184        "chrome": false,
185    })
186}
187
188fn atomic_write(path: &Path, body: &str) -> Result<(), CliError> {
189    let parent = path.parent().unwrap_or_else(|| Path::new("."));
190    let tmp = parent.join(format!(
191        ".browser-automation-cli-sg-{}.tmp",
192        std::process::id()
193    ));
194    fs::write(&tmp, body)
195        .map_err(|e| CliError::new(ErrorKind::Io, format!("write temp {}: {e}", tmp.display())))?;
196    fs::rename(&tmp, path).map_err(|e| {
197        let _ = fs::remove_file(&tmp);
198        CliError::new(
199            ErrorKind::Io,
200            format!("rename {} โ†’ {}: {e}", tmp.display(), path.display()),
201        )
202    })
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use std::io::Write;
209
210    #[test]
211    fn scan_finds_unwrap_in_temp_rs() {
212        let dir = tempfile::tempdir().unwrap();
213        let f = dir.path().join("prod.rs");
214        let mut file = fs::File::create(&f).unwrap();
215        writeln!(file, "fn x() {{ let _ = \"a\".parse::<u32>().unwrap(); }}").unwrap();
216        let v = sg_scan(&[dir.path().to_path_buf()], 50).unwrap();
217        assert!(v.get("count").and_then(|c| c.as_u64()).unwrap_or(0) >= 1);
218    }
219}