Skip to main content

rightkit_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    let extension_runs = canonical
193        .extension()
194        .and_then(OsStr::to_str)
195        .map(|ext| RUNNABLE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
196        .unwrap_or(false);
197    if extension_runs {
198        return true;
199    }
200    #[cfg(unix)]
201    if let Ok(meta) = std::fs::metadata(canonical) {
202        use std::os::unix::fs::PermissionsExt;
203        if meta.is_file() && meta.permissions().mode() & 0o111 != 0 {
204            return true;
205        }
206    }
207    false
208}
209
210/// Checks `path` against `scope` (and, for [`RevealAction::Open`], against runnable files),
211/// then returns the command, not yet started. Stdio is null and the environment is
212/// [`DEFAULT_ENV_ALLOWLIST`] plus the desktop-session variables a launcher needs.
213pub fn launch_command(
214    path: &Path,
215    scope: &PathScope,
216    action: RevealAction,
217) -> io::Result<OwnedCommand> {
218    let canonical = scope.resolve(path)?;
219    if action == RevealAction::Open && is_runnable(&canonical) {
220        return Err(io::Error::new(
221            io::ErrorKind::PermissionDenied,
222            format!("{} would be run, not opened", canonical.display()),
223        ));
224    }
225    let plan = plan(RevealPlatform::current(), action, &canonical)?;
226    let mut command = OwnedCommand::new(&plan.program);
227    {
228        let inner = command.command_mut();
229        #[cfg(windows)]
230        if plan.raw_windows_args {
231            use std::os::windows::process::CommandExt;
232            for arg in &plan.args {
233                inner.raw_arg(arg);
234            }
235        } else {
236            inner.args(&plan.args);
237        }
238        #[cfg(not(windows))]
239        inner.args(&plan.args);
240        inner
241            .stdin(Stdio::null())
242            .stdout(Stdio::null())
243            .stderr(Stdio::null());
244    }
245    command.env_allowlist(DEFAULT_ENV_ALLOWLIST.iter().chain(LAUNCHER_ENV.iter()));
246    Ok(command)
247}
248
249/// Starts the launcher detached (the file manager must outlive this call) and returns it.
250pub fn launch(path: &Path, scope: &PathScope, action: RevealAction) -> io::Result<OwnedChild> {
251    launch_command(path, scope, action)?.spawn_uncontained_detached()
252}
253
254fn run(path: &Path, scope: &PathScope, action: RevealAction) -> io::Result<()> {
255    let mut child = launch(path, scope, action)?;
256    match child.wait_timeout(LAUNCH_WAIT)? {
257        // `explorer.exe` reports failure with exit code 1 even when it succeeded.
258        Some(status) if !status.success() && !cfg!(windows) => Err(io::Error::other(format!(
259            "the launcher exited with {status}"
260        ))),
261        // Success, or still running: a launcher that stays up is handing off to the
262        // desktop, and the detached child is reaped in the background.
263        _ => Ok(()),
264    }
265}
266
267/// Reveals `path` in the system file manager. See the module documentation.
268pub fn reveal(path: &Path, scope: &PathScope) -> io::Result<()> {
269    run(path, scope, RevealAction::Reveal)
270}
271
272/// Opens `path` with its default handler. See the module documentation.
273pub fn open(path: &Path, scope: &PathScope) -> io::Result<()> {
274    run(path, scope, RevealAction::Open)
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use std::fs;
281
282    fn temp(tag: &str) -> PathBuf {
283        let dir =
284            std::env::temp_dir().join(format!("rightkit-reveal-{tag}-{}", std::process::id()));
285        let _ = fs::remove_dir_all(&dir);
286        fs::create_dir_all(&dir).unwrap();
287        dir
288    }
289
290    #[test]
291    fn plans_per_platform() {
292        let p = Path::new("/data/a b/file.txt");
293        let mac = plan(RevealPlatform::MacOs, RevealAction::Reveal, p).unwrap();
294        assert_eq!(mac.program, "open");
295        assert_eq!(
296            mac.args,
297            vec![OsString::from("-R"), p.as_os_str().to_os_string()]
298        );
299        let mac_open = plan(RevealPlatform::MacOs, RevealAction::Open, p).unwrap();
300        assert_eq!(mac_open.args, vec![p.as_os_str().to_os_string()]);
301        let linux = plan(RevealPlatform::Linux, RevealAction::Reveal, p).unwrap();
302        assert_eq!(linux.program, "xdg-open");
303        assert_eq!(linux.args, vec![OsString::from("/data/a b")]);
304        let linux_open = plan(RevealPlatform::Linux, RevealAction::Open, p).unwrap();
305        assert_eq!(linux_open.args, vec![p.as_os_str().to_os_string()]);
306    }
307
308    #[test]
309    fn linux_reveal_of_the_filesystem_root_opens_the_root() {
310        let linux = plan(RevealPlatform::Linux, RevealAction::Reveal, Path::new("/")).unwrap();
311        assert_eq!(linux.args, vec![OsString::from("/")]);
312    }
313
314    #[test]
315    fn the_windows_reveal_argument_is_quoted_verbatim() {
316        let plan = plan(
317            RevealPlatform::Windows,
318            RevealAction::Reveal,
319            Path::new("/c/My Docs/a.txt"),
320        )
321        .unwrap();
322        assert_eq!(plan.program, "explorer.exe");
323        assert!(plan.raw_windows_args);
324        assert_eq!(
325            plan.args,
326            vec![OsString::from("/select,\"/c/My Docs/a.txt\"")]
327        );
328    }
329
330    #[test]
331    fn a_relative_path_gets_no_plan() {
332        let error = plan(RevealPlatform::Linux, RevealAction::Open, Path::new("a/b")).unwrap_err();
333        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
334    }
335
336    #[test]
337    fn an_empty_scope_is_refused() {
338        let error = PathScope::new(Vec::<PathBuf>::new()).unwrap_err();
339        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
340    }
341
342    #[test]
343    fn paths_inside_the_root_resolve_and_the_root_itself_does() {
344        let root = temp("inside");
345        let file = root.join("a.txt");
346        fs::write(&file, b"x").unwrap();
347        let scope = PathScope::new([&root]).unwrap();
348        assert_eq!(
349            scope.resolve(&file).unwrap(),
350            plain(fs::canonicalize(&file).unwrap())
351        );
352        assert!(scope.resolve(&root).is_ok());
353        let _ = fs::remove_dir_all(&root);
354    }
355
356    #[test]
357    fn a_dotdot_escape_is_refused() {
358        let root = temp("dotdot");
359        let inner = root.join("inner");
360        fs::create_dir_all(&inner).unwrap();
361        let outside = temp("dotdot-outside");
362        let scope = PathScope::new([&inner]).unwrap();
363        let sneaky = inner
364            .join("..")
365            .join("..")
366            .join(outside.file_name().unwrap());
367        let error = scope.resolve(&sneaky).unwrap_err();
368        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
369        let _ = fs::remove_dir_all(&root);
370        let _ = fs::remove_dir_all(&outside);
371    }
372
373    #[test]
374    fn a_sibling_sharing_the_root_prefix_is_refused() {
375        let base = temp("prefix");
376        let root = base.join("data");
377        let sibling = base.join("data2");
378        fs::create_dir_all(&root).unwrap();
379        fs::create_dir_all(&sibling).unwrap();
380        let scope = PathScope::new([&root]).unwrap();
381        let error = scope.resolve(&sibling).unwrap_err();
382        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
383        let _ = fs::remove_dir_all(&base);
384    }
385
386    #[test]
387    fn a_missing_path_is_refused() {
388        let root = temp("missing");
389        let scope = PathScope::new([&root]).unwrap();
390        let error = scope.resolve(&root.join("nope")).unwrap_err();
391        assert_eq!(error.kind(), io::ErrorKind::NotFound);
392        let _ = fs::remove_dir_all(&root);
393    }
394
395    #[cfg(unix)]
396    #[test]
397    fn a_symlink_out_of_the_root_is_refused() {
398        let root = temp("link");
399        let outside = temp("link-outside");
400        fs::write(outside.join("secret.txt"), b"x").unwrap();
401        std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
402        let scope = PathScope::new([&root]).unwrap();
403        let error = scope
404            .resolve(&root.join("escape").join("secret.txt"))
405            .unwrap_err();
406        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
407        let error = launch_command(&root.join("escape"), &scope, RevealAction::Reveal).unwrap_err();
408        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
409        let _ = fs::remove_dir_all(&root);
410        let _ = fs::remove_dir_all(&outside);
411    }
412
413    #[test]
414    fn launch_command_refuses_an_outside_path_before_building_anything() {
415        let root = temp("cmd-root");
416        let outside = temp("cmd-outside");
417        let scope = PathScope::new([&root]).unwrap();
418        for action in [RevealAction::Reveal, RevealAction::Open] {
419            let error = launch_command(&outside, &scope, action).unwrap_err();
420            assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
421        }
422        let _ = fs::remove_dir_all(&root);
423        let _ = fs::remove_dir_all(&outside);
424    }
425
426    #[test]
427    fn open_refuses_runnable_files_and_reveal_does_not() {
428        let root = temp("runnable");
429        let script = root.join("run.bat");
430        fs::write(&script, b"echo x").unwrap();
431        let bundle = root.join("Thing.app");
432        fs::create_dir_all(&bundle).unwrap();
433        let scope = PathScope::new([&root]).unwrap();
434        for path in [&script, &bundle] {
435            let error = launch_command(path, &scope, RevealAction::Open).unwrap_err();
436            assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
437            assert!(launch_command(path, &scope, RevealAction::Reveal).is_ok());
438        }
439        let document = root.join("notes.txt");
440        fs::write(&document, b"x").unwrap();
441        assert!(launch_command(&document, &scope, RevealAction::Open).is_ok());
442        let _ = fs::remove_dir_all(&root);
443    }
444
445    #[cfg(unix)]
446    #[test]
447    fn open_refuses_a_file_with_an_execute_bit() {
448        use std::os::unix::fs::PermissionsExt;
449        let root = temp("execbit");
450        let file = root.join("tool");
451        fs::write(&file, b"#!/bin/sh\n").unwrap();
452        fs::set_permissions(&file, fs::Permissions::from_mode(0o755)).unwrap();
453        let scope = PathScope::new([&root]).unwrap();
454        let error = launch_command(&file, &scope, RevealAction::Open).unwrap_err();
455        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
456        let _ = fs::remove_dir_all(&root);
457    }
458}