tauri-plugin-deeprespond-im 1.0.0-alpha

深应智能 IM 的 Tauri 插件:本地存储、安全存储、窗口间的锁与消息、系统事件、通知、角标、文件
//! 允许读写的路径(《4.实现与测试》第 3.6 节)。
//!
//! - 允许读取:本次运行中对话框、拖放登记的文件和 SDK 下载的文件;`allow_read_dir` 的目录;临时目录;数据目录中用户目录下的附件;
//!   发送队列中只记路径的大附件(`allowed_paths.json`,跨重启)。
//! - 允许写入:系统“下载”目录、`pick_save` 的路径、`allow_write_dir` 的目录、临时目录、数据目录中用户目录下的位置。
//!
//! 一律按解析符号链接后的真实路径判断:允许的目录中放一个指向 `~/.ssh` 的符号链接,读到的仍然是目录以外的文件,必须拒绝。
//! 数据目录只开放用户目录(`{appKey 哈希}/{用户名哈希}/…`):`master.key`、`secure.bin`、数据库不能经文件命令读取或覆盖,
//! 否则界面窗口可以借此拿到令牌(《2.功能概述文档》第 11 节)。

use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::sync::Mutex;

use serde::{Deserialize, Serialize};

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

/// 跨重启允许读取的路径的文件名(数据目录下)。
pub const ALLOWED_PATHS_FILE: &str = "allowed_paths.json";

/// `allowed_paths.json`:同一个文件可能被多条消息引用(同一个大文件发了两次),按引用计数,最后一个释放时才去掉。
#[derive(Default, Serialize, Deserialize)]
struct Persisted {
    v: u32,
    paths: BTreeMap<String, u32>,
}

pub struct Allow {
    data: PathBuf,
    temp: PathBuf,
    downloads: Option<PathBuf>,
    read_dirs: Vec<PathBuf>,
    write_dirs: Vec<PathBuf>,
    /// 本次运行中登记的可读文件(真实路径)。
    read: Mutex<HashSet<PathBuf>>,
    /// pick_save 得到的路径(真实路径,文件可能还不存在)。
    write: Mutex<HashSet<PathBuf>>,
    /// allowed_paths.json 的内容,第一次用到时读入。
    persisted: Mutex<Option<Persisted>>,
}

impl Allow {
    pub fn new(data: PathBuf, temp: PathBuf, downloads: Option<PathBuf>, read_dirs: Vec<PathBuf>, write_dirs: Vec<PathBuf>) -> Self {
        Self {
            data,
            temp,
            downloads,
            read_dirs,
            write_dirs,
            read: Mutex::new(HashSet::new()),
            write: Mutex::new(HashSet::new()),
            persisted: Mutex::new(None),
        }
    }

    /// 系统的“下载”目录(下载的默认位置)。
    pub fn downloads(&self) -> Option<&Path> {
        self.downloads.as_deref()
    }

    /// 登记一个可读的文件(对话框、拖放、SDK 下载的);返回真实路径,不存在或不是普通文件的返回 None。
    pub fn register_read(&self, path: &Path) -> Option<PathBuf> {
        let real = fs::canonicalize(path).ok()?;
        if !fs::metadata(&real).map(|m| m.is_file()).unwrap_or(false) {
            return None;
        }
        self.read.lock().unwrap().insert(real.clone());
        Some(real)
    }

    /// 登记 pick_save 得到的路径(文件可能还不存在,按上级目录解析)。
    pub fn register_write(&self, path: &Path) -> Option<PathBuf> {
        let (real, _) = resolve(path)?;
        self.write.lock().unwrap().insert(real.clone());
        Some(real)
    }

    /// 这个真实路径是不是 pick_save 得到的(下载到这里时不再改名:系统的保存对话框已经问过是否覆盖)。
    pub fn is_picked_save(&self, real: &Path) -> bool {
        self.write.lock().unwrap().contains(real)
    }

    /// 读取前的检查:返回真实路径。不允许的以 permission_denied(path_not_allowed)拒绝;允许但不存在的以 file_not_found 拒绝。
    pub fn check_read(&self, path: &Path) -> Result<PathBuf> {
        let Some((real, exists)) = resolve(path) else { return Err(Error::permission_denied("path_not_allowed")) };
        if !self.readable(&real) {
            return Err(Error::permission_denied("path_not_allowed"));
        }
        if !exists {
            return Err(Error::validation("file_not_found", format!("文件不存在:{}", path.display())));
        }
        Ok(real)
    }

    /// 写入前的检查:返回真实路径(文件可以还不存在)。
    pub fn check_write(&self, path: &Path) -> Result<PathBuf> {
        let Some((real, exists)) = resolve(path) else { return Err(Error::permission_denied("path_not_allowed")) };
        // 不存在却解析不了的“文件”可能是悬空的符号链接:写入会跟着它写到别处
        if !exists && fs::symlink_metadata(&real).is_ok() {
            return Err(Error::permission_denied("path_not_allowed"));
        }
        if !self.writable(&real) {
            return Err(Error::permission_denied("path_not_allowed"));
        }
        Ok(real)
    }

    pub fn readable(&self, real: &Path) -> bool {
        if self.read.lock().unwrap().contains(real) {
            return true;
        }
        if self.persisted_contains(real) {
            return true;
        }
        if under_any(real, &self.read_dirs) || under(real, &self.temp) {
            return true;
        }
        self.in_user_dir(real)
    }

    pub fn writable(&self, real: &Path) -> bool {
        if self.write.lock().unwrap().contains(real) {
            return true;
        }
        if under_any(real, &self.write_dirs) || under(real, &self.temp) {
            return true;
        }
        if self.downloads.as_deref().is_some_and(|d| under(real, d)) {
            return true;
        }
        self.in_user_dir(real)
    }

    /// 临时目录中的文件(Blob 落盘、处理后的图片):进入发送队列时可以移动。
    pub fn in_temp(&self, real: &Path) -> bool {
        under(real, &self.temp)
    }

    /// 数据目录中用户目录下的位置(附件),至少三层:{appKey 哈希}/{用户名哈希}/…
    pub fn in_user_dir(&self, real: &Path) -> bool {
        let Some(data) = canonical_dir(&self.data) else { return false };
        match real.strip_prefix(&data) {
            Ok(rest) => rest.components().count() >= 3 && rest.components().all(|c| matches!(c, Component::Normal(_))),
            Err(_) => false,
        }
    }

    /// 只记路径的大附件:跨重启仍允许读取(引用计数加一)。
    pub fn persist_add(&self, real: &Path) -> Result<()> {
        let mut guard = self.persisted.lock().unwrap();
        let persisted = guard.get_or_insert_with(|| load(&self.data));
        *persisted.paths.entry(real.to_string_lossy().into_owned()).or_insert(0) += 1;
        save(&self.data, persisted)
    }

    /// 附件发送完成或被删除:引用计数减一,到零时去掉。文件已经不在的按上级目录解析后的路径去掉。
    pub fn persist_remove(&self, path: &Path) -> Result<()> {
        let mut keys = vec![path.to_string_lossy().into_owned()];
        if let Some((real, _)) = resolve(path) {
            keys.push(real.to_string_lossy().into_owned());
        }
        let mut guard = self.persisted.lock().unwrap();
        let persisted = guard.get_or_insert_with(|| load(&self.data));
        let mut changed = false;
        for key in keys {
            if let Some(count) = persisted.paths.get_mut(&key) {
                *count = count.saturating_sub(1);
                if *count == 0 {
                    persisted.paths.remove(&key);
                }
                changed = true;
                break;
            }
        }
        if changed {
            save(&self.data, persisted)?;
        }
        Ok(())
    }

    fn persisted_contains(&self, real: &Path) -> bool {
        let mut guard = self.persisted.lock().unwrap();
        let persisted = guard.get_or_insert_with(|| load(&self.data));
        persisted.paths.contains_key(real.to_string_lossy().as_ref())
    }
}

fn load(data: &Path) -> Persisted {
    let path = data.join(ALLOWED_PATHS_FILE);
    match fs::read(&path) {
        Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|err| {
            log::warn!("{ALLOWED_PATHS_FILE} 读不出({err}),按空列表处理");
            Persisted { v: 1, ..Default::default() }
        }),
        Err(_) => Persisted { v: 1, ..Default::default() },
    }
}

fn save(data: &Path, persisted: &mut Persisted) -> Result<()> {
    persisted.v = 1;
    let bytes = serde_json::to_vec(persisted)?;
    crate::paths::write_atomic(&data.join(ALLOWED_PATHS_FILE), &bytes)
}

/// 解析为真实路径:存在的直接 canonicalize;不存在的解析最近一层存在的上级目录,再接上其余部分(其余部分只能是普通的名称,
/// 不能有 `..`)。第二个值表示路径本身是否存在。相对路径一律不接受。
pub fn resolve(path: &Path) -> Option<(PathBuf, bool)> {
    if !path.is_absolute() {
        return None;
    }
    if let Ok(real) = fs::canonicalize(path) {
        return Some((real, true));
    }
    for ancestor in path.ancestors().skip(1) {
        let Ok(base) = fs::canonicalize(ancestor) else { continue };
        let rest = path.strip_prefix(ancestor).ok()?;
        if !rest.components().all(|c| matches!(c, Component::Normal(_))) {
            return None;
        }
        return Some((base.join(rest), false));
    }
    None
}

fn canonical_dir(dir: &Path) -> Option<PathBuf> {
    fs::canonicalize(dir).ok()
}

/// real 在 dir 之中(含 dir 本身)。dir 不存在的不算。
fn under(real: &Path, dir: &Path) -> bool {
    canonical_dir(dir).is_some_and(|d| real.starts_with(d))
}

fn under_any(real: &Path, dirs: &[PathBuf]) -> bool {
    dirs.iter().any(|d| under(real, d))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn setup() -> (tempfile::TempDir, Allow) {
        let root = tempfile::tempdir().unwrap();
        for d in ["data", "temp", "downloads", "readable", "writable", "outside"] {
            fs::create_dir_all(root.path().join(d)).unwrap();
        }
        let allow = Allow::new(
            root.path().join("data"),
            root.path().join("temp"),
            Some(root.path().join("downloads")),
            vec![root.path().join("readable")],
            vec![root.path().join("writable")],
        );
        (root, allow)
    }

    #[test]
    fn read_rules() {
        let (root, allow) = setup();
        let r = root.path();
        let secret = r.join("outside/id_rsa");
        fs::write(&secret, b"secret").unwrap();
        assert_eq!(allow.check_read(&secret).unwrap_err().reason.as_deref(), Some("path_not_allowed"));
        // 相对路径、带 .. 绕出允许目录的路径
        assert!(allow.check_read(Path::new("outside/id_rsa")).is_err());
        assert!(allow.check_read(&r.join("readable/../outside/id_rsa")).is_err());
        // 允许的目录:存在的可读,不存在的为 file_not_found
        fs::write(r.join("readable/a.txt"), b"a").unwrap();
        assert!(allow.check_read(&r.join("readable/a.txt")).is_ok());
        assert_eq!(allow.check_read(&r.join("readable/none.txt")).unwrap_err().reason.as_deref(), Some("file_not_found"));
        // 不允许的目录中不存在的文件:不透露是否存在
        assert_eq!(allow.check_read(&r.join("outside/none")).unwrap_err().reason.as_deref(), Some("path_not_allowed"));
        // 登记后可读
        let real = allow.register_read(&secret).unwrap();
        assert!(allow.check_read(&secret).is_ok());
        assert_eq!(allow.check_read(&secret).unwrap(), real);
        // 目录不能登记
        assert!(allow.register_read(&r.join("outside")).is_none());
        // 数据目录:只有用户目录下的附件可读,master.key、数据库不行
        fs::write(r.join("data/master.key"), b"k").unwrap();
        fs::create_dir_all(r.join("data/app/user/outbox")).unwrap();
        fs::write(r.join("data/app/u.db"), b"db").unwrap();
        fs::write(r.join("data/app/user/outbox/x.bin"), b"x").unwrap();
        assert!(allow.check_read(&r.join("data/master.key")).is_err());
        assert!(allow.check_read(&r.join("data/app/u.db")).is_err());
        assert!(allow.check_read(&r.join("data/app/user/outbox/x.bin")).is_ok());
        // 临时目录
        fs::write(r.join("temp/blob"), b"b").unwrap();
        assert!(allow.check_read(&r.join("temp/blob")).is_ok());
    }

    #[cfg(unix)]
    #[test]
    fn symlink_cannot_escape() {
        let (root, allow) = setup();
        let r = root.path();
        fs::write(r.join("outside/secret"), b"s").unwrap();
        std::os::unix::fs::symlink(r.join("outside/secret"), r.join("readable/link")).unwrap();
        std::os::unix::fs::symlink(r.join("outside"), r.join("readable/dir")).unwrap();
        assert_eq!(allow.check_read(&r.join("readable/link")).unwrap_err().reason.as_deref(), Some("path_not_allowed"));
        assert!(allow.check_read(&r.join("readable/dir/secret")).is_err());
        // 写入:允许的目录中指向外面的符号链接,以及悬空的符号链接
        std::os::unix::fs::symlink(r.join("outside"), r.join("downloads/out")).unwrap();
        assert!(allow.check_write(&r.join("downloads/out/evil")).is_err());
        std::os::unix::fs::symlink(r.join("outside/new"), r.join("downloads/dangling")).unwrap();
        assert!(allow.check_write(&r.join("downloads/dangling")).is_err());
        // 指向允许目录之内的符号链接照常可读
        fs::write(r.join("readable/real.txt"), b"r").unwrap();
        std::os::unix::fs::symlink(r.join("readable/real.txt"), r.join("temp/inner")).unwrap();
        assert!(allow.check_read(&r.join("temp/inner")).is_ok());
    }

    #[test]
    fn write_rules() {
        let (root, allow) = setup();
        let r = root.path();
        assert!(allow.check_write(&r.join("downloads/a.zip")).is_ok());
        assert!(allow.check_write(&r.join("downloads/sub/a.zip")).is_ok());
        assert!(allow.check_write(&r.join("writable/a.zip")).is_ok());
        assert!(allow.check_write(&r.join("temp/a.zip")).is_ok());
        assert!(allow.check_write(&r.join("outside/a.zip")).is_err());
        assert!(allow.check_write(&r.join("readable/a.zip")).is_err());
        assert!(allow.check_write(&r.join("data/secure.bin")).is_err());
        assert!(allow.check_write(&r.join("downloads/../outside/a")).is_err());
        let picked = allow.register_write(&r.join("outside/chosen.pdf")).unwrap();
        assert!(allow.check_write(&r.join("outside/chosen.pdf")).is_ok());
        assert!(allow.is_picked_save(&picked));
        assert!(allow.check_write(&r.join("outside/other.pdf")).is_err());
    }

    #[test]
    fn persisted_paths_survive_restart() {
        let (root, allow) = setup();
        let r = root.path();
        let big = r.join("outside/big.mov");
        fs::write(&big, b"movie").unwrap();
        let real = fs::canonicalize(&big).unwrap();
        allow.persist_add(&real).unwrap();
        allow.persist_add(&real).unwrap();
        assert!(r.join("data").join(ALLOWED_PATHS_FILE).exists());
        // 重启:新的实例读入 allowed_paths.json
        let again = Allow::new(r.join("data"), r.join("temp"), None, vec![], vec![]);
        assert!(again.check_read(&big).is_ok());
        // 两次引用:释放一次仍可读,释放两次后不可读
        again.persist_remove(&big).unwrap();
        assert!(again.check_read(&big).is_ok());
        again.persist_remove(&big).unwrap();
        assert!(again.check_read(&big).is_err());
        let third = Allow::new(r.join("data"), r.join("temp"), None, vec![], vec![]);
        assert!(third.check_read(&big).is_err());
        // 原文件被删除:仍能按上级目录解析后的路径去掉,删除后为 file_not_found(附件丢失)
        third.persist_add(&real).unwrap();
        fs::remove_file(&big).unwrap();
        assert_eq!(third.check_read(&big).unwrap_err().reason.as_deref(), Some("file_not_found"));
        third.persist_remove(&big).unwrap();
        assert_eq!(third.check_read(&big).unwrap_err().reason.as_deref(), Some("path_not_allowed"));
    }
}