1use 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("ed);
738 if should_rewrite {
739 *line = line.replace("ed, &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}