You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tauri-plugins-workspace/plugins/fs/src/scope.rs

369 lines
13 KiB

// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::{
collections::{HashMap, HashSet},
fmt,
path::{Path, PathBuf, MAIN_SEPARATOR},
sync::{Arc, Mutex},
};
use crate::config::FsScope;
pub use glob::Pattern;
use uuid::Uuid;
use crate::{Manager, Runtime};
/// Scope change event.
#[derive(Debug, Clone)]
pub enum Event {
/// A path has been allowed.
PathAllowed(PathBuf),
/// A path has been forbidden.
PathForbidden(PathBuf),
}
type EventListener = Box<dyn Fn(&Event) + Send>;
/// Scope for filesystem access.
#[derive(Clone)]
pub struct Scope {
allowed_patterns: Arc<Mutex<HashSet<Pattern>>>,
forbidden_patterns: Arc<Mutex<HashSet<Pattern>>>,
event_listeners: Arc<Mutex<HashMap<Uuid, EventListener>>>,
}
impl fmt::Debug for Scope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Scope")
.field(
"allowed_patterns",
&self
.allowed_patterns
.lock()
.unwrap()
.iter()
.map(|p| p.as_str())
.collect::<Vec<&str>>(),
)
.field(
"forbidden_patterns",
&self
.forbidden_patterns
.lock()
.unwrap()
.iter()
.map(|p| p.as_str())
.collect::<Vec<&str>>(),
)
.finish()
}
}
fn push_pattern<P: AsRef<Path>, F: Fn(&str) -> Result<Pattern, glob::PatternError>>(
list: &mut HashSet<Pattern>,
pattern: P,
f: F,
) -> crate::Result<()> {
let path: PathBuf = pattern.as_ref().components().collect();
list.insert(f(&path.to_string_lossy())?);
#[cfg(windows)]
{
if let Ok(p) = std::fs::canonicalize(&path) {
list.insert(f(&p.to_string_lossy())?);
} else {
list.insert(f(&format!("\\\\?\\{}", path.display()))?);
}
}
Ok(())
}
impl Scope {
/// Creates a new scope from a `FsScope` configuration.
pub(crate) fn new<R: Runtime, M: Manager<R>>(
manager: &M,
scope: &FsScope,
) -> crate::Result<Self> {
let mut allowed_patterns = HashSet::new();
for path in scope.allowed_paths() {
if let Ok(path) = manager.path().parse(path) {
push_pattern(&mut allowed_patterns, path, Pattern::new)?;
}
}
let mut forbidden_patterns = HashSet::new();
if let Some(forbidden_paths) = scope.forbidden_paths() {
for path in forbidden_paths {
if let Ok(path) = manager.path().parse(path) {
push_pattern(&mut forbidden_patterns, path, Pattern::new)?;
}
}
}
Ok(Self {
allowed_patterns: Arc::new(Mutex::new(allowed_patterns)),
forbidden_patterns: Arc::new(Mutex::new(forbidden_patterns)),
event_listeners: Default::default(),
})
}
/// The list of allowed patterns.
pub fn allowed_patterns(&self) -> HashSet<Pattern> {
self.allowed_patterns.lock().unwrap().clone()
}
/// The list of forbidden patterns.
pub fn forbidden_patterns(&self) -> HashSet<Pattern> {
self.forbidden_patterns.lock().unwrap().clone()
}
/// Listen to an event on this scope.
pub fn listen<F: Fn(&Event) + Send + 'static>(&self, f: F) -> Uuid {
let id = Uuid::new_v4();
self.event_listeners.lock().unwrap().insert(id, Box::new(f));
id
}
fn trigger(&self, event: Event) {
let listeners = self.event_listeners.lock().unwrap();
let handlers = listeners.values();
for listener in handlers {
listener(&event);
}
}
/// Extend the allowed patterns with the given directory.
///
/// After this function has been called, the frontend will be able to use the Tauri API to read
/// the directory and all of its files. If `recursive` is `true`, subdirectories will be accessible too.
pub fn allow_directory<P: AsRef<Path>>(&self, path: P, recursive: bool) -> crate::Result<()> {
let path = path.as_ref();
{
let mut list = self.allowed_patterns.lock().unwrap();
// allow the directory to be read
push_pattern(&mut list, path, escaped_pattern)?;
// allow its files and subdirectories to be read
push_pattern(&mut list, path, |p| {
escaped_pattern_with(p, if recursive { "**" } else { "*" })
})?;
}
self.trigger(Event::PathAllowed(path.to_path_buf()));
Ok(())
}
/// Extend the allowed patterns with the given file path.
///
/// After this function has been called, the frontend will be able to use the Tauri API to read the contents of this file.
pub fn allow_file<P: AsRef<Path>>(&self, path: P) -> crate::Result<()> {
let path = path.as_ref();
push_pattern(
&mut self.allowed_patterns.lock().unwrap(),
path,
escaped_pattern,
)?;
self.trigger(Event::PathAllowed(path.to_path_buf()));
Ok(())
}
/// Set the given directory path to be forbidden by this scope.
///
/// **Note:** this takes precedence over allowed paths, so its access gets denied **always**.
pub fn forbid_directory<P: AsRef<Path>>(&self, path: P, recursive: bool) -> crate::Result<()> {
let path = path.as_ref();
{
let mut list = self.forbidden_patterns.lock().unwrap();
// allow the directory to be read
push_pattern(&mut list, path, escaped_pattern)?;
// allow its files and subdirectories to be read
push_pattern(&mut list, path, |p| {
escaped_pattern_with(p, if recursive { "**" } else { "*" })
})?;
}
self.trigger(Event::PathForbidden(path.to_path_buf()));
Ok(())
}
/// Set the given file path to be forbidden by this scope.
///
/// **Note:** this takes precedence over allowed paths, so its access gets denied **always**.
pub fn forbid_file<P: AsRef<Path>>(&self, path: P) -> crate::Result<()> {
let path = path.as_ref();
push_pattern(
&mut self.forbidden_patterns.lock().unwrap(),
path,
escaped_pattern,
)?;
self.trigger(Event::PathForbidden(path.to_path_buf()));
Ok(())
}
/// Determines if the given path is allowed on this scope.
pub fn is_allowed<P: AsRef<Path>>(&self, path: P) -> bool {
let path = path.as_ref();
let path = if !path.exists() {
crate::Result::Ok(path.to_path_buf())
} else {
std::fs::canonicalize(path).map_err(Into::into)
};
if let Ok(path) = path {
let path: PathBuf = path.components().collect();
let options = glob::MatchOptions {
// this is needed so `/dir/*` doesn't match files within subdirectories such as `/dir/subdir/file.txt`
// see: https://github.com/tauri-apps/tauri/security/advisories/GHSA-6mv3-wm7j-h4w5
require_literal_separator: true,
// dotfiles are not supposed to be exposed by default
#[cfg(unix)]
require_literal_leading_dot: true,
..Default::default()
};
let forbidden = self
.forbidden_patterns
.lock()
.unwrap()
.iter()
.any(|p| p.matches_path_with(&path, options));
if forbidden {
false
} else {
let allowed = self
.allowed_patterns
.lock()
.unwrap()
.iter()
.any(|p| p.matches_path_with(&path, options));
allowed
}
} else {
false
}
}
}
fn escaped_pattern(p: &str) -> Result<Pattern, glob::PatternError> {
Pattern::new(&glob::Pattern::escape(p))
}
fn escaped_pattern_with(p: &str, append: &str) -> Result<Pattern, glob::PatternError> {
Pattern::new(&format!(
"{}{}{append}",
glob::Pattern::escape(p),
MAIN_SEPARATOR
))
}
#[cfg(test)]
mod tests {
use super::Scope;
fn new_scope() -> Scope {
Scope {
allowed_patterns: Default::default(),
forbidden_patterns: Default::default(),
event_listeners: Default::default(),
}
}
#[test]
fn path_is_escaped() {
let scope = new_scope();
#[cfg(unix)]
{
scope.allow_directory("/home/tauri/**", false).unwrap();
assert!(scope.is_allowed("/home/tauri/**"));
assert!(scope.is_allowed("/home/tauri/**/file"));
assert!(!scope.is_allowed("/home/tauri/anyfile"));
}
#[cfg(windows)]
{
scope.allow_directory("C:\\home\\tauri\\**", false).unwrap();
assert!(scope.is_allowed("C:\\home\\tauri\\**"));
assert!(scope.is_allowed("C:\\home\\tauri\\**\\file"));
assert!(!scope.is_allowed("C:\\home\\tauri\\anyfile"));
}
let scope = new_scope();
#[cfg(unix)]
{
scope.allow_file("/home/tauri/**").unwrap();
assert!(scope.is_allowed("/home/tauri/**"));
assert!(!scope.is_allowed("/home/tauri/**/file"));
assert!(!scope.is_allowed("/home/tauri/anyfile"));
}
#[cfg(windows)]
{
scope.allow_file("C:\\home\\tauri\\**").unwrap();
assert!(scope.is_allowed("C:\\home\\tauri\\**"));
assert!(!scope.is_allowed("C:\\home\\tauri\\**\\file"));
assert!(!scope.is_allowed("C:\\home\\tauri\\anyfile"));
}
let scope = new_scope();
#[cfg(unix)]
{
scope.allow_directory("/home/tauri", true).unwrap();
scope.forbid_directory("/home/tauri/**", false).unwrap();
assert!(!scope.is_allowed("/home/tauri/**"));
assert!(!scope.is_allowed("/home/tauri/**/file"));
assert!(scope.is_allowed("/home/tauri/**/inner/file"));
assert!(scope.is_allowed("/home/tauri/inner/folder/anyfile"));
assert!(scope.is_allowed("/home/tauri/anyfile"));
}
#[cfg(windows)]
{
scope.allow_directory("C:\\home\\tauri", true).unwrap();
scope
.forbid_directory("C:\\home\\tauri\\**", false)
.unwrap();
assert!(!scope.is_allowed("C:\\home\\tauri\\**"));
assert!(!scope.is_allowed("C:\\home\\tauri\\**\\file"));
assert!(scope.is_allowed("C:\\home\\tauri\\**\\inner\\file"));
assert!(scope.is_allowed("C:\\home\\tauri\\inner\\folder\\anyfile"));
assert!(scope.is_allowed("C:\\home\\tauri\\anyfile"));
}
let scope = new_scope();
#[cfg(unix)]
{
scope.allow_directory("/home/tauri", true).unwrap();
scope.forbid_file("/home/tauri/**").unwrap();
assert!(!scope.is_allowed("/home/tauri/**"));
assert!(scope.is_allowed("/home/tauri/**/file"));
assert!(scope.is_allowed("/home/tauri/**/inner/file"));
assert!(scope.is_allowed("/home/tauri/anyfile"));
}
#[cfg(windows)]
{
scope.allow_directory("C:\\home\\tauri", true).unwrap();
scope.forbid_file("C:\\home\\tauri\\**").unwrap();
assert!(!scope.is_allowed("C:\\home\\tauri\\**"));
assert!(scope.is_allowed("C:\\home\\tauri\\**\\file"));
assert!(scope.is_allowed("C:\\home\\tauri\\**\\inner\\file"));
assert!(scope.is_allowed("C:\\home\\tauri\\anyfile"));
}
let scope = new_scope();
#[cfg(unix)]
{
scope.allow_directory("/home/tauri", false).unwrap();
assert!(scope.is_allowed("/home/tauri/**"));
assert!(!scope.is_allowed("/home/tauri/**/file"));
assert!(!scope.is_allowed("/home/tauri/**/inner/file"));
assert!(scope.is_allowed("/home/tauri/anyfile"));
}
#[cfg(windows)]
{
scope.allow_directory("C:\\home\\tauri", false).unwrap();
assert!(scope.is_allowed("C:\\home\\tauri\\**"));
assert!(!scope.is_allowed("C:\\home\\tauri\\**\\file"));
assert!(!scope.is_allowed("C:\\home\\tauri\\**\\inner\\file"));
assert!(scope.is_allowed("C:\\home\\tauri\\anyfile"));
}
}
}