1use 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
12pub fn is_production_executable_name(file_name: &OsStr) -> bool {
21 let Some(name) = file_name.to_str() else {
22 return false;
24 };
25 let stem = strip_exe_suffix(name).0.to_ascii_lowercase();
26 stem == "ck" || stem.starts_with("ck-")
27}
28
29pub 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
49fn 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
68pub 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
102pub 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
111pub 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
126pub 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
146pub 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
159pub 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
184pub fn ckdev_binary(built: impl AsRef<Path>) -> PathBuf {
193 ckdev_binary_at(built.as_ref(), &publish_root())
194}
195
196fn 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
238fn 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 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 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
296fn 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
319fn 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
362fn 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
375fn 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 #[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
398const 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
426fn 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 #[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 #[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 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 #[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 #[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 #[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 let output = dev_command(&placed).output().unwrap();
788 assert_eq!(String::from_utf8_lossy(&output.stdout), "one\n");
789 }
790
791 #[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 #[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 #[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}