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.9".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, "0.9");
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}