1use 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}