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.
369 lines
13 KiB
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"));
|
|
}
|
|
}
|
|
}
|