diff --git a/docs/dev/parallel-dev-harness.md b/docs/dev/parallel-dev-harness.md index d5572309..cd972fdc 100644 --- a/docs/dev/parallel-dev-harness.md +++ b/docs/dev/parallel-dev-harness.md @@ -318,7 +318,8 @@ owned (102 SIGTERM exits in the launchd log; #673). place (Docker; a macOS-only test on a disposable VM or a dedicated account). The Docker container is non-root (uid 1000) with `--network none`, no mounts, and no `--privileged`, `--pid=host`, or Docker socket; the tree enters only - through the `docker build` context or `docker cp`. + through the `docker build` context or `docker cp`. Run both baseline and + mutation passes under this unprivileged user (`USER 1000` or `--user 1000:1000`). - `cargo mutants`' `--timeout` does not reap processes a test left behind (for example a child that ignores `TERM`), so each run uses a disposable container and the whole container is discarded afterwards (`docker rm -f`). On the diff --git a/src/planner/auto_recovery/issue458_tests.rs b/src/planner/auto_recovery/issue458_tests.rs index 1f7aaaf7..472e5853 100644 --- a/src/planner/auto_recovery/issue458_tests.rs +++ b/src/planner/auto_recovery/issue458_tests.rs @@ -430,6 +430,9 @@ mod tests { std::fs::write(path, contract.to_string()).unwrap(); } if mode == "promotion" { + // Retain the read-only file permission to exercise the OS permission denial path + // on unprivileged accounts (uid 1000) when the test hook is absent (enabling + // two-point measurement on uid 0, where root bypasses DAC). let path = dir.path().join("app.txt"); let mut permissions = std::fs::metadata(&path).unwrap().permissions(); permissions.set_readonly(true); @@ -505,6 +508,15 @@ mod tests { } _ => {} } + let _hook_guard = if mode == "promotion" { + Some( + crate::planner::recovery_snapshot::test_hooks::fail_sync_source_tree_copy_for_workspace( + dir.path(), + ), + ) + } else { + None + }; let outcome = real.finish(1, &first, success("candidate execution finished")); assert!(outcome.result.is_err(), "{mode}: unexpected promotion"); assert_eq!(current_source_sha256(dir.path()).unwrap(), before, "{mode}"); diff --git a/src/planner/recovery_snapshot.rs b/src/planner/recovery_snapshot.rs index c8353bb7..4e248967 100644 --- a/src/planner/recovery_snapshot.rs +++ b/src/planner/recovery_snapshot.rs @@ -348,6 +348,14 @@ fn sync_source_tree( if let Some(parent) = target.parent() { std::fs::create_dir_all(parent)?; } + #[cfg(test)] + if test_hooks::should_fail_sync_copy(destination_root) { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("simulated failure copying {}", relative.display()), + )) + .with_context(|| format!("restore Recovery control artifact {}", relative.display())); + } std::fs::copy(source, &target) .with_context(|| format!("restore Recovery control artifact {}", relative.display()))?; if let Ok(metadata) = std::fs::metadata(source) { @@ -524,6 +532,61 @@ fn excluded(relative: &Path) -> bool { ) } +#[cfg(test)] +pub(crate) mod test_hooks { + use std::cell::RefCell; + use std::path::{Path, PathBuf}; + + thread_local! { + static FAIL_SYNC_COPY_WORKSPACE: RefCell> = const { RefCell::new(None) }; + } + + #[must_use] + pub(crate) struct FailSyncCopyGuard { + _private: (), + } + + impl Drop for FailSyncCopyGuard { + fn drop(&mut self) { + FAIL_SYNC_COPY_WORKSPACE.with(|cell| { + cell.borrow_mut().take(); + }); + } + } + + /// Arms a thread-local fault injection hook that causes `sync_source_tree` + /// to fail when copying an artifact into the specified workspace root. + /// + /// The hook consumes itself upon the first injected failure so that any + /// subsequent control retention / transaction rollback can succeed, and + /// resets automatically when the returned guard is dropped. + pub(crate) fn fail_sync_source_tree_copy_for_workspace( + workspace_root: &Path, + ) -> FailSyncCopyGuard { + let canonical = workspace_root + .canonicalize() + .unwrap_or_else(|_| workspace_root.to_path_buf()); + FAIL_SYNC_COPY_WORKSPACE.with(|cell| { + *cell.borrow_mut() = Some(canonical); + }); + FailSyncCopyGuard { _private: () } + } + + pub(crate) fn should_fail_sync_copy(destination_root: &Path) -> bool { + let canonical = destination_root + .canonicalize() + .unwrap_or_else(|_| destination_root.to_path_buf()); + FAIL_SYNC_COPY_WORKSPACE.with(|cell| { + let mut borrow = cell.borrow_mut(); + if borrow.as_ref() == Some(&canonical) { + borrow.take(); + return true; + } + false + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -690,4 +753,32 @@ mod tests { "fca205d27f585d85835e310c03faf89448c6707cc889e2fda18085ae527122bf" ); } + + #[test] + fn test_hooks_fail_sync_copy_is_scoped_and_resets_on_drop() { + let dir1 = tempfile::tempdir().unwrap(); + let dir2 = tempfile::tempdir().unwrap(); + + assert!(!test_hooks::should_fail_sync_copy(dir1.path())); + assert!(!test_hooks::should_fail_sync_copy(dir2.path())); + + { + let _guard = test_hooks::fail_sync_source_tree_copy_for_workspace(dir1.path()); + // dir2 must not be affected + assert!(!test_hooks::should_fail_sync_copy(dir2.path())); + // dir1 should trigger and consume + assert!(test_hooks::should_fail_sync_copy(dir1.path())); + // after consuming, dir1 returns false + assert!(!test_hooks::should_fail_sync_copy(dir1.path())); + } + + // After guard drops, still false + assert!(!test_hooks::should_fail_sync_copy(dir1.path())); + + // Check that drop resets even without consuming + { + let _guard = test_hooks::fail_sync_source_tree_copy_for_workspace(dir1.path()); + } + assert!(!test_hooks::should_fail_sync_copy(dir1.path())); + } } diff --git a/src/tools/path_guard.rs b/src/tools/path_guard.rs index bd2f791f..56bb292e 100644 --- a/src/tools/path_guard.rs +++ b/src/tools/path_guard.rs @@ -988,13 +988,15 @@ mod tests { std::fs::create_dir_all(&locked).unwrap(); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); - if std::fs::read_dir(&locked).is_ok() { - std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); - return; - } - + let probe = std::fs::read_dir(&locked); let result = ensure_bash_write_target(&root, "locked/f.txt"); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); + + assert_eq!( + probe.as_ref().err().map(|e| e.kind()), + Some(std::io::ErrorKind::PermissionDenied), + "this test must exercise actual EACCES; run under an unprivileged account" + ); assert!(result.is_err()); } diff --git a/tests/missing_read_paths.rs b/tests/missing_read_paths.rs index fc92603f..31b219c2 100644 --- a/tests/missing_read_paths.rs +++ b/tests/missing_read_paths.rs @@ -227,8 +227,8 @@ fn actual_permission_denial_is_not_ordinary_missing() { let result = read_error(&context(root.path()), "private/missing"); std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o700)).unwrap(); assert_eq!( - probe.unwrap_err().kind(), - std::io::ErrorKind::PermissionDenied, + probe.as_ref().err().map(|e| e.kind()), + Some(std::io::ErrorKind::PermissionDenied), "this test must exercise actual EACCES; run under an unprivileged account" ); assert_ne!(tool_error_kind(&result), MISSING);