diff --git a/src/adapter.rs b/src/adapter.rs index 8d6ff14..e0d3fae 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -1899,12 +1899,11 @@ pub fn verify_member(name: &str) -> Result<()> { #[cfg(all(test, unix))] mod quipu_alias_layout_tests { use super::verify_quipu_alias; - use std::os::unix::fs::{symlink, PermissionsExt}; + use std::os::unix::fs::symlink; use std::{fs, path::Path}; fn script(path: &Path, version: &str) { - fs::write(path, format!("#!/bin/sh\necho '{version}'\n")).unwrap(); - fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); + crate::test_exec::write_executable(path, &format!("#!/bin/sh\necho '{version}'\n")); } #[test] diff --git a/src/adapter/manifest.rs b/src/adapter/manifest.rs index 2e16cb6..1d36436 100644 --- a/src/adapter/manifest.rs +++ b/src/adapter/manifest.rs @@ -862,7 +862,6 @@ mod release_verification_tests { #[cfg(all(test, unix))] mod seeds_identity_tests { use super::*; - use std::os::unix::fs::PermissionsExt; #[test] fn seeds_identity_accepts_both_editions_and_refuses_foreign_sd() { @@ -877,8 +876,10 @@ mod seeds_identity_tests { ("sd is an intuitive find and replace CLI", false), ("seeds is a different program", false), ] { - std::fs::write(&executable, format!("#!/bin/sh\nprintf '%s\\n' '{text}'\n")).unwrap(); - std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + crate::test_exec::write_executable( + &executable, + &format!("#!/bin/sh\nprintf '%s\\n' '{text}'\n"), + ); assert_eq!(identify(&executable, program).is_ok(), accepted, "{text}"); } } diff --git a/src/apt_owned.rs b/src/apt_owned.rs index 83190e8..41fbef3 100644 --- a/src/apt_owned.rs +++ b/src/apt_owned.rs @@ -66,12 +66,11 @@ fn refuse_if_owned(tool: &str, path: &Path, owner: Option) -> anyhow::Re #[cfg(all(test, unix))] mod tests { use super::*; - use std::os::unix::fs::{symlink, PermissionsExt}; + use std::os::unix::fs::symlink; fn fake_dpkg_query(dir: &Path, body: &str) -> std::path::PathBuf { let path = dir.join("dpkg-query"); - fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); - fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + crate::test_exec::write_executable(&path, &format!("#!/bin/sh\n{body}\n")); path } diff --git a/src/dp_alias.rs b/src/dp_alias.rs index f4013ae..113856a 100644 --- a/src/dp_alias.rs +++ b/src/dp_alias.rs @@ -207,9 +207,8 @@ mod tests { /// first: delete that read and `keeps_a_different_local_target` fails. #[cfg(unix)] fn fake_dp(dir: &Path) -> PathBuf { - use std::os::unix::fs::PermissionsExt; let script = dir.join("dp"); - std::fs::write( + crate::test_exec::write_executable( &script, r#"#!/bin/sh store="$(dirname "$0")/bd.rule" @@ -229,9 +228,7 @@ case "$1" in *) exit 2 ;; esac "#, - ) - .unwrap(); - std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + ); script } diff --git a/src/lib.rs b/src/lib.rs index 7ba1042..34dacc5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,4 +31,6 @@ mod quipu_auth; mod quipu_credential; #[cfg(unix)] pub mod quipu_mcp; +#[cfg(all(test, unix))] +mod test_exec; pub mod vocabulary; diff --git a/src/release_update/rollback.rs b/src/release_update/rollback.rs index 0a8e22f..a98144a 100644 --- a/src/release_update/rollback.rs +++ b/src/release_update/rollback.rs @@ -185,7 +185,6 @@ fn rollback_at( #[cfg(test)] mod tests { use super::*; - use std::os::unix::fs::PermissionsExt; fn fixture() -> (tempfile::TempDir, Plan, PathBuf, PathBuf, String, String) { let dir = tempfile::tempdir().unwrap(); let state_path = dir.path().join("state.json"); @@ -213,10 +212,8 @@ mod tests { }; let destination = dir.path().join("sd"); let prior = dir.path().join("prior"); - fs::write(&destination, script("0.2.2")).unwrap(); - fs::write(&prior, script("0.2.0")).unwrap(); - fs::set_permissions(&destination, fs::Permissions::from_mode(0o755)).unwrap(); - fs::set_permissions(&prior, fs::Permissions::from_mode(0o755)).unwrap(); + crate::test_exec::write_executable(&destination, &script("0.2.2")); + crate::test_exec::write_executable(&prior, &script("0.2.0")); let current_hash = hash(&destination).unwrap(); let prior_hash = hash(&prior).unwrap(); let backup = dir.path().join("release-backups/sd").join(&prior_hash); @@ -260,8 +257,7 @@ mod tests { fn failed_functional_proof_restores_current_bytes_and_state() { let (dir, plan, state, destination, current, _prior) = fixture(); let prior = dir.path().join("broken"); - fs::write(&prior, "#!/bin/sh\ncase \"$1\" in --help) echo 'seeds is the beads-compatible tracker';; --version) echo 'sd 0.2.0 (seeds)';; *) exit 7;; esac\n").unwrap(); - fs::set_permissions(&prior, fs::Permissions::from_mode(0o755)).unwrap(); + crate::test_exec::write_executable(&prior, "#!/bin/sh\ncase \"$1\" in --help) echo 'seeds is the beads-compatible tracker';; --version) echo 'sd 0.2.0 (seeds)';; *) exit 7;; esac\n"); let prior_hash = hash(&prior).unwrap(); let backup = dir.path().join("release-backups/sd").join(&prior_hash); atomic_copy(&prior, &backup).unwrap(); diff --git a/src/test_exec.rs b/src/test_exec.rs new file mode 100644 index 0000000..23ef029 --- /dev/null +++ b/src/test_exec.rs @@ -0,0 +1,33 @@ +//! Test-only: write a fake executable that a parallel test cannot make busy. +//! +//! Parallel tests write scripts and then exec them. If any sibling thread forks +//! while this process holds the script open for writing, the forked child +//! inherits that descriptor until it calls exec (`O_CLOEXEC` does not close the +//! window between fork and exec). An exec of the script in that window fails +//! with ETXTBSY, "Text file busy" (aegis-zke). +//! +//! Writing through a short-lived `sh` child means the write descriptor only +//! ever exists in that child. This process never holds it, so no sibling fork +//! can inherit it, and once the child has exited the file has no writer. + +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; + +/// Write `contents` to `path` with mode 0755, from a child process. +pub(crate) fn write_executable(path: &Path, contents: &str) { + let mut child = Command::new("/bin/sh") + .args(["-c", "cat > \"$1\" && chmod 0755 \"$1\"", "sh"]) + .arg(path) + .stdin(Stdio::piped()) + .spawn() + .expect("spawn sh to write a fake executable"); + child + .stdin + .take() + .expect("piped stdin") + .write_all(contents.as_bytes()) + .expect("write fake executable contents"); + let status = child.wait().expect("wait for the fake executable writer"); + assert!(status.success(), "writing {} failed", path.display()); +}