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