1use crate::eval::{run_behavior_evals, stable_hash_hex, BehaviorEvalReport};
8use crate::policy::{load_project_policy, PolicyCategory, ProjectPolicy};
9use mdx_rust_analysis::editing::{
10 cleanup_isolated_workspace, create_isolated_workspace, restore_transaction,
11 snapshot_transaction, validate_build_detailed_with_budget, ValidationCommandRecord,
12};
13use mdx_rust_analysis::{
14 analyze_hardening, HardeningAnalyzeConfig, HardeningFileChange, HardeningFinding,
15};
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use std::path::{Component, Path, PathBuf};
19use std::time::Duration;
20
21#[derive(Debug, Clone)]
22pub struct HardeningConfig {
23 pub target: Option<PathBuf>,
24 pub policy_path: Option<PathBuf>,
25 pub behavior_spec_path: Option<PathBuf>,
26 pub apply: bool,
27 pub max_files: usize,
28 pub validation_timeout: Duration,
29}
30
31impl Default for HardeningConfig {
32 fn default() -> Self {
33 Self {
34 target: None,
35 policy_path: None,
36 behavior_spec_path: None,
37 apply: false,
38 max_files: 100,
39 validation_timeout: Duration::from_secs(180),
40 }
41 }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
45pub struct HardeningRun {
46 pub schema_version: String,
47 pub root: String,
48 pub target: Option<String>,
49 pub mode: HardeningMode,
50 pub workspace: WorkspaceSummary,
51 pub policy: Option<ProjectPolicy>,
52 pub policy_matches: Vec<PolicyFindingMatch>,
53 pub risk_summary: HardeningRiskSummary,
54 pub files_scanned: usize,
55 pub findings: Vec<HardeningFinding>,
56 pub changes: Vec<HardeningChangeSummary>,
57 pub behavior_evaluation: Option<BehaviorEvalReport>,
58 pub final_behavior_evaluation: Option<BehaviorEvalReport>,
59 pub outcome: HardeningOutcome,
60 pub artifact_path: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
64pub enum HardeningMode {
65 Review,
66 Apply,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
70pub struct WorkspaceSummary {
71 pub cargo_metadata_available: bool,
72 pub package_count: usize,
73 pub package_names: Vec<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
77pub struct PolicyFindingMatch {
78 pub finding_id: String,
79 pub rule_id: String,
80 pub category: PolicyCategory,
81 pub reason: String,
82}
83
84pub type HardeningPolicyRecord = ProjectPolicy;
85
86#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
87pub struct HardeningRiskSummary {
88 pub high: usize,
89 pub medium: usize,
90 pub low: usize,
91 pub patchable: usize,
92 pub top_recommendations: Vec<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
96pub struct HardeningChangeSummary {
97 pub file: String,
98 pub strategy: String,
99 pub finding_ids: Vec<String>,
100 pub description: String,
101 pub old_hash: String,
102 pub new_hash: String,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
106pub struct HardeningOutcome {
107 pub status: HardeningStatus,
108 pub isolated_validation_passed: bool,
109 pub applied: bool,
110 pub final_validation_passed: bool,
111 pub validation_commands: Vec<ValidationCommandRecord>,
112 pub final_validation_commands: Vec<ValidationCommandRecord>,
113 pub behavior_evaluation: Option<BehaviorEvalReport>,
114 pub final_behavior_evaluation: Option<BehaviorEvalReport>,
115 pub rollback_succeeded: Option<bool>,
116 pub rollback_error: Option<String>,
117 pub note: String,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
121pub enum HardeningStatus {
122 NoChanges,
123 Reviewed,
124 Applied,
125 ValidationFailed,
126 BehaviorValidationFailed,
127 FinalValidationFailedRolledBack,
128 Rejected,
129}
130
131pub fn run_hardening(
132 root: &Path,
133 artifact_root: Option<&Path>,
134 config: &HardeningConfig,
135) -> anyhow::Result<HardeningRun> {
136 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
137 let analysis = analyze_hardening(
138 &root,
139 HardeningAnalyzeConfig {
140 target: config.target.as_deref(),
141 max_files: config.max_files,
142 },
143 )?;
144 let workspace = workspace_summary(&root);
145 let policy = load_project_policy(&root, config.policy_path.as_deref())?;
146 let policy_matches = policy
147 .as_ref()
148 .map(|policy| match_findings_to_policy(&analysis.findings, policy))
149 .unwrap_or_default();
150 let mode = if config.apply {
151 HardeningMode::Apply
152 } else {
153 HardeningMode::Review
154 };
155 let changes = summarize_changes(&analysis.changes);
156 let risk_summary = summarize_risk(&analysis.findings);
157
158 let outcome = if analysis.changes.is_empty() {
159 HardeningOutcome {
160 status: HardeningStatus::NoChanges,
161 isolated_validation_passed: false,
162 applied: false,
163 final_validation_passed: false,
164 validation_commands: Vec::new(),
165 final_validation_commands: Vec::new(),
166 behavior_evaluation: None,
167 final_behavior_evaluation: None,
168 rollback_succeeded: None,
169 rollback_error: None,
170 note: "no high-confidence hardening changes were available".to_string(),
171 }
172 } else {
173 execute_hardening_changes(&root, &analysis.changes, config)?
174 };
175
176 let mut run = HardeningRun {
177 schema_version: "0.4".to_string(),
178 root: root.display().to_string(),
179 target: config
180 .target
181 .as_ref()
182 .map(|path| path.display().to_string()),
183 mode,
184 workspace,
185 policy,
186 policy_matches,
187 risk_summary,
188 files_scanned: analysis.files_scanned,
189 findings: analysis.findings,
190 changes,
191 behavior_evaluation: outcome.behavior_evaluation.clone(),
192 final_behavior_evaluation: outcome.final_behavior_evaluation.clone(),
193 outcome,
194 artifact_path: None,
195 };
196
197 if let Some(artifact_root) = artifact_root {
198 let path = persist_hardening_run(artifact_root, &run)?;
199 run.artifact_path = Some(path.display().to_string());
200 std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
201 }
202
203 Ok(run)
204}
205
206fn execute_hardening_changes(
207 root: &Path,
208 changes: &[HardeningFileChange],
209 config: &HardeningConfig,
210) -> anyhow::Result<HardeningOutcome> {
211 ensure_scoped_changes(root, changes)?;
212 let behavior_spec_path = config
213 .behavior_spec_path
214 .as_deref()
215 .map(|path| resolve_behavior_spec_path(root, path));
216
217 let isolated = create_isolated_workspace(root, "hardening-v0-4")?;
218 write_changes(&isolated, changes)?;
219 let validation = validate_build_detailed_with_budget(&isolated, config.validation_timeout);
220 let behavior_evaluation = if validation.passed {
221 run_optional_behavior_eval(&isolated, behavior_spec_path.as_deref())?
222 } else {
223 None
224 };
225 cleanup_isolated_workspace(root, &isolated);
226
227 if !validation.passed {
228 return Ok(HardeningOutcome {
229 status: HardeningStatus::ValidationFailed,
230 isolated_validation_passed: false,
231 applied: false,
232 final_validation_passed: false,
233 validation_commands: validation.command_records,
234 final_validation_commands: Vec::new(),
235 rollback_succeeded: None,
236 rollback_error: None,
237 note: "proposed hardening changes failed isolated validation".to_string(),
238 behavior_evaluation: None,
239 final_behavior_evaluation: None,
240 });
241 }
242
243 if behavior_evaluation
244 .as_ref()
245 .is_some_and(|report| !report.passed())
246 {
247 return Ok(HardeningOutcome {
248 status: HardeningStatus::BehaviorValidationFailed,
249 isolated_validation_passed: true,
250 applied: false,
251 final_validation_passed: false,
252 validation_commands: validation.command_records,
253 final_validation_commands: Vec::new(),
254 rollback_succeeded: None,
255 rollback_error: None,
256 note: "proposed hardening changes failed behavior evaluation".to_string(),
257 behavior_evaluation,
258 final_behavior_evaluation: None,
259 });
260 }
261
262 if !config.apply {
263 return Ok(HardeningOutcome {
264 status: HardeningStatus::Reviewed,
265 isolated_validation_passed: true,
266 applied: false,
267 final_validation_passed: false,
268 validation_commands: validation.command_records,
269 final_validation_commands: Vec::new(),
270 rollback_succeeded: None,
271 rollback_error: None,
272 note: "changes validated in isolation; rerun with --apply to land them".to_string(),
273 behavior_evaluation,
274 final_behavior_evaluation: None,
275 });
276 }
277
278 let real_paths: Vec<PathBuf> = changes
279 .iter()
280 .map(|change| root.join(&change.file))
281 .collect();
282 let snapshot = snapshot_transaction(&real_paths)?;
283 write_changes(root, changes)?;
284 let final_validation = validate_build_detailed_with_budget(root, config.validation_timeout);
285 let final_behavior_evaluation = if final_validation.passed {
286 run_optional_behavior_eval(root, behavior_spec_path.as_deref())?
287 } else {
288 None
289 };
290
291 if final_validation.passed
292 && final_behavior_evaluation
293 .as_ref()
294 .map(BehaviorEvalReport::passed)
295 .unwrap_or(true)
296 {
297 return Ok(HardeningOutcome {
298 status: HardeningStatus::Applied,
299 isolated_validation_passed: true,
300 applied: true,
301 final_validation_passed: true,
302 validation_commands: validation.command_records,
303 final_validation_commands: final_validation.command_records,
304 rollback_succeeded: None,
305 rollback_error: None,
306 note: "hardening changes applied and final validation passed".to_string(),
307 behavior_evaluation,
308 final_behavior_evaluation,
309 });
310 }
311
312 let rollback = restore_transaction(&snapshot);
313 let rollback_error = rollback.as_ref().err().map(ToString::to_string);
314 Ok(HardeningOutcome {
315 status: HardeningStatus::FinalValidationFailedRolledBack,
316 isolated_validation_passed: true,
317 applied: false,
318 final_validation_passed: false,
319 validation_commands: validation.command_records,
320 final_validation_commands: final_validation.command_records,
321 rollback_succeeded: Some(rollback.is_ok()),
322 rollback_error,
323 note: if final_validation.passed {
324 "final behavior evaluation failed; transaction rollback attempted".to_string()
325 } else {
326 "final validation failed; transaction rollback attempted".to_string()
327 },
328 behavior_evaluation,
329 final_behavior_evaluation,
330 })
331}
332
333fn run_optional_behavior_eval(
334 root: &Path,
335 spec_path: Option<&Path>,
336) -> anyhow::Result<Option<BehaviorEvalReport>> {
337 let Some(spec_path) = spec_path else {
338 return Ok(None);
339 };
340 Ok(Some(run_behavior_evals(root, spec_path)?))
341}
342
343fn resolve_behavior_spec_path(root: &Path, spec_path: &Path) -> PathBuf {
344 if spec_path.is_absolute() {
345 spec_path.to_path_buf()
346 } else {
347 root.join(spec_path)
348 }
349}
350
351fn ensure_scoped_changes(root: &Path, changes: &[HardeningFileChange]) -> anyhow::Result<()> {
352 if changes.is_empty() {
353 anyhow::bail!("hardening transaction has no changes");
354 }
355 for change in changes {
356 if change.file.components().any(|component| {
357 matches!(
358 component,
359 Component::ParentDir | Component::RootDir | Component::Prefix(_)
360 )
361 }) {
362 anyhow::bail!("unscoped hardening path: {}", change.file.display());
363 }
364 let target = root.join(&change.file);
365 if !target.starts_with(root) {
366 anyhow::bail!("hardening path escapes root: {}", change.file.display());
367 }
368 }
369 Ok(())
370}
371
372fn write_changes(root: &Path, changes: &[HardeningFileChange]) -> anyhow::Result<()> {
373 for change in changes {
374 let path = root.join(&change.file);
375 if let Some(parent) = path.parent() {
376 std::fs::create_dir_all(parent)?;
377 }
378 std::fs::write(path, &change.new_content)?;
379 }
380 Ok(())
381}
382
383fn summarize_changes(changes: &[HardeningFileChange]) -> Vec<HardeningChangeSummary> {
384 changes
385 .iter()
386 .map(|change| HardeningChangeSummary {
387 file: change.file.display().to_string(),
388 strategy: format!("{:?}", change.strategy),
389 finding_ids: change.finding_ids.clone(),
390 description: change.description.clone(),
391 old_hash: stable_hash_hex(change.old_content.as_bytes()),
392 new_hash: stable_hash_hex(change.new_content.as_bytes()),
393 })
394 .collect()
395}
396
397fn match_findings_to_policy(
398 findings: &[HardeningFinding],
399 policy: &ProjectPolicy,
400) -> Vec<PolicyFindingMatch> {
401 findings
402 .iter()
403 .filter_map(|finding| {
404 let category = category_for_finding(finding);
405 let rule = policy
406 .rules
407 .iter()
408 .find(|rule| rule.category == category)
409 .or_else(|| {
410 policy
411 .rules
412 .iter()
413 .find(|rule| rule.category == PolicyCategory::General)
414 })?;
415 Some(PolicyFindingMatch {
416 finding_id: finding.id.clone(),
417 rule_id: rule.id.clone(),
418 category: rule.category.clone(),
419 reason: format!("finding maps to policy rule on line {}", rule.line),
420 })
421 })
422 .collect()
423}
424
425fn category_for_finding(finding: &HardeningFinding) -> PolicyCategory {
426 match finding.strategy {
427 mdx_rust_analysis::HardeningStrategy::BorrowParameterTightening
428 | mdx_rust_analysis::HardeningStrategy::IteratorCloned
429 | mdx_rust_analysis::HardeningStrategy::MechanicalTier1Cleanup
430 | mdx_rust_analysis::HardeningStrategy::MustUsePublicReturn => PolicyCategory::General,
431 mdx_rust_analysis::HardeningStrategy::ErrorContextPropagation => {
432 PolicyCategory::ErrorContext
433 }
434 mdx_rust_analysis::HardeningStrategy::ResultUnwrapContext => PolicyCategory::PanicSafety,
435 mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview => {
436 PolicyCategory::ProcessExecution
437 }
438 mdx_rust_analysis::HardeningStrategy::UnsafeReview => PolicyCategory::UnsafeCode,
439 mdx_rust_analysis::HardeningStrategy::EnvAccessReview => PolicyCategory::Environment,
440 mdx_rust_analysis::HardeningStrategy::FileIoReview => PolicyCategory::ErrorContext,
441 mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview => PolicyCategory::InputValidation,
442 }
443}
444
445fn summarize_risk(findings: &[HardeningFinding]) -> HardeningRiskSummary {
446 let mut summary = HardeningRiskSummary {
447 high: 0,
448 medium: 0,
449 low: 0,
450 patchable: findings.iter().filter(|finding| finding.patchable).count(),
451 top_recommendations: Vec::new(),
452 };
453
454 let mut saw_patchable = false;
455 let mut saw_process = false;
456 let mut saw_http = false;
457 let mut saw_file = false;
458
459 for finding in findings {
460 match finding.strategy {
461 mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview
462 | mdx_rust_analysis::HardeningStrategy::UnsafeReview => summary.high += 1,
463 mdx_rust_analysis::HardeningStrategy::BorrowParameterTightening
464 | mdx_rust_analysis::HardeningStrategy::IteratorCloned
465 | mdx_rust_analysis::HardeningStrategy::MechanicalTier1Cleanup
466 | mdx_rust_analysis::HardeningStrategy::MustUsePublicReturn => summary.low += 1,
467 mdx_rust_analysis::HardeningStrategy::ErrorContextPropagation
468 | mdx_rust_analysis::HardeningStrategy::ResultUnwrapContext
469 | mdx_rust_analysis::HardeningStrategy::EnvAccessReview
470 | mdx_rust_analysis::HardeningStrategy::FileIoReview
471 | mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview => summary.medium += 1,
472 }
473
474 saw_patchable |= finding.patchable;
475 saw_process |= matches!(
476 finding.strategy,
477 mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview
478 );
479 saw_http |= matches!(
480 finding.strategy,
481 mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview
482 );
483 saw_file |= matches!(
484 finding.strategy,
485 mdx_rust_analysis::HardeningStrategy::FileIoReview
486 );
487 }
488
489 if saw_patchable {
490 summary
491 .top_recommendations
492 .push("Run mdx-rust improve <target> --apply after reviewing the proposed Tier 1 mechanical changes.".to_string());
493 }
494 if saw_process {
495 summary.top_recommendations.push(
496 "Review process execution callsites for allowlisted commands and validated arguments."
497 .to_string(),
498 );
499 }
500 if saw_http {
501 summary.top_recommendations.push(
502 "Review HTTP route surfaces for request validation and typed error handling."
503 .to_string(),
504 );
505 }
506 if saw_file {
507 summary.top_recommendations.push(
508 "Review filesystem boundaries for contextual errors and path validation.".to_string(),
509 );
510 }
511
512 summary
513}
514
515fn persist_hardening_run(artifact_root: &Path, run: &HardeningRun) -> anyhow::Result<PathBuf> {
516 let dir = artifact_root.join("hardening");
517 std::fs::create_dir_all(&dir)?;
518 let millis = std::time::SystemTime::now()
519 .duration_since(std::time::UNIX_EPOCH)
520 .map(|duration| duration.as_millis())
521 .unwrap_or(0);
522 let mode = match run.mode {
523 HardeningMode::Review => "review",
524 HardeningMode::Apply => "apply",
525 };
526 Ok(dir.join(format!("hardening-{mode}-{millis}.json")))
527}
528
529pub(crate) fn workspace_summary(root: &Path) -> WorkspaceSummary {
530 let mut command = std::process::Command::new("cargo");
531 command
532 .current_dir(root)
533 .args(["metadata", "--no-deps", "--format-version", "1"]);
534 let Some(output) =
535 mdx_rust_analysis::editing::run_command_with_timeout(&mut command, Duration::from_secs(20))
536 else {
537 return WorkspaceSummary {
538 cargo_metadata_available: false,
539 package_count: 0,
540 package_names: Vec::new(),
541 };
542 };
543
544 if !output.success() {
545 return WorkspaceSummary {
546 cargo_metadata_available: false,
547 package_count: 0,
548 package_names: Vec::new(),
549 };
550 }
551
552 let value: serde_json::Value = match serde_json::from_str(&output.stdout) {
553 Ok(value) => value,
554 Err(_) => {
555 return WorkspaceSummary {
556 cargo_metadata_available: false,
557 package_count: 0,
558 package_names: Vec::new(),
559 }
560 }
561 };
562 let package_names: Vec<String> = value
563 .get("packages")
564 .and_then(|packages| packages.as_array())
565 .map(|packages| {
566 packages
567 .iter()
568 .filter_map(|package| package.get("name").and_then(|name| name.as_str()))
569 .map(ToString::to_string)
570 .collect()
571 })
572 .unwrap_or_default();
573 WorkspaceSummary {
574 cargo_metadata_available: true,
575 package_count: package_names.len(),
576 package_names,
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583 use tempfile::tempdir;
584
585 fn write_fixture(root: &Path) {
586 std::fs::write(
587 root.join("Cargo.toml"),
588 r#"[package]
589name = "hardening-fixture"
590version = "0.1.0"
591edition = "2021"
592
593[dependencies]
594anyhow = "1"
595"#,
596 )
597 .unwrap();
598 let src = root.join("src");
599 std::fs::create_dir_all(&src).unwrap();
600 std::fs::write(
601 src.join("lib.rs"),
602 r#"pub fn load_config() -> anyhow::Result<String> {
603 let content = std::fs::read_to_string("missing.toml").unwrap();
604 Ok(content)
605}
606"#,
607 )
608 .unwrap();
609 }
610
611 fn write_behavior_spec(root: &Path, expected: &str) -> PathBuf {
612 let path = root.join("evals.json");
613 std::fs::write(
614 &path,
615 format!(
616 r#"{{
617 "version": "v1",
618 "commands": [
619 {{
620 "id": "cargo-check",
621 "command": "cargo",
622 "args": ["check"],
623 "expect_success": true,
624 "expect_stderr_contains": ["{expected}"],
625 "timeout_seconds": 120
626 }}
627 ]
628}}"#
629 ),
630 )
631 .unwrap();
632 path
633 }
634
635 fn write_artifact_behavior_spec(root: &Path) -> PathBuf {
636 let dir = root.join(".mdx-rust");
637 std::fs::create_dir_all(&dir).unwrap();
638 let path = dir.join("evals.json");
639 std::fs::write(
640 &path,
641 r#"{
642 "version": "v1",
643 "commands": [
644 {
645 "id": "cargo-check",
646 "command": "cargo",
647 "args": ["check"],
648 "expect_success": true,
649 "timeout_seconds": 120
650 }
651 ]
652}"#,
653 )
654 .unwrap();
655 PathBuf::from(".mdx-rust/evals.json")
656 }
657
658 #[test]
659 fn hardening_review_validates_without_touching_real_tree() {
660 let dir = tempdir().unwrap();
661 write_fixture(dir.path());
662 let before = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
663
664 let run = run_hardening(
665 dir.path(),
666 None,
667 &HardeningConfig {
668 target: Some(PathBuf::from("src/lib.rs")),
669 validation_timeout: Duration::from_secs(120),
670 ..HardeningConfig::default()
671 },
672 )
673 .unwrap();
674
675 let after = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
676 assert_eq!(before, after);
677 assert_eq!(run.outcome.status, HardeningStatus::Reviewed);
678 assert_eq!(run.schema_version, "0.4");
679 assert!(run.outcome.isolated_validation_passed);
680 assert!(!run.changes.is_empty());
681 }
682
683 #[test]
684 fn hardening_apply_lands_validated_transaction() {
685 let dir = tempdir().unwrap();
686 write_fixture(dir.path());
687
688 let run = run_hardening(
689 dir.path(),
690 None,
691 &HardeningConfig {
692 target: Some(PathBuf::from("src/lib.rs")),
693 apply: true,
694 validation_timeout: Duration::from_secs(120),
695 ..HardeningConfig::default()
696 },
697 )
698 .unwrap();
699
700 let after = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
701 assert_eq!(run.outcome.status, HardeningStatus::Applied);
702 assert!(run.outcome.final_validation_passed);
703 assert!(after.contains("use anyhow::Context;"));
704 assert!(after.contains(".context(\"load_config failed instead of panicking\")?"));
705 }
706
707 #[test]
708 fn hardening_maps_findings_to_structured_policy_rules() {
709 let dir = tempdir().unwrap();
710 write_fixture(dir.path());
711 std::fs::create_dir_all(dir.path().join(".mdx-rust")).unwrap();
712 std::fs::write(
713 dir.path().join(".mdx-rust/policies.md"),
714 "1. Avoid unwrap in service boundaries.",
715 )
716 .unwrap();
717
718 let run = run_hardening(
719 dir.path(),
720 None,
721 &HardeningConfig {
722 target: Some(PathBuf::from("src/lib.rs")),
723 validation_timeout: Duration::from_secs(120),
724 ..HardeningConfig::default()
725 },
726 )
727 .unwrap();
728
729 assert!(run.policy.is_some());
730 assert!(!run.policy_matches.is_empty());
731 assert_eq!(run.policy_matches[0].category, PolicyCategory::PanicSafety);
732 }
733
734 #[test]
735 fn hardening_behavior_eval_failure_blocks_apply() {
736 let dir = tempdir().unwrap();
737 write_fixture(dir.path());
738 let spec = write_behavior_spec(dir.path(), "definitely-not-in-cargo-output");
739 let before = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
740
741 let run = run_hardening(
742 dir.path(),
743 None,
744 &HardeningConfig {
745 target: Some(PathBuf::from("src/lib.rs")),
746 behavior_spec_path: Some(spec),
747 apply: true,
748 validation_timeout: Duration::from_secs(120),
749 ..HardeningConfig::default()
750 },
751 )
752 .unwrap();
753
754 let after = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
755 assert_eq!(before, after);
756 assert_eq!(
757 run.outcome.status,
758 HardeningStatus::BehaviorValidationFailed
759 );
760 assert!(run.outcome.behavior_evaluation.is_some());
761 }
762
763 #[test]
764 fn hardening_behavior_eval_spec_in_artifact_dir_runs_in_isolation() {
765 let dir = tempdir().unwrap();
766 write_fixture(dir.path());
767 let spec = write_artifact_behavior_spec(dir.path());
768
769 let run = run_hardening(
770 dir.path(),
771 None,
772 &HardeningConfig {
773 target: Some(PathBuf::from("src/lib.rs")),
774 behavior_spec_path: Some(spec),
775 apply: true,
776 validation_timeout: Duration::from_secs(120),
777 ..HardeningConfig::default()
778 },
779 )
780 .unwrap();
781
782 assert_eq!(run.outcome.status, HardeningStatus::Applied);
783 assert!(run
784 .outcome
785 .behavior_evaluation
786 .as_ref()
787 .is_some_and(BehaviorEvalReport::passed));
788 assert!(run
789 .outcome
790 .final_behavior_evaluation
791 .as_ref()
792 .is_some_and(BehaviorEvalReport::passed));
793 }
794
795 #[test]
796 fn hardening_rejects_unscoped_transaction_paths() {
797 let dir = tempdir().unwrap();
798 let changes = vec![HardeningFileChange {
799 file: PathBuf::from("../escape.rs"),
800 old_content: String::new(),
801 new_content: String::new(),
802 strategy: mdx_rust_analysis::HardeningStrategy::ResultUnwrapContext,
803 finding_ids: vec!["escape".to_string()],
804 description: "bad path".to_string(),
805 }];
806
807 let err = ensure_scoped_changes(dir.path(), &changes).unwrap_err();
808 assert!(err.to_string().contains("unscoped hardening path"));
809 }
810}