use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
const COMMON: &[&str] = &[
"exe", "msi", "bat", "cmd", "com", "ps1", "psm1", "vbs", "vbe", "js", "jse", "wsf", "wsh", "scr", "pif", "lnk", "url", "reg", "hta", "cpl", "msc",
"jar", "app", "pkg", "dmg", "command", "sh", "bash", "zsh", "desktop", "run", "bin", "appimage", "deb", "rpm",
];
#[cfg(windows)]
const PLATFORM: &[&str] = &[
"msp", "msix", "msixbundle", "appx", "appxbundle", "appinstaller", "psd1", "ps1xml", "psc1", "psc2", "wsc", "ws", "inf", "scf", "application",
"appref-ms", "gadget", "settingcontent-ms", "library-ms", "search-ms", "searchconnector-ms", "website", "xbap", "jnlp", "py", "pyw", "pyz", "pyc",
"hlp", "chm", "mht", "mhtml", "iso", "img", "vhd", "vhdx", "diagcab", "xll",
];
#[cfg(target_os = "macos")]
const PLATFORM: &[&str] = &[
"mpkg", "tool", "csh", "ksh", "tcsh", "fish", "scpt", "scptd", "applescript", "workflow", "action", "terminal", "webloc", "inetloc", "fileloc",
"prefpane", "kext",
];
#[cfg(all(unix, not(target_os = "macos")))]
const PLATFORM: &[&str] = &["csh", "ksh", "tcsh", "fish", "flatpakref", "flatpakrepo", "flatpak", "snap", "kexe"];
#[cfg(not(any(windows, unix)))]
const PLATFORM: &[&str] = &[];
fn extension(path: &Path) -> Option<String> {
let name = path.file_name()?.to_string_lossy().into_owned();
let name = if cfg!(windows) { name.split(':').next().unwrap_or("").to_string() } else { name };
let name = name.trim_end_matches(['.', ' ']);
let (stem, ext) = name.rsplit_once('.')?;
(!stem.is_empty()).then(|| ext.to_ascii_lowercase())
}
pub fn is_executable(path: &Path) -> bool {
if let Some(ext) = extension(path) {
if COMMON.contains(&ext.as_str()) || PLATFORM.contains(&ext.as_str()) {
return true;
}
}
unix_executable(path)
}
#[cfg(unix)]
fn unix_executable(path: &Path) -> bool {
use std::io::Read;
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = std::fs::metadata(path) else { return false };
if !meta.is_file() || meta.permissions().mode() & 0o111 == 0 {
return false;
}
if extension(path).is_none() {
return true;
}
let mut head = [0u8; 4];
let read = std::fs::File::open(path).and_then(|mut f| f.read(&mut head)).unwrap_or(0);
let head = &head[..read];
head.starts_with(b"\x7fELF") || head.starts_with(b"#!") || matches!(head, [0xfe, 0xed, 0xfa, 0xce | 0xcf] | [0xce | 0xcf, 0xfa, 0xed, 0xfe] | [0xca, 0xfe, 0xba, 0xbe])
}
#[cfg(not(unix))]
fn unix_executable(_path: &Path) -> bool {
false
}
pub fn system_path(real: &Path) -> PathBuf {
#[cfg(windows)]
{
let text = real.to_string_lossy();
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
return PathBuf::from(format!(r"\\{rest}"));
}
if let Some(rest) = text.strip_prefix(r"\\?\") {
return PathBuf::from(rest);
}
}
real.to_path_buf()
}
pub fn open_path(real: &Path) -> Result<()> {
if is_executable(real) {
return Err(Error::permission_denied("executable_file"));
}
open::that_detached(system_path(real)).map_err(|e| Error::internal(format!("打开失败:{e}")))
}
pub async fn reveal(real: &Path) -> Result<()> {
reveal_in_manager(&system_path(real)).await
}
#[cfg(windows)]
async fn reveal_in_manager(path: &Path) -> Result<()> {
use std::os::windows::process::CommandExt;
spawn_detached(std::process::Command::new("explorer").raw_arg(format!("/select,\"{}\"", path.display())))
}
#[cfg(target_os = "macos")]
async fn reveal_in_manager(path: &Path) -> Result<()> {
spawn_detached(std::process::Command::new("open").arg("-R").arg(path))
}
#[cfg(any(windows, target_os = "macos"))]
fn spawn_detached(command: &mut std::process::Command) -> Result<()> {
let mut child = command.spawn().map_err(|e| Error::internal(format!("定位失败:{e}")))?;
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(())
}
#[cfg(all(unix, not(target_os = "macos")))]
async fn reveal_in_manager(path: &Path) -> Result<()> {
match tokio::time::timeout(std::time::Duration::from_secs(3), show_items(path)).await {
Ok(Ok(())) => Ok(()),
other => {
log::debug!("文件管理器的 ShowItems 不可用({other:?}),改为打开所在目录");
let dir = path.parent().unwrap_or(path).to_path_buf();
open::that_detached(dir).map_err(|e| Error::internal(format!("定位失败:{e}")))
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
async fn show_items(path: &Path) -> std::result::Result<(), String> {
let uri = file_uri(path);
let conn = zbus::Connection::session().await.map_err(|e| e.to_string())?;
conn.call_method(Some("org.freedesktop.FileManager1"), "/org/freedesktop/FileManager1", Some("org.freedesktop.FileManager1"), "ShowItems", &(vec![uri.as_str()], ""))
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[cfg(all(unix, not(target_os = "macos")))]
pub fn file_uri(path: &Path) -> String {
use percent_encoding::{percent_encode, AsciiSet, CONTROLS};
use std::os::unix::ffi::OsStrExt;
const SET: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'#').add(b'%').add(b'<').add(b'>').add(b'?').add(b'[').add(b'\\').add(b']').add(b'^').add(b'`').add(b'{').add(b'|').add(b'}');
format!("file://{}", percent_encode(path.as_os_str().as_bytes(), SET))
}
fn host_url(url: &str) -> &str {
url.split(['?', '#']).next().unwrap_or("")
}
pub fn mark_origin(path: &Path, url: Option<&str>) {
#[cfg(windows)]
{
let mut content = String::from("[ZoneTransfer]\r\nZoneId=3\r\n");
if let Some(url) = url.map(host_url).filter(|u| u.starts_with("http")) {
content.push_str(&format!("HostUrl={url}\r\n"));
}
if let Err(err) = std::fs::write(format!("{}:Zone.Identifier", path.display()), content) {
log::debug!("写入 Zone.Identifier 失败:{err}");
}
}
#[cfg(target_os = "macos")]
{
let _ = url.map(host_url);
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let agent = std::env::current_exe().ok().and_then(|p| p.file_stem().map(|s| s.to_string_lossy().replace(';', "_"))).unwrap_or_default();
let value = format!("0081;{secs:08x};{agent};");
if let Err(err) = xattr::set(path, "com.apple.quarantine", value.as_bytes()) {
log::debug!("设置 com.apple.quarantine 失败:{err}");
}
}
#[cfg(not(any(windows, target_os = "macos")))]
{
let _ = (path, url.map(host_url));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn executable_table() {
for name in ["a.exe", "b.MSI", "c.bat", "x.cmd", "s.ps1", "v.vbs", "j.js", "l.lnk", "u.url", "r.reg", "h.hta", "p.jar", "A.app", "i.pkg", "d.dmg", "run.command", "x.sh", "y.desktop", "z.AppImage", "w.deb", "q.rpm", "evil.exe.", "evil.exe "] {
assert!(is_executable(Path::new(name)), "{name}");
}
for name in ["a.pdf", "b.docx", "photo.jpg", "a.exe.txt", "notes", ".exe", "readme.md"] {
assert!(!is_executable(Path::new(name)), "{name}");
}
#[cfg(windows)]
{
assert!(is_executable(Path::new("evil.exe::$DATA")));
assert!(is_executable(Path::new("disk.iso")));
assert!(is_executable(Path::new("help.chm")));
}
#[cfg(target_os = "macos")]
assert!(is_executable(Path::new("a.scpt")) && is_executable(Path::new("b.webloc")));
#[cfg(target_os = "linux")]
assert!(is_executable(Path::new("a.fish")) && is_executable(Path::new("b.flatpakref")));
}
#[cfg(unix)]
#[test]
fn unix_mode_and_content() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("tool");
std::fs::write(&script, b"#!/bin/sh\necho hi\n").unwrap();
assert!(!is_executable(&script));
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(is_executable(&script));
let photo = dir.path().join("photo.jpg");
std::fs::write(&photo, [0xff, 0xd8, 0xff, 0xe0]).unwrap();
std::fs::set_permissions(&photo, std::fs::Permissions::from_mode(0o777)).unwrap();
assert!(!is_executable(&photo));
let elf = dir.path().join("cat.jpg");
std::fs::write(&elf, b"\x7fELF\x02\x01").unwrap();
std::fs::set_permissions(&elf, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(is_executable(&elf));
assert_eq!(open_path(&elf).unwrap_err().reason.as_deref(), Some("executable_file"));
#[cfg(not(target_os = "macos"))]
assert_eq!(file_uri(Path::new("/tmp/a b#1.txt")), "file:///tmp/a%20b%231.txt");
}
#[test]
fn origin_url_drops_signature() {
assert_eq!(host_url("https://s3.example.com/b/a.pdf?X-Amz-Signature=abc#x"), "https://s3.example.com/b/a.pdf");
}
}