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";
#[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>>,
write: Mutex<HashSet<PathBuf>>,
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()
}
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)
}
pub fn register_write(&self, path: &Path) -> Option<PathBuf> {
let (real, _) = resolve(path)?;
self.write.lock().unwrap().insert(real.clone());
Some(real)
}
pub fn is_picked_save(&self, real: &Path) -> bool {
self.write.lock().unwrap().contains(real)
}
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)
}
pub fn in_temp(&self, real: &Path) -> bool {
under(real, &self.temp)
}
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)
}
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()
}
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());
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());
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());
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());
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"));
}
}