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: "1.0".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-8")?;
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::OptionContextPropagation
440 | mdx_rust_analysis::HardeningStrategy::RepeatedStringLiteralConst => {
441 PolicyCategory::General
442 }
443 mdx_rust_analysis::HardeningStrategy::ErrorContextPropagation => {
444 PolicyCategory::ErrorContext
445 }
446 mdx_rust_analysis::HardeningStrategy::ClonePressureReview
447 | mdx_rust_analysis::HardeningStrategy::LongFunctionReview => PolicyCategory::General,
448 mdx_rust_analysis::HardeningStrategy::ResultUnwrapContext => PolicyCategory::PanicSafety,
449 mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview => {
450 PolicyCategory::ProcessExecution
451 }
452 mdx_rust_analysis::HardeningStrategy::UnsafeReview => PolicyCategory::UnsafeCode,
453 mdx_rust_analysis::HardeningStrategy::EnvAccessReview => PolicyCategory::Environment,
454 mdx_rust_analysis::HardeningStrategy::FileIoReview => PolicyCategory::ErrorContext,
455 mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview => PolicyCategory::InputValidation,
456 }
457}
458
459fn summarize_risk(findings: &[HardeningFinding]) -> HardeningRiskSummary {
460 let mut summary = HardeningRiskSummary {
461 high: 0,
462 medium: 0,
463 low: 0,
464 patchable: findings.iter().filter(|finding| finding.patchable).count(),
465 top_recommendations: Vec::new(),
466 };
467
468 let mut saw_patchable = false;
469 let mut saw_process = false;
470 let mut saw_http = false;
471 let mut saw_file = false;
472
473 for finding in findings {
474 match finding.strategy {
475 mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview
476 | mdx_rust_analysis::HardeningStrategy::UnsafeReview => summary.high += 1,
477 mdx_rust_analysis::HardeningStrategy::BorrowParameterTightening
478 | mdx_rust_analysis::HardeningStrategy::LenCheckIsEmpty
479 | mdx_rust_analysis::HardeningStrategy::IteratorCloned
480 | mdx_rust_analysis::HardeningStrategy::MechanicalTier1Cleanup
481 | mdx_rust_analysis::HardeningStrategy::MustUsePublicReturn
482 | mdx_rust_analysis::HardeningStrategy::OptionContextPropagation
483 | mdx_rust_analysis::HardeningStrategy::RepeatedStringLiteralConst => summary.low += 1,
484 mdx_rust_analysis::HardeningStrategy::ErrorContextPropagation
485 | mdx_rust_analysis::HardeningStrategy::ClonePressureReview
486 | mdx_rust_analysis::HardeningStrategy::LongFunctionReview
487 | mdx_rust_analysis::HardeningStrategy::ResultUnwrapContext
488 | mdx_rust_analysis::HardeningStrategy::EnvAccessReview
489 | mdx_rust_analysis::HardeningStrategy::FileIoReview
490 | mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview => summary.medium += 1,
491 }
492
493 saw_patchable |= finding.patchable;
494 saw_process |= matches!(
495 finding.strategy,
496 mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview
497 );
498 saw_http |= matches!(
499 finding.strategy,
500 mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview
501 );
502 saw_file |= matches!(
503 finding.strategy,
504 mdx_rust_analysis::HardeningStrategy::FileIoReview
505 );
506 }
507
508 if saw_patchable {
509 summary
510 .top_recommendations
511 .push("Run mdx-rust improve <target> --apply after reviewing the proposed mechanical changes.".to_string());
512 }
513 if saw_process {
514 summary.top_recommendations.push(
515 "Review process execution callsites for allowlisted commands and validated arguments."
516 .to_string(),
517 );
518 }
519 if saw_http {
520 summary.top_recommendations.push(
521 "Review HTTP route surfaces for request validation and typed error handling."
522 .to_string(),
523 );
524 }
525 if saw_file {
526 summary.top_recommendations.push(
527 "Review filesystem boundaries for contextual errors and path validation.".to_string(),
528 );
529 }
530
531 summary
532}
533
534fn persist_hardening_run(artifact_root: &Path, run: &HardeningRun) -> anyhow::Result<PathBuf> {
535 let dir = artifact_root.join("hardening");
536 std::fs::create_dir_all(&dir)?;
537 let millis = std::time::SystemTime::now()
538 .duration_since(std::time::UNIX_EPOCH)
539 .map(|duration| duration.as_millis())
540 .unwrap_or(0);
541 let mode = match run.mode {
542 HardeningMode::Review => "review",
543 HardeningMode::Apply => "apply",
544 };
545 Ok(dir.join(format!("hardening-{mode}-{millis}.json")))
546}
547
548pub(crate) fn workspace_summary(root: &Path) -> WorkspaceSummary {
549 let mut command = std::process::Command::new("cargo");
550 command
551 .current_dir(root)
552 .args(["metadata", "--no-deps", "--format-version", "1"]);
553 let Some(output) =
554 mdx_rust_analysis::editing::run_command_with_timeout(&mut command, Duration::from_secs(20))
555 else {
556 return WorkspaceSummary {
557 cargo_metadata_available: false,
558 package_count: 0,
559 package_names: Vec::new(),
560 };
561 };
562
563 if !output.success() {
564 return WorkspaceSummary {
565 cargo_metadata_available: false,
566 package_count: 0,
567 package_names: Vec::new(),
568 };
569 }
570
571 let value: serde_json::Value = match serde_json::from_str(&output.stdout) {
572 Ok(value) => value,
573 Err(_) => {
574 return WorkspaceSummary {
575 cargo_metadata_available: false,
576 package_count: 0,
577 package_names: Vec::new(),
578 }
579 }
580 };
581 let package_names: Vec<String> = value
582 .get("packages")
583 .and_then(|packages| packages.as_array())
584 .map(|packages| {
585 packages
586 .iter()
587 .filter_map(|package| package.get("name").and_then(|name| name.as_str()))
588 .map(ToString::to_string)
589 .collect()
590 })
591 .unwrap_or_default();
592 WorkspaceSummary {
593 cargo_metadata_available: true,
594 package_count: package_names.len(),
595 package_names,
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602 use tempfile::tempdir;
603
604 fn write_fixture(root: &Path) {
605 std::fs::write(
606 root.join("Cargo.toml"),
607 r#"[package]
608name = "hardening-fixture"
609version = "0.1.0"
610edition = "2021"
611
612[dependencies]
613anyhow = "1"
614"#,
615 )
616 .unwrap();
617 let src = root.join("src");
618 std::fs::create_dir_all(&src).unwrap();
619 std::fs::write(
620 src.join("lib.rs"),
621 r#"pub fn load_config() -> anyhow::Result<String> {
622 let content = std::fs::read_to_string("missing.toml").unwrap();
623 Ok(content)
624}
625"#,
626 )
627 .unwrap();
628 }
629
630 fn write_behavior_spec(root: &Path, expected: &str) -> PathBuf {
631 let path = root.join("evals.json");
632 std::fs::write(
633 &path,
634 format!(
635 r#"{{
636 "version": "v1",
637 "commands": [
638 {{
639 "id": "cargo-check",
640 "command": "cargo",
641 "args": ["check"],
642 "expect_success": true,
643 "expect_stderr_contains": ["{expected}"],
644 "timeout_seconds": 120
645 }}
646 ]
647}}"#
648 ),
649 )
650 .unwrap();
651 path
652 }
653
654 fn write_artifact_behavior_spec(root: &Path) -> PathBuf {
655 let dir = root.join(".mdx-rust");
656 std::fs::create_dir_all(&dir).unwrap();
657 let path = dir.join("evals.json");
658 std::fs::write(
659 &path,
660 r#"{
661 "version": "v1",
662 "commands": [
663 {
664 "id": "cargo-check",
665 "command": "cargo",
666 "args": ["check"],
667 "expect_success": true,
668 "timeout_seconds": 120
669 }
670 ]
671}"#,
672 )
673 .unwrap();
674 PathBuf::from(".mdx-rust/evals.json")
675 }
676
677 #[test]
678 fn hardening_review_validates_without_touching_real_tree() {
679 let dir = tempdir().unwrap();
680 write_fixture(dir.path());
681 let before = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
682
683 let run = run_hardening(
684 dir.path(),
685 None,
686 &HardeningConfig {
687 target: Some(PathBuf::from("src/lib.rs")),
688 validation_timeout: Duration::from_secs(120),
689 ..HardeningConfig::default()
690 },
691 )
692 .unwrap();
693
694 let after = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
695 assert_eq!(before, after);
696 assert_eq!(run.outcome.status, HardeningStatus::Reviewed);
697 assert_eq!(run.schema_version, "1.0");
698 assert!(run.outcome.isolated_validation_passed);
699 assert!(!run.changes.is_empty());
700 }
701
702 #[test]
703 fn hardening_apply_lands_validated_transaction() {
704 let dir = tempdir().unwrap();
705 write_fixture(dir.path());
706
707 let run = run_hardening(
708 dir.path(),
709 None,
710 &HardeningConfig {
711 target: Some(PathBuf::from("src/lib.rs")),
712 apply: true,
713 validation_timeout: Duration::from_secs(120),
714 ..HardeningConfig::default()
715 },
716 )
717 .unwrap();
718
719 let after = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
720 assert_eq!(run.outcome.status, HardeningStatus::Applied);
721 assert!(run.outcome.final_validation_passed);
722 assert!(after.contains("use anyhow::Context;"));
723 assert!(after.contains(".context(\"load_config failed instead of panicking\")?"));
724 }
725
726 #[test]
727 fn hardening_maps_findings_to_structured_policy_rules() {
728 let dir = tempdir().unwrap();
729 write_fixture(dir.path());
730 std::fs::create_dir_all(dir.path().join(".mdx-rust")).unwrap();
731 std::fs::write(
732 dir.path().join(".mdx-rust/policies.md"),
733 "1. Avoid unwrap in service boundaries.",
734 )
735 .unwrap();
736
737 let run = run_hardening(
738 dir.path(),
739 None,
740 &HardeningConfig {
741 target: Some(PathBuf::from("src/lib.rs")),
742 validation_timeout: Duration::from_secs(120),
743 ..HardeningConfig::default()
744 },
745 )
746 .unwrap();
747
748 assert!(run.policy.is_some());
749 assert!(!run.policy_matches.is_empty());
750 assert_eq!(run.policy_matches[0].category, PolicyCategory::PanicSafety);
751 }
752
753 #[test]
754 fn hardening_behavior_eval_failure_blocks_apply() {
755 let dir = tempdir().unwrap();
756 write_fixture(dir.path());
757 let spec = write_behavior_spec(dir.path(), "definitely-not-in-cargo-output");
758 let before = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
759
760 let run = run_hardening(
761 dir.path(),
762 None,
763 &HardeningConfig {
764 target: Some(PathBuf::from("src/lib.rs")),
765 behavior_spec_path: Some(spec),
766 apply: true,
767 validation_timeout: Duration::from_secs(120),
768 ..HardeningConfig::default()
769 },
770 )
771 .unwrap();
772
773 let after = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
774 assert_eq!(before, after);
775 assert_eq!(
776 run.outcome.status,
777 HardeningStatus::BehaviorValidationFailed
778 );
779 assert!(run.outcome.behavior_evaluation.is_some());
780 }
781
782 #[test]
783 fn hardening_behavior_eval_spec_in_artifact_dir_runs_in_isolation() {
784 let dir = tempdir().unwrap();
785 write_fixture(dir.path());
786 let spec = write_artifact_behavior_spec(dir.path());
787
788 let run = run_hardening(
789 dir.path(),
790 None,
791 &HardeningConfig {
792 target: Some(PathBuf::from("src/lib.rs")),
793 behavior_spec_path: Some(spec),
794 apply: true,
795 validation_timeout: Duration::from_secs(120),
796 ..HardeningConfig::default()
797 },
798 )
799 .unwrap();
800
801 assert_eq!(run.outcome.status, HardeningStatus::Applied);
802 assert!(run
803 .outcome
804 .behavior_evaluation
805 .as_ref()
806 .is_some_and(BehaviorEvalReport::passed));
807 assert!(run
808 .outcome
809 .final_behavior_evaluation
810 .as_ref()
811 .is_some_and(BehaviorEvalReport::passed));
812 }
813
814 #[test]
815 fn hardening_rejects_unscoped_transaction_paths() {
816 let dir = tempdir().unwrap();
817 let changes = vec![HardeningFileChange {
818 file: PathBuf::from("../escape.rs"),
819 old_content: String::new(),
820 new_content: String::new(),
821 strategy: mdx_rust_analysis::HardeningStrategy::ResultUnwrapContext,
822 finding_ids: vec!["escape".to_string()],
823 description: "bad path".to_string(),
824 }];
825
826 let err = ensure_scoped_changes(dir.path(), &changes).unwrap_err();
827 assert!(err.to_string().contains("unscoped hardening path"));
828 }
829}