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)
}