Skip to main content

xtask/
install.rs

1// Copyright (c) 2026 Piotr Gorski
2//
3// This software may be used and distributed according to the terms of the
4// GNU General Public License version 2.
5
6//! `xtask install`: put the data files the scheduler catalog needs where
7//! `services/CATALOG.md` says they go: the unit, its configuration, the
8//! shipped catalog and the polkit policy.
9//!
10//! It copies those four files and nothing else. It installs no binaries
11//! (a package does that; `cargo install` alone puts scxcli in
12//! `~/.cargo/bin`, not where the policy names it), never builds, never calls
13//! `systemctl`, and never touches a running unit, so it works in a
14//! package build with `--destdir` as well as on a live machine.
15//!
16//! The runtime layout is not relocatable: `scx_catalog` reads
17//! `/usr/share/scx/catalog.json`, the unit reads `/etc/default/scx`,
18//! and scxtui expects scxcli at the path the policy names,
19//! `/usr/bin/scxcli`. So those paths are fixed here too, and only the
20//! directories that vary between distributions (the systemd unit
21//! directory and the polkit actions directory) can be set. The policy is
22//! installed verbatim; the install checks that every action in it still
23//! names the fixed path, since a policy that named another path would
24//! make every change in scxtui ask for a password.
25
26use 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
36/// Where the contract puts scxcli, and what every action in the policy
37/// must name.
38const SCXCLI_PATH: &str = "/usr/bin/scxcli";
39/// `scx_catalog::catalog::SHIPPED_PATH`.
40const CATALOG_PATH: &str = "/usr/share/scx/catalog.json";
41/// `EnvironmentFile=` of `services/scx.service`.
42const 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    /// Staging root; every installed path is placed under it (DESTDIR)
49    #[arg(long, value_name = "DIR")]
50    destdir: Option<PathBuf>,
51
52    /// Repository checkout to take the files from; without it the
53    /// workspace root is asked of cargo (as `cargo metadata --no-deps`)
54    #[arg(long, value_name = "DIR")]
55    source_dir: Option<PathBuf>,
56
57    /// Directory for the systemd unit
58    #[arg(long, default_value = "/usr/lib/systemd/system", value_name = "DIR")]
59    unitdir: PathBuf,
60
61    /// Directory for the polkit policy
62    #[arg(
63        long,
64        default_value = "/usr/share/polkit-1/actions",
65        value_name = "DIR"
66    )]
67    polkitdir: PathBuf,
68}
69
70/// The files in the repository that get installed.
71struct 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
89/// The installed layout, as final paths (without DESTDIR).
90struct 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
104/// A directory the install writes under must be absolute and plain:
105/// no `.` or `..`, so joining it under DESTDIR cannot leave DESTDIR,
106/// and no empty segments. The string is checked, not the normalised
107/// components, so `/usr/./lib` is refused as written rather than
108/// silently cleaned.
109fn 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/// One file to write: its contents, read up front, its final path and
126/// its mode.
127#[derive(Debug)]
128struct Item {
129    data: Vec<u8>,
130    dst: PathBuf,
131    mode: u32,
132    /// On a live system the file is the administrator's once it exists
133    /// and is left alone. Under DESTDIR it is the package's file.
134    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        // --no-deps: the install phase must not resolve or fetch
141        // anything, only find the checkout. The shared metadata of the
142        // other commands is not used, since it resolves dependencies.
143        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
156/// Reads every source and checks the policy before anything is written,
157/// so a missing file or a wrong policy fails with nothing changed.
158fn 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
192/// Every `<action>` in the policy must carry exactly one `exec.path`
193/// annotation naming the fixed scxcli path, and there must be at least
194/// one action. pkexec matches an action on that path, so an action
195/// without one, or with another, is not covered by the contract.
196fn 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    // The staging root itself is made first, so that every directory
227    // created afterwards lies below an existing root it is checked
228    // against.
229    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
247/// Whether anything is at the path: a file, a directory, or a symlink
248/// whether or not its target exists. `Path::exists` follows the link and
249/// would call a dangling one absent, and the rename would then replace
250/// the administrator's link with a file.
251fn 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
259/// Creates the missing directories of `dir`, each 0700 at creation and
260/// widened to 0755 once it exists, so the mode does not depend on the
261/// umask (`mkdir` applies it to the requested mode: under 077 a 0755
262/// request yields 0700, and `scxcli list` as a user could not read
263/// `/usr/share/scx`). Directories that already exist are left as they
264/// are, whatever their mode: those are the administrator's.
265///
266/// With `within` (DESTDIR), the existing prefix is resolved before each
267/// missing directory is made under it: `pkg/usr/share -> /outside`
268/// would otherwise get an `scx` created in `/outside` before the file
269/// itself is refused.
270fn 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            // Another install, or anyone, got there first; it exists now.
284            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
291/// Writes a fresh temporary file beside the target and renames it over
292/// the target. The temporary is created with `create_new`, so it never
293/// opens an existing file or follows a symlink, under a name unique to
294/// this process and call, so two installs do not share it; it starts at
295/// 0600 whatever the umask and gets its mode before the rename, so there
296/// is no moment at which another user can write what root will install.
297/// A failed write removes the temporary and leaves the old file whole.
298///
299/// With `within` (DESTDIR), the directory the file lands in must resolve
300/// to somewhere under it. The settable directories cannot climb out by
301/// their spelling, but a symlink already in the staging tree can point
302/// anywhere, and the rename would follow it: `pkg/usr/share/scx ->
303/// /usr/share/scx` would install the catalog on the build host. The
304/// check runs before any directory is created (see [`ensure_dir`]) and
305/// again on the directory the file lands in. A symlink that stays
306/// inside the staging tree is the package build's own and is followed. The target itself may be a symlink; the rename
307/// replaces the link rather than following it.
308fn 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    // The open is outside the cleanup: a temporary that already exists
330    // (a previous install interrupted, the pid reused) is not this
331    // install's to remove, and create_new refuses it rather than
332    // reusing it.
333    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
354/// Refuses a directory that resolves, symlinks followed, to outside
355/// `root`. Both are canonicalised, so a `root` given through a symlink
356/// of its own is compared by where it really is.
357fn 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        // The fixed paths do not move with them.
497        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        // The second action keeps its argv1 but loses its exec.path: a
560        // policy that only counted annotations would pass it.
561        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    /// Re-runs one test in a child under umask 077 and checks that the
626    /// child ran exactly that test, so the mode is pinned down rather
627    /// than inherited from whatever umask the suite happens to run with.
628    #[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        // A "live" system is simulated by rewriting the item paths under
662        // the scratch directory, since the fixed paths are absolute.
663        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        // Every temporary of this process shares the pid; plant one for
688        // every sequence number this call could take.
689        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    /// The policy in the tree is what the install checks and ships: the
737    /// three actions of services/CATALOG.md, each naming the fixed path.
738    #[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        // Each action is bound to one subcommand by exec.argv1, and the
746        // default for a console user follows it: starting and stopping
747        // without a password, running with options as an administrator.
748        // A policy that bound `start` to `run` would pass check_policy
749        // and silently hand `run` the password-free default.
750        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    /// A symlink in the staging tree that points out of it would make
780    /// the rename land outside DESTDIR; one that stays inside is the
781    /// build's own layout and is followed.
782    #[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        // A link higher up, to a directory in which the install would
802        // have to create `scx`: refused before anything is made there.
803        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        // Inside: `usr/share/scx -> ../../data/scx` stays under pkg.
816        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}