Skip to main content

mdx_rust_analysis/
hardening.rs

1//! Conservative Rust hardening analysis for ordinary Rust modules.
2//!
3//! This module intentionally starts with high-confidence static patterns. It
4//! can inspect normal Rust crates without requiring agent registration.
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
11pub struct HardeningAnalysis {
12    pub root: PathBuf,
13    pub target: Option<PathBuf>,
14    pub files_scanned: usize,
15    pub findings: Vec<HardeningFinding>,
16    pub changes: Vec<HardeningFileChange>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
20pub struct HardeningFinding {
21    pub id: String,
22    pub title: String,
23    pub description: String,
24    pub file: PathBuf,
25    pub line: usize,
26    pub strategy: HardeningStrategy,
27    pub patchable: bool,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
31pub enum HardeningStrategy {
32    BorrowParameterTightening,
33    ClonePressureReview,
34    ErrorContextPropagation,
35    IteratorCloned,
36    LenCheckIsEmpty,
37    LongFunctionReview,
38    MechanicalTier1Cleanup,
39    MustUsePublicReturn,
40    RepeatedStringLiteralConst,
41    ResultUnwrapContext,
42    ProcessExecutionReview,
43    UnsafeReview,
44    EnvAccessReview,
45    FileIoReview,
46    HttpSurfaceReview,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
50pub struct HardeningFileChange {
51    pub file: PathBuf,
52    pub old_content: String,
53    pub new_content: String,
54    pub strategy: HardeningStrategy,
55    pub finding_ids: Vec<String>,
56    pub description: String,
57}
58
59#[derive(Debug, Clone, Copy)]
60pub struct HardeningAnalyzeConfig<'a> {
61    pub target: Option<&'a Path>,
62    pub max_files: usize,
63    pub max_recipe_tier: u8,
64    pub evidence_depth: HardeningEvidenceDepth,
65}
66
67#[derive(
68    Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord,
69)]
70pub enum HardeningEvidenceDepth {
71    Basic,
72    Tested,
73    Covered,
74    Hardened,
75    Proven,
76}
77
78pub fn analyze_hardening(
79    root: &Path,
80    config: HardeningAnalyzeConfig<'_>,
81) -> anyhow::Result<HardeningAnalysis> {
82    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
83    let files = collect_rust_files(&root, config.target)?;
84    let mut findings = Vec::new();
85    let mut changes = Vec::new();
86
87    for file in files.iter().take(config.max_files) {
88        let content = std::fs::read_to_string(file)?;
89        let rel = relative_path(&root, file);
90        let function_ranges = find_function_ranges(&content);
91
92        for (index, line) in content.lines().enumerate() {
93            let line_no = index + 1;
94            let pattern_line = line_without_comments_or_strings(line);
95            let trimmed = pattern_line.trim();
96
97            if trimmed.contains("Command::new(") || trimmed.contains("std::process::Command") {
98                findings.push(HardeningFinding {
99                    id: format!("process-execution:{}:{line_no}", rel.display()),
100                    title: "Process execution surface".to_string(),
101                    description:
102                        "External process execution should have explicit input validation or allowlisting."
103                            .to_string(),
104                    file: rel.clone(),
105                    line: line_no,
106                    strategy: HardeningStrategy::ProcessExecutionReview,
107                    patchable: false,
108                });
109            }
110
111            if trimmed.contains("unsafe ") || trimmed == "unsafe" || trimmed.contains("unsafe{") {
112                findings.push(HardeningFinding {
113                    id: format!("unsafe-rust:{}:{line_no}", rel.display()),
114                    title: "Unsafe Rust requires review".to_string(),
115                    description:
116                        "Unsafe code should be isolated and documented before automated edits touch it."
117                            .to_string(),
118                    file: rel.clone(),
119                    line: line_no,
120                    strategy: HardeningStrategy::UnsafeReview,
121                    patchable: false,
122                });
123            }
124
125            if trimmed.contains("std::env::var(") || trimmed.contains("env::var(") {
126                findings.push(HardeningFinding {
127                    id: format!("env-access:{}:{line_no}", rel.display()),
128                    title: "Environment variable access".to_string(),
129                    description:
130                        "Environment-derived configuration should return contextual errors at boundaries."
131                            .to_string(),
132                    file: rel.clone(),
133                    line: line_no,
134                    strategy: HardeningStrategy::EnvAccessReview,
135                    patchable: false,
136                });
137            }
138
139            let filesystem_call = trimmed.contains("std::fs::read_to_string(")
140                || trimmed.contains("fs::read_to_string(")
141                || trimmed.contains("std::fs::write(")
142                || trimmed.contains("fs::write(");
143            let has_visible_error_handling = trimmed.contains('?')
144                || trimmed.contains(".unwrap(")
145                || trimmed.contains(".expect(");
146            if filesystem_call && !has_visible_error_handling {
147                findings.push(HardeningFinding {
148                    id: format!("file-io:{}:{line_no}", rel.display()),
149                    title: "Filesystem boundary".to_string(),
150                    description:
151                        "Filesystem access should preserve contextual errors and validated paths."
152                            .to_string(),
153                    file: rel.clone(),
154                    line: line_no,
155                    strategy: HardeningStrategy::FileIoReview,
156                    patchable: false,
157                });
158            }
159
160            if trimmed.contains("Router::new(")
161                || trimmed.contains(".route(")
162                || trimmed.contains("#[get(")
163                || trimmed.contains("#[post(")
164            {
165                findings.push(HardeningFinding {
166                    id: format!("http-surface:{}:{line_no}", rel.display()),
167                    title: "HTTP or route surface".to_string(),
168                    description:
169                        "HTTP-facing surfaces should validate inputs and preserve typed errors."
170                            .to_string(),
171                    file: rel.clone(),
172                    line: line_no,
173                    strategy: HardeningStrategy::HttpSurfaceReview,
174                    patchable: false,
175                });
176            }
177        }
178
179        if config.evidence_depth >= HardeningEvidenceDepth::Hardened {
180            add_hardened_evidence_findings(&rel, &content, &function_ranges, &mut findings);
181        }
182
183        if let Some(change) =
184            build_mechanical_change(&root, file, &content, &function_ranges, &config)?
185        {
186            findings.extend(change.findings);
187            changes.push(change.change);
188        }
189    }
190
191    Ok(HardeningAnalysis {
192        root,
193        target: config.target.map(Path::to_path_buf),
194        files_scanned: files.len().min(config.max_files),
195        findings,
196        changes,
197    })
198}
199
200struct MechanicalChange {
201    change: HardeningFileChange,
202    findings: Vec<HardeningFinding>,
203}
204
205fn build_mechanical_change(
206    root: &Path,
207    file: &Path,
208    content: &str,
209    function_ranges: &[FunctionRange],
210    config: &HardeningAnalyzeConfig<'_>,
211) -> anyhow::Result<Option<MechanicalChange>> {
212    let rel = relative_path(root, file);
213    let mut lines: Vec<String> = content.lines().map(ToString::to_string).collect();
214    let mut finding_ids = Vec::new();
215    let mut findings = Vec::new();
216
217    apply_result_context_recipe(
218        &rel,
219        &mut lines,
220        function_ranges,
221        &mut finding_ids,
222        &mut findings,
223    );
224    apply_error_context_recipe(
225        &rel,
226        &mut lines,
227        function_ranges,
228        &mut finding_ids,
229        &mut findings,
230    );
231    apply_borrow_parameter_recipe(
232        &rel,
233        &mut lines,
234        function_ranges,
235        &mut finding_ids,
236        &mut findings,
237    );
238    apply_borrowed_vec_literal_recipe(&rel, &mut lines, &mut finding_ids, &mut findings);
239    apply_iterator_cloned_recipe(&rel, &mut lines, &mut finding_ids, &mut findings);
240    apply_must_use_recipe(
241        &rel,
242        &mut lines,
243        function_ranges,
244        &mut finding_ids,
245        &mut findings,
246    );
247    if config.max_recipe_tier >= 2 {
248        apply_len_check_is_empty_recipe(&rel, &mut lines, &mut finding_ids, &mut findings);
249        apply_repeated_string_literal_const_recipe(
250            &rel,
251            &mut lines,
252            &mut finding_ids,
253            &mut findings,
254        );
255    }
256
257    if finding_ids.is_empty() {
258        return Ok(None);
259    }
260
261    let mut new_content = lines.join("\n");
262    if content.ends_with('\n') {
263        new_content.push('\n');
264    }
265    if findings.iter().any(|finding| {
266        matches!(
267            finding.strategy,
268            HardeningStrategy::ErrorContextPropagation | HardeningStrategy::ResultUnwrapContext
269        )
270    }) {
271        new_content = ensure_anyhow_context_import(&new_content);
272    }
273    if syn::parse_file(&new_content).is_err() {
274        return Ok(None);
275    }
276
277    Ok(Some(MechanicalChange {
278        change: HardeningFileChange {
279            file: rel,
280            old_content: content.to_string(),
281            new_content,
282            strategy: HardeningStrategy::MechanicalTier1Cleanup,
283            finding_ids,
284            description:
285                "Apply enabled mechanical hardening recipes under compile and clippy validation."
286                    .to_string(),
287        },
288        findings,
289    }))
290}
291
292fn add_hardened_evidence_findings(
293    rel: &Path,
294    content: &str,
295    function_ranges: &[FunctionRange],
296    findings: &mut Vec<HardeningFinding>,
297) {
298    let mut clone_lines = Vec::new();
299    for (index, line) in content.lines().enumerate() {
300        let pattern_line = line_without_comments_or_strings(line);
301        if pattern_line.contains(".clone()") {
302            clone_lines.push(index + 1);
303        }
304    }
305    if clone_lines.len() >= 3 {
306        findings.push(HardeningFinding {
307            id: format!("clone-pressure-review:{}:{}", rel.display(), clone_lines[0]),
308            title: "Clone pressure review".to_string(),
309            description: format!(
310                "Hardened evidence unlocks deeper clone-pressure analysis; this file has {} visible clone callsites for future semantic cleanup.",
311                clone_lines.len()
312            ),
313            file: rel.to_path_buf(),
314            line: clone_lines[0],
315            strategy: HardeningStrategy::ClonePressureReview,
316            patchable: false,
317        });
318    }
319
320    for range in function_ranges {
321        let function_len = range.end_line.saturating_sub(range.start_line) + 1;
322        if function_len >= 50 {
323            findings.push(HardeningFinding {
324                id: format!(
325                    "long-function-review:{}:{}",
326                    rel.display(),
327                    range.signature_start_line
328                ),
329                title: "Long function refactor candidate".to_string(),
330                description: format!(
331                    "Hardened evidence unlocks deeper function-shape analysis; `{}` spans {function_len} lines and may be ready for extract-function planning.",
332                    range.name
333                ),
334                file: rel.to_path_buf(),
335                line: range.signature_start_line,
336                strategy: HardeningStrategy::LongFunctionReview,
337                patchable: false,
338            });
339        }
340    }
341}
342
343fn apply_result_context_recipe(
344    rel: &Path,
345    lines: &mut [String],
346    function_ranges: &[FunctionRange],
347    finding_ids: &mut Vec<String>,
348    findings: &mut Vec<HardeningFinding>,
349) {
350    for range in function_ranges {
351        if !range.returns_anyhow_result {
352            continue;
353        }
354
355        for line_index in range.start_line.saturating_sub(1)..range.end_line.min(lines.len()) {
356            let original = lines[line_index].clone();
357            if original.trim_start().starts_with("//") {
358                continue;
359            }
360
361            let mut rewritten = original.clone();
362            if rewritten.contains(".unwrap()") {
363                rewritten = rewritten.replace(
364                    ".unwrap()",
365                    &format!(".context(\"{} failed instead of panicking\")?", range.name),
366                );
367            }
368            rewritten = replace_expect_calls(&rewritten);
369
370            if rewritten != original {
371                lines[line_index] = rewritten;
372                let line = line_index + 1;
373                let id = format!("unwrap-in-result:{}:{line}", rel.display());
374                finding_ids.push(id.clone());
375                findings.push(HardeningFinding {
376                    id,
377                    title: "Panic-prone unwrap in anyhow Result function".to_string(),
378                    description: "Replace unwrap/expect with anyhow Context and ? so failure is reported instead of panicking.".to_string(),
379                    file: rel.to_path_buf(),
380                    line,
381                    strategy: HardeningStrategy::ResultUnwrapContext,
382                    patchable: true,
383                });
384            }
385        }
386    }
387}
388
389fn apply_error_context_recipe(
390    rel: &Path,
391    lines: &mut [String],
392    function_ranges: &[FunctionRange],
393    finding_ids: &mut Vec<String>,
394    findings: &mut Vec<HardeningFinding>,
395) {
396    for range in function_ranges {
397        if !range.returns_anyhow_result {
398            continue;
399        }
400
401        for line_index in range.start_line.saturating_sub(1)..range.end_line.min(lines.len()) {
402            let original = lines[line_index].clone();
403            if original.trim_start().starts_with("//")
404                || original.contains(".context(")
405                || original.contains(".with_context(")
406            {
407                continue;
408            }
409
410            let pattern_line = line_without_comments_or_strings(&original);
411            let Some(boundary) = boundary_call_kind(&pattern_line) else {
412                continue;
413            };
414            if !pattern_line.contains('?') {
415                continue;
416            }
417
418            let Some(rewritten) = add_context_before_question_mark(
419                &original,
420                &format!("{} failed at {boundary} boundary", range.name),
421            ) else {
422                continue;
423            };
424            if rewritten == original {
425                continue;
426            }
427
428            lines[line_index] = rewritten;
429            let line = line_index + 1;
430            let id = format!("error-context-propagation:{}:{line}", rel.display());
431            finding_ids.push(id.clone());
432            findings.push(HardeningFinding {
433                id,
434                title: "Propagate boundary errors with context".to_string(),
435                description: "Add anyhow Context to fallible boundary calls that already use ? so failures explain where they came from.".to_string(),
436                file: rel.to_path_buf(),
437                line,
438                strategy: HardeningStrategy::ErrorContextPropagation,
439                patchable: true,
440            });
441        }
442    }
443}
444
445fn boundary_call_kind(line: &str) -> Option<&'static str> {
446    if line.contains("std::fs::")
447        || line.contains("fs::read")
448        || line.contains("fs::write")
449        || line.contains("File::open(")
450    {
451        Some("filesystem")
452    } else if line.contains("std::env::var(") || line.contains("env::var(") {
453        Some("environment")
454    } else {
455        None
456    }
457}
458
459fn add_context_before_question_mark(line: &str, message: &str) -> Option<String> {
460    let question = line.find('?')?;
461    let (before, after) = line.split_at(question);
462    Some(format!(
463        "{}.context(\"{}\"){}",
464        before,
465        escape_string(message),
466        after
467    ))
468}
469
470fn apply_borrow_parameter_recipe(
471    rel: &Path,
472    lines: &mut [String],
473    function_ranges: &[FunctionRange],
474    finding_ids: &mut Vec<String>,
475    findings: &mut Vec<HardeningFinding>,
476) {
477    for range in function_ranges {
478        if range.is_public {
479            continue;
480        }
481
482        let start = range.signature_start_line.saturating_sub(1);
483        let end = range.signature_end_line.min(lines.len());
484        let mut changed = false;
485        for line in &mut lines[start..end] {
486            let original = line.clone();
487            let tightened = tighten_borrow_parameters(&original);
488            if tightened != original {
489                *line = tightened;
490                changed = true;
491            }
492        }
493
494        if changed {
495            let id = format!(
496                "borrow-parameter-tightening:{}:{}",
497                rel.display(),
498                range.signature_start_line
499            );
500            finding_ids.push(id.clone());
501            findings.push(HardeningFinding {
502                id,
503                title: "Tighten private borrowed parameter type".to_string(),
504                description: "Prefer &str and slices over borrowed owned containers in private functions when compile gates prove the change.".to_string(),
505                file: rel.to_path_buf(),
506                line: range.signature_start_line,
507                strategy: HardeningStrategy::BorrowParameterTightening,
508                patchable: true,
509            });
510        }
511    }
512}
513
514fn apply_must_use_recipe(
515    rel: &Path,
516    lines: &mut Vec<String>,
517    function_ranges: &[FunctionRange],
518    finding_ids: &mut Vec<String>,
519    findings: &mut Vec<HardeningFinding>,
520) {
521    let mut inserted = 0usize;
522    for range in function_ranges {
523        if !range.is_public || !range.returns_value || range.returns_common_must_use {
524            continue;
525        }
526        if has_nearby_must_use(lines, range.signature_start_line + inserted) {
527            continue;
528        }
529
530        let insert_at = range.signature_start_line.saturating_sub(1) + inserted;
531        let indent: String = lines
532            .get(insert_at)
533            .map(|line| line.chars().take_while(|ch| ch.is_whitespace()).collect())
534            .unwrap_or_default();
535        lines.insert(insert_at, format!("{indent}#[must_use]"));
536        inserted += 1;
537
538        let id = format!(
539            "must-use-public-return:{}:{}",
540            rel.display(),
541            range.signature_start_line
542        );
543        finding_ids.push(id.clone());
544        findings.push(HardeningFinding {
545            id,
546            title: "Public return value should be marked must_use".to_string(),
547            description: "Add #[must_use] to public value-returning functions so ignored results are visible to callers.".to_string(),
548            file: rel.to_path_buf(),
549            line: range.signature_start_line,
550            strategy: HardeningStrategy::MustUsePublicReturn,
551            patchable: true,
552        });
553    }
554}
555
556fn apply_iterator_cloned_recipe(
557    rel: &Path,
558    lines: &mut [String],
559    finding_ids: &mut Vec<String>,
560    findings: &mut Vec<HardeningFinding>,
561) {
562    for (line_index, line) in lines.iter_mut().enumerate() {
563        if line.trim_start().starts_with("//") {
564            continue;
565        }
566        let original = line.clone();
567        let rewritten = replace_map_clone_calls(&original);
568        if rewritten == original {
569            continue;
570        }
571
572        *line = rewritten;
573        let line_no = line_index + 1;
574        let id = format!("iterator-cloned:{}:{line_no}", rel.display());
575        finding_ids.push(id.clone());
576        findings.push(HardeningFinding {
577            id,
578            title: "Simplify iterator clone collection".to_string(),
579            description: "Replace clone-mapping collection with a simpler form when compile gates prove the iterator item type.".to_string(),
580            file: rel.to_path_buf(),
581            line: line_no,
582            strategy: HardeningStrategy::IteratorCloned,
583            patchable: true,
584        });
585    }
586}
587
588fn apply_borrowed_vec_literal_recipe(
589    rel: &Path,
590    lines: &mut [String],
591    finding_ids: &mut Vec<String>,
592    findings: &mut Vec<HardeningFinding>,
593) {
594    for (line_index, line) in lines.iter_mut().enumerate() {
595        if line.trim_start().starts_with("//") || !line.contains("&vec![") {
596            continue;
597        }
598
599        *line = line.replace("&vec![", "&[");
600        let line_no = line_index + 1;
601        let id = format!("borrowed-vec-literal:{}:{line_no}", rel.display());
602        finding_ids.push(id.clone());
603        findings.push(HardeningFinding {
604            id,
605            title: "Use a borrowed slice literal".to_string(),
606            description: "Replace &vec![..] with a borrowed slice literal when validation proves the callsite.".to_string(),
607            file: rel.to_path_buf(),
608            line: line_no,
609            strategy: HardeningStrategy::BorrowParameterTightening,
610            patchable: true,
611        });
612    }
613}
614
615fn apply_len_check_is_empty_recipe(
616    rel: &Path,
617    lines: &mut [String],
618    finding_ids: &mut Vec<String>,
619    findings: &mut Vec<HardeningFinding>,
620) {
621    for (line_index, line) in lines.iter_mut().enumerate() {
622        if line.trim_start().starts_with("//") || !line.contains(".len() == 0") {
623            continue;
624        }
625        let original = line.clone();
626        let rewritten = original.replace(".len() == 0", ".is_empty()");
627        if rewritten == original {
628            continue;
629        }
630
631        *line = rewritten;
632        let line_no = line_index + 1;
633        let id = format!("len-check-is-empty:{}:{line_no}", rel.display());
634        finding_ids.push(id.clone());
635        findings.push(HardeningFinding {
636            id,
637            title: "Use is_empty for zero-length check".to_string(),
638            description: "Replace len() == 0 with is_empty() under Tier 2 evidence gates and compile validation.".to_string(),
639            file: rel.to_path_buf(),
640            line: line_no,
641            strategy: HardeningStrategy::LenCheckIsEmpty,
642            patchable: true,
643        });
644    }
645}
646
647fn apply_repeated_string_literal_const_recipe(
648    rel: &Path,
649    lines: &mut Vec<String>,
650    finding_ids: &mut Vec<String>,
651    findings: &mut Vec<HardeningFinding>,
652) {
653    let content = lines.join("\n");
654    let Some((literal, count, first_line)) = repeated_safe_string_literal(&content) else {
655        return;
656    };
657    let const_name = format!("MDX_LITERAL_{}", short_literal_hash(&literal));
658    if content.contains(&const_name) {
659        return;
660    }
661
662    let quoted = format!("\"{}\"", escape_string(&literal));
663    let mut replacement_count = 0usize;
664    for line in lines.iter_mut() {
665        let should_rewrite = !line.trim_start().starts_with("//") && line.contains(&quoted);
666        if should_rewrite {
667            *line = line.replace(&quoted, &const_name);
668            replacement_count += 1;
669        }
670    }
671    if replacement_count < 3 {
672        return;
673    }
674
675    let insert_at = const_insert_index(lines);
676    lines.insert(insert_at, format!("const {const_name}: &str = {quoted};"));
677
678    let id = format!(
679        "repeated-string-literal-const:{}:{first_line}",
680        rel.display()
681    );
682    finding_ids.push(id.clone());
683    findings.push(HardeningFinding {
684        id,
685        title: "Extract repeated string literal".to_string(),
686        description: format!(
687            "Extract repeated private string literal used {count} times into a file-local const under Tier 2 evidence gates."
688        ),
689        file: rel.to_path_buf(),
690        line: first_line,
691        strategy: HardeningStrategy::RepeatedStringLiteralConst,
692        patchable: true,
693    });
694}
695
696fn repeated_safe_string_literal(content: &str) -> Option<(String, usize, usize)> {
697    let mut counts = std::collections::BTreeMap::<String, (usize, usize)>::new();
698    for (line_index, line) in content.lines().enumerate() {
699        if line.trim_start().starts_with("//") || line.trim_start().starts_with("const ") {
700            continue;
701        }
702        for literal in string_literals_in_line(line) {
703            if !is_safe_extractable_literal(&literal) {
704                continue;
705            }
706            let entry = counts.entry(literal).or_insert((0, line_index + 1));
707            entry.0 += 1;
708        }
709    }
710
711    counts
712        .into_iter()
713        .filter(|(_, (count, _))| *count >= 3)
714        .max_by(|left, right| {
715            left.1
716                 .0
717                .cmp(&right.1 .0)
718                .then_with(|| left.0.len().cmp(&right.0.len()))
719        })
720        .map(|(literal, (count, line))| (literal, count, line))
721}
722
723fn string_literals_in_line(line: &str) -> Vec<String> {
724    let mut literals = Vec::new();
725    let mut chars = line.char_indices().peekable();
726    while let Some((_, ch)) = chars.next() {
727        if ch != '"' {
728            continue;
729        }
730        let mut literal = String::new();
731        let mut escaped = false;
732        for (_, next) in chars.by_ref() {
733            if escaped {
734                literal.push(next);
735                escaped = false;
736                continue;
737            }
738            if next == '\\' {
739                escaped = true;
740                continue;
741            }
742            if next == '"' {
743                literals.push(literal);
744                break;
745            }
746            literal.push(next);
747        }
748    }
749    literals
750}
751
752fn is_safe_extractable_literal(value: &str) -> bool {
753    value.len() >= 8
754        && value.len() <= 80
755        && !value.contains('{')
756        && !value.contains('}')
757        && !value.contains('\n')
758        && value.chars().all(|ch| {
759            ch.is_ascii_alphanumeric()
760                || matches!(ch, ' ' | '-' | '_' | '.' | '/' | ':' | ',' | '(' | ')')
761        })
762}
763
764fn const_insert_index(lines: &[String]) -> usize {
765    let mut index = 0usize;
766    while index < lines.len() {
767        let trimmed = lines[index].trim_start();
768        if trimmed.starts_with("#![") || trimmed.starts_with("//!") || trimmed.is_empty() {
769            index += 1;
770            continue;
771        }
772        if trimmed.starts_with("use ") {
773            index += 1;
774            continue;
775        }
776        break;
777    }
778    index
779}
780
781fn short_literal_hash(value: &str) -> String {
782    use std::hash::{Hash, Hasher};
783
784    let mut hasher = std::collections::hash_map::DefaultHasher::new();
785    value.hash(&mut hasher);
786    format!("{:08X}", hasher.finish() as u32)
787}
788
789fn replace_map_clone_calls(line: &str) -> String {
790    let mut output = String::new();
791    let mut rest = line;
792    while let Some(start) = rest.find(".map(|") {
793        let (before, after_start) = rest.split_at(start);
794        output.push_str(before);
795        let Some((variable, after_variable)) = after_start[".map(|".len()..].split_once('|') else {
796            output.push_str(after_start);
797            return output;
798        };
799        let variable = variable.trim();
800        if variable.is_empty()
801            || !variable
802                .chars()
803                .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
804        {
805            output.push_str(after_start);
806            return output;
807        }
808
809        let expected = format!(" {}.clone())", variable);
810        let trimmed_expected = format!("{}.clone())", variable);
811        if let Some(next) = after_variable.strip_prefix(&expected) {
812            rest = push_clone_replacement(&mut output, next);
813        } else if let Some(next) = after_variable.strip_prefix(&trimmed_expected) {
814            rest = push_clone_replacement(&mut output, next);
815        } else {
816            output.push_str(".map(|");
817            rest = &after_start[".map(|".len()..];
818        }
819    }
820    output.push_str(rest);
821    output
822}
823
824fn push_clone_replacement<'a>(output: &mut String, next: &'a str) -> &'a str {
825    if next.starts_with(".collect()") && output.ends_with(".iter()") {
826        output.truncate(output.len() - ".iter()".len());
827        output.push_str(".to_vec()");
828        &next[".collect()".len()..]
829    } else {
830        output.push_str(".cloned()");
831        next
832    }
833}
834
835fn tighten_borrow_parameters(line: &str) -> String {
836    replace_borrowed_vec(&line.replace("&String", "&str"))
837}
838
839fn replace_borrowed_vec(line: &str) -> String {
840    let mut output = String::new();
841    let mut index = 0usize;
842    while let Some(relative_start) = line[index..].find("&Vec<") {
843        let start = index + relative_start;
844        output.push_str(&line[index..start]);
845        let generic_start = start + "&Vec<".len();
846        let Some(generic_end) = matching_angle_end(line, generic_start) else {
847            output.push_str(&line[start..]);
848            return output;
849        };
850        output.push_str("&[");
851        output.push_str(&line[generic_start..generic_end]);
852        output.push(']');
853        index = generic_end + 1;
854    }
855    output.push_str(&line[index..]);
856    output
857}
858
859fn matching_angle_end(value: &str, start: usize) -> Option<usize> {
860    let mut depth = 1isize;
861    for (offset, ch) in value[start..].char_indices() {
862        match ch {
863            '<' => depth += 1,
864            '>' => {
865                depth -= 1;
866                if depth == 0 {
867                    return Some(start + offset);
868                }
869            }
870            _ => {}
871        }
872    }
873    None
874}
875
876fn has_nearby_must_use(lines: &[String], signature_line: usize) -> bool {
877    let signature_index = signature_line.saturating_sub(1);
878    let start = signature_index.saturating_sub(4);
879    lines[start..signature_index.min(lines.len())]
880        .iter()
881        .any(|line| line.contains("must_use"))
882}
883
884fn replace_expect_calls(line: &str) -> String {
885    let mut output = String::new();
886    let mut rest = line;
887    while let Some(start) = rest.find(".expect(\"") {
888        let (before, after_start) = rest.split_at(start);
889        output.push_str(before);
890        let msg_start = ".expect(\"".len();
891        let after_msg_start = &after_start[msg_start..];
892        if let Some(end) = after_msg_start.find("\")") {
893            let message = &after_msg_start[..end];
894            output.push_str(&format!(".context(\"{}\")?", escape_string(message)));
895            rest = &after_msg_start[end + 2..];
896        } else {
897            output.push_str(after_start);
898            rest = "";
899        }
900    }
901    output.push_str(rest);
902    output
903}
904
905fn escape_string(value: &str) -> String {
906    value.replace('\\', "\\\\").replace('"', "\\\"")
907}
908
909fn line_without_comments_or_strings(line: &str) -> String {
910    let mut output = String::with_capacity(line.len());
911    let mut chars = line.chars().peekable();
912    let mut in_string = false;
913    let mut escaped = false;
914
915    while let Some(ch) = chars.next() {
916        if !in_string && ch == '/' && chars.peek() == Some(&'/') {
917            break;
918        }
919
920        if ch == '"' && !escaped {
921            in_string = !in_string;
922            output.push(' ');
923            continue;
924        }
925
926        if in_string {
927            escaped = ch == '\\' && !escaped;
928            output.push(' ');
929            continue;
930        }
931
932        escaped = false;
933        output.push(ch);
934    }
935
936    output
937}
938
939fn ensure_anyhow_context_import(content: &str) -> String {
940    if content.contains("anyhow::Context") || content.contains("Context,") {
941        return content.to_string();
942    }
943
944    let mut lines: Vec<&str> = content.lines().collect();
945    let insert_at = lines
946        .iter()
947        .position(|line| !line.starts_with("#![") && !line.trim().is_empty())
948        .unwrap_or(0);
949    lines.insert(insert_at, "use anyhow::Context;");
950    let mut result = lines.join("\n");
951    if content.ends_with('\n') {
952        result.push('\n');
953    }
954    result
955}
956
957#[derive(Debug)]
958struct FunctionRange {
959    name: String,
960    start_line: usize,
961    end_line: usize,
962    signature_start_line: usize,
963    signature_end_line: usize,
964    is_public: bool,
965    returns_anyhow_result: bool,
966    returns_value: bool,
967    returns_common_must_use: bool,
968}
969
970fn find_function_ranges(content: &str) -> Vec<FunctionRange> {
971    let lines: Vec<&str> = content.lines().collect();
972    let has_anyhow_result_alias =
973        content.contains("use anyhow::Result") || content.contains("use anyhow::{Result");
974    let mut ranges = Vec::new();
975    let mut index = 0;
976    while index < lines.len() {
977        let line = lines[index];
978        if !line.contains("fn ") {
979            index += 1;
980            continue;
981        }
982
983        let mut signature = line.to_string();
984        let start_line = index + 1;
985        let mut open_line = index;
986        while !signature.contains('{') && open_line + 1 < lines.len() {
987            open_line += 1;
988            signature.push(' ');
989            signature.push_str(lines[open_line]);
990        }
991
992        if !signature.contains('{') {
993            index += 1;
994            continue;
995        }
996
997        let Some(name) = function_name(&signature) else {
998            index += 1;
999            continue;
1000        };
1001
1002        let mut depth = 0isize;
1003        let mut end_line = open_line + 1;
1004        for (body_index, body_line) in lines.iter().enumerate().skip(open_line) {
1005            depth += body_line.matches('{').count() as isize;
1006            depth -= body_line.matches('}').count() as isize;
1007            end_line = body_index + 1;
1008            if depth == 0 {
1009                break;
1010            }
1011        }
1012
1013        let return_text = signature
1014            .split_once("->")
1015            .map(|(_, rest)| rest.split('{').next().unwrap_or_default().trim())
1016            .unwrap_or_default();
1017        let returns_anyhow_result = return_text.starts_with("anyhow::Result")
1018            || (has_anyhow_result_alias && return_text.starts_with("Result<"));
1019        let returns_value = !return_text.is_empty() && return_text != "()";
1020        let returns_common_must_use = return_text.starts_with("Result<")
1021            || return_text.starts_with("anyhow::Result")
1022            || return_text.starts_with("Option<")
1023            || signature.contains("async fn ");
1024        ranges.push(FunctionRange {
1025            name,
1026            start_line,
1027            end_line,
1028            signature_start_line: start_line,
1029            signature_end_line: open_line + 1,
1030            is_public: signature.trim_start().starts_with("pub "),
1031            returns_anyhow_result,
1032            returns_value,
1033            returns_common_must_use,
1034        });
1035        index = end_line;
1036    }
1037    ranges
1038}
1039
1040fn function_name(signature: &str) -> Option<String> {
1041    let rest = signature.split_once("fn ")?.1;
1042    let name = rest
1043        .split(|c: char| !(c.is_alphanumeric() || c == '_'))
1044        .next()?;
1045    if name.is_empty() {
1046        None
1047    } else {
1048        Some(name.to_string())
1049    }
1050}
1051
1052fn collect_rust_files(root: &Path, target: Option<&Path>) -> anyhow::Result<Vec<PathBuf>> {
1053    let requested_scan_root = target
1054        .map(|path| {
1055            if path.is_absolute() {
1056                path.to_path_buf()
1057            } else {
1058                root.join(path)
1059            }
1060        })
1061        .unwrap_or_else(|| root.to_path_buf());
1062    if target.is_some() && !requested_scan_root.exists() {
1063        anyhow::bail!(
1064            "hardening target does not exist: {}",
1065            requested_scan_root.display()
1066        );
1067    }
1068    let scan_root = requested_scan_root
1069        .canonicalize()
1070        .unwrap_or(requested_scan_root);
1071    if !scan_root.starts_with(root) {
1072        anyhow::bail!("hardening target is outside root: {}", scan_root.display());
1073    }
1074
1075    if scan_root.is_file() {
1076        return Ok(if scan_root.extension().is_some_and(|ext| ext == "rs") {
1077            vec![scan_root]
1078        } else {
1079            Vec::new()
1080        });
1081    }
1082
1083    let mut files = Vec::new();
1084    for result in ignore::WalkBuilder::new(scan_root)
1085        .hidden(false)
1086        .filter_entry(|entry| {
1087            let name = entry.file_name().to_string_lossy();
1088            !matches!(
1089                name.as_ref(),
1090                "target" | ".git" | ".worktrees" | ".mdx-rust"
1091            )
1092        })
1093        .build()
1094    {
1095        let entry = result?;
1096        let path = entry.path();
1097        if path.is_file() && path.extension().is_some_and(|ext| ext == "rs") {
1098            files.push(path.to_path_buf());
1099        }
1100    }
1101    files.sort();
1102    Ok(files)
1103}
1104
1105fn relative_path(root: &Path, path: &Path) -> PathBuf {
1106    path.strip_prefix(root).unwrap_or(path).to_path_buf()
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111    use super::*;
1112    use tempfile::tempdir;
1113
1114    #[test]
1115    fn hardening_rewrites_unwrap_in_anyhow_result_function() {
1116        let dir = tempdir().unwrap();
1117        let src = dir.path().join("src");
1118        std::fs::create_dir_all(&src).unwrap();
1119        std::fs::write(
1120            src.join("lib.rs"),
1121            r#"pub fn load() -> anyhow::Result<String> {
1122    let value = std::fs::read_to_string("config.toml").unwrap();
1123    Ok(value)
1124}
1125"#,
1126        )
1127        .unwrap();
1128
1129        let analysis = analyze_hardening(
1130            dir.path(),
1131            HardeningAnalyzeConfig {
1132                target: None,
1133                max_files: 10,
1134                max_recipe_tier: 1,
1135                evidence_depth: HardeningEvidenceDepth::Basic,
1136            },
1137        )
1138        .unwrap();
1139
1140        assert_eq!(analysis.changes.len(), 1);
1141        let change = &analysis.changes[0];
1142        assert!(change.new_content.contains("use anyhow::Context;"));
1143        assert!(change
1144            .new_content
1145            .contains(".context(\"load failed instead of panicking\")?"));
1146        assert!(syn::parse_file(&change.new_content).is_ok());
1147    }
1148
1149    #[test]
1150    fn hardening_adds_context_to_question_mark_boundaries() {
1151        let dir = tempdir().unwrap();
1152        let src = dir.path().join("src");
1153        std::fs::create_dir_all(&src).unwrap();
1154        std::fs::write(
1155            src.join("lib.rs"),
1156            r#"pub fn load(path: &str) -> anyhow::Result<String> {
1157    let value = std::fs::read_to_string(path)?;
1158    Ok(value)
1159}
1160"#,
1161        )
1162        .unwrap();
1163
1164        let analysis = analyze_hardening(
1165            dir.path(),
1166            HardeningAnalyzeConfig {
1167                target: None,
1168                max_files: 10,
1169                max_recipe_tier: 1,
1170                evidence_depth: HardeningEvidenceDepth::Basic,
1171            },
1172        )
1173        .unwrap();
1174
1175        assert_eq!(analysis.changes.len(), 1);
1176        let change = &analysis.changes[0];
1177        assert!(change.new_content.contains("use anyhow::Context;"));
1178        assert!(change
1179            .new_content
1180            .contains(".context(\"load failed at filesystem boundary\")?"));
1181        assert!(change
1182            .finding_ids
1183            .iter()
1184            .any(|id| id.contains("error-context-propagation")));
1185        assert!(syn::parse_file(&change.new_content).is_ok());
1186    }
1187
1188    #[test]
1189    fn hardening_does_not_rewrite_plain_result_without_anyhow_alias() {
1190        let dir = tempdir().unwrap();
1191        let src = dir.path().join("src");
1192        std::fs::create_dir_all(&src).unwrap();
1193        std::fs::write(
1194            src.join("lib.rs"),
1195            r#"pub fn load() -> Result<String, std::io::Error> {
1196    let value = std::fs::read_to_string("config.toml").unwrap();
1197    Ok(value)
1198}
1199"#,
1200        )
1201        .unwrap();
1202
1203        let analysis = analyze_hardening(
1204            dir.path(),
1205            HardeningAnalyzeConfig {
1206                target: None,
1207                max_files: 10,
1208                max_recipe_tier: 1,
1209                evidence_depth: HardeningEvidenceDepth::Basic,
1210            },
1211        )
1212        .unwrap();
1213
1214        assert!(analysis.changes.is_empty());
1215    }
1216
1217    #[test]
1218    fn hardening_tightens_private_borrowed_owned_parameters() {
1219        let dir = tempdir().unwrap();
1220        let src = dir.path().join("src");
1221        std::fs::create_dir_all(&src).unwrap();
1222        std::fs::write(
1223            src.join("lib.rs"),
1224            r#"fn score(name: &String, values: &Vec<u8>) -> usize {
1225    name.len() + values.len()
1226}
1227"#,
1228        )
1229        .unwrap();
1230
1231        let analysis = analyze_hardening(
1232            dir.path(),
1233            HardeningAnalyzeConfig {
1234                target: None,
1235                max_files: 10,
1236                max_recipe_tier: 1,
1237                evidence_depth: HardeningEvidenceDepth::Basic,
1238            },
1239        )
1240        .unwrap();
1241
1242        assert_eq!(analysis.changes.len(), 1);
1243        let change = &analysis.changes[0];
1244        assert!(change
1245            .new_content
1246            .contains("fn score(name: &str, values: &[u8])"));
1247        assert!(change
1248            .finding_ids
1249            .iter()
1250            .any(|id| id.contains("borrow-parameter-tightening")));
1251        assert!(syn::parse_file(&change.new_content).is_ok());
1252    }
1253
1254    #[test]
1255    fn hardening_marks_public_value_returns_must_use() {
1256        let dir = tempdir().unwrap();
1257        let src = dir.path().join("src");
1258        std::fs::create_dir_all(&src).unwrap();
1259        std::fs::write(
1260            src.join("lib.rs"),
1261            r#"pub fn total(values: &[u8]) -> usize {
1262    values.iter().map(|value| *value as usize).sum()
1263}
1264"#,
1265        )
1266        .unwrap();
1267
1268        let analysis = analyze_hardening(
1269            dir.path(),
1270            HardeningAnalyzeConfig {
1271                target: None,
1272                max_files: 10,
1273                max_recipe_tier: 1,
1274                evidence_depth: HardeningEvidenceDepth::Basic,
1275            },
1276        )
1277        .unwrap();
1278
1279        assert_eq!(analysis.changes.len(), 1);
1280        let change = &analysis.changes[0];
1281        assert!(change.new_content.contains("#[must_use]\npub fn total"));
1282        assert!(change
1283            .finding_ids
1284            .iter()
1285            .any(|id| id.contains("must-use-public-return")));
1286        assert!(syn::parse_file(&change.new_content).is_ok());
1287    }
1288
1289    #[test]
1290    fn hardening_replaces_map_clone_collect_with_to_vec() {
1291        let dir = tempdir().unwrap();
1292        let src = dir.path().join("src");
1293        std::fs::create_dir_all(&src).unwrap();
1294        std::fs::write(
1295            src.join("lib.rs"),
1296            r#"pub fn copy_values(values: &[String]) -> Vec<String> {
1297    values.iter().map(|value| value.clone()).collect()
1298}
1299"#,
1300        )
1301        .unwrap();
1302
1303        let analysis = analyze_hardening(
1304            dir.path(),
1305            HardeningAnalyzeConfig {
1306                target: None,
1307                max_files: 10,
1308                max_recipe_tier: 1,
1309                evidence_depth: HardeningEvidenceDepth::Basic,
1310            },
1311        )
1312        .unwrap();
1313
1314        assert_eq!(analysis.changes.len(), 1);
1315        let change = &analysis.changes[0];
1316        assert!(change.new_content.contains("values.to_vec()"));
1317        assert!(change
1318            .finding_ids
1319            .iter()
1320            .any(|id| id.contains("iterator-cloned")));
1321        assert!(syn::parse_file(&change.new_content).is_ok());
1322    }
1323
1324    #[test]
1325    fn tier2_extracts_repeated_private_string_literal_when_enabled() {
1326        let dir = tempdir().unwrap();
1327        let src = dir.path().join("src");
1328        std::fs::create_dir_all(&src).unwrap();
1329        std::fs::write(
1330            src.join("lib.rs"),
1331            r#"fn labels() -> Vec<&'static str> {
1332    vec![
1333        "shared boundary label",
1334        "shared boundary label",
1335        "shared boundary label",
1336    ]
1337}
1338"#,
1339        )
1340        .unwrap();
1341
1342        let tier1 = analyze_hardening(
1343            dir.path(),
1344            HardeningAnalyzeConfig {
1345                target: None,
1346                max_files: 10,
1347                max_recipe_tier: 1,
1348                evidence_depth: HardeningEvidenceDepth::Basic,
1349            },
1350        )
1351        .unwrap();
1352        assert!(tier1.changes.is_empty());
1353
1354        let tier2 = analyze_hardening(
1355            dir.path(),
1356            HardeningAnalyzeConfig {
1357                target: None,
1358                max_files: 10,
1359                max_recipe_tier: 2,
1360                evidence_depth: HardeningEvidenceDepth::Covered,
1361            },
1362        )
1363        .unwrap();
1364
1365        assert_eq!(tier2.changes.len(), 1);
1366        let change = &tier2.changes[0];
1367        assert!(change.new_content.contains("const MDX_LITERAL_"));
1368        assert!(change
1369            .finding_ids
1370            .iter()
1371            .any(|id| id.contains("repeated-string-literal-const")));
1372        assert!(syn::parse_file(&change.new_content).is_ok());
1373    }
1374
1375    #[test]
1376    fn tier2_rewrites_len_zero_checks_when_enabled() {
1377        let dir = tempdir().unwrap();
1378        let src = dir.path().join("src");
1379        std::fs::create_dir_all(&src).unwrap();
1380        std::fs::write(
1381            src.join("lib.rs"),
1382            r#"pub fn empty(items: &[String]) -> bool {
1383    items.len() == 0
1384}
1385"#,
1386        )
1387        .unwrap();
1388
1389        let tier2 = analyze_hardening(
1390            dir.path(),
1391            HardeningAnalyzeConfig {
1392                target: None,
1393                max_files: 10,
1394                max_recipe_tier: 2,
1395                evidence_depth: HardeningEvidenceDepth::Covered,
1396            },
1397        )
1398        .unwrap();
1399
1400        assert_eq!(tier2.changes.len(), 1);
1401        let change = &tier2.changes[0];
1402        assert!(change.new_content.contains("items.is_empty()"));
1403        assert!(change
1404            .finding_ids
1405            .iter()
1406            .any(|id| id.contains("len-check-is-empty")));
1407        assert!(syn::parse_file(&change.new_content).is_ok());
1408    }
1409
1410    #[test]
1411    fn hardened_evidence_adds_deeper_review_findings() {
1412        let dir = tempdir().unwrap();
1413        let src = dir.path().join("src");
1414        std::fs::create_dir_all(&src).unwrap();
1415        let mut body = String::from("pub fn clone_pressure(values: &[String]) -> Vec<String> {\n");
1416        body.push_str("    let a = values[0].clone();\n");
1417        body.push_str("    let b = values[1].clone();\n");
1418        body.push_str("    let c = values[2].clone();\n");
1419        for index in 0..50 {
1420            body.push_str(&format!("    let _v{index} = {index};\n"));
1421        }
1422        body.push_str("    vec![a, b, c]\n}\n");
1423        std::fs::write(src.join("lib.rs"), body).unwrap();
1424
1425        let basic = analyze_hardening(
1426            dir.path(),
1427            HardeningAnalyzeConfig {
1428                target: None,
1429                max_files: 10,
1430                max_recipe_tier: 1,
1431                evidence_depth: HardeningEvidenceDepth::Basic,
1432            },
1433        )
1434        .unwrap();
1435        assert!(!basic.findings.iter().any(|finding| matches!(
1436            finding.strategy,
1437            HardeningStrategy::ClonePressureReview | HardeningStrategy::LongFunctionReview
1438        )));
1439
1440        let hardened = analyze_hardening(
1441            dir.path(),
1442            HardeningAnalyzeConfig {
1443                target: None,
1444                max_files: 10,
1445                max_recipe_tier: 1,
1446                evidence_depth: HardeningEvidenceDepth::Hardened,
1447            },
1448        )
1449        .unwrap();
1450        assert!(hardened.findings.iter().any(|finding| {
1451            finding.strategy == HardeningStrategy::ClonePressureReview && !finding.patchable
1452        }));
1453        assert!(hardened.findings.iter().any(|finding| {
1454            finding.strategy == HardeningStrategy::LongFunctionReview && !finding.patchable
1455        }));
1456    }
1457
1458    #[test]
1459    fn hardening_does_not_flag_patterns_inside_strings_or_comments() {
1460        let dir = tempdir().unwrap();
1461        let src = dir.path().join("src");
1462        std::fs::create_dir_all(&src).unwrap();
1463        std::fs::write(
1464            src.join("lib.rs"),
1465            r#"fn describe() -> &'static str {
1466    // Command::new("ignored")
1467    "unsafe std::process::Command env::var("
1468}
1469"#,
1470        )
1471        .unwrap();
1472
1473        let analysis = analyze_hardening(
1474            dir.path(),
1475            HardeningAnalyzeConfig {
1476                target: None,
1477                max_files: 10,
1478                max_recipe_tier: 1,
1479                evidence_depth: HardeningEvidenceDepth::Basic,
1480            },
1481        )
1482        .unwrap();
1483
1484        assert!(analysis.findings.is_empty(), "{:?}", analysis.findings);
1485    }
1486
1487    #[test]
1488    fn hardening_rejects_missing_target() {
1489        let dir = tempdir().unwrap();
1490        let err = analyze_hardening(
1491            dir.path(),
1492            HardeningAnalyzeConfig {
1493                target: Some(Path::new("src/missing.rs")),
1494                max_files: 10,
1495                max_recipe_tier: 1,
1496                evidence_depth: HardeningEvidenceDepth::Basic,
1497            },
1498        )
1499        .unwrap_err();
1500
1501        assert!(err.to_string().contains("hardening target does not exist"));
1502    }
1503}