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