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