1use 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
23const 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
35pub const LAUNCH_WAIT: Duration = Duration::from_secs(5);
37
38const 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum RevealAction {
48 Reveal,
50 Open,
52}
53
54#[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#[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#[derive(Clone, Debug, Eq, PartialEq)]
86pub struct PathScope {
87 roots: Vec<PathBuf>,
88}
89
90impl PathScope {
91 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 pub fn resolve(&self, path: &Path) -> io::Result<PathBuf> {
115 let canonical = plain(std::fs::canonicalize(path)?);
116 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
128fn 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
142pub 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
189fn is_runnable(canonical: &Path) -> bool {
192 if canonical.is_dir() {
193 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
215fn 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
226pub 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
274pub 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 Some(status) if !status.success() && !cfg!(windows) => Err(io::Error::other(format!(
294 "the launcher exited with {status}"
295 ))),
296 _ => Ok(()),
299 }
300}
301
302pub fn reveal(path: &Path, scope: &PathScope) -> io::Result<()> {
304 run(path, scope, RevealAction::Reveal)
305}
306
307pub fn open(path: &Path, scope: &PathScope) -> io::Result<()> {
309 run(path, scope, RevealAction::Open)
310}
311
312pub 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
327pub 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 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 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}