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};
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}