1use crate::eval::{run_behavior_evals, stable_hash_hex, BehaviorEvalReport};
8use crate::policy::{load_project_policy, PolicyCategory, ProjectPolicy};
9use mdx_rust_analysis::editing::{
10 cleanup_isolated_workspace, create_isolated_workspace, restore_transaction,
11 snapshot_transaction, validate_build_detailed_with_budget, ValidationCommandRecord,
12};
13pub use mdx_rust_analysis::HardeningEvidenceDepth;
14use mdx_rust_analysis::{
15 analyze_hardening, HardeningAnalyzeConfig, HardeningFileChange, HardeningFinding,
16};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use std::path::{Component, Path, PathBuf};
20use std::time::Duration;
21
22#[derive(Debug, Clone)]
23pub struct HardeningConfig {
24 pub target: Option<PathBuf>,
25 pub policy_path: Option<PathBuf>,
26 pub behavior_spec_path: Option<PathBuf>,
27 pub apply: bool,
28 pub max_files: usize,
29 pub max_recipe_tier: u8,
30 pub evidence_depth: HardeningEvidenceDepth,
31 pub validation_timeout: Duration,
32}
33
34impl Default for HardeningConfig {
35 fn default() -> Self {
36 Self {
37 target: None,
38 policy_path: None,
39 behavior_spec_path: None,
40 apply: false,
41 max_files: 100,
42 max_recipe_tier: 1,
43 evidence_depth: HardeningEvidenceDepth::Basic,
44 validation_timeout: Duration::from_secs(180),
45 }
46 }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
50pub struct HardeningRun {
51 pub schema_version: String,
52 pub root: String,
53 pub target: Option<String>,
54 pub mode: HardeningMode,
55 pub workspace: WorkspaceSummary,
56 pub policy: Option<ProjectPolicy>,
57 pub policy_matches: Vec<PolicyFindingMatch>,
58 pub risk_summary: HardeningRiskSummary,
59 pub files_scanned: usize,
60 pub findings: Vec<HardeningFinding>,
61 pub changes: Vec<HardeningChangeSummary>,
62 pub behavior_evaluation: Option<BehaviorEvalReport>,
63 pub final_behavior_evaluation: Option<BehaviorEvalReport>,
64 pub outcome: HardeningOutcome,
65 pub artifact_path: Option<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
69pub enum HardeningMode {
70 Review,
71 Apply,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
75pub struct WorkspaceSummary {
76 pub cargo_metadata_available: bool,
77 pub package_count: usize,
78 pub package_names: Vec<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
82pub struct PolicyFindingMatch {
83 pub finding_id: String,
84 pub rule_id: String,
85 pub category: PolicyCategory,
86 pub reason: String,
87}
88
89pub type HardeningPolicyRecord = ProjectPolicy;
90
91#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
92pub struct HardeningRiskSummary {
93 pub high: usize,
94 pub medium: usize,
95 pub low: usize,
96 pub patchable: usize,
97 pub top_recommendations: Vec<String>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
101pub struct HardeningChangeSummary {
102 pub file: String,
103 pub strategy: String,
104 pub finding_ids: Vec<String>,
105 pub description: String,
106 pub old_hash: String,
107 pub new_hash: String,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
111pub struct HardeningOutcome {
112 pub status: HardeningStatus,
113 pub isolated_validation_passed: bool,
114 pub applied: bool,
115 pub final_validation_passed: bool,
116 pub validation_commands: Vec<ValidationCommandRecord>,
117 pub final_validation_commands: Vec<ValidationCommandRecord>,
118 pub behavior_evaluation: Option<BehaviorEvalReport>,
119 pub final_behavior_evaluation: Option<BehaviorEvalReport>,
120 pub rollback_succeeded: Option<bool>,
121 pub rollback_error: Option<String>,
122 pub note: String,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
126pub enum HardeningStatus {
127 NoChanges,
128 Reviewed,
129 Applied,
130 ValidationFailed,
131 BehaviorValidationFailed,
132 FinalValidationFailedRolledBack,
133 Rejected,
134}
135
136pub fn run_hardening(
137 root: &Path,
138 artifact_root: Option<&Path>,
139 config: &HardeningConfig,
140) -> anyhow::Result<HardeningRun> {
141 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
142 let analysis = analyze_hardening(
143 &root,
144 HardeningAnalyzeConfig {
145 target: config.target.as_deref(),
146 max_files: config.max_files,
147 max_recipe_tier: config.max_recipe_tier,
148 evidence_depth: config.evidence_depth,
149 },
150 )?;
151 let workspace = workspace_summary(&root);
152 let policy = load_project_policy(&root, config.policy_path.as_deref())?;
153 let policy_matches = policy
154 .as_ref()
155 .map(|policy| match_findings_to_policy(&analysis.findings, policy))
156 .unwrap_or_default();
157 let mode = if config.apply {
158 HardeningMode::Apply
159 } else {
160 HardeningMode::Review
161 };
162 let changes = summarize_changes(&analysis.changes);
163 let risk_summary = summarize_risk(&analysis.findings);
164
165 let outcome = if analysis.changes.is_empty() {
166 HardeningOutcome {
167 status: HardeningStatus::NoChanges,
168 isolated_validation_passed: false,
169 applied: false,
170 final_validation_passed: false,
171 validation_commands: Vec::new(),
172 final_validation_commands: Vec::new(),
173 behavior_evaluation: None,
174 final_behavior_evaluation: None,
175 rollback_succeeded: None,
176 rollback_error: None,
177 note: "no high-confidence hardening changes were available".to_string(),
178 }
179 } else {
180 execute_hardening_changes(&root, &analysis.changes, config)?
181 };
182
183 let mut run = HardeningRun {
184 schema_version: "0.7".to_string(),
185 root: root.display().to_string(),
186 target: config
187 .target
188 .as_ref()
189 .map(|path| path.display().to_string()),
190 mode,
191 workspace,
192 policy,
193 policy_matches,
194 risk_summary,
195 files_scanned: analysis.files_scanned,
196 findings: analysis.findings,
197 changes,
198 behavior_evaluation: outcome.behavior_evaluation.clone(),
199 final_behavior_evaluation: outcome.final_behavior_evaluation.clone(),
200 outcome,
201 artifact_path: None,
202 };
203
204 if let Some(artifact_root) = artifact_root {
205 let path = persist_hardening_run(artifact_root, &run)?;
206 run.artifact_path = Some(path.display().to_string());
207 std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
208 }
209
210 Ok(run)
211}
212
213fn execute_hardening_changes(
214 root: &Path,
215 changes: &[HardeningFileChange],
216 config: &HardeningConfig,
217) -> anyhow::Result<HardeningOutcome> {
218 ensure_scoped_changes(root, changes)?;
219 let behavior_spec_path = config
220 .behavior_spec_path
221 .as_deref()
222 .map(|path| resolve_behavior_spec_path(root, path));
223
224 let isolated = create_isolated_workspace(root, "hardening-v0-7")?;
225 write_changes(&isolated, changes)?;
226 let validation = validate_build_detailed_with_budget(&isolated, config.validation_timeout);
227 let behavior_evaluation = if validation.passed {
228 run_optional_behavior_eval(&isolated, behavior_spec_path.as_deref())?
229 } else {
230 None
231 };
232 cleanup_isolated_workspace(root, &isolated);
233
234 if !validation.passed {
235 return Ok(HardeningOutcome {
236 status: HardeningStatus::ValidationFailed,
237 isolated_validation_passed: false,
238 applied: false,
239 final_validation_passed: false,
240 validation_commands: validation.command_records,
241 final_validation_commands: Vec::new(),
242 rollback_succeeded: None,
243 rollback_error: None,
244 note: "proposed hardening changes failed isolated validation".to_string(),
245 behavior_evaluation: None,
246 final_behavior_evaluation: None,
247 });
248 }
249
250 if behavior_evaluation
251 .as_ref()
252 .is_some_and(|report| !report.passed())
253 {
254 return Ok(HardeningOutcome {
255 status: HardeningStatus::BehaviorValidationFailed,
256 isolated_validation_passed: true,
257 applied: false,
258 final_validation_passed: false,
259 validation_commands: validation.command_records,
260 final_validation_commands: Vec::new(),
261 rollback_succeeded: None,
262 rollback_error: None,
263 note: "proposed hardening changes failed behavior evaluation".to_string(),
264 behavior_evaluation,
265 final_behavior_evaluation: None,
266 });
267 }
268
269 if !config.apply {
270 return Ok(HardeningOutcome {
271 status: HardeningStatus::Reviewed,
272 isolated_validation_passed: true,
273 applied: false,
274 final_validation_passed: false,
275 validation_commands: validation.command_records,
276 final_validation_commands: Vec::new(),
277 rollback_succeeded: None,
278 rollback_error: None,
279 note: "changes validated in isolation; rerun with --apply to land them".to_string(),
280 behavior_evaluation,
281 final_behavior_evaluation: None,
282 });
283 }
284
285 let real_paths: Vec<PathBuf> = changes
286 .iter()
287 .map(|change| root.join(&change.file))
288 .collect();
289 let snapshot = snapshot_transaction(&real_paths)?;
290 write_changes(root, changes)?;
291 let final_validation = validate_build_detailed_with_budget(root, config.validation_timeout);
292 let final_behavior_evaluation = if final_validation.passed {
293 run_optional_behavior_eval(root, behavior_spec_path.as_deref())?
294 } else {
295 None
296 };
297
298 if final_validation.passed
299 && final_behavior_evaluation
300 .as_ref()
301 .map(BehaviorEvalReport::passed)
302 .unwrap_or(true)
303 {
304 return Ok(HardeningOutcome {
305 status: HardeningStatus::Applied,
306 isolated_validation_passed: true,
307 applied: true,
308 final_validation_passed: true,
309 validation_commands: validation.command_records,
310 final_validation_commands: final_validation.command_records,
311 rollback_succeeded: None,
312 rollback_error: None,
313 note: "hardening changes applied and final validation passed".to_string(),
314 behavior_evaluation,
315 final_behavior_evaluation,
316 });
317 }
318
319 let rollback = restore_transaction(&snapshot);
320 let rollback_error = rollback.as_ref().err().map(ToString::to_string);
321 Ok(HardeningOutcome {
322 status: HardeningStatus::FinalValidationFailedRolledBack,
323 isolated_validation_passed: true,
324 applied: false,
325 final_validation_passed: false,
326 validation_commands: validation.command_records,
327 final_validation_commands: final_validation.command_records,
328 rollback_succeeded: Some(rollback.is_ok()),
329 rollback_error,
330 note: if final_validation.passed {
331 "final behavior evaluation failed; transaction rollback attempted".to_string()
332 } else {
333 "final validation failed; transaction rollback attempted".to_string()
334 },
335 behavior_evaluation,
336 final_behavior_evaluation,
337 })
338}
339
340fn run_optional_behavior_eval(
341 root: &Path,
342 spec_path: Option<&Path>,
343) -> anyhow::Result<Option<BehaviorEvalReport>> {
344 let Some(spec_path) = spec_path else {
345 return Ok(None);
346 };
347 Ok(Some(run_behavior_evals(root, spec_path)?))
348}
349
350fn resolve_behavior_spec_path(root: &Path, spec_path: &Path) -> PathBuf {
351 if spec_path.is_absolute() {
352 spec_path.to_path_buf()
353 } else {
354 root.join(spec_path)
355 }
356}
357
358fn ensure_scoped_changes(root: &Path, changes: &[HardeningFileChange]) -> anyhow::Result<()> {
359 if changes.is_empty() {
360 anyhow::bail!("hardening transaction has no changes");
361 }
362 for change in changes {
363 if change.file.components().any(|component| {
364 matches!(
365 component,
366 Component::ParentDir | Component::RootDir | Component::Prefix(_)
367 )
368 }) {
369 anyhow::bail!("unscoped hardening path: {}", change.file.display());
370 }
371 let target = root.join(&change.file);
372 if !target.starts_with(root) {
373 anyhow::bail!("hardening path escapes root: {}", change.file.display());
374 }
375 }
376 Ok(())
377}
378
379fn write_changes(root: &Path, changes: &[HardeningFileChange]) -> anyhow::Result<()> {
380 for change in changes {
381 let path = root.join(&change.file);
382 if let Some(parent) = path.parent() {
383 std::fs::create_dir_all(parent)?;
384 }
385 std::fs::write(path, &change.new_content)?;
386 }
387 Ok(())
388}
389
390fn summarize_changes(changes: &[HardeningFileChange]) -> Vec<HardeningChangeSummary> {
391 changes
392 .iter()
393 .map(|change| HardeningChangeSummary {
394 file: change.file.display().to_string(),
395 strategy: format!("{:?}", change.strategy),
396 finding_ids: change.finding_ids.clone(),
397 description: change.description.clone(),
398 old_hash: stable_hash_hex(change.old_content.as_bytes()),
399 new_hash: stable_hash_hex(change.new_content.as_bytes()),
400 })
401 .collect()
402}
403
404fn match_findings_to_policy(
405 findings: &[HardeningFinding],
406 policy: &ProjectPolicy,
407) -> Vec<PolicyFindingMatch> {
408 findings
409 .iter()
410 .filter_map(|finding| {
411 let category = category_for_finding(finding);
412 let rule = policy
413 .rules
414 .iter()
415 .find(|rule| rule.category == category)
416 .or_else(|| {
417 policy
418 .rules
419 .iter()
420 .find(|rule| rule.category == PolicyCategory::General)
421 })?;
422 Some(PolicyFindingMatch {
423 finding_id: finding.id.clone(),
424 rule_id: rule.id.clone(),
425 category: rule.category.clone(),
426 reason: format!("finding maps to policy rule on line {}", rule.line),
427 })
428 })
429 .collect()
430}
431
432fn category_for_finding(finding: &HardeningFinding) -> PolicyCategory {
433 match finding.strategy {
434 mdx_rust_analysis::HardeningStrategy::BorrowParameterTightening
435 | mdx_rust_analysis::HardeningStrategy::LenCheckIsEmpty
436 | mdx_rust_analysis::HardeningStrategy::IteratorCloned
437 | mdx_rust_analysis::HardeningStrategy::MechanicalTier1Cleanup
438 | mdx_rust_analysis::HardeningStrategy::MustUsePublicReturn
439 | mdx_rust_analysis::HardeningStrategy::RepeatedStringLiteralConst => {
440 PolicyCategory::General
441 }
442 mdx_rust_analysis::HardeningStrategy::ErrorContextPropagation => {
443 PolicyCategory::ErrorContext
444 }
445 mdx_rust_analysis::HardeningStrategy::ClonePressureReview
446 | mdx_rust_analysis::HardeningStrategy::LongFunctionReview => PolicyCategory::General,
447 mdx_rust_analysis::HardeningStrategy::ResultUnwrapContext => PolicyCategory::PanicSafety,
448 mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview => {
449 PolicyCategory::ProcessExecution
450 }
451 mdx_rust_analysis::HardeningStrategy::UnsafeReview => PolicyCategory::UnsafeCode,
452 mdx_rust_analysis::HardeningStrategy::EnvAccessReview => PolicyCategory::Environment,
453 mdx_rust_analysis::HardeningStrategy::FileIoReview => PolicyCategory::ErrorContext,
454 mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview => PolicyCategory::InputValidation,
455 }
456}
457
458fn summarize_risk(findings: &[HardeningFinding]) -> HardeningRiskSummary {
459 let mut summary = HardeningRiskSummary {
460 high: 0,
461 medium: 0,
462 low: 0,
463 patchable: findings.iter().filter(|finding| finding.patchable).count(),
464 top_recommendations: Vec::new(),
465 };
466
467 let mut saw_patchable = false;
468 let mut saw_process = false;
469 let mut saw_http = false;
470 let mut saw_file = false;
471
472 for finding in findings {
473 match finding.strategy {
474 mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview
475 | mdx_rust_analysis::HardeningStrategy::UnsafeReview => summary.high += 1,
476 mdx_rust_analysis::HardeningStrategy::BorrowParameterTightening
477 | mdx_rust_analysis::HardeningStrategy::LenCheckIsEmpty
478 | mdx_rust_analysis::HardeningStrategy::IteratorCloned
479 | mdx_rust_analysis::HardeningStrategy::MechanicalTier1Cleanup
480 | mdx_rust_analysis::HardeningStrategy::MustUsePublicReturn
481 | mdx_rust_analysis::HardeningStrategy::RepeatedStringLiteralConst => summary.low += 1,
482 mdx_rust_analysis::HardeningStrategy::ErrorContextPropagation
483 | mdx_rust_analysis::HardeningStrategy::ClonePressureReview
484 | mdx_rust_analysis::HardeningStrategy::LongFunctionReview
485 | mdx_rust_analysis::HardeningStrategy::ResultUnwrapContext
486 | mdx_rust_analysis::HardeningStrategy::EnvAccessReview
487 | mdx_rust_analysis::HardeningStrategy::FileIoReview
488 | mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview => summary.medium += 1,
489 }
490
491 saw_patchable |= finding.patchable;
492 saw_process |= matches!(
493 finding.strategy,
494 mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview
495 );
496 saw_http |= matches!(
497 finding.strategy,
498 mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview
499 );
500 saw_file |= matches!(
501 finding.strategy,
502 mdx_rust_analysis::HardeningStrategy::FileIoReview
503 );
504 }
505
506 if saw_patchable {
507 summary
508 .top_recommendations
509 .push("Run mdx-rust improve <target> --apply after reviewing the proposed mechanical changes.".to_string());
510 }
511 if saw_process {
512 summary.top_recommendations.push(
513 "Review process execution callsites for allowlisted commands and validated arguments."
514 .to_string(),
515 );
516 }
517 if saw_http {
518 summary.top_recommendations.push(
519 "Review HTTP route surfaces for request validation and typed error handling."
520 .to_string(),
521 );
522 }
523 if saw_file {
524 summary.top_recommendations.push(
525 "Review filesystem boundaries for contextual errors and path validation.".to_string(),
526 );
527 }
528
529 summary
530}
531
532fn persist_hardening_run(artifact_root: &Path, run: &HardeningRun) -> anyhow::Result<PathBuf> {
533 let dir = artifact_root.join("hardening");
534 std::fs::create_dir_all(&dir)?;
535 let millis = std::time::SystemTime::now()
536 .duration_since(std::time::UNIX_EPOCH)
537 .map(|duration| duration.as_millis())
538 .unwrap_or(0);
539 let mode = match run.mode {
540 HardeningMode::Review => "review",
541 HardeningMode::Apply => "apply",
542 };
543 Ok(dir.join(format!("hardening-{mode}-{millis}.json")))
544}
545
546pub(crate) fn workspace_summary(root: &Path) -> WorkspaceSummary {
547 let mut command = std::process::Command::new("cargo");
548 command
549 .current_dir(root)
550 .args(["metadata", "--no-deps", "--format-version", "1"]);
551 let Some(output) =
552 mdx_rust_analysis::editing::run_command_with_timeout(&mut command, Duration::from_secs(20))
553 else {
554 return WorkspaceSummary {
555 cargo_metadata_available: false,
556 package_count: 0,
557 package_names: Vec::new(),
558 };
559 };
560
561 if !output.success() {
562 return WorkspaceSummary {
563 cargo_metadata_available: false,
564 package_count: 0,
565 package_names: Vec::new(),
566 };
567 }
568
569 let value: serde_json::Value = match serde_json::from_str(&output.stdout) {
570 Ok(value) => value,
571 Err(_) => {
572 return WorkspaceSummary {
573 cargo_metadata_available: false,
574 package_count: 0,
575 package_names: Vec::new(),
576 }
577 }
578 };
579 let package_names: Vec<String> = value
580 .get("packages")
581 .and_then(|packages| packages.as_array())
582 .map(|packages| {
583 packages
584 .iter()
585 .filter_map(|package| package.get("name").and_then(|name| name.as_str()))
586 .map(ToString::to_string)
587 .collect()
588 })
589 .unwrap_or_default();
590 WorkspaceSummary {
591 cargo_metadata_available: true,
592 package_count: package_names.len(),
593 package_names,
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600 use tempfile::tempdir;
601
602 fn write_fixture(root: &Path) {
603 std::fs::write(
604 root.join("Cargo.toml"),
605 r#"[package]
606name = "hardening-fixture"
607version = "0.1.0"
608edition = "2021"
609
610[dependencies]
611anyhow = "1"
612"#,
613 )
614 .unwrap();
615 let src = root.join("src");
616 std::fs::create_dir_all(&src).unwrap();
617 std::fs::write(
618 src.join("lib.rs"),
619 r#"pub fn load_config() -> anyhow::Result<String> {
620 let content = std::fs::read_to_string("missing.toml").unwrap();
621 Ok(content)
622}
623"#,
624 )
625 .unwrap();
626 }
627
628 fn write_behavior_spec(root: &Path, expected: &str) -> PathBuf {
629 let path = root.join("evals.json");
630 std::fs::write(
631 &path,
632 format!(
633 r#"{{
634 "version": "v1",
635 "commands": [
636 {{
637 "id": "cargo-check",
638 "command": "cargo",
639 "args": ["check"],
640 "expect_success": true,
641 "expect_stderr_contains": ["{expected}"],
642 "timeout_seconds": 120
643 }}
644 ]
645}}"#
646 ),
647 )
648 .unwrap();
649 path
650 }
651
652 fn write_artifact_behavior_spec(root: &Path) -> PathBuf {
653 let dir = root.join(".mdx-rust");
654 std::fs::create_dir_all(&dir).unwrap();
655 let path = dir.join("evals.json");
656 std::fs::write(
657 &path,
658 r#"{
659 "version": "v1",
660 "commands": [
661 {
662 "id": "cargo-check",
663 "command": "cargo",
664 "args": ["check"],
665 "expect_success": true,
666 "timeout_seconds": 120
667 }
668 ]
669}"#,
670 )
671 .unwrap();
672 PathBuf::from(".mdx-rust/evals.json")
673 }
674
675 #[test]
676 fn hardening_review_validates_without_touching_real_tree() {
677 let dir = tempdir().unwrap();
678 write_fixture(dir.path());
679 let before = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
680
681 let run = run_hardening(
682 dir.path(),
683 None,
684 &HardeningConfig {
685 target: Some(PathBuf::from("src/lib.rs")),
686 validation_timeout: Duration::from_secs(120),
687 ..HardeningConfig::default()
688 },
689 )
690 .unwrap();
691
692 let after = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
693 assert_eq!(before, after);
694 assert_eq!(run.outcome.status, HardeningStatus::Reviewed);
695 assert_eq!(run.schema_version, "0.7");
696 assert!(run.outcome.isolated_validation_passed);
697 assert!(!run.changes.is_empty());
698 }
699
700 #[test]
701 fn hardening_apply_lands_validated_transaction() {
702 let dir = tempdir().unwrap();
703 write_fixture(dir.path());
704
705 let run = run_hardening(
706 dir.path(),
707 None,
708 &HardeningConfig {
709 target: Some(PathBuf::from("src/lib.rs")),
710 apply: true,
711 validation_timeout: Duration::from_secs(120),
712 ..HardeningConfig::default()
713 },
714 )
715 .unwrap();
716
717 let after = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
718 assert_eq!(run.outcome.status, HardeningStatus::Applied);
719 assert!(run.outcome.final_validation_passed);
720 assert!(after.contains("use anyhow::Context;"));
721 assert!(after.contains(".context(\"load_config failed instead of panicking\")?"));
722 }
723
724 #[test]
725 fn hardening_maps_findings_to_structured_policy_rules() {
726 let dir = tempdir().unwrap();
727 write_fixture(dir.path());
728 std::fs::create_dir_all(dir.path().join(".mdx-rust")).unwrap();
729 std::fs::write(
730 dir.path().join(".mdx-rust/policies.md"),
731 "1. Avoid unwrap in service boundaries.",
732 )
733 .unwrap();
734
735 let run = run_hardening(
736 dir.path(),
737 None,
738 &HardeningConfig {
739 target: Some(PathBuf::from("src/lib.rs")),
740 validation_timeout: Duration::from_secs(120),
741 ..HardeningConfig::default()
742 },
743 )
744 .unwrap();
745
746 assert!(run.policy.is_some());
747 assert!(!run.policy_matches.is_empty());
748 assert_eq!(run.policy_matches[0].category, PolicyCategory::PanicSafety);
749 }
750
751 #[test]
752 fn hardening_behavior_eval_failure_blocks_apply() {
753 let dir = tempdir().unwrap();
754 write_fixture(dir.path());
755 let spec = write_behavior_spec(dir.path(), "definitely-not-in-cargo-output");
756 let before = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
757
758 let run = run_hardening(
759 dir.path(),
760 None,
761 &HardeningConfig {
762 target: Some(PathBuf::from("src/lib.rs")),
763 behavior_spec_path: Some(spec),
764 apply: true,
765 validation_timeout: Duration::from_secs(120),
766 ..HardeningConfig::default()
767 },
768 )
769 .unwrap();
770
771 let after = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
772 assert_eq!(before, after);
773 assert_eq!(
774 run.outcome.status,
775 HardeningStatus::BehaviorValidationFailed
776 );
777 assert!(run.outcome.behavior_evaluation.is_some());
778 }
779
780 #[test]
781 fn hardening_behavior_eval_spec_in_artifact_dir_runs_in_isolation() {
782 let dir = tempdir().unwrap();
783 write_fixture(dir.path());
784 let spec = write_artifact_behavior_spec(dir.path());
785
786 let run = run_hardening(
787 dir.path(),
788 None,
789 &HardeningConfig {
790 target: Some(PathBuf::from("src/lib.rs")),
791 behavior_spec_path: Some(spec),
792 apply: true,
793 validation_timeout: Duration::from_secs(120),
794 ..HardeningConfig::default()
795 },
796 )
797 .unwrap();
798
799 assert_eq!(run.outcome.status, HardeningStatus::Applied);
800 assert!(run
801 .outcome
802 .behavior_evaluation
803 .as_ref()
804 .is_some_and(BehaviorEvalReport::passed));
805 assert!(run
806 .outcome
807 .final_behavior_evaluation
808 .as_ref()
809 .is_some_and(BehaviorEvalReport::passed));
810 }
811
812 #[test]
813 fn hardening_rejects_unscoped_transaction_paths() {
814 let dir = tempdir().unwrap();
815 let changes = vec![HardeningFileChange {
816 file: PathBuf::from("../escape.rs"),
817 old_content: String::new(),
818 new_content: String::new(),
819 strategy: mdx_rust_analysis::HardeningStrategy::ResultUnwrapContext,
820 finding_ids: vec!["escape".to_string()],
821 description: "bad path".to_string(),
822 }];
823
824 let err = ensure_scoped_changes(dir.path(), &changes).unwrap_err();
825 assert!(err.to_string().contains("unscoped hardening path"));
826 }
827}