// 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; /// Scope for filesystem access. #[derive(Clone)] pub struct Scope { allowed_patterns: Arc>>, forbidden_patterns: Arc>>, event_listeners: Arc>>, } 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::>(), ) .field( "forbidden_patterns", &self .forbidden_patterns .lock() .unwrap() .iter() .map(|p| p.as_str()) .collect::>(), ) .finish() } } fn push_pattern, F: Fn(&str) -> Result>( list: &mut HashSet, 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>( manager: &M, scope: &FsScope, ) -> crate::Result { 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 { self.allowed_patterns.lock().unwrap().clone() } /// The list of forbidden patterns. pub fn forbidden_patterns(&self) -> HashSet { self.forbidden_patterns.lock().unwrap().clone() } /// Listen to an event on this scope. pub fn listen(&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>(&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>(&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>(&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>(&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>(&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::new(&glob::Pattern::escape(p)) } fn escaped_pattern_with(p: &str, append: &str) -> Result { 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")); } } }