Skip to main content

rightkit_child_process/
reveal.rs

1//! Reveal a file or folder in the system file manager, or open it with its default handler,
2//! through [`OwnedCommand`], and only inside caller-supplied roots.
3//!
4//! The helper starts `open -R` (macOS), `explorer.exe /select,` (Windows) or `xdg-open` on the
5//! parent folder (other Unix). Every path is first resolved with `std::fs::canonicalize`
6//! (symlinks and `..` followed) and must lie inside one of the roots of a [`PathScope`]; a
7//! path outside every root, a path that does not exist and an empty scope are all refused
8//! before anything is started. Opening a file the system would run (an executable, a script,
9//! an application bundle, a shortcut) is refused as well: [`RevealAction::Open`] is for
10//! documents and folders.
11//!
12//! The helper is detached: the file manager outlives the call and is never killed by it.
13
14use crate::{OwnedChild, OwnedCommand, DEFAULT_ENV_ALLOWLIST};
15use std::{
16    ffi::{OsStr, OsString},
17    io,
18    path::{Path, PathBuf},
19    process::Stdio,
20    time::Duration,
21};
22
23/// Extra variables a Linux desktop launcher needs on top of [`DEFAULT_ENV_ALLOWLIST`].
24const LAUNCHER_ENV: &[&str] = &[
25    "DISPLAY",
26    "WAYLAND_DISPLAY",
27    "DBUS_SESSION_BUS_ADDRESS",
28    "XDG_CURRENT_DESKTOP",
29    "XDG_SESSION_TYPE",
30    "XDG_CONFIG_HOME",
31    "XDG_CONFIG_DIRS",
32    "XDG_DATA_DIRS",
33];
34
35/// How long [`reveal`] and [`open`] wait for the launcher to report before leaving it running.
36pub const LAUNCH_WAIT: Duration = Duration::from_secs(5);
37
38/// File extensions (lower case, no dot) that mean "this runs code" on at least one platform.
39const RUNNABLE_EXTENSIONS: &[&str] = &[
40    "app", "command", "tool", "workflow", "action", "pkg", "dmg", "exe", "com", "bat", "cmd",
41    "msi", "msp", "scr", "ps1", "psm1", "vbs", "vbe", "js", "jse", "wsf", "wsh", "hta", "reg",
42    "lnk", "url", "scf", "cpl", "dll", "jar", "desktop", "sh", "run", "appimage",
43];
44
45/// What to do with a path.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum RevealAction {
48    /// Show the item selected in its folder (Linux: open the parent folder).
49    Reveal,
50    /// Open the item with its default handler. Refused for runnable files.
51    Open,
52}
53
54/// The host a [`LaunchPlan`] is built for. Present so every platform's command line can be
55/// checked on any host.
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum RevealPlatform {
58    MacOs,
59    Windows,
60    Linux,
61}
62
63impl RevealPlatform {
64    pub const fn current() -> Self {
65        if cfg!(target_os = "macos") {
66            Self::MacOs
67        } else if cfg!(windows) {
68            Self::Windows
69        } else {
70            Self::Linux
71        }
72    }
73}
74
75/// The program and arguments to start. `raw_windows_args` means the single argument is already
76/// quoted the way `explorer.exe` wants it and must reach the command line verbatim.
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct LaunchPlan {
79    pub program: OsString,
80    pub args: Vec<OsString>,
81    pub raw_windows_args: bool,
82}
83
84/// The roots a path must lie inside. Roots are canonicalised when the scope is built.
85#[derive(Clone, Debug, Eq, PartialEq)]
86pub struct PathScope {
87    roots: Vec<PathBuf>,
88}
89
90impl PathScope {
91    /// Fails when a root cannot be canonicalised or when no root is given: a scope that
92    /// allows nothing is a mistake, not a policy.
93    pub fn new<I, P>(roots: I) -> io::Result<Self>
94    where
95        I: IntoIterator<Item = P>,
96        P: AsRef<Path>,
97    {
98        let mut resolved = Vec::new();
99        for root in roots {
100            resolved.push(plain(std::fs::canonicalize(root.as_ref())?));
101        }
102        if resolved.is_empty() {
103            return Err(io::Error::new(
104                io::ErrorKind::InvalidInput,
105                "a path scope needs at least one allowed root",
106            ));
107        }
108        Ok(Self { roots: resolved })
109    }
110
111    /// The canonical form of `path` when it exists and lies inside a root (a root itself
112    /// counts); `PermissionDenied` when it resolves outside every root, `NotFound` (or the
113    /// OS error) when it cannot be resolved.
114    pub fn resolve(&self, path: &Path) -> io::Result<PathBuf> {
115        let canonical = plain(std::fs::canonicalize(path)?);
116        // `starts_with` compares whole components: `/data2` is not inside `/data`.
117        if self.roots.iter().any(|root| canonical.starts_with(root)) {
118            Ok(canonical)
119        } else {
120            Err(io::Error::new(
121                io::ErrorKind::PermissionDenied,
122                format!("{} is outside the allowed roots", canonical.display()),
123            ))
124        }
125    }
126}
127
128/// Drops the `\\?\` prefix `canonicalize` adds on Windows (Explorer does not take it). A UNC
129/// path keeps its prefix.
130fn plain(path: PathBuf) -> PathBuf {
131    #[cfg(windows)]
132    if let Some(text) = path.to_str() {
133        if let Some(rest) = text.strip_prefix(r"\\?\") {
134            if !rest.starts_with("UNC\\") {
135                return PathBuf::from(rest);
136            }
137        }
138    }
139    path
140}
141
142/// The command line for `action` on `canonical` (an absolute, canonical path) on `platform`.
143/// Pure: starts nothing and reads nothing from the file system.
144pub fn plan(
145    platform: RevealPlatform,
146    action: RevealAction,
147    canonical: &Path,
148) -> io::Result<LaunchPlan> {
149    if !canonical.has_root() {
150        return Err(io::Error::new(
151            io::ErrorKind::InvalidInput,
152            "a launch plan needs a rooted canonical path",
153        ));
154    }
155    let path = canonical.as_os_str().to_os_string();
156    let simple = |program: &str, args: Vec<OsString>| LaunchPlan {
157        program: program.into(),
158        args,
159        raw_windows_args: false,
160    };
161    Ok(match (platform, action) {
162        (RevealPlatform::MacOs, RevealAction::Reveal) => simple("open", vec!["-R".into(), path]),
163        (RevealPlatform::MacOs, RevealAction::Open) => simple("open", vec![path]),
164        (RevealPlatform::Windows, RevealAction::Reveal) => {
165            let text = canonical.to_str().ok_or_else(|| {
166                io::Error::new(io::ErrorKind::InvalidInput, "path is not valid Unicode")
167            })?;
168            if text.contains('"') {
169                return Err(io::Error::new(
170                    io::ErrorKind::InvalidInput,
171                    "path contains a quote",
172                ));
173            }
174            LaunchPlan {
175                program: "explorer.exe".into(),
176                args: vec![format!("/select,\"{text}\"").into()],
177                raw_windows_args: true,
178            }
179        }
180        (RevealPlatform::Windows, RevealAction::Open) => simple("explorer.exe", vec![path]),
181        (RevealPlatform::Linux, RevealAction::Reveal) => {
182            let folder = canonical.parent().unwrap_or(canonical);
183            simple("xdg-open", vec![folder.as_os_str().to_os_string()])
184        }
185        (RevealPlatform::Linux, RevealAction::Open) => simple("xdg-open", vec![path]),
186    })
187}
188
189/// Whether the system would run `canonical` rather than show it: a runnable extension, an
190/// execute permission bit on a Unix file, or an application bundle directory.
191fn is_runnable(canonical: &Path) -> bool {
192    if canonical.is_dir() {
193        // A folder is runnable only when it is a real application or workflow bundle. A folder
194        // named `chart.js`, `highlight.js` or `Foo.app` without a bundle is opened as a folder.
195        return is_bundle(canonical);
196    }
197    let extension_runs = canonical
198        .extension()
199        .and_then(OsStr::to_str)
200        .map(|ext| RUNNABLE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
201        .unwrap_or(false);
202    if extension_runs {
203        return true;
204    }
205    #[cfg(unix)]
206    if let Ok(meta) = std::fs::metadata(canonical) {
207        use std::os::unix::fs::PermissionsExt;
208        if meta.is_file() && meta.permissions().mode() & 0o111 != 0 {
209            return true;
210        }
211    }
212    false
213}
214
215/// A macOS application or workflow bundle: a directory with the bundle extension and a
216/// `Contents/Info.plist`.
217fn is_bundle(dir: &Path) -> bool {
218    let bundle_extension = dir
219        .extension()
220        .and_then(OsStr::to_str)
221        .map(|ext| ext.to_ascii_lowercase());
222    matches!(bundle_extension.as_deref(), Some("app") | Some("workflow"))
223        && dir.join("Contents").join("Info.plist").is_file()
224}
225
226/// Checks `path` against `scope` (and, for [`RevealAction::Open`], against runnable files),
227/// then returns the command, not yet started. Stdio is null and the environment is
228/// [`DEFAULT_ENV_ALLOWLIST`] plus the desktop-session variables a launcher needs.
229pub fn launch_command(
230    path: &Path,
231    scope: &PathScope,
232    action: RevealAction,
233) -> io::Result<OwnedCommand> {
234    launch_checked(path, scope, action, false)
235}
236
237fn launch_checked(
238    path: &Path,
239    scope: &PathScope,
240    action: RevealAction,
241    allow_runnable: bool,
242) -> io::Result<OwnedCommand> {
243    let canonical = scope.resolve(path)?;
244    if action == RevealAction::Open && !allow_runnable && is_runnable(&canonical) {
245        return Err(io::Error::new(
246            io::ErrorKind::PermissionDenied,
247            format!("{} would be run, not opened", canonical.display()),
248        ));
249    }
250    let plan = plan(RevealPlatform::current(), action, &canonical)?;
251    let mut command = OwnedCommand::new(&plan.program);
252    {
253        let inner = command.command_mut();
254        #[cfg(windows)]
255        if plan.raw_windows_args {
256            use std::os::windows::process::CommandExt;
257            for arg in &plan.args {
258                inner.raw_arg(arg);
259            }
260        } else {
261            inner.args(&plan.args);
262        }
263        #[cfg(not(windows))]
264        inner.args(&plan.args);
265        inner
266            .stdin(Stdio::null())
267            .stdout(Stdio::null())
268            .stderr(Stdio::null());
269    }
270    command.env_allowlist(DEFAULT_ENV_ALLOWLIST.iter().chain(LAUNCHER_ENV.iter()));
271    Ok(command)
272}
273
274/// Starts the launcher detached (the file manager must outlive this call) and returns it.
275pub fn launch(path: &Path, scope: &PathScope, action: RevealAction) -> io::Result<OwnedChild> {
276    launch_command(path, scope, action)?.spawn_uncontained_detached()
277}
278
279fn run(path: &Path, scope: &PathScope, action: RevealAction) -> io::Result<()> {
280    run_checked(path, scope, action, false)
281}
282
283fn run_checked(
284    path: &Path,
285    scope: &PathScope,
286    action: RevealAction,
287    allow_runnable: bool,
288) -> io::Result<()> {
289    let mut child =
290        launch_checked(path, scope, action, allow_runnable)?.spawn_uncontained_detached()?;
291    match child.wait_timeout(LAUNCH_WAIT)? {
292        // `explorer.exe` reports failure with exit code 1 even when it succeeded.
293        Some(status) if !status.success() && !cfg!(windows) => Err(io::Error::other(format!(
294            "the launcher exited with {status}"
295        ))),
296        // Success, or still running: a launcher that stays up is handing off to the
297        // desktop, and the detached child is reaped in the background.
298        _ => Ok(()),
299    }
300}
301
302/// Reveals `path` in the system file manager. See the module documentation.
303pub fn reveal(path: &Path, scope: &PathScope) -> io::Result<()> {
304    run(path, scope, RevealAction::Reveal)
305}
306
307/// Opens `path` with its default handler. See the module documentation.
308pub fn open(path: &Path, scope: &PathScope) -> io::Result<()> {
309    run(path, scope, RevealAction::Open)
310}
311
312/// Opens the folder `path` itself (on Windows, `explorer.exe <folder>`, not the parent with the
313/// folder selected, which is what [`reveal`] does). `path` must be a directory inside the scope.
314/// Folders are never refused for their name, so a folder named `chart.js` opens; a real
315/// application bundle still refuses (it would run).
316pub fn open_folder(path: &Path, scope: &PathScope) -> io::Result<()> {
317    let canonical = scope.resolve(path)?;
318    if !canonical.is_dir() {
319        return Err(io::Error::new(
320            io::ErrorKind::InvalidInput,
321            format!("{} is not a folder", canonical.display()),
322        ));
323    }
324    run(path, scope, RevealAction::Open)
325}
326
327/// Runs a setup program (`.exe`, `.msi` or `.msp`) on Windows through the shell (`explorer.exe`
328/// on the file), not as a plain child process, so the shell's Mark-of-the-Web and SmartScreen
329/// checks still apply. [`open`] refuses these files because they run code; this is the one
330/// path that runs them, and only for those three extensions inside the scope. Other platforms
331/// return `Unsupported`. Whether SmartScreen prompts is decided by Windows, not by this crate.
332pub fn open_installer(path: &Path, scope: &PathScope) -> io::Result<()> {
333    if !cfg!(windows) {
334        return Err(io::Error::from(io::ErrorKind::Unsupported));
335    }
336    let canonical = scope.resolve(path)?;
337    let extension = canonical
338        .extension()
339        .and_then(OsStr::to_str)
340        .map(|ext| ext.to_ascii_lowercase());
341    if !canonical.is_file() || !matches!(extension.as_deref(), Some("exe" | "msi" | "msp")) {
342        return Err(io::Error::new(
343            io::ErrorKind::InvalidInput,
344            format!(
345                "{} is not a setup program (exe, msi, msp)",
346                canonical.display()
347            ),
348        ));
349    }
350    run_checked(path, scope, RevealAction::Open, true)
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use std::fs;
357
358    fn temp(tag: &str) -> PathBuf {
359        let dir =
360            std::env::temp_dir().join(format!("rightkit-reveal-{tag}-{}", std::process::id()));
361        let _ = fs::remove_dir_all(&dir);
362        fs::create_dir_all(&dir).unwrap();
363        dir
364    }
365
366    #[test]
367    fn plans_per_platform() {
368        let p = Path::new("/data/a b/file.txt");
369        let mac = plan(RevealPlatform::MacOs, RevealAction::Reveal, p).unwrap();
370        assert_eq!(mac.program, "open");
371        assert_eq!(
372            mac.args,
373            vec![OsString::from("-R"), p.as_os_str().to_os_string()]
374        );
375        let mac_open = plan(RevealPlatform::MacOs, RevealAction::Open, p).unwrap();
376        assert_eq!(mac_open.args, vec![p.as_os_str().to_os_string()]);
377        let linux = plan(RevealPlatform::Linux, RevealAction::Reveal, p).unwrap();
378        assert_eq!(linux.program, "xdg-open");
379        assert_eq!(linux.args, vec![OsString::from("/data/a b")]);
380        let linux_open = plan(RevealPlatform::Linux, RevealAction::Open, p).unwrap();
381        assert_eq!(linux_open.args, vec![p.as_os_str().to_os_string()]);
382    }
383
384    #[test]
385    fn linux_reveal_of_the_filesystem_root_opens_the_root() {
386        let linux = plan(RevealPlatform::Linux, RevealAction::Reveal, Path::new("/")).unwrap();
387        assert_eq!(linux.args, vec![OsString::from("/")]);
388    }
389
390    #[test]
391    fn the_windows_reveal_argument_is_quoted_verbatim() {
392        let plan = plan(
393            RevealPlatform::Windows,
394            RevealAction::Reveal,
395            Path::new("/c/My Docs/a.txt"),
396        )
397        .unwrap();
398        assert_eq!(plan.program, "explorer.exe");
399        assert!(plan.raw_windows_args);
400        assert_eq!(
401            plan.args,
402            vec![OsString::from("/select,\"/c/My Docs/a.txt\"")]
403        );
404    }
405
406    #[test]
407    fn a_relative_path_gets_no_plan() {
408        let error = plan(RevealPlatform::Linux, RevealAction::Open, Path::new("a/b")).unwrap_err();
409        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
410    }
411
412    #[test]
413    fn an_empty_scope_is_refused() {
414        let error = PathScope::new(Vec::<PathBuf>::new()).unwrap_err();
415        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
416    }
417
418    #[test]
419    fn paths_inside_the_root_resolve_and_the_root_itself_does() {
420        let root = temp("inside");
421        let file = root.join("a.txt");
422        fs::write(&file, b"x").unwrap();
423        let scope = PathScope::new([&root]).unwrap();
424        assert_eq!(
425            scope.resolve(&file).unwrap(),
426            plain(fs::canonicalize(&file).unwrap())
427        );
428        assert!(scope.resolve(&root).is_ok());
429        let _ = fs::remove_dir_all(&root);
430    }
431
432    #[test]
433    fn a_dotdot_escape_is_refused() {
434        let root = temp("dotdot");
435        let inner = root.join("inner");
436        fs::create_dir_all(&inner).unwrap();
437        let outside = temp("dotdot-outside");
438        let scope = PathScope::new([&inner]).unwrap();
439        let sneaky = inner
440            .join("..")
441            .join("..")
442            .join(outside.file_name().unwrap());
443        let error = scope.resolve(&sneaky).unwrap_err();
444        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
445        let _ = fs::remove_dir_all(&root);
446        let _ = fs::remove_dir_all(&outside);
447    }
448
449    #[test]
450    fn a_sibling_sharing_the_root_prefix_is_refused() {
451        let base = temp("prefix");
452        let root = base.join("data");
453        let sibling = base.join("data2");
454        fs::create_dir_all(&root).unwrap();
455        fs::create_dir_all(&sibling).unwrap();
456        let scope = PathScope::new([&root]).unwrap();
457        let error = scope.resolve(&sibling).unwrap_err();
458        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
459        let _ = fs::remove_dir_all(&base);
460    }
461
462    #[test]
463    fn a_missing_path_is_refused() {
464        let root = temp("missing");
465        let scope = PathScope::new([&root]).unwrap();
466        let error = scope.resolve(&root.join("nope")).unwrap_err();
467        assert_eq!(error.kind(), io::ErrorKind::NotFound);
468        let _ = fs::remove_dir_all(&root);
469    }
470
471    #[cfg(unix)]
472    #[test]
473    fn a_symlink_out_of_the_root_is_refused() {
474        let root = temp("link");
475        let outside = temp("link-outside");
476        fs::write(outside.join("secret.txt"), b"x").unwrap();
477        std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
478        let scope = PathScope::new([&root]).unwrap();
479        let error = scope
480            .resolve(&root.join("escape").join("secret.txt"))
481            .unwrap_err();
482        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
483        let error = launch_command(&root.join("escape"), &scope, RevealAction::Reveal).unwrap_err();
484        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
485        let _ = fs::remove_dir_all(&root);
486        let _ = fs::remove_dir_all(&outside);
487    }
488
489    #[test]
490    fn launch_command_refuses_an_outside_path_before_building_anything() {
491        let root = temp("cmd-root");
492        let outside = temp("cmd-outside");
493        let scope = PathScope::new([&root]).unwrap();
494        for action in [RevealAction::Reveal, RevealAction::Open] {
495            let error = launch_command(&outside, &scope, action).unwrap_err();
496            assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
497        }
498        let _ = fs::remove_dir_all(&root);
499        let _ = fs::remove_dir_all(&outside);
500    }
501
502    #[test]
503    fn open_refuses_runnable_files_and_reveal_does_not() {
504        let root = temp("runnable");
505        let script = root.join("run.bat");
506        fs::write(&script, b"echo x").unwrap();
507        // A bundle is runnable only with a Contents/Info.plist (see `is_bundle`).
508        let bundle = root.join("Thing.app");
509        fs::create_dir_all(bundle.join("Contents")).unwrap();
510        fs::write(bundle.join("Contents").join("Info.plist"), b"<plist/>").unwrap();
511        let scope = PathScope::new([&root]).unwrap();
512        for path in [&script, &bundle] {
513            let error = launch_command(path, &scope, RevealAction::Open).unwrap_err();
514            assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
515            assert!(launch_command(path, &scope, RevealAction::Reveal).is_ok());
516        }
517        let document = root.join("notes.txt");
518        fs::write(&document, b"x").unwrap();
519        assert!(launch_command(&document, &scope, RevealAction::Open).is_ok());
520        let _ = fs::remove_dir_all(&root);
521    }
522
523    #[test]
524    fn a_folder_named_like_a_script_opens_but_a_bundle_refuses() {
525        let root = temp("folder-named");
526        let folder = root.join("chart.js");
527        fs::create_dir_all(&folder).unwrap();
528        let fake_app = root.join("Foo.app");
529        fs::create_dir_all(&fake_app).unwrap();
530        let real_app = root.join("Real.app");
531        fs::create_dir_all(real_app.join("Contents")).unwrap();
532        fs::write(real_app.join("Contents").join("Info.plist"), b"<plist/>").unwrap();
533        let scope = PathScope::new([&root]).unwrap();
534        assert!(!is_runnable(&folder.canonicalize().unwrap()));
535        assert!(!is_runnable(&fake_app.canonicalize().unwrap()));
536        assert!(is_runnable(&real_app.canonicalize().unwrap()));
537        assert!(launch_command(&folder, &scope, RevealAction::Open).is_ok());
538        let error = launch_command(&real_app, &scope, RevealAction::Open).unwrap_err();
539        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
540        let _ = fs::remove_dir_all(&root);
541    }
542
543    #[test]
544    fn open_installer_is_refused_off_windows_and_outside_the_scope() {
545        let root = temp("installer");
546        let setup = root.join("setup.exe");
547        fs::write(&setup, b"MZ").unwrap();
548        let scope = PathScope::new([&root]).unwrap();
549        // Off Windows the call is refused before anything starts. On Windows it would launch the
550        // file, so that case is left to a Windows run.
551        if !cfg!(windows) {
552            let error = open_installer(&setup, &scope).unwrap_err();
553            assert_eq!(error.kind(), io::ErrorKind::Unsupported);
554        }
555        let _ = fs::remove_dir_all(&root);
556    }
557
558    #[cfg(unix)]
559    #[test]
560    fn open_refuses_a_file_with_an_execute_bit() {
561        use std::os::unix::fs::PermissionsExt;
562        let root = temp("execbit");
563        let file = root.join("tool");
564        fs::write(&file, b"#!/bin/sh\n").unwrap();
565        fs::set_permissions(&file, fs::Permissions::from_mode(0o755)).unwrap();
566        let scope = PathScope::new([&root]).unwrap();
567        let error = launch_command(&file, &scope, RevealAction::Open).unwrap_err();
568        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
569        let _ = fs::remove_dir_all(&root);
570    }
571}