Skip to main content

mdx_rust_core/
hardening.rs

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