Skip to main content

mdx_rust_core/
hardening.rs

1//! Safe scoped hardening for ordinary Rust modules.
2//!
3//! This is the v0.3 path for non-agent Rust code. It reuses the same boring
4//! safety philosophy as the optimizer: analyze, propose, validate in isolation,
5//! and only touch the real tree when explicitly requested.
6
7use crate::eval::stable_hash_hex;
8use mdx_rust_analysis::editing::{
9    cleanup_isolated_workspace, create_isolated_workspace, restore_transaction,
10    snapshot_transaction, validate_build_detailed_with_budget, ValidationCommandRecord,
11};
12use mdx_rust_analysis::{
13    analyze_hardening, HardeningAnalyzeConfig, HardeningFileChange, HardeningFinding,
14};
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use std::path::{Component, Path, PathBuf};
18use std::time::Duration;
19
20#[derive(Debug, Clone)]
21pub struct HardeningConfig {
22    pub target: Option<PathBuf>,
23    pub policy_path: Option<PathBuf>,
24    pub apply: bool,
25    pub max_files: usize,
26    pub validation_timeout: Duration,
27}
28
29impl Default for HardeningConfig {
30    fn default() -> Self {
31        Self {
32            target: None,
33            policy_path: None,
34            apply: false,
35            max_files: 100,
36            validation_timeout: Duration::from_secs(180),
37        }
38    }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
42pub struct HardeningRun {
43    pub schema_version: String,
44    pub root: String,
45    pub target: Option<String>,
46    pub mode: HardeningMode,
47    pub workspace: WorkspaceSummary,
48    pub policy: Option<HardeningPolicyRecord>,
49    pub files_scanned: usize,
50    pub findings: Vec<HardeningFinding>,
51    pub changes: Vec<HardeningChangeSummary>,
52    pub outcome: HardeningOutcome,
53    pub artifact_path: Option<String>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
57pub enum HardeningMode {
58    Review,
59    Apply,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
63pub struct WorkspaceSummary {
64    pub cargo_metadata_available: bool,
65    pub package_count: usize,
66    pub package_names: Vec<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
70pub struct HardeningPolicyRecord {
71    pub path: String,
72    pub hash: String,
73    pub rules: Vec<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
77pub struct HardeningChangeSummary {
78    pub file: String,
79    pub strategy: String,
80    pub finding_ids: Vec<String>,
81    pub description: String,
82    pub old_hash: String,
83    pub new_hash: String,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
87pub struct HardeningOutcome {
88    pub status: HardeningStatus,
89    pub isolated_validation_passed: bool,
90    pub applied: bool,
91    pub final_validation_passed: bool,
92    pub validation_commands: Vec<ValidationCommandRecord>,
93    pub final_validation_commands: Vec<ValidationCommandRecord>,
94    pub rollback_succeeded: Option<bool>,
95    pub rollback_error: Option<String>,
96    pub note: String,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
100pub enum HardeningStatus {
101    NoChanges,
102    Reviewed,
103    Applied,
104    ValidationFailed,
105    FinalValidationFailedRolledBack,
106    Rejected,
107}
108
109pub fn run_hardening(
110    root: &Path,
111    artifact_root: Option<&Path>,
112    config: &HardeningConfig,
113) -> anyhow::Result<HardeningRun> {
114    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
115    let analysis = analyze_hardening(
116        &root,
117        HardeningAnalyzeConfig {
118            target: config.target.as_deref(),
119            max_files: config.max_files,
120        },
121    )?;
122    let workspace = workspace_summary(&root);
123    let policy = load_policy(&root, config.policy_path.as_deref())?;
124    let mode = if config.apply {
125        HardeningMode::Apply
126    } else {
127        HardeningMode::Review
128    };
129    let changes = summarize_changes(&analysis.changes);
130
131    let outcome = if analysis.changes.is_empty() {
132        HardeningOutcome {
133            status: HardeningStatus::NoChanges,
134            isolated_validation_passed: false,
135            applied: false,
136            final_validation_passed: false,
137            validation_commands: Vec::new(),
138            final_validation_commands: Vec::new(),
139            rollback_succeeded: None,
140            rollback_error: None,
141            note: "no high-confidence hardening changes were available".to_string(),
142        }
143    } else {
144        execute_hardening_changes(&root, &analysis.changes, config)?
145    };
146
147    let mut run = HardeningRun {
148        schema_version: "0.3".to_string(),
149        root: root.display().to_string(),
150        target: config
151            .target
152            .as_ref()
153            .map(|path| path.display().to_string()),
154        mode,
155        workspace,
156        policy,
157        files_scanned: analysis.files_scanned,
158        findings: analysis.findings,
159        changes,
160        outcome,
161        artifact_path: None,
162    };
163
164    if let Some(artifact_root) = artifact_root {
165        let path = persist_hardening_run(artifact_root, &run)?;
166        run.artifact_path = Some(path.display().to_string());
167        std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
168    }
169
170    Ok(run)
171}
172
173fn execute_hardening_changes(
174    root: &Path,
175    changes: &[HardeningFileChange],
176    config: &HardeningConfig,
177) -> anyhow::Result<HardeningOutcome> {
178    ensure_scoped_changes(root, changes)?;
179
180    let isolated = create_isolated_workspace(root, "hardening-v0-3")?;
181    write_changes(&isolated, changes)?;
182    let validation = validate_build_detailed_with_budget(&isolated, config.validation_timeout);
183    cleanup_isolated_workspace(root, &isolated);
184
185    if !validation.passed {
186        return Ok(HardeningOutcome {
187            status: HardeningStatus::ValidationFailed,
188            isolated_validation_passed: false,
189            applied: false,
190            final_validation_passed: false,
191            validation_commands: validation.command_records,
192            final_validation_commands: Vec::new(),
193            rollback_succeeded: None,
194            rollback_error: None,
195            note: "proposed hardening changes failed isolated validation".to_string(),
196        });
197    }
198
199    if !config.apply {
200        return Ok(HardeningOutcome {
201            status: HardeningStatus::Reviewed,
202            isolated_validation_passed: true,
203            applied: false,
204            final_validation_passed: false,
205            validation_commands: validation.command_records,
206            final_validation_commands: Vec::new(),
207            rollback_succeeded: None,
208            rollback_error: None,
209            note: "changes validated in isolation; rerun with --apply to land them".to_string(),
210        });
211    }
212
213    let real_paths: Vec<PathBuf> = changes
214        .iter()
215        .map(|change| root.join(&change.file))
216        .collect();
217    let snapshot = snapshot_transaction(&real_paths)?;
218    write_changes(root, changes)?;
219    let final_validation = validate_build_detailed_with_budget(root, config.validation_timeout);
220
221    if final_validation.passed {
222        return Ok(HardeningOutcome {
223            status: HardeningStatus::Applied,
224            isolated_validation_passed: true,
225            applied: true,
226            final_validation_passed: true,
227            validation_commands: validation.command_records,
228            final_validation_commands: final_validation.command_records,
229            rollback_succeeded: None,
230            rollback_error: None,
231            note: "hardening changes applied and final validation passed".to_string(),
232        });
233    }
234
235    let rollback = restore_transaction(&snapshot);
236    let rollback_error = rollback.as_ref().err().map(ToString::to_string);
237    Ok(HardeningOutcome {
238        status: HardeningStatus::FinalValidationFailedRolledBack,
239        isolated_validation_passed: true,
240        applied: false,
241        final_validation_passed: false,
242        validation_commands: validation.command_records,
243        final_validation_commands: final_validation.command_records,
244        rollback_succeeded: Some(rollback.is_ok()),
245        rollback_error,
246        note: "final validation failed; transaction rollback attempted".to_string(),
247    })
248}
249
250fn ensure_scoped_changes(root: &Path, changes: &[HardeningFileChange]) -> anyhow::Result<()> {
251    if changes.is_empty() {
252        anyhow::bail!("hardening transaction has no changes");
253    }
254    for change in changes {
255        if change.file.components().any(|component| {
256            matches!(
257                component,
258                Component::ParentDir | Component::RootDir | Component::Prefix(_)
259            )
260        }) {
261            anyhow::bail!("unscoped hardening path: {}", change.file.display());
262        }
263        let target = root.join(&change.file);
264        if !target.starts_with(root) {
265            anyhow::bail!("hardening path escapes root: {}", change.file.display());
266        }
267    }
268    Ok(())
269}
270
271fn write_changes(root: &Path, changes: &[HardeningFileChange]) -> anyhow::Result<()> {
272    for change in changes {
273        let path = root.join(&change.file);
274        if let Some(parent) = path.parent() {
275            std::fs::create_dir_all(parent)?;
276        }
277        std::fs::write(path, &change.new_content)?;
278    }
279    Ok(())
280}
281
282fn summarize_changes(changes: &[HardeningFileChange]) -> Vec<HardeningChangeSummary> {
283    changes
284        .iter()
285        .map(|change| HardeningChangeSummary {
286            file: change.file.display().to_string(),
287            strategy: format!("{:?}", change.strategy),
288            finding_ids: change.finding_ids.clone(),
289            description: change.description.clone(),
290            old_hash: stable_hash_hex(change.old_content.as_bytes()),
291            new_hash: stable_hash_hex(change.new_content.as_bytes()),
292        })
293        .collect()
294}
295
296fn load_policy(
297    root: &Path,
298    policy_path: Option<&Path>,
299) -> anyhow::Result<Option<HardeningPolicyRecord>> {
300    let Some(policy_path) = policy_path else {
301        return Ok(None);
302    };
303    let path = if policy_path.is_absolute() {
304        policy_path.to_path_buf()
305    } else {
306        root.join(policy_path)
307    };
308    let content = std::fs::read(&path)?;
309    let rules = String::from_utf8_lossy(&content)
310        .lines()
311        .map(str::trim)
312        .filter(|line| {
313            line.starts_with("- ") || line.chars().next().is_some_and(|ch| ch.is_ascii_digit())
314        })
315        .take(20)
316        .map(|line| {
317            line.trim_start_matches("- ")
318                .trim_start_matches(|ch: char| ch.is_ascii_digit() || ch == '.' || ch == ')')
319                .trim()
320                .to_string()
321        })
322        .filter(|line| !line.is_empty())
323        .collect();
324    Ok(Some(HardeningPolicyRecord {
325        path: path.display().to_string(),
326        hash: stable_hash_hex(&content),
327        rules,
328    }))
329}
330
331fn persist_hardening_run(artifact_root: &Path, run: &HardeningRun) -> anyhow::Result<PathBuf> {
332    let dir = artifact_root.join("hardening");
333    std::fs::create_dir_all(&dir)?;
334    let millis = std::time::SystemTime::now()
335        .duration_since(std::time::UNIX_EPOCH)
336        .map(|duration| duration.as_millis())
337        .unwrap_or(0);
338    let mode = match run.mode {
339        HardeningMode::Review => "review",
340        HardeningMode::Apply => "apply",
341    };
342    Ok(dir.join(format!("hardening-{mode}-{millis}.json")))
343}
344
345fn workspace_summary(root: &Path) -> WorkspaceSummary {
346    let mut command = std::process::Command::new("cargo");
347    command
348        .current_dir(root)
349        .args(["metadata", "--no-deps", "--format-version", "1"]);
350    let Some(output) =
351        mdx_rust_analysis::editing::run_command_with_timeout(&mut command, Duration::from_secs(20))
352    else {
353        return WorkspaceSummary {
354            cargo_metadata_available: false,
355            package_count: 0,
356            package_names: Vec::new(),
357        };
358    };
359
360    if !output.success() {
361        return WorkspaceSummary {
362            cargo_metadata_available: false,
363            package_count: 0,
364            package_names: Vec::new(),
365        };
366    }
367
368    let value: serde_json::Value = match serde_json::from_str(&output.stdout) {
369        Ok(value) => value,
370        Err(_) => {
371            return WorkspaceSummary {
372                cargo_metadata_available: false,
373                package_count: 0,
374                package_names: Vec::new(),
375            }
376        }
377    };
378    let package_names: Vec<String> = value
379        .get("packages")
380        .and_then(|packages| packages.as_array())
381        .map(|packages| {
382            packages
383                .iter()
384                .filter_map(|package| package.get("name").and_then(|name| name.as_str()))
385                .map(ToString::to_string)
386                .collect()
387        })
388        .unwrap_or_default();
389    WorkspaceSummary {
390        cargo_metadata_available: true,
391        package_count: package_names.len(),
392        package_names,
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use tempfile::tempdir;
400
401    fn write_fixture(root: &Path) {
402        std::fs::write(
403            root.join("Cargo.toml"),
404            r#"[package]
405name = "hardening-fixture"
406version = "0.1.0"
407edition = "2021"
408
409[dependencies]
410anyhow = "1"
411"#,
412        )
413        .unwrap();
414        let src = root.join("src");
415        std::fs::create_dir_all(&src).unwrap();
416        std::fs::write(
417            src.join("lib.rs"),
418            r#"pub fn load_config() -> anyhow::Result<String> {
419    let content = std::fs::read_to_string("missing.toml").unwrap();
420    Ok(content)
421}
422"#,
423        )
424        .unwrap();
425    }
426
427    #[test]
428    fn hardening_review_validates_without_touching_real_tree() {
429        let dir = tempdir().unwrap();
430        write_fixture(dir.path());
431        let before = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
432
433        let run = run_hardening(
434            dir.path(),
435            None,
436            &HardeningConfig {
437                target: Some(PathBuf::from("src/lib.rs")),
438                validation_timeout: Duration::from_secs(120),
439                ..HardeningConfig::default()
440            },
441        )
442        .unwrap();
443
444        let after = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
445        assert_eq!(before, after);
446        assert_eq!(run.outcome.status, HardeningStatus::Reviewed);
447        assert!(run.outcome.isolated_validation_passed);
448        assert!(!run.changes.is_empty());
449    }
450
451    #[test]
452    fn hardening_apply_lands_validated_transaction() {
453        let dir = tempdir().unwrap();
454        write_fixture(dir.path());
455
456        let run = run_hardening(
457            dir.path(),
458            None,
459            &HardeningConfig {
460                target: Some(PathBuf::from("src/lib.rs")),
461                apply: true,
462                validation_timeout: Duration::from_secs(120),
463                ..HardeningConfig::default()
464            },
465        )
466        .unwrap();
467
468        let after = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
469        assert_eq!(run.outcome.status, HardeningStatus::Applied);
470        assert!(run.outcome.final_validation_passed);
471        assert!(after.contains("use anyhow::Context;"));
472        assert!(after.contains(".context(\"load_config failed instead of panicking\")?"));
473    }
474
475    #[test]
476    fn hardening_rejects_unscoped_transaction_paths() {
477        let dir = tempdir().unwrap();
478        let changes = vec![HardeningFileChange {
479            file: PathBuf::from("../escape.rs"),
480            old_content: String::new(),
481            new_content: String::new(),
482            strategy: mdx_rust_analysis::HardeningStrategy::ResultUnwrapContext,
483            finding_ids: vec!["escape".to_string()],
484            description: "bad path".to_string(),
485        }];
486
487        let err = ensure_scoped_changes(dir.path(), &changes).unwrap_err();
488        assert!(err.to_string().contains("unscoped hardening path"));
489    }
490}