use crate::{OwnedChild, OwnedCommand, DEFAULT_ENV_ALLOWLIST};
use std::{
ffi::{OsStr, OsString},
io,
path::{Path, PathBuf},
process::Stdio,
time::Duration,
};
const LAUNCHER_ENV: &[&str] = &[
"DISPLAY",
"WAYLAND_DISPLAY",
"DBUS_SESSION_BUS_ADDRESS",
"XDG_CURRENT_DESKTOP",
"XDG_SESSION_TYPE",
"XDG_CONFIG_HOME",
"XDG_CONFIG_DIRS",
"XDG_DATA_DIRS",
];
pub const LAUNCH_WAIT: Duration = Duration::from_secs(5);
const RUNNABLE_EXTENSIONS: &[&str] = &[
"app", "command", "tool", "workflow", "action", "pkg", "dmg", "exe", "com", "bat", "cmd",
"msi", "msp", "scr", "ps1", "psm1", "vbs", "vbe", "js", "jse", "wsf", "wsh", "hta", "reg",
"lnk", "url", "scf", "cpl", "dll", "jar", "desktop", "sh", "run", "appimage",
];
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RevealAction {
Reveal,
Open,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RevealPlatform {
MacOs,
Windows,
Linux,
}
impl RevealPlatform {
pub const fn current() -> Self {
if cfg!(target_os = "macos") {
Self::MacOs
} else if cfg!(windows) {
Self::Windows
} else {
Self::Linux
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LaunchPlan {
pub program: OsString,
pub args: Vec<OsString>,
pub raw_windows_args: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PathScope {
roots: Vec<PathBuf>,
}
impl PathScope {
pub fn new<I, P>(roots: I) -> io::Result<Self>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
let mut resolved = Vec::new();
for root in roots {
resolved.push(plain(std::fs::canonicalize(root.as_ref())?));
}
if resolved.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"a path scope needs at least one allowed root",
));
}
Ok(Self { roots: resolved })
}
pub fn resolve(&self, path: &Path) -> io::Result<PathBuf> {
let canonical = plain(std::fs::canonicalize(path)?);
if self.roots.iter().any(|root| canonical.starts_with(root)) {
Ok(canonical)
} else {
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("{} is outside the allowed roots", canonical.display()),
))
}
}
}
fn plain(path: PathBuf) -> PathBuf {
#[cfg(windows)]
if let Some(text) = path.to_str() {
if let Some(rest) = text.strip_prefix(r"\\?\") {
if !rest.starts_with("UNC\\") {
return PathBuf::from(rest);
}
}
}
path
}
pub fn plan(
platform: RevealPlatform,
action: RevealAction,
canonical: &Path,
) -> io::Result<LaunchPlan> {
if !canonical.has_root() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"a launch plan needs a rooted canonical path",
));
}
let path = canonical.as_os_str().to_os_string();
let simple = |program: &str, args: Vec<OsString>| LaunchPlan {
program: program.into(),
args,
raw_windows_args: false,
};
Ok(match (platform, action) {
(RevealPlatform::MacOs, RevealAction::Reveal) => simple("open", vec!["-R".into(), path]),
(RevealPlatform::MacOs, RevealAction::Open) => simple("open", vec![path]),
(RevealPlatform::Windows, RevealAction::Reveal) => {
let text = canonical.to_str().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "path is not valid Unicode")
})?;
if text.contains('"') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"path contains a quote",
));
}
LaunchPlan {
program: "explorer.exe".into(),
args: vec![format!("/select,\"{text}\"").into()],
raw_windows_args: true,
}
}
(RevealPlatform::Windows, RevealAction::Open) => simple("explorer.exe", vec![path]),
(RevealPlatform::Linux, RevealAction::Reveal) => {
let folder = canonical.parent().unwrap_or(canonical);
simple("xdg-open", vec![folder.as_os_str().to_os_string()])
}
(RevealPlatform::Linux, RevealAction::Open) => simple("xdg-open", vec![path]),
})
}
fn is_runnable(canonical: &Path) -> bool {
if canonical.is_dir() {
return is_bundle(canonical);
}
let extension_runs = canonical
.extension()
.and_then(OsStr::to_str)
.map(|ext| RUNNABLE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
.unwrap_or(false);
if extension_runs {
return true;
}
#[cfg(unix)]
if let Ok(meta) = std::fs::metadata(canonical) {
use std::os::unix::fs::PermissionsExt;
if meta.is_file() && meta.permissions().mode() & 0o111 != 0 {
return true;
}
}
false
}
fn is_bundle(dir: &Path) -> bool {
let bundle_extension = dir
.extension()
.and_then(OsStr::to_str)
.map(|ext| ext.to_ascii_lowercase());
matches!(bundle_extension.as_deref(), Some("app") | Some("workflow"))
&& dir.join("Contents").join("Info.plist").is_file()
}
pub fn launch_command(
path: &Path,
scope: &PathScope,
action: RevealAction,
) -> io::Result<OwnedCommand> {
launch_checked(path, scope, action, false)
}
fn launch_checked(
path: &Path,
scope: &PathScope,
action: RevealAction,
allow_runnable: bool,
) -> io::Result<OwnedCommand> {
let canonical = scope.resolve(path)?;
if action == RevealAction::Open && !allow_runnable && is_runnable(&canonical) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("{} would be run, not opened", canonical.display()),
));
}
let plan = plan(RevealPlatform::current(), action, &canonical)?;
let mut command = OwnedCommand::new(&plan.program);
{
let inner = command.command_mut();
#[cfg(windows)]
if plan.raw_windows_args {
use std::os::windows::process::CommandExt;
for arg in &plan.args {
inner.raw_arg(arg);
}
} else {
inner.args(&plan.args);
}
#[cfg(not(windows))]
inner.args(&plan.args);
inner
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
}
command.env_allowlist(DEFAULT_ENV_ALLOWLIST.iter().chain(LAUNCHER_ENV.iter()));
Ok(command)
}
pub fn launch(path: &Path, scope: &PathScope, action: RevealAction) -> io::Result<OwnedChild> {
launch_command(path, scope, action)?.spawn_uncontained_detached()
}
fn run(path: &Path, scope: &PathScope, action: RevealAction) -> io::Result<()> {
run_checked(path, scope, action, false)
}
fn run_checked(
path: &Path,
scope: &PathScope,
action: RevealAction,
allow_runnable: bool,
) -> io::Result<()> {
let mut child =
launch_checked(path, scope, action, allow_runnable)?.spawn_uncontained_detached()?;
match child.wait_timeout(LAUNCH_WAIT)? {
Some(status) if !status.success() && !cfg!(windows) => Err(io::Error::other(format!(
"the launcher exited with {status}"
))),
_ => Ok(()),
}
}
pub fn reveal(path: &Path, scope: &PathScope) -> io::Result<()> {
run(path, scope, RevealAction::Reveal)
}
pub fn open(path: &Path, scope: &PathScope) -> io::Result<()> {
run(path, scope, RevealAction::Open)
}
pub fn open_folder(path: &Path, scope: &PathScope) -> io::Result<()> {
let canonical = scope.resolve(path)?;
if !canonical.is_dir() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{} is not a folder", canonical.display()),
));
}
run(path, scope, RevealAction::Open)
}
pub fn open_installer(path: &Path, scope: &PathScope) -> io::Result<()> {
if !cfg!(windows) {
return Err(io::Error::from(io::ErrorKind::Unsupported));
}
let canonical = scope.resolve(path)?;
let extension = canonical
.extension()
.and_then(OsStr::to_str)
.map(|ext| ext.to_ascii_lowercase());
if !canonical.is_file() || !matches!(extension.as_deref(), Some("exe" | "msi" | "msp")) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"{} is not a setup program (exe, msi, msp)",
canonical.display()
),
));
}
run_checked(path, scope, RevealAction::Open, true)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("rightkit-reveal-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn plans_per_platform() {
let p = Path::new("/data/a b/file.txt");
let mac = plan(RevealPlatform::MacOs, RevealAction::Reveal, p).unwrap();
assert_eq!(mac.program, "open");
assert_eq!(
mac.args,
vec![OsString::from("-R"), p.as_os_str().to_os_string()]
);
let mac_open = plan(RevealPlatform::MacOs, RevealAction::Open, p).unwrap();
assert_eq!(mac_open.args, vec![p.as_os_str().to_os_string()]);
let linux = plan(RevealPlatform::Linux, RevealAction::Reveal, p).unwrap();
assert_eq!(linux.program, "xdg-open");
assert_eq!(linux.args, vec![OsString::from("/data/a b")]);
let linux_open = plan(RevealPlatform::Linux, RevealAction::Open, p).unwrap();
assert_eq!(linux_open.args, vec![p.as_os_str().to_os_string()]);
}
#[test]
fn linux_reveal_of_the_filesystem_root_opens_the_root() {
let linux = plan(RevealPlatform::Linux, RevealAction::Reveal, Path::new("/")).unwrap();
assert_eq!(linux.args, vec![OsString::from("/")]);
}
#[test]
fn the_windows_reveal_argument_is_quoted_verbatim() {
let plan = plan(
RevealPlatform::Windows,
RevealAction::Reveal,
Path::new("/c/My Docs/a.txt"),
)
.unwrap();
assert_eq!(plan.program, "explorer.exe");
assert!(plan.raw_windows_args);
assert_eq!(
plan.args,
vec![OsString::from("/select,\"/c/My Docs/a.txt\"")]
);
}
#[test]
fn a_relative_path_gets_no_plan() {
let error = plan(RevealPlatform::Linux, RevealAction::Open, Path::new("a/b")).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn an_empty_scope_is_refused() {
let error = PathScope::new(Vec::<PathBuf>::new()).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn paths_inside_the_root_resolve_and_the_root_itself_does() {
let root = temp("inside");
let file = root.join("a.txt");
fs::write(&file, b"x").unwrap();
let scope = PathScope::new([&root]).unwrap();
assert_eq!(
scope.resolve(&file).unwrap(),
plain(fs::canonicalize(&file).unwrap())
);
assert!(scope.resolve(&root).is_ok());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_dotdot_escape_is_refused() {
let root = temp("dotdot");
let inner = root.join("inner");
fs::create_dir_all(&inner).unwrap();
let outside = temp("dotdot-outside");
let scope = PathScope::new([&inner]).unwrap();
let sneaky = inner
.join("..")
.join("..")
.join(outside.file_name().unwrap());
let error = scope.resolve(&sneaky).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&outside);
}
#[test]
fn a_sibling_sharing_the_root_prefix_is_refused() {
let base = temp("prefix");
let root = base.join("data");
let sibling = base.join("data2");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&sibling).unwrap();
let scope = PathScope::new([&root]).unwrap();
let error = scope.resolve(&sibling).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn a_missing_path_is_refused() {
let root = temp("missing");
let scope = PathScope::new([&root]).unwrap();
let error = scope.resolve(&root.join("nope")).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::NotFound);
let _ = fs::remove_dir_all(&root);
}
#[cfg(unix)]
#[test]
fn a_symlink_out_of_the_root_is_refused() {
let root = temp("link");
let outside = temp("link-outside");
fs::write(outside.join("secret.txt"), b"x").unwrap();
std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
let scope = PathScope::new([&root]).unwrap();
let error = scope
.resolve(&root.join("escape").join("secret.txt"))
.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
let error = launch_command(&root.join("escape"), &scope, RevealAction::Reveal).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&outside);
}
#[test]
fn launch_command_refuses_an_outside_path_before_building_anything() {
let root = temp("cmd-root");
let outside = temp("cmd-outside");
let scope = PathScope::new([&root]).unwrap();
for action in [RevealAction::Reveal, RevealAction::Open] {
let error = launch_command(&outside, &scope, action).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
}
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&outside);
}
#[test]
fn open_refuses_runnable_files_and_reveal_does_not() {
let root = temp("runnable");
let script = root.join("run.bat");
fs::write(&script, b"echo x").unwrap();
let bundle = root.join("Thing.app");
fs::create_dir_all(bundle.join("Contents")).unwrap();
fs::write(bundle.join("Contents").join("Info.plist"), b"<plist/>").unwrap();
let scope = PathScope::new([&root]).unwrap();
for path in [&script, &bundle] {
let error = launch_command(path, &scope, RevealAction::Open).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert!(launch_command(path, &scope, RevealAction::Reveal).is_ok());
}
let document = root.join("notes.txt");
fs::write(&document, b"x").unwrap();
assert!(launch_command(&document, &scope, RevealAction::Open).is_ok());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_folder_named_like_a_script_opens_but_a_bundle_refuses() {
let root = temp("folder-named");
let folder = root.join("chart.js");
fs::create_dir_all(&folder).unwrap();
let fake_app = root.join("Foo.app");
fs::create_dir_all(&fake_app).unwrap();
let real_app = root.join("Real.app");
fs::create_dir_all(real_app.join("Contents")).unwrap();
fs::write(real_app.join("Contents").join("Info.plist"), b"<plist/>").unwrap();
let scope = PathScope::new([&root]).unwrap();
assert!(!is_runnable(&folder.canonicalize().unwrap()));
assert!(!is_runnable(&fake_app.canonicalize().unwrap()));
assert!(is_runnable(&real_app.canonicalize().unwrap()));
assert!(launch_command(&folder, &scope, RevealAction::Open).is_ok());
let error = launch_command(&real_app, &scope, RevealAction::Open).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn open_installer_is_refused_off_windows_and_outside_the_scope() {
let root = temp("installer");
let setup = root.join("setup.exe");
fs::write(&setup, b"MZ").unwrap();
let scope = PathScope::new([&root]).unwrap();
if !cfg!(windows) {
let error = open_installer(&setup, &scope).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
}
let _ = fs::remove_dir_all(&root);
}
#[cfg(unix)]
#[test]
fn open_refuses_a_file_with_an_execute_bit() {
use std::os::unix::fs::PermissionsExt;
let root = temp("execbit");
let file = root.join("tool");
fs::write(&file, b"#!/bin/sh\n").unwrap();
fs::set_permissions(&file, fs::Permissions::from_mode(0o755)).unwrap();
let scope = PathScope::new([&root]).unwrap();
let error = launch_command(&file, &scope, RevealAction::Open).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
let _ = fs::remove_dir_all(&root);
}
}