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 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
210pub 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
249pub 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 Some(status) if !status.success() && !cfg!(windows) => Err(io::Error::other(format!(
259 "the launcher exited with {status}"
260 ))),
261 _ => Ok(()),
264 }
265}
266
267pub fn reveal(path: &Path, scope: &PathScope) -> io::Result<()> {
269 run(path, scope, RevealAction::Reveal)
270}
271
272pub 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}