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