Skip to main content

cortexkit_test_support/
binaries.rs

1//! Content-addressed executable copies and production-name guards.
2
3use std::{
4    ffi::OsStr,
5    fs, io,
6    path::{Path, PathBuf},
7    process::{Command, ExitStatus, Output},
8    sync::atomic::{AtomicU64, Ordering},
9};
10static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
11
12/// Executable names a test must never run a process under.
13///
14/// macOS (Activity Monitor, `ps -o comm`) and other process listings show a
15/// process by its executable's file name. Names that start with `ck-`, plus the
16/// `ck` CLI itself, belong to the production binaries installed in the
17/// CortexKit bin directory, so a test daemon running as `ck-subc` looks exactly
18/// like a second production daemon. Test processes run under `ckdev-<name>`
19/// instead; [`ckdev_binary`] publishes a built binary under such a name.
20pub fn is_production_executable_name(file_name: &OsStr) -> bool {
21    let Some(name) = file_name.to_str() else {
22        // Production names are ASCII; a non-UTF-8 name cannot be one.
23        return false;
24    };
25    let stem = strip_exe_suffix(name).0.to_ascii_lowercase();
26    stem == "ck" || stem.starts_with("ck-")
27}
28
29/// Panics when `program` would run under a production executable name (see
30/// [`is_production_executable_name`]). Call it before spawning any CortexKit
31/// binary from a test; [`dev_command`] and [`ckdev_binary`] already do.
32///
33/// Cargo's own test harness for the `ck` bin target is not refused: cargo names
34/// it `target/<profile>/deps/ck-<16 hex digits>`, and re-running that harness
35/// (a test that starts its own executable) is cargo's naming, not a copy of a
36/// production binary.
37pub fn refuse_production_executable(program: &Path) {
38    let name = program.file_name().unwrap_or_default();
39    assert!(
40        !is_production_executable_name(name) || is_cargo_test_harness(program),
41        "refusing to run a test process under the production executable name {:?} ({}): \
42         `ck-*` and `ck` are reserved for installed binaries; run it through \
43         cortexkit_test_support::ckdev_binary so it shows as ckdev-*",
44        name,
45        program.display()
46    );
47}
48
49/// Whether `program` is a test harness cargo built for a bin target: a file
50/// in a `deps` directory named `<target>-<16 lowercase hex digits>`.
51fn is_cargo_test_harness(program: &Path) -> bool {
52    let in_deps = program
53        .parent()
54        .and_then(Path::file_name)
55        .is_some_and(|dir| dir == "deps");
56    let Some(name) = program.file_name().and_then(OsStr::to_str) else {
57        return false;
58    };
59    let stem = strip_exe_suffix(name).0;
60    let hash = stem.rsplit_once('-').map(|(_, hash)| hash).unwrap_or("");
61    in_deps
62        && hash.len() == 16
63        && hash
64            .bytes()
65            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
66}
67
68/// Validate a caller-supplied exact `(test name, executable stem)` exemption.
69/// The calling test thread must also match; exemptions cannot be borrowed.
70/// Returns `program` when the test named `test` is the one test allowed to
71/// place an executable with `program`'s production name (the exemption
72/// table in this crate); panics for any other test or name. A
73/// trailing `.exe` is ignored, so the exemption holds on Windows too.
74///
75/// When the calling thread carries a test name (libtest names each test's
76/// thread after it), that name must be `test` as well, so another test cannot
77/// borrow the exemption by passing the exempt test's name.
78pub fn exempt_production_executable<'a>(
79    test: &str,
80    program: &'a Path,
81    exemptions: &[(&str, &str)],
82) -> &'a Path {
83    let file_name = program.file_name().and_then(OsStr::to_str).unwrap_or("");
84    let stem = strip_exe_suffix(file_name).0;
85    let caller = std::thread::current().name().map(str::to_string);
86    let caller_matches = match caller.as_deref() {
87        None | Some("main") => true,
88        Some(thread) => thread == test || thread.ends_with(&format!("::{test}")),
89    };
90    assert!(
91        caller_matches
92            && exemptions
93                .iter()
94                .any(|&(exempt_test, exempt_name)| exempt_test == test && exempt_name == stem),
95        "no production-name exemption for {file_name:?} in test {test:?} (running on \
96         thread {caller:?}); test processes run as ckdev-* through \
97         cortexkit_test_support::ckdev_binary"
98    );
99    program
100}
101
102/// A `Command` for `program` that refuses (panics) when `program` has a
103/// production executable name. Use it for every test spawn of a CortexKit
104/// binary, with a path made by [`ckdev_binary`].
105pub fn dev_command(program: impl AsRef<Path>) -> Command {
106    let program = program.as_ref();
107    refuse_production_executable(program);
108    Command::new(program)
109}
110
111/// Execute a prepared command, returning an error with the exit code or signal
112/// number and name on failure. Plain `Command::output` does not reject failed exits.
113pub fn checked_output(command: &mut Command) -> io::Result<Output> {
114    let output = command.output()?;
115    if !output.status.success() {
116        return Err(io::Error::other(format!(
117            "{} failed: {}; stderr: {}",
118            command.get_program().to_string_lossy(),
119            describe_exit_status(output.status),
120            String::from_utf8_lossy(&output.stderr)
121        )));
122    }
123    Ok(output)
124}
125
126/// Human-readable child failure, including Unix signal kills rather than `None`.
127pub fn describe_exit_status(status: ExitStatus) -> String {
128    #[cfg(unix)]
129    {
130        use std::os::unix::process::ExitStatusExt;
131        if let Some(signal) = status.signal() {
132            let name = match rustix::process::Signal::from_named_raw(signal) {
133                Some(rustix::process::Signal::KILL) => "SIGKILL".to_string(),
134                Some(rustix::process::Signal::TERM) => "SIGTERM".to_string(),
135                Some(rustix::process::Signal::ABORT) => "SIGABRT".to_string(),
136                Some(rustix::process::Signal::SEGV) => "SIGSEGV".to_string(),
137                Some(s) => format!("{s:?}"),
138                None => "unknown signal".to_string(),
139            };
140            return format!("signal {signal} ({name})");
141        }
142    }
143    format!("exit code {:?}", status.code())
144}
145
146/// Compatibility staging: `ck-*` artifacts become `ckdev-*`; other names pass through.
147pub fn stage_test_binary(source: &Path) -> PathBuf {
148    if source
149        .file_name()
150        .and_then(OsStr::to_str)
151        .is_some_and(|s| s.starts_with("ck-"))
152    {
153        ckdev_binary(source)
154    } else {
155        source.to_owned()
156    }
157}
158
159/// The `ckdev-` name for a built binary's file name: `ck-subc` becomes
160/// `ckdev-subc`, `ck` becomes `ckdev-ck`, any other name `n` becomes
161/// `ckdev-n`, and a name that already starts with `ckdev-` is returned as it
162/// is. A trailing `.exe` stays at the end, so Windows still runs the result.
163pub fn ckdev_file_name(file_name: &str) -> String {
164    let (stem, exe) = strip_exe_suffix(file_name);
165    if stem.starts_with("ckdev-") {
166        return file_name.to_string();
167    }
168    let base = match stem.strip_prefix("ck-") {
169        Some(rest) => rest,
170        None => stem,
171    };
172    format!("ckdev-{base}{exe}")
173}
174
175fn strip_exe_suffix(name: &str) -> (&str, &str) {
176    let len = name.len();
177    if len > 4 && name.is_char_boundary(len - 4) && name[len - 4..].eq_ignore_ascii_case(".exe") {
178        (&name[..len - 4], &name[len - 4..])
179    } else {
180        (name, "")
181    }
182}
183
184/// Publish a built executable under a content-addressed `ckdev-*` name.
185/// Already-dev-named paths pass through. Unix copies live under `/tmp`, never
186/// `$TMPDIR`, to avoid per-user temporary-directory execution assessment delays.
187/// A child writes a private staging copy, then an atomic rename publishes it.
188/// This avoids hard-link execution failures and writable descriptors inherited
189/// by concurrently spawned children. Published files are read-only, verified
190/// against their digest on reuse, and pruned after three days.
191/// Use [`checked_output`] to report failed child exits, including signal names.
192pub fn ckdev_binary(built: impl AsRef<Path>) -> PathBuf {
193    ckdev_binary_at(built.as_ref(), &publish_root())
194}
195
196/// Where published `ckdev-` binaries live. `/tmp`, not `$TMPDIR`: see
197/// [`ckdev_binary`].
198fn publish_root() -> PathBuf {
199    #[cfg(unix)]
200    {
201        PathBuf::from(format!(
202            "/tmp/cortexkit-ckdev-{}",
203            rustix::process::getuid().as_raw()
204        ))
205    }
206    #[cfg(not(unix))]
207    {
208        std::env::temp_dir().join("cortexkit-ckdev")
209    }
210}
211
212fn ckdev_binary_at(built: &Path, root: &Path) -> PathBuf {
213    let file_name = built
214        .file_name()
215        .and_then(OsStr::to_str)
216        .unwrap_or_else(|| panic!("built binary has no UTF-8 file name: {}", built.display()));
217    let dev_name = ckdev_file_name(file_name);
218    #[cfg(windows)]
219    let dev_name = if dev_name.to_ascii_lowercase().ends_with(".exe") {
220        dev_name
221    } else {
222        format!("{dev_name}.exe")
223    };
224    if dev_name == file_name {
225        return built.to_path_buf();
226    }
227    let placed = publish(built, &dev_name, root).unwrap_or_else(|error| {
228        panic!(
229            "could not publish {} as {dev_name} under {}: {error}",
230            built.display(),
231            root.display()
232        )
233    });
234    refuse_production_executable(&placed);
235    placed
236}
237
238/// The content address: the first 32 hex digits of SHA-256 over the
239/// published name, a NUL, and the file's bytes.
240fn content_digest(path: &Path, dev_name: &str) -> io::Result<String> {
241    use sha2::{Digest, Sha256};
242    let mut hasher = Sha256::new();
243    hasher.update(dev_name.as_bytes());
244    hasher.update([0u8]);
245    let mut file = fs::File::open(path)?;
246    io::copy(&mut file, &mut hasher)?;
247    let digest: String = hasher.finalize()[..16]
248        .iter()
249        .map(|byte| format!("{byte:02x}"))
250        .collect();
251    Ok(digest)
252}
253
254fn publish(built: &Path, dev_name: &str, root: &Path) -> io::Result<PathBuf> {
255    let digest = content_digest(built, dev_name)?;
256    prepare_root(root)?;
257    let dir = root.join(&digest);
258    let placed = dir.join(dev_name);
259    if dir.exists() {
260        verify_published(&placed, dev_name, &digest)?;
261        return Ok(placed);
262    }
263    prune_stale(root);
264    let nonce = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
265    let staging = root.join(format!(".staging-{}-{nonce}", std::process::id()));
266    fs::create_dir(&staging)?;
267    let staged = staging.join(dev_name);
268    let result = (|| {
269        copy_executable(built, &staged)?;
270        // Permissions are set by path (chmod), so no writable descriptor is
271        // held; on Unix the published file is read-only and executable.
272        fs::set_permissions(&staged, published_permissions(built)?)?;
273        if content_digest(&staged, dev_name)? != digest {
274            return Err(io::Error::other(format!(
275                "{} changed while it was being published",
276                built.display()
277            )));
278        }
279        seal_dir(&staging)?;
280        match fs::rename(&staging, &dir) {
281            Ok(()) => Ok(()),
282            // Another process published the same build first; its file is
283            // checked below like any reused one.
284            Err(_) if dir.exists() => Ok(()),
285            Err(error) => Err(error),
286        }
287    })();
288    if staging.exists() {
289        remove_published(&staging);
290    }
291    result?;
292    verify_published(&placed, dev_name, &digest)?;
293    Ok(placed)
294}
295
296/// Re-hashes a published file before trusting it: a partial or altered file
297/// is refused, never run.
298fn verify_published(placed: &Path, dev_name: &str, digest: &str) -> io::Result<()> {
299    let directory = fs::symlink_metadata(placed.parent().expect("publication directory"))?;
300    let file = fs::symlink_metadata(placed)?;
301    if !directory.is_dir() || directory.is_symlink() || !file.is_file() || file.is_symlink() {
302        return Err(io::Error::other(
303            "publication must be a real directory and regular file",
304        ));
305    }
306    let found = content_digest(placed, dev_name)?;
307    if found == digest {
308        Ok(())
309    } else {
310        Err(io::Error::other(format!(
311            "published {} does not match its content address {digest} (found {found}); \
312             remove {} to republish it",
313            placed.display(),
314            placed.parent().unwrap_or(placed).display()
315        )))
316    }
317}
318
319/// Creates the publish root, and refuses one this user does not own or that
320/// others can write: `/tmp` is shared, and a planted file there would be run.
321fn prepare_root(root: &Path) -> io::Result<()> {
322    if !root.exists() {
323        fs::create_dir_all(root)?;
324        #[cfg(unix)]
325        {
326            use std::os::unix::fs::PermissionsExt;
327            fs::set_permissions(root, fs::Permissions::from_mode(0o700))?;
328        }
329    }
330    let metadata = fs::symlink_metadata(root)?;
331    if !metadata.is_dir() || metadata.is_symlink() {
332        return Err(io::Error::other("publish root must be a real directory"));
333    }
334    #[cfg(unix)]
335    {
336        use std::os::unix::fs::MetadataExt;
337        let uid = rustix::process::getuid().as_raw();
338        if !metadata.is_dir() || metadata.uid() != uid || metadata.mode() & 0o022 != 0 {
339            return Err(io::Error::other(format!(
340                "{} must be a directory owned by uid {uid} and writable by no one else",
341                root.display()
342            )));
343        }
344    }
345    Ok(())
346}
347
348#[cfg(unix)]
349fn published_permissions(built: &Path) -> io::Result<fs::Permissions> {
350    use std::os::unix::fs::PermissionsExt;
351    let mode = fs::metadata(built)?.permissions().mode();
352    Ok(fs::Permissions::from_mode(mode & 0o555))
353}
354
355#[cfg(not(unix))]
356fn published_permissions(built: &Path) -> io::Result<fs::Permissions> {
357    let mut permissions = fs::metadata(built)?.permissions();
358    permissions.set_readonly(true);
359    Ok(permissions)
360}
361
362/// Makes a published directory read-only, so nothing can be renamed into it
363/// or removed from it.
364fn seal_dir(dir: &Path) -> io::Result<()> {
365    #[cfg(unix)]
366    {
367        use std::os::unix::fs::PermissionsExt;
368        fs::set_permissions(dir, fs::Permissions::from_mode(0o555))?;
369    }
370    #[cfg(not(unix))]
371    let _ = dir;
372    Ok(())
373}
374
375/// Removes a staging or published directory, restoring the write permission
376/// sealing took away.
377fn remove_published(dir: &Path) {
378    #[cfg(unix)]
379    {
380        use std::os::unix::fs::PermissionsExt;
381        let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
382    }
383    #[cfg(windows)]
384    if let Ok(entries) = fs::read_dir(dir) {
385        for entry in entries.flatten() {
386            if let Ok(metadata) = entry.metadata() {
387                let mut permissions = metadata.permissions();
388                // This branch clears a Windows file attribute, not Unix mode bits.
389                #[allow(clippy::permissions_set_readonly_false)]
390                permissions.set_readonly(false);
391                let _ = fs::set_permissions(entry.path(), permissions);
392            }
393        }
394    }
395    let _ = fs::remove_dir_all(dir);
396}
397
398/// Published builds are kept this long after publishing; older ones are
399/// removed when a new build is published. A process still running one keeps
400/// its open image, and asking again republishes it.
401const PUBLISHED_RETENTION: std::time::Duration = std::time::Duration::from_secs(3 * 24 * 60 * 60);
402
403fn prune_stale(root: &Path) {
404    let Ok(entries) = fs::read_dir(root) else {
405        return;
406    };
407    let now = std::time::SystemTime::now();
408    for entry in entries.flatten() {
409        let Ok(metadata) = fs::symlink_metadata(entry.path()) else {
410            continue;
411        };
412        if !metadata.is_dir() || metadata.is_symlink() {
413            continue;
414        }
415        let stale = Ok::<_, io::Error>(metadata)
416            .and_then(|metadata| metadata.modified())
417            .ok()
418            .and_then(|modified| now.duration_since(modified).ok())
419            .is_some_and(|age| age > PUBLISHED_RETENTION);
420        if stale {
421            remove_published(&entry.path());
422        }
423    }
424}
425
426/// Copies through a `cp` child on Unix: a writable descriptor held by this
427/// multi-threaded test process would be inherited by a child another thread
428/// forks at that moment, and executing the copy while that child still holds
429/// it fails with "text file busy".
430fn copy_executable(src: &Path, dst: &Path) -> io::Result<()> {
431    #[cfg(unix)]
432    {
433        let status = Command::new("cp").arg(src).arg(dst).status()?;
434        if status.success() {
435            Ok(())
436        } else {
437            Err(io::Error::other(format!(
438                "cp failed: {}",
439                describe_exit_status(status)
440            )))
441        }
442    }
443    #[cfg(not(unix))]
444    {
445        fs::copy(src, dst).map(|_| ())
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn staged_child_fixture() {
455        #[cfg(unix)]
456        if std::env::var("CORTEXKIT_STAGED_FIXTURE").as_deref() == Ok("kill") {
457            rustix::process::kill_process(rustix::process::getpid(), rustix::process::Signal::KILL)
458                .unwrap();
459            panic!("SIGKILL did not terminate child");
460        }
461    }
462
463    #[cfg(unix)]
464    fn built_fixture(scratch: &crate::ScratchDir) -> PathBuf {
465        let nonce = std::time::SystemTime::now()
466            .duration_since(std::time::UNIX_EPOCH)
467            .unwrap()
468            .as_nanos();
469        let built = scratch.join(format!("ck-stage-{}-{nonce}", std::process::id()));
470        copy_executable(&std::env::current_exe().unwrap(), &built).unwrap();
471        built
472    }
473    #[cfg(unix)]
474    fn fixture_command(placed: &Path) -> Command {
475        let mut command = dev_command(placed);
476        command.args([
477            "--exact",
478            "binaries::tests::staged_child_fixture",
479            "--nocapture",
480        ]);
481        command
482    }
483    #[cfg(unix)]
484    #[test]
485    fn staging_lives_under_tmp_and_other_names_pass_through() {
486        let scratch = crate::ScratchDir::new("publish-tmp");
487        let built = built_fixture(&scratch);
488        let placed = stage_test_binary(&built);
489        assert!(
490            placed.starts_with("/tmp"),
491            "published at {}",
492            placed.display()
493        );
494        assert!(placed
495            .file_name()
496            .unwrap()
497            .to_str()
498            .unwrap()
499            .starts_with("ckdev-"));
500        assert_eq!(
501            stage_test_binary(Path::new("/nonexistent/other")),
502            Path::new("/nonexistent/other")
503        );
504        checked_output(&mut fixture_command(&placed)).unwrap();
505    }
506    #[cfg(unix)]
507    #[test]
508    fn sigkill_failure_names_signal_nine() {
509        let scratch = crate::ScratchDir::new("signal-status");
510        let placed = ckdev_binary(built_fixture(&scratch));
511        let error =
512            checked_output(fixture_command(&placed).env("CORTEXKIT_STAGED_FIXTURE", "kill"))
513                .unwrap_err();
514        let text = error.to_string();
515        assert!(
516            text.contains("signal 9") && text.contains("SIGKILL"),
517            "{text}"
518        );
519        assert!(!text.contains("exit code None"), "{text}");
520    }
521    #[cfg(unix)]
522    #[test]
523    fn concurrent_staging_publishes_one_copy_and_every_spawn_succeeds() {
524        let scratch = crate::ScratchDir::new("staging-concurrency");
525        let built = built_fixture(&scratch);
526        let barrier = std::sync::Arc::new(std::sync::Barrier::new(16));
527        let threads: Vec<_> = (0..16)
528            .map(|_| {
529                let built = built.clone();
530                let barrier = barrier.clone();
531                std::thread::spawn(move || {
532                    barrier.wait();
533                    let placed = ckdev_binary(built);
534                    let output = checked_output(&mut fixture_command(&placed)).unwrap();
535                    assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
536                    placed
537                })
538            })
539            .collect();
540        let paths: Vec<_> = threads.into_iter().map(|t| t.join().unwrap()).collect();
541        assert!(paths.iter().all(|p| p == &paths[0]));
542        assert_eq!(fs::read_dir(paths[0].parent().unwrap()).unwrap().count(), 1);
543        use std::os::unix::fs::MetadataExt;
544        let inode = fs::metadata(&paths[0]).unwrap().ino();
545        assert_eq!(ckdev_binary(&built), paths[0]);
546        assert_eq!(fs::metadata(&paths[0]).unwrap().ino(), inode);
547    }
548    #[cfg(target_os = "linux")]
549    #[test]
550    fn published_file_has_no_write_descriptor_in_test_process() {
551        use std::os::unix::fs::MetadataExt;
552        let scratch = crate::ScratchDir::new("publish-descriptors");
553        let placed = ckdev_binary(built_fixture(&scratch));
554        let published = fs::metadata(&placed).unwrap();
555        for entry in fs::read_dir("/proc/self/fd").unwrap().flatten() {
556            let Ok(metadata) = fs::metadata(entry.path()) else {
557                continue;
558            };
559            if metadata.dev() != published.dev() || metadata.ino() != published.ino() {
560                continue;
561            }
562            let info =
563                fs::read_to_string(Path::new("/proc/self/fdinfo").join(entry.file_name())).unwrap();
564            let flags = info
565                .lines()
566                .find_map(|line| line.strip_prefix("flags:\t"))
567                .unwrap();
568            let flags = u32::from_str_radix(flags.trim(), 8).unwrap();
569            assert_eq!(
570                flags & 3,
571                0,
572                "write descriptor for published executable: {info}"
573            );
574        }
575        checked_output(&mut fixture_command(&placed)).unwrap();
576    }
577    #[cfg(unix)]
578    #[test]
579    fn three_day_prune_keeps_recent_and_symlink_entries() {
580        let root = PublishRoot::new("publish-prune");
581        let old = root.path().join("old");
582        let recent = root.path().join("recent");
583        for path in [&old, &recent] {
584            fs::create_dir(path).unwrap();
585            fs::write(path.join("payload"), "keep").unwrap();
586        }
587        fs::File::open(&old)
588            .unwrap()
589            .set_times(fs::FileTimes::new().set_modified(
590                std::time::SystemTime::now()
591                    - PUBLISHED_RETENTION
592                    - std::time::Duration::from_secs(10),
593            ))
594            .unwrap();
595        std::os::unix::fs::symlink(&old, root.path().join("link")).unwrap();
596        prune_stale(&root.path());
597        assert!(!old.exists());
598        assert!(recent.join("payload").exists());
599        assert!(fs::symlink_metadata(root.path().join("link"))
600            .unwrap()
601            .is_symlink());
602    }
603
604    #[test]
605    fn ckdev_names_drop_the_production_prefix_and_keep_exe() {
606        assert_eq!(ckdev_file_name("ck-subc"), "ckdev-subc");
607        assert_eq!(ckdev_file_name("ck-subc-mcp"), "ckdev-subc-mcp");
608        assert_eq!(ckdev_file_name("ck-bus.exe"), "ckdev-bus.exe");
609        assert_eq!(ckdev_file_name("ck"), "ckdev-ck");
610        assert_eq!(ckdev_file_name("ck.exe"), "ckdev-ck.exe");
611        assert_eq!(ckdev_file_name("ck-under-test"), "ckdev-under-test");
612        assert_eq!(ckdev_file_name("fake-aft-stub"), "ckdev-fake-aft-stub");
613        assert_eq!(ckdev_file_name("ckdev-subc"), "ckdev-subc");
614        assert_eq!(ckdev_file_name("ckdev-subc.exe"), "ckdev-subc.exe");
615    }
616
617    #[test]
618    fn production_names_are_recognised() {
619        for name in ["ck-subc", "ck-bus.exe", "CK-SUBC.EXE", "ck", "ck.exe"] {
620            assert!(
621                is_production_executable_name(OsStr::new(name)),
622                "{name} is a production executable name"
623            );
624        }
625        for name in [
626            "ckdev-subc",
627            "ckdev-ck.exe",
628            "cksum",
629            "fake-aft-stub",
630            "subc",
631        ] {
632            assert!(
633                !is_production_executable_name(OsStr::new(name)),
634                "{name} is not a production executable name"
635            );
636        }
637    }
638
639    /// The guard itself: the spawn helper refuses a `ck-*` path before any
640    /// process starts.
641    #[test]
642    #[should_panic(
643        expected = "refusing to run a test process under the production executable name"
644    )]
645    fn dev_command_refuses_a_production_named_binary() {
646        let _ = dev_command(Path::new("/nonexistent/target/debug/ck-subc"));
647    }
648
649    #[test]
650    fn cargo_test_harness_for_the_ck_bin_is_not_refused() {
651        let _ = dev_command(Path::new("/w/target/debug/deps/ck-0123456789abcdef"));
652        let _ = dev_command(Path::new("/w/target/debug/deps/ck-0123456789abcdef.exe"));
653        for refused in [
654            "/w/target/debug/ck-0123456789abcdef",
655            "/w/target/debug/deps/ck-subc",
656            "/w/target/debug/deps/ck-0123456789ABCDEF",
657            "/w/target/debug/deps/ck-0123456789abcde",
658        ] {
659            assert!(
660                std::panic::catch_unwind(|| dev_command(Path::new(refused))).is_err(),
661                "{refused} must be refused"
662            );
663        }
664    }
665
666    const EXEMPT_TEST: &str = "executable_discovery";
667    const EXEMPTIONS: &[(&str, &str)] = &[(EXEMPT_TEST, "ck-twin"), (EXEMPT_TEST, "ck-twin-two")];
668
669    /// The exemption admits exactly the two twin copies, and only on the
670    /// thread of the one test it names.
671    #[test]
672    fn the_exemption_admits_only_the_named_test_and_its_two_copies() {
673        let outcomes = std::thread::Builder::new()
674            .name(EXEMPT_TEST.to_string())
675            .spawn(|| {
676                [
677                    "ck-twin",
678                    "ck-twin-two",
679                    "ck-twin.exe",
680                    "ck-twin-three",
681                    "ck-subc",
682                    "ck",
683                ]
684                .map(|name| {
685                    let path = Path::new("/fixture/bin").join(name);
686                    std::panic::catch_unwind(|| {
687                        exempt_production_executable(EXEMPT_TEST, &path, EXEMPTIONS);
688                    })
689                    .is_ok()
690                })
691            })
692            .unwrap()
693            .join()
694            .unwrap();
695        assert_eq!(outcomes, [true, true, true, false, false, false]);
696        // Another test cannot borrow the exemption by naming the exempt test:
697        // this thread carries this test's own name.
698        assert!(std::panic::catch_unwind(|| {
699            exempt_production_executable(
700                EXEMPT_TEST,
701                Path::new("/fixture/bin/ck-twin"),
702                EXEMPTIONS,
703            );
704        })
705        .is_err());
706    }
707
708    /// A publish root inside a test temp dir. Published directories are
709    /// sealed read-only, so the guard restores write permission before the
710    /// temp dir removes the tree.
711    #[cfg(unix)]
712    struct PublishRoot {
713        temp: crate::ScratchDir,
714    }
715
716    #[cfg(unix)]
717    impl PublishRoot {
718        fn new(label: &str) -> Self {
719            let temp = crate::ScratchDir::new(label);
720            fs::create_dir_all(temp.join("root")).unwrap();
721            Self { temp }
722        }
723
724        fn path(&self) -> PathBuf {
725            self.temp.join("root")
726        }
727    }
728
729    #[cfg(unix)]
730    impl Drop for PublishRoot {
731        fn drop(&mut self) {
732            if let Ok(entries) = fs::read_dir(self.path()) {
733                for entry in entries.flatten() {
734                    remove_published(&entry.path());
735                }
736            }
737        }
738    }
739
740    /// The guard wired through the helper: binaries built as `ck-subc` and `ck`
741    /// are published under `ckdev-*` names and the spawn helper runs them. If
742    /// the helper handed back the built path, `dev_command` would refuse it
743    /// here.
744    #[cfg(unix)]
745    #[test]
746    fn a_placed_production_binary_spawns_under_its_ckdev_name() {
747        let build = crate::ScratchDir::new("ckdev-guard-build");
748        let root = PublishRoot::new("ckdev-guard-root");
749        for (name, published) in [("ck-subc", "ckdev-subc"), ("ck", "ckdev-ck")] {
750            let built = build.join(name);
751            write_script(&built, &format!("#!/bin/sh\necho {name}\n"));
752            let placed = ckdev_binary_at(&built, &root.path());
753            let output = dev_command(&placed).output().unwrap();
754            assert_eq!(String::from_utf8_lossy(&output.stdout), format!("{name}\n"));
755            assert_eq!(placed.file_name().unwrap(), published);
756            assert_eq!(
757                placed.parent().and_then(Path::parent),
758                Some(root.path().as_path())
759            );
760        }
761    }
762
763    /// Two requests for the same build, even from different build paths, get
764    /// the same published file; a changed binary gets a new one.
765    #[cfg(unix)]
766    #[test]
767    fn the_same_build_shares_one_published_path_and_a_changed_build_gets_another() {
768        let first = crate::ScratchDir::new("ckdev-address-first");
769        let second = crate::ScratchDir::new("ckdev-address-second");
770        let root = PublishRoot::new("ckdev-address-root");
771        let (a, b) = (first.join("ck-subc"), second.join("ck-subc"));
772        write_script(&a, "#!/bin/sh\necho one\n");
773        write_script(&b, "#!/bin/sh\necho one\n");
774        let placed = ckdev_binary_at(&a, &root.path());
775        assert_eq!(ckdev_binary_at(&a, &root.path()), placed);
776        assert_eq!(ckdev_binary_at(&b, &root.path()), placed);
777
778        let changed = crate::ScratchDir::new("ckdev-address-changed");
779        let c = changed.join("ck-subc");
780        write_script(&c, "#!/bin/sh\necho two\n");
781        let republished = ckdev_binary_at(&c, &root.path());
782        assert_ne!(republished, placed);
783        assert_eq!(republished.file_name(), placed.file_name());
784        let output = dev_command(&republished).output().unwrap();
785        assert_eq!(String::from_utf8_lossy(&output.stdout), "two\n");
786        // The first build is still published, unchanged.
787        let output = dev_command(&placed).output().unwrap();
788        assert_eq!(String::from_utf8_lossy(&output.stdout), "one\n");
789    }
790
791    /// The published file is a read-only copy in a read-only directory: its
792    /// own inode, the same bytes, executable, and no staging left behind.
793    #[cfg(unix)]
794    #[test]
795    fn a_published_binary_is_a_sealed_copy() {
796        use std::os::unix::fs::{MetadataExt, PermissionsExt};
797        let build = crate::ScratchDir::new("ckdev-copy-build");
798        let root = PublishRoot::new("ckdev-copy-root");
799        let built = build.join("ck-subc");
800        write_script(&built, "#!/bin/sh\necho copied\n");
801        let placed = ckdev_binary_at(&built, &root.path());
802        let (source, copy) = (
803            fs::metadata(&built).unwrap(),
804            fs::metadata(&placed).unwrap(),
805        );
806        assert_ne!(
807            (source.dev(), source.ino()),
808            (copy.dev(), copy.ino()),
809            "a published binary must not share the built binary's inode"
810        );
811        assert_eq!(fs::read(&built).unwrap(), fs::read(&placed).unwrap());
812        assert_eq!(copy.permissions().mode() & 0o777, 0o555);
813        let dir = placed.parent().unwrap();
814        assert_eq!(
815            fs::metadata(dir).unwrap().permissions().mode() & 0o777,
816            0o555
817        );
818        assert!(
819            rustix::process::getuid().is_root()
820                || fs::OpenOptions::new().write(true).open(&placed).is_err(),
821            "a published binary must not be writable"
822        );
823        let entries: Vec<_> = fs::read_dir(root.path())
824            .unwrap()
825            .map(|entry| entry.unwrap().file_name())
826            .collect();
827        assert_eq!(entries, [dir.file_name().unwrap()]);
828    }
829
830    /// A published file that no longer matches its address is refused, not
831    /// run, and so is a publish root other users can write.
832    #[cfg(unix)]
833    #[test]
834    fn altered_publications_and_shared_roots_are_refused() {
835        use std::os::unix::fs::PermissionsExt;
836        let build = crate::ScratchDir::new("ckdev-refuse-build");
837        let root = PublishRoot::new("ckdev-refuse-root");
838        let built = build.join("ck-bus");
839        write_script(&built, "#!/bin/sh\necho genuine\n");
840        let placed = ckdev_binary_at(&built, &root.path());
841        let dir = placed.parent().unwrap().to_path_buf();
842        fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap();
843        fs::set_permissions(&placed, fs::Permissions::from_mode(0o755)).unwrap();
844        let altered = build.join("altered");
845        write_script(&altered, "#!/bin/sh\necho planted\n");
846        fs::remove_file(&placed).unwrap();
847        copy_executable(&altered, &placed).unwrap();
848        let error = publish(&built, "ckdev-bus", &root.path()).unwrap_err();
849        assert!(
850            error
851                .to_string()
852                .contains("does not match its content address"),
853            "{error}"
854        );
855
856        let open = PublishRoot::new("ckdev-open-root");
857        fs::set_permissions(open.path(), fs::Permissions::from_mode(0o777)).unwrap();
858        let error = publish(&built, "ckdev-bus", &open.path()).unwrap_err();
859        assert!(
860            error.to_string().contains("writable by no one else"),
861            "{error}"
862        );
863    }
864
865    #[test]
866    fn an_already_ckdev_binary_is_returned_unchanged() {
867        let name = format!("/nonexistent/ckdev-subc{}", std::env::consts::EXE_SUFFIX);
868        let built = Path::new(&name);
869        assert_eq!(ckdev_binary(built), built);
870    }
871
872    /// Writes through a staging file and a `cp` child, so this multi-threaded
873    /// test process never holds a writable descriptor to the file it runs. On
874    /// Linux, a child forked by another test thread inherits any open write
875    /// descriptor until it execs, and while one exists, executing the file fails
876    /// with ETXTBSY ("text file busy").
877    #[cfg(unix)]
878    fn write_script(path: &Path, body: &str) {
879        use std::os::unix::fs::PermissionsExt;
880        let staging = path.with_extension("staging");
881        fs::write(&staging, body).unwrap();
882        fs::set_permissions(&staging, fs::Permissions::from_mode(0o755)).unwrap();
883        copy_executable(&staging, path).unwrap();
884        fs::remove_file(&staging).unwrap();
885    }
886}