Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/dev/parallel-dev-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/planner/auto_recovery/issue458_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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}");
Expand Down
91 changes: 91 additions & 0 deletions src/planner/recovery_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<Option<PathBuf>> = 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::*;
Expand Down Expand Up @@ -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()));
}
}
12 changes: 7 additions & 5 deletions src/tools/path_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down
4 changes: 2 additions & 2 deletions tests/missing_read_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading