Bladeren bron

feat: extend scopes with user selected paths, closes #3591 (#3595)

Lucas Fernandes Nogueira 3 jaren geleden
bovenliggende
commit
b744cd2758

+ 5 - 0
.changes/fs-absolute-paths.md

@@ -0,0 +1,5 @@
+---
+"tauri": patch
+---
+
+Allow absolute paths on the filesystem APIs as long as it does not include parent directory components.

+ 5 - 0
.changes/fs-scope-runtime.md

@@ -0,0 +1,5 @@
+---
+"tauri": patch
+---
+
+Extend the allowed patterns for the filesystem and asset protocol when the user selects a path (dialog open and save commands and file drop on the window).

+ 34 - 6
core/tauri/src/endpoints/dialog.rs

@@ -3,9 +3,9 @@
 // SPDX-License-Identifier: MIT
 
 use super::{InvokeContext, InvokeResponse};
-#[cfg(any(dialog_open, dialog_save))]
-use crate::api::dialog::blocking::FileDialogBuilder;
 use crate::Runtime;
+#[cfg(any(dialog_open, dialog_save))]
+use crate::{api::dialog::blocking::FileDialogBuilder, Manager, Scopes};
 use serde::Deserialize;
 use tauri_macros::{module_command_handler, CommandModule};
 
@@ -36,6 +36,10 @@ pub struct OpenDialogOptions {
   pub directory: bool,
   /// The initial path of the dialog.
   pub default_path: Option<PathBuf>,
+  /// If [`Self::directory`] is true, indicates that it will be read recursively later.
+  /// Defines whether subdirectories will be allowed on the scope or not.
+  #[serde(default)]
+  pub recursive: bool,
 }
 
 /// The options for the save dialog API.
@@ -97,12 +101,28 @@ impl Cmd {
       dialog_builder = dialog_builder.add_filter(filter.name, &extensions);
     }
 
+    let scopes = context.window.state::<Scopes>();
+
     let res = if options.directory {
-      dialog_builder.pick_folder().into()
+      let folder = dialog_builder.pick_folder();
+      if let Some(path) = &folder {
+        scopes.allow_directory(path, options.recursive);
+      }
+      folder.into()
     } else if options.multiple {
-      dialog_builder.pick_files().into()
+      let files = dialog_builder.pick_files();
+      if let Some(files) = &files {
+        for file in files {
+          scopes.allow_file(file);
+        }
+      }
+      files.into()
     } else {
-      dialog_builder.pick_file().into()
+      let file = dialog_builder.pick_file();
+      if let Some(file) = &file {
+        scopes.allow_file(file);
+      }
+      file.into()
     };
 
     Ok(res)
@@ -127,7 +147,14 @@ impl Cmd {
       dialog_builder = dialog_builder.add_filter(filter.name, &extensions);
     }
 
-    Ok(dialog_builder.save_file())
+    let scopes = context.window.state::<Scopes>();
+
+    let path = dialog_builder.save_file();
+    if let Some(p) = &path {
+      scopes.allow_file(p);
+    }
+
+    Ok(path)
   }
 
   #[module_command_handler(dialog_message, "dialog > message")]
@@ -198,6 +225,7 @@ mod tests {
         directory: bool::arbitrary(g),
         default_path: Option::arbitrary(g),
         title: Option::arbitrary(g),
+        recursive: bool::arbitrary(g),
       }
     }
   }

+ 1 - 6
core/tauri/src/endpoints/file_system.rs

@@ -41,12 +41,7 @@ impl<'de> Deserialize<'de> for SafePathBuf {
     D: Deserializer<'de>,
   {
     let path = std::path::PathBuf::deserialize(deserializer)?;
-    if path.components().any(|x| {
-      matches!(
-        x,
-        Component::ParentDir | Component::RootDir | Component::Prefix(_)
-      )
-    }) {
+    if path.components().any(|x| matches!(x, Component::ParentDir)) {
       Err(DeError::custom("cannot traverse directory"))
     } else {
       Ok(SafePathBuf(path))

+ 12 - 2
core/tauri/src/manager.rs

@@ -47,7 +47,7 @@ use crate::{
     config::{AppUrl, Config, WindowUrl},
     PackageInfo,
   },
-  Context, Invoke, Pattern, StateManager, Window,
+  Context, Invoke, Manager, Pattern, Scopes, StateManager, Window,
 };
 
 #[cfg(any(target_os = "linux", target_os = "windows"))]
@@ -828,7 +828,17 @@ impl<R: Runtime> WindowManager<R> {
       let window = Window::new(manager.clone(), window, app_handle.clone());
       let _ = match event {
         FileDropEvent::Hovered(paths) => window.emit_and_trigger("tauri://file-drop-hover", paths),
-        FileDropEvent::Dropped(paths) => window.emit_and_trigger("tauri://file-drop", paths),
+        FileDropEvent::Dropped(paths) => {
+          let scopes = window.state::<Scopes>();
+          for path in &paths {
+            if path.is_file() {
+              scopes.allow_file(path);
+            } else {
+              scopes.allow_directory(path, false);
+            }
+          }
+          window.emit_and_trigger("tauri://file-drop", paths)
+        }
         FileDropEvent::Cancelled => window.emit_and_trigger("tauri://file-drop-cancelled", ()),
         _ => unimplemented!(),
       };

+ 45 - 11
core/tauri/src/scope/fs.rs

@@ -5,6 +5,7 @@
 use std::{
   fmt,
   path::{Path, PathBuf},
+  sync::{Arc, Mutex},
 };
 
 use glob::Pattern;
@@ -18,7 +19,7 @@ use crate::api::path::parse as parse_path;
 /// Scope for filesystem access.
 #[derive(Clone)]
 pub struct Scope {
-  allow_patterns: Vec<Pattern>,
+  allow_patterns: Arc<Mutex<Vec<Pattern>>>,
 }
 
 impl fmt::Debug for Scope {
@@ -28,6 +29,8 @@ impl fmt::Debug for Scope {
         "allow_patterns",
         &self
           .allow_patterns
+          .lock()
+          .unwrap()
           .iter()
           .map(|p| p.as_str())
           .collect::<Vec<&str>>(),
@@ -36,6 +39,16 @@ impl fmt::Debug for Scope {
   }
 }
 
+fn push_pattern<P: AsRef<Path>>(list: &mut Vec<Pattern>, pattern: P) {
+  let pattern: PathBuf = pattern.as_ref().components().collect();
+  list.push(Pattern::new(&pattern.to_string_lossy()).expect("invalid glob pattern"));
+  #[cfg(windows)]
+  {
+    list
+      .push(Pattern::new(&format!("\\\\?\\{}", pattern.display())).expect("invalid glob pattern"));
+  }
+}
+
 impl Scope {
   /// Creates a new scope from a `FsAllowlistScope` configuration.
   pub fn for_fs_api(
@@ -47,17 +60,33 @@ impl Scope {
     let mut allow_patterns = Vec::new();
     for path in &scope.0 {
       if let Ok(path) = parse_path(config, package_info, env, path) {
-        let path: PathBuf = path.components().collect();
-        allow_patterns.push(Pattern::new(&path.to_string_lossy()).expect("invalid glob pattern"));
-        #[cfg(windows)]
-        {
-          allow_patterns.push(
-            Pattern::new(&format!("\\\\?\\{}", path.display())).expect("invalid glob pattern"),
-          );
-        }
+        push_pattern(&mut allow_patterns, path);
       }
     }
-    Self { allow_patterns }
+    Self {
+      allow_patterns: Arc::new(Mutex::new(allow_patterns)),
+    }
+  }
+
+  /// 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 and subdirectories.
+  pub fn allow_directory<P: AsRef<Path>>(&self, path: P, recursive: bool) {
+    let path = path.as_ref().to_path_buf();
+    let mut list = self.allow_patterns.lock().unwrap();
+
+    // allow the directory to be read
+    push_pattern(&mut list, &path);
+    // allow its files and subdirectories to be read
+    push_pattern(&mut list, path.join(if recursive { "**" } else { "*" }));
+  }
+
+  /// 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) {
+    push_pattern(&mut self.allow_patterns.lock().unwrap(), path);
   }
 
   /// Determines if the given path is allowed on this scope.
@@ -71,7 +100,12 @@ impl Scope {
 
     if let Ok(path) = path {
       let path: PathBuf = path.components().collect();
-      let allowed = self.allow_patterns.iter().any(|p| p.matches_path(&path));
+      let allowed = self
+        .allow_patterns
+        .lock()
+        .unwrap()
+        .iter()
+        .any(|p| p.matches_path(&path));
       allowed
     } else {
       false

+ 17 - 0
core/tauri/src/scope/mod.rs

@@ -15,6 +15,7 @@ pub use shell::{
   ScopeAllowedCommand as ShellScopeAllowedCommand, ScopeConfig as ShellScopeConfig,
   ScopeError as ShellScopeError,
 };
+use std::path::Path;
 
 pub(crate) struct Scopes {
   pub fs: FsScope,
@@ -25,3 +26,19 @@ pub(crate) struct Scopes {
   #[cfg(shell_scope)]
   pub shell: ShellScope,
 }
+
+impl Scopes {
+  #[allow(dead_code)]
+  pub(crate) fn allow_directory(&self, path: &Path, recursive: bool) {
+    self.fs.allow_directory(path, recursive);
+    #[cfg(protocol_asset)]
+    self.asset_protocol.allow_directory(path, recursive);
+  }
+
+  #[allow(dead_code)]
+  pub(crate) fn allow_file(&self, path: &Path) {
+    self.fs.allow_file(path);
+    #[cfg(protocol_asset)]
+    self.asset_protocol.allow_file(path);
+  }
+}

+ 20 - 1
tooling/api/src/dialog.ts

@@ -53,6 +53,11 @@ interface OpenDialogOptions {
   multiple?: boolean
   /** Whether the dialog is a directory selection or not. */
   directory?: boolean
+  /**
+   * If `directory` is true, indicates that it will be read recursively later.
+   * Defines whether subdirectories will be allowed on the scope or not.
+   */
+  recursive?: boolean
 }
 
 /** Options for the save dialog. */
@@ -70,7 +75,14 @@ interface SaveDialogOptions {
 }
 
 /**
- * Open a file/directory selection dialog
+ * Open a file/directory selection dialog.
+ *
+ * The selected paths are added to the filesystem and asset protocol allowlist scopes.
+ * When security is more important than the easy of use of this API,
+ * prefer writing a dedicated command instead.
+ *
+ * Note that the allowlist scope change is not persisted, so the values are cleared when the application is restarted.
+ * You can save it to the filesystem using [tauri-plugin-persisted-scope](https://github.com/tauri-apps/tauri-plugin-persisted-scope).
  *
  * @returns A promise resolving to the selected path(s)
  */
@@ -93,6 +105,13 @@ async function open(
 /**
  * Open a file/directory save dialog.
  *
+ * The selected path is added to the filesystem and asset protocol allowlist scopes.
+ * When security is more important than the easy of use of this API,
+ * prefer writing a dedicated command instead.
+ *
+ * Note that the allowlist scope change is not persisted, so the values are cleared when the application is restarted.
+ * You can save it to the filesystem using [tauri-plugin-persisted-scope](https://github.com/tauri-apps/tauri-plugin-persisted-scope).
+ *
  * @returns A promise resolving to the selected path.
  */
 async function save(options: SaveDialogOptions = {}): Promise<string> {

+ 10 - 10
tooling/api/src/window.ts

@@ -879,12 +879,12 @@ class WindowManager extends WebviewWindowHandle {
             type: 'setMinSize',
             payload: size
               ? {
-                type: size.type,
-                data: {
-                  width: size.width,
-                  height: size.height
+                  type: size.type,
+                  data: {
+                    width: size.width,
+                    height: size.height
+                  }
                 }
-              }
               : null
           }
         }
@@ -921,12 +921,12 @@ class WindowManager extends WebviewWindowHandle {
             type: 'setMaxSize',
             payload: size
               ? {
-                type: size.type,
-                data: {
-                  width: size.width,
-                  height: size.height
+                  type: size.type,
+                  data: {
+                    width: size.width,
+                    height: size.height
+                  }
                 }
-              }
               : null
           }
         }