1use std::fs;
27use std::io::Write;
28use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
29use std::path::{Path, PathBuf};
30use std::sync::atomic::{AtomicU64, Ordering};
31
32use anyhow::{Context, Result, bail};
33use cargo_metadata::MetadataCommand;
34use clap::Args;
35
36const SCXCLI_PATH: &str = "/usr/bin/scxcli";
39const CATALOG_PATH: &str = "/usr/share/scx/catalog.json";
41const CONFIG_PATH: &str = "/etc/default/scx";
43
44const POLICY_PATH_KEY: &str = "org.freedesktop.policykit.exec.path\">";
45
46#[derive(Args)]
47pub struct InstallArgs {
48 #[arg(long, value_name = "DIR")]
50 destdir: Option<PathBuf>,
51
52 #[arg(long, value_name = "DIR")]
55 source_dir: Option<PathBuf>,
56
57 #[arg(long, default_value = "/usr/lib/systemd/system", value_name = "DIR")]
59 unitdir: PathBuf,
60
61 #[arg(
63 long,
64 default_value = "/usr/share/polkit-1/actions",
65 value_name = "DIR"
66 )]
67 polkitdir: PathBuf,
68}
69
70struct Sources {
72 service: PathBuf,
73 config: PathBuf,
74 catalog: PathBuf,
75 policy: PathBuf,
76}
77
78impl Sources {
79 fn from_workspace(root: &Path) -> Self {
80 Sources {
81 service: root.join("services/scx.service"),
82 config: root.join("services/scx"),
83 catalog: root.join("services/catalog.json"),
84 policy: root.join("tools/scxcli/org.sched_ext.scxcli.policy"),
85 }
86 }
87}
88
89struct Layout {
91 unitdir: PathBuf,
92 polkitdir: PathBuf,
93}
94
95impl Layout {
96 fn from_args(a: &InstallArgs) -> Result<Self> {
97 Ok(Layout {
98 unitdir: checked_dir("--unitdir", &a.unitdir)?,
99 polkitdir: checked_dir("--polkitdir", &a.polkitdir)?,
100 })
101 }
102}
103
104fn checked_dir(flag: &str, dir: &Path) -> Result<PathBuf> {
110 let s = dir
111 .to_str()
112 .with_context(|| format!("{flag} is not valid UTF-8"))?;
113 let Some(rest) = s.strip_prefix('/') else {
114 bail!("{flag} must be an absolute path, got {s}");
115 };
116 if rest
117 .split('/')
118 .any(|seg| seg.is_empty() || seg == "." || seg == "..")
119 {
120 bail!("{flag} must not contain `.`, `..` or empty segments: {s}");
121 }
122 Ok(dir.to_path_buf())
123}
124
125#[derive(Debug)]
128struct Item {
129 data: Vec<u8>,
130 dst: PathBuf,
131 mode: u32,
132 keep_existing: bool,
135}
136
137pub fn install_command(args: InstallArgs) -> Result<()> {
138 let root = match &args.source_dir {
139 Some(d) => d.clone(),
140 None => MetadataCommand::new()
144 .no_deps()
145 .exec()
146 .context("finding the workspace root; pass --source-dir to install without cargo")?
147 .workspace_root
148 .into_std_path_buf(),
149 };
150 let sources = Sources::from_workspace(&root);
151 let layout = Layout::from_args(&args)?;
152 let items = prepare(&sources, &layout)?;
153 write_all(&items, args.destdir.as_deref())
154}
155
156fn prepare(src: &Sources, layout: &Layout) -> Result<Vec<Item>> {
159 let read = |p: &Path| fs::read(p).with_context(|| format!("reading {}", p.display()));
160
161 let policy = read(&src.policy)?;
162 check_policy(std::str::from_utf8(&policy).context("policy is not UTF-8")?)?;
163
164 Ok(vec![
165 Item {
166 data: read(&src.service)?,
167 dst: layout.unitdir.join("scx.service"),
168 mode: 0o644,
169 keep_existing: false,
170 },
171 Item {
172 data: read(&src.config)?,
173 dst: PathBuf::from(CONFIG_PATH),
174 mode: 0o644,
175 keep_existing: true,
176 },
177 Item {
178 data: read(&src.catalog)?,
179 dst: PathBuf::from(CATALOG_PATH),
180 mode: 0o644,
181 keep_existing: false,
182 },
183 Item {
184 data: policy,
185 dst: layout.polkitdir.join("org.sched_ext.scxcli.policy"),
186 mode: 0o644,
187 keep_existing: false,
188 },
189 ])
190}
191
192fn check_policy(policy: &str) -> Result<()> {
197 let mut actions = 0;
198 for block in policy.split("<action ").skip(1) {
199 actions += 1;
200 let id = block
201 .split_once("id=\"")
202 .and_then(|(_, r)| r.split('"').next())
203 .unwrap_or("?");
204 let block = block.split("</action>").next().unwrap_or(block);
205 let paths: Vec<&str> = block
206 .split(POLICY_PATH_KEY)
207 .skip(1)
208 .map(|r| r.split('<').next().unwrap_or(""))
209 .collect();
210 match paths.as_slice() {
211 [p] if *p == SCXCLI_PATH => {}
212 [p] => bail!(
213 "org.sched_ext.scxcli.policy: action {id} names {p}, the contract names {SCXCLI_PATH}"
214 ),
215 [] => bail!("org.sched_ext.scxcli.policy: action {id} names no scxcli path"),
216 _ => bail!("org.sched_ext.scxcli.policy: action {id} names more than one path"),
217 }
218 }
219 if actions == 0 {
220 bail!("org.sched_ext.scxcli.policy declares no action");
221 }
222 Ok(())
223}
224
225fn write_all(items: &[Item], destdir: Option<&Path>) -> Result<()> {
226 if let Some(root) = destdir {
230 ensure_dir(root, None)?;
231 }
232 for item in items {
233 let dst = match destdir {
234 Some(d) => d.join(item.dst.strip_prefix("/").unwrap_or(&item.dst)),
235 None => item.dst.clone(),
236 };
237 if item.keep_existing && destdir.is_none() && entry_exists(&dst)? {
238 println!("kept {} (exists)", dst.display());
239 continue;
240 }
241 install_bytes(&item.data, &dst, item.mode, destdir)?;
242 println!("install {} ({:o})", dst.display(), item.mode);
243 }
244 Ok(())
245}
246
247fn entry_exists(p: &Path) -> Result<bool> {
252 match fs::symlink_metadata(p) {
253 Ok(_) => Ok(true),
254 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
255 Err(e) => Err(e).with_context(|| format!("checking {}", p.display())),
256 }
257}
258
259fn ensure_dir(dir: &Path, within: Option<&Path>) -> Result<()> {
271 let mut cur = PathBuf::new();
272 for c in dir.components() {
273 cur.push(c);
274 if entry_exists(&cur)? {
275 continue;
276 }
277 if let (Some(root), Some(parent)) = (within, cur.parent()) {
278 confine(parent, root)?;
279 }
280 match fs::DirBuilder::new().mode(0o700).create(&cur) {
281 Ok(()) => fs::set_permissions(&cur, fs::Permissions::from_mode(0o755))
282 .with_context(|| format!("setting the mode of {}", cur.display()))?,
283 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
285 Err(e) => return Err(e).with_context(|| format!("creating {}", cur.display())),
286 }
287 }
288 Ok(())
289}
290
291fn install_bytes(data: &[u8], dst: &Path, mode: u32, within: Option<&Path>) -> Result<()> {
309 static SEQ: AtomicU64 = AtomicU64::new(0);
310
311 let parent = dst
312 .parent()
313 .with_context(|| format!("{} has no parent directory", dst.display()))?;
314 ensure_dir(parent, within)?;
315 if let Some(root) = within {
316 confine(parent, root)?;
317 }
318
319 let name = dst
320 .file_name()
321 .with_context(|| format!("{} has no file name", dst.display()))?;
322 let tmp = parent.join(format!(
323 ".{}.{}.{}.xtask-tmp",
324 name.to_string_lossy(),
325 std::process::id(),
326 SEQ.fetch_add(1, Ordering::Relaxed)
327 ));
328
329 let mut f = fs::OpenOptions::new()
334 .write(true)
335 .create_new(true)
336 .mode(0o600)
337 .open(&tmp)
338 .with_context(|| format!("creating {}", tmp.display()))?;
339
340 let write = |f: &mut fs::File| -> Result<()> {
341 f.write_all(data)?;
342 f.set_permissions(fs::Permissions::from_mode(mode))?;
343 f.sync_all()?;
344 fs::rename(&tmp, dst)?;
345 Ok(())
346 };
347 if let Err(e) = write(&mut f) {
348 let _ = fs::remove_file(&tmp);
349 return Err(e).with_context(|| format!("installing {}", dst.display()));
350 }
351 Ok(())
352}
353
354fn confine(dir: &Path, root: &Path) -> Result<()> {
358 let real_dir = fs::canonicalize(dir).with_context(|| format!("resolving {}", dir.display()))?;
359 let real_root =
360 fs::canonicalize(root).with_context(|| format!("resolving {}", root.display()))?;
361 if !real_dir.starts_with(&real_root) {
362 bail!(
363 "{} resolves to {}, outside --destdir {}; a symlink in the staging tree points \
364out of it",
365 dir.display(),
366 real_dir.display(),
367 root.display()
368 );
369 }
370 Ok(())
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 const POLICY: &str = "<policyconfig>\n\
378 <action id=\"org.sched_ext.scxcli.start\">\n\
379 <annotate key=\"org.freedesktop.policykit.exec.path\">/usr/bin/scxcli</annotate>\n\
380 <annotate key=\"org.freedesktop.policykit.exec.argv1\">start</annotate>\n\
381 </action>\n\
382 <action id=\"org.sched_ext.scxcli.stop\">\n\
383 <annotate key=\"org.freedesktop.policykit.exec.path\">/usr/bin/scxcli</annotate>\n\
384 <annotate key=\"org.freedesktop.policykit.exec.argv1\">stop</annotate>\n\
385 </action>\n\
386 <action id=\"org.sched_ext.scxcli.run\">\n\
387 <annotate key=\"org.freedesktop.policykit.exec.path\">/usr/bin/scxcli</annotate>\n\
388 <annotate key=\"org.freedesktop.policykit.exec.argv1\">run</annotate>\n\
389 </action>\n\
390 </policyconfig>\n";
391
392 fn scratch(name: &str) -> PathBuf {
393 let d = std::env::temp_dir().join(format!(
394 "xtask-install-{name}-{}-{}",
395 std::process::id(),
396 std::time::SystemTime::now()
397 .duration_since(std::time::UNIX_EPOCH)
398 .unwrap()
399 .as_nanos()
400 ));
401 fs::create_dir_all(&d).unwrap();
402 d
403 }
404
405 fn fixture(root: &Path) -> Sources {
406 let w = |rel: &str, body: &str| -> PathBuf {
407 let p = root.join(rel);
408 fs::create_dir_all(p.parent().unwrap()).unwrap();
409 fs::write(&p, body).unwrap();
410 p
411 };
412 Sources {
413 service: w("services/scx.service", "[Unit]\n"),
414 config: w("services/scx", "SCX_SCHEDULER=scx_cosmos\n"),
415 catalog: w("services/catalog.json", "{}\n"),
416 policy: w("tools/scxcli/org.sched_ext.scxcli.policy", POLICY),
417 }
418 }
419
420 fn default_layout() -> Layout {
421 Layout::from_args(&InstallArgs {
422 destdir: None,
423 source_dir: None,
424 unitdir: "/usr/lib/systemd/system".into(),
425 polkitdir: "/usr/share/polkit-1/actions".into(),
426 })
427 .unwrap()
428 }
429
430 fn mode(p: &Path) -> u32 {
431 fs::metadata(p).unwrap().permissions().mode() & 0o777
432 }
433
434 const INSTALLED: [&str; 4] = [
435 "usr/lib/systemd/system/scx.service",
436 "etc/default/scx",
437 "usr/share/scx/catalog.json",
438 "usr/share/polkit-1/actions/org.sched_ext.scxcli.policy",
439 ];
440
441 fn no_tmp_left(root: &Path) -> bool {
442 fn walk(d: &Path) -> bool {
443 fs::read_dir(d).unwrap().all(|e| {
444 let e = e.unwrap();
445 let n = e.file_name().to_string_lossy().to_string();
446 !n.ends_with(".xtask-tmp") && (!e.path().is_dir() || walk(&e.path()))
447 })
448 }
449 walk(root)
450 }
451
452 #[test]
453 fn installs_the_four_files_under_destdir_with_modes() {
454 let root = scratch("destdir");
455 let src = fixture(&root);
456 let destdir = root.join("pkg");
457
458 let items = prepare(&src, &default_layout()).unwrap();
459 write_all(&items, Some(&destdir)).unwrap();
460
461 for rel in INSTALLED {
462 let p = destdir.join(rel);
463 assert!(p.is_file(), "{rel} not installed");
464 assert_eq!(mode(&p), 0o644, "{rel} mode");
465 }
466 assert_eq!(
467 fs::read_to_string(destdir.join(INSTALLED[3])).unwrap(),
468 POLICY
469 );
470 assert!(!destdir.join("usr/bin").exists(), "binaries installed");
471 assert!(no_tmp_left(&destdir));
472 fs::remove_dir_all(&root).unwrap();
473 }
474
475 #[test]
476 fn unit_and_policy_directories_can_move() {
477 let root = scratch("dirs");
478 let src = fixture(&root);
479 let destdir = root.join("pkg");
480 let layout = Layout::from_args(&InstallArgs {
481 destdir: None,
482 source_dir: None,
483 unitdir: "/lib/systemd/system".into(),
484 polkitdir: "/usr/local/share/polkit-1/actions".into(),
485 })
486 .unwrap();
487
488 write_all(&prepare(&src, &layout).unwrap(), Some(&destdir)).unwrap();
489
490 assert!(destdir.join("lib/systemd/system/scx.service").is_file());
491 assert!(
492 destdir
493 .join("usr/local/share/polkit-1/actions/org.sched_ext.scxcli.policy")
494 .is_file()
495 );
496 assert!(destdir.join("etc/default/scx").is_file());
498 assert!(destdir.join("usr/share/scx/catalog.json").is_file());
499 fs::remove_dir_all(&root).unwrap();
500 }
501
502 #[test]
503 fn directories_that_could_leave_destdir_are_refused() {
504 for bad in [
505 "relative/dir",
506 "/usr/../etc",
507 "/usr/./lib",
508 "/../etc",
509 "/usr//lib",
510 "/",
511 ] {
512 assert!(
513 checked_dir("--unitdir", Path::new(bad)).is_err(),
514 "{bad} accepted"
515 );
516 }
517 assert!(checked_dir("--unitdir", Path::new("/usr/lib/systemd/system")).is_ok());
518 }
519
520 #[test]
521 fn a_missing_source_writes_nothing() {
522 let root = scratch("missing");
523 let src = fixture(&root);
524 fs::remove_file(&src.catalog).unwrap();
525 let destdir = root.join("pkg");
526
527 let err = prepare(&src, &default_layout()).unwrap_err();
528
529 assert!(err.to_string().contains("catalog.json"), "{err}");
530 assert!(!destdir.exists());
531 fs::remove_dir_all(&root).unwrap();
532 }
533
534 #[test]
535 fn a_policy_naming_another_path_is_refused_before_any_write() {
536 let root = scratch("policy");
537 let src = fixture(&root);
538 fs::write(
539 &src.policy,
540 POLICY.replacen("/usr/bin/scxcli", "/usr/local/bin/scxcli", 1),
541 )
542 .unwrap();
543
544 let err = prepare(&src, &default_layout()).unwrap_err();
545
546 assert!(err.to_string().contains("/usr/local/bin/scxcli"), "{err}");
547 assert!(!root.join("pkg").exists());
548 fs::remove_dir_all(&root).unwrap();
549 }
550
551 #[test]
552 fn a_policy_with_no_action_is_refused() {
553 assert!(check_policy("<policyconfig/>\n").is_err());
554 assert!(check_policy(POLICY).is_ok());
555 }
556
557 #[test]
558 fn an_action_without_a_path_is_refused() {
559 let policy = POLICY.replacen(
562 "<annotate key=\"org.freedesktop.policykit.exec.path\">/usr/bin/scxcli</annotate>\n\
563 <annotate key=\"org.freedesktop.policykit.exec.argv1\">run</annotate>",
564 "<annotate key=\"org.freedesktop.policykit.exec.argv1\">run</annotate>",
565 1,
566 );
567 assert_ne!(policy, POLICY);
568 let err = check_policy(&policy).unwrap_err();
569 assert!(
570 err.to_string().contains("org.sched_ext.scxcli.run"),
571 "{err}"
572 );
573 }
574
575 #[test]
576 fn an_action_with_two_paths_is_refused() {
577 let policy = POLICY.replacen(
578 "<annotate key=\"org.freedesktop.policykit.exec.argv1\">start</annotate>",
579 "<annotate key=\"org.freedesktop.policykit.exec.path\">/usr/bin/scxcli</annotate>",
580 1,
581 );
582 assert!(check_policy(&policy).is_err());
583 }
584
585 #[test]
586 fn a_live_install_preserves_a_dangling_config_symlink() {
587 let root = scratch("dangling");
588 let src = fixture(&root);
589 let sys = root.join("sys");
590 let mut items = prepare(&src, &default_layout()).unwrap();
591 for it in &mut items {
592 it.dst = sys.join(it.dst.strip_prefix("/").unwrap());
593 }
594 let cfg = sys.join("etc/default/scx");
595 fs::create_dir_all(cfg.parent().unwrap()).unwrap();
596 std::os::unix::fs::symlink(sys.join("etc/scx/my-config"), &cfg).unwrap();
597 assert!(!cfg.exists(), "the link must be dangling for this test");
598
599 write_all(&items, None).unwrap();
600
601 let md = fs::symlink_metadata(&cfg).unwrap();
602 assert!(
603 md.file_type().is_symlink(),
604 "the link was replaced by a file"
605 );
606 assert!(!cfg.exists(), "the link was written through");
607 fs::remove_dir_all(&root).unwrap();
608 }
609
610 #[test]
611 fn existing_directories_keep_their_mode() {
612 let root = scratch("dirmode");
613 let private = root.join("private");
614 fs::create_dir(&private).unwrap();
615 fs::set_permissions(&private, fs::Permissions::from_mode(0o700)).unwrap();
616
617 ensure_dir(&private.join("new/deeper"), None).unwrap();
618
619 assert_eq!(mode(&private), 0o700, "an existing directory was widened");
620 assert_eq!(mode(&private.join("new")), 0o755);
621 assert_eq!(mode(&private.join("new/deeper")), 0o755);
622 fs::remove_dir_all(&root).unwrap();
623 }
624
625 #[test]
629 fn new_directories_are_0755_under_umask_077() {
630 if std::env::var_os("XTASK_UMASK_CHILD").is_some() {
631 let root = scratch("umask");
632 let dir = root.join("usr/share/scx");
633 ensure_dir(&dir, None).unwrap();
634 assert_eq!(mode(&dir), 0o755);
635 assert_eq!(mode(&root.join("usr/share")), 0o755);
636 fs::remove_dir_all(&root).unwrap();
637 return;
638 }
639 let exe = std::env::current_exe().unwrap();
640 let out = std::process::Command::new("sh")
641 .arg("-c")
642 .arg("umask 077 && exec \"$0\" --exact \"$1\" --nocapture")
643 .arg(&exe)
644 .arg("install::tests::new_directories_are_0755_under_umask_077")
645 .env("XTASK_UMASK_CHILD", "1")
646 .output()
647 .unwrap();
648 let stdout = String::from_utf8_lossy(&out.stdout);
649 assert!(
650 out.status.success(),
651 "child failed:\n{stdout}\n{}",
652 String::from_utf8_lossy(&out.stderr)
653 );
654 assert!(stdout.contains("test result: ok. 1 passed"), "{stdout}");
655 }
656
657 #[test]
658 fn a_live_install_keeps_an_existing_configuration() {
659 let root = scratch("live");
660 let src = fixture(&root);
661 let sys = root.join("sys");
664 let mut items = prepare(&src, &default_layout()).unwrap();
665 for it in &mut items {
666 it.dst = sys.join(it.dst.strip_prefix("/").unwrap());
667 }
668 let cfg = sys.join("etc/default/scx");
669 fs::create_dir_all(cfg.parent().unwrap()).unwrap();
670 fs::write(&cfg, "SCX_SCHEDULER=scx_lavd\n").unwrap();
671
672 write_all(&items, None).unwrap();
673
674 assert_eq!(
675 fs::read_to_string(&cfg).unwrap(),
676 "SCX_SCHEDULER=scx_lavd\n"
677 );
678 assert!(sys.join("usr/lib/systemd/system/scx.service").is_file());
679 assert!(sys.join("usr/share/scx/catalog.json").is_file());
680 fs::remove_dir_all(&root).unwrap();
681 }
682
683 #[test]
684 fn a_temporary_this_install_did_not_create_is_not_removed() {
685 let root = scratch("stale-tmp");
686 let dst = root.join("scx.service");
687 let planted: Vec<PathBuf> = (0..64)
690 .map(|n| root.join(format!(".scx.service.{}.{n}.xtask-tmp", std::process::id())))
691 .collect();
692 for p in &planted {
693 fs::write(p, "stale").unwrap();
694 }
695
696 let err = install_bytes(b"[Unit]\n", &dst, 0o644, None).unwrap_err();
697
698 assert!(err.to_string().contains("creating"), "{err}");
699 assert!(!dst.exists());
700 for p in &planted {
701 assert_eq!(
702 fs::read_to_string(p).unwrap(),
703 "stale",
704 "{} removed",
705 p.display()
706 );
707 }
708 fs::remove_dir_all(&root).unwrap();
709 }
710
711 #[test]
712 fn concurrent_installs_into_one_destdir_both_finish() {
713 let root = scratch("concurrent");
714 let src = fixture(&root);
715 let destdir = root.join("pkg");
716 let items = std::sync::Arc::new(prepare(&src, &default_layout()).unwrap());
717
718 let handles: Vec<_> = (0..4)
719 .map(|_| {
720 let items = items.clone();
721 let destdir = destdir.clone();
722 std::thread::spawn(move || write_all(&items, Some(&destdir)))
723 })
724 .collect();
725 for h in handles {
726 h.join().unwrap().unwrap();
727 }
728
729 for rel in INSTALLED {
730 assert!(destdir.join(rel).is_file(), "{rel}");
731 }
732 assert!(no_tmp_left(&destdir));
733 fs::remove_dir_all(&root).unwrap();
734 }
735
736 #[test]
739 fn the_shipped_policy_has_the_three_actions_on_the_fixed_path() {
740 let policy = fs::read_to_string(
741 Path::new(env!("CARGO_MANIFEST_DIR")).join("../scxcli/org.sched_ext.scxcli.policy"),
742 )
743 .unwrap();
744 check_policy(&policy).unwrap();
745 let actions: Vec<(&str, &str, &str)> = policy
751 .split("<action id=\"")
752 .skip(1)
753 .map(|block| {
754 let id = block.split('"').next().unwrap();
755 let block = block.split("</action>").next().unwrap();
756 let after = |key: &str| {
757 block
758 .split_once(key)
759 .map(|(_, r)| r.split('<').next().unwrap())
760 .unwrap_or("")
761 };
762 (
763 id,
764 after("org.freedesktop.policykit.exec.argv1\">"),
765 after("<allow_active>"),
766 )
767 })
768 .collect();
769 assert_eq!(
770 actions,
771 [
772 ("org.sched_ext.scxcli.start", "start", "yes"),
773 ("org.sched_ext.scxcli.stop", "stop", "yes"),
774 ("org.sched_ext.scxcli.run", "run", "auth_admin_keep"),
775 ]
776 );
777 }
778
779 #[test]
783 fn a_staging_symlink_cannot_point_the_install_out_of_destdir() {
784 let root = scratch("escape");
785 let src = fixture(&root);
786 let destdir = root.join("pkg");
787 let outside = root.join("outside");
788 fs::create_dir_all(destdir.join("usr/share")).unwrap();
789 fs::create_dir_all(&outside).unwrap();
790 std::os::unix::fs::symlink(&outside, destdir.join("usr/share/scx")).unwrap();
791
792 let items = prepare(&src, &default_layout()).unwrap();
793 let err = write_all(&items, Some(&destdir)).unwrap_err();
794 assert!(err.to_string().contains("outside --destdir"), "{err}");
795 assert!(
796 fs::read_dir(&outside).unwrap().next().is_none(),
797 "nothing written outside"
798 );
799 assert!(no_tmp_left(&root));
800
801 fs::remove_file(destdir.join("usr/share/scx")).unwrap();
804 fs::remove_dir(destdir.join("usr/share")).unwrap();
805 std::os::unix::fs::symlink(&outside, destdir.join("usr/share")).unwrap();
806 let err = write_all(&items, Some(&destdir)).unwrap_err();
807 assert!(err.to_string().contains("outside --destdir"), "{err}");
808 assert!(
809 fs::read_dir(&outside).unwrap().next().is_none(),
810 "no directory created outside"
811 );
812 fs::remove_file(destdir.join("usr/share")).unwrap();
813 fs::create_dir_all(destdir.join("usr/share")).unwrap();
814
815 fs::create_dir_all(destdir.join("data/scx")).unwrap();
817 std::os::unix::fs::symlink("../../data/scx", destdir.join("usr/share/scx")).unwrap();
818 write_all(&items, Some(&destdir)).unwrap();
819 assert!(destdir.join("data/scx/catalog.json").is_file());
820 let _ = fs::remove_dir_all(&root);
821 }
822}