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