tauri-plugin-deeprespond-im 1.0.0-alpha

深应智能 IM 的 Tauri 插件:本地存储、安全存储、窗口间的锁与消息、系统事件、通知、角标、文件
//! 打开与定位(desktop.openPath、revealPath)、可执行文件的判断、下载文件的来源标记(《2.功能概述文档》第 9.4 节)。
//!
//! 聊天中收到的文件被一键运行是桌面 IM 的常见攻击方式:可执行和脚本类文件不直接打开,以 permission_denied(executable_file)
//! 拒绝,App 改为定位并提示用户。下载的文件按系统的做法标记为来自网络,用户自己打开时系统照常给出安全提示。

use std::path::{Path, PathBuf};

use crate::error::{Error, Result};

/// 各系统共同的可执行、脚本类扩展名(《4.实现与测试》第 3.6 节的表)。
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",
];

/// Windows:安装包、快捷方式、能执行脚本的帮助和网页存档,以及挂载后能绕过来源标记的磁盘映像。
#[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",
];

/// macOS:安装包、在终端中运行的脚本、AppleScript 与自动操作、会打开网址的位置文件。
#[cfg(target_os = "macos")]
const PLATFORM: &[&str] = &[
    "mpkg", "tool", "csh", "ksh", "tcsh", "fish", "scpt", "scptd", "applescript", "workflow", "action", "terminal", "webloc", "inetloc", "fileloc",
    "prefpane", "kext",
];

/// Linux:各种 shell 脚本与安装包。
#[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] = &[];

/// 文件名的扩展名(小写):先去掉 Windows 的备用数据流(`a.exe::$DATA`)和结尾的点、空格(Windows 打开时会去掉)。
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)
}

/// macOS、Linux:带执行权限、没有扩展名或内容就是程序(ELF、Mach-O、#! 脚本)的文件。
/// 只看执行权限会误伤 U 盘等文件系统上全部带执行权限的普通文件。
#[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
}

/// 交给系统程序的路径:Windows 去掉 canonicalize 加上的 `\\?\` 前缀(资源管理器和 ShellExecute 不认)。
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()
}

/// 用系统默认程序打开(real 已经过读取检查)。
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;
    // explorer 的参数要整体写成 /select,"路径";它的退出码不表示成败,不检查
    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}")))
        }
    }
}

/// freedesktop 的文件管理器接口(Nautilus、Dolphin、Nemo 等都实现)。
#[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("")
}

/// 把下载的文件标记为来自网络:Windows 写 Zone.Identifier 备用数据流,macOS 设置 com.apple.quarantine 扩展属性。
/// 文件系统不支持的(FAT 等)忽略。
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")))]
    {
        // Linux 没有系统层面的来源标记
        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));
        // U 盘上带执行权限的普通文件不算
        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");
    }
}