Quellcode durchsuchen

feat(core): allow extending the shell scope, closes #5910

Lucas Nogueira vor 2 Jahren
Ursprung
Commit
aa1dadd5e2

+ 5 - 0
.changes/extend-shell-scope.md

@@ -0,0 +1,5 @@
+---
+"tauri": minor
+---
+
+Added `tauri::scope::ShellScope::allow` API to extend the allowed command list.

+ 63 - 67
core/tauri-codegen/src/context.rs

@@ -575,75 +575,71 @@ fn find_icon<F: Fn(&&String) -> bool>(
 
 #[cfg(feature = "shell-scope")]
 fn get_allowed_clis(root: &TokenStream, scope: &ShellAllowlistScope) -> TokenStream {
-  let commands = scope
-    .0
-    .iter()
-    .map(|scope| {
-      let sidecar = &scope.sidecar;
-
-      let name = &scope.name;
-      let name = quote!(#name.into());
-
-      let command = scope.command.to_string_lossy();
-      let command = quote!(::std::path::PathBuf::from(#command));
-
-      let args = match &scope.args {
-        ShellAllowedArgs::Flag(true) => quote!(::std::option::Option::None),
-        ShellAllowedArgs::Flag(false) => quote!(::std::option::Option::Some(::std::vec![])),
-        ShellAllowedArgs::List(list) => {
-          let list = list.iter().map(|arg| match arg {
-            ShellAllowedArg::Fixed(fixed) => {
-              quote!(#root::scope::ShellScopeAllowedArg::Fixed(#fixed.into()))
-            }
-            ShellAllowedArg::Var { validator } => {
-              let validator = match regex::Regex::new(validator) {
-                Ok(regex) => {
-                  let regex = regex.as_str();
-                  quote!(#root::regex::Regex::new(#regex).unwrap())
-                }
-                Err(error) => {
-                  let error = error.to_string();
-                  quote!({
-                    compile_error!(#error);
-                    #root::regex::Regex::new(#validator).unwrap()
-                  })
-                }
-              };
-
-              quote!(#root::scope::ShellScopeAllowedArg::Var { validator: #validator })
-            }
-            _ => panic!("unknown shell scope arg, unable to prepare"),
-          });
+  use quote::TokenStreamExt;
 
-          quote!(::std::option::Option::Some(::std::vec![#(#list),*]))
-        }
-        _ => panic!("unknown shell scope command, unable to prepare"),
-      };
-
-      (
-        quote!(#name),
-        quote!(
-          #root::scope::ShellScopeAllowedCommand {
-            command: #command,
-            args: #args,
-            sidecar: #sidecar,
+  let commands = scope.0.iter().map(|scope| {
+    let name = &scope.name;
+    let name = quote!(#name.into());
+
+    let command = scope.command.to_string_lossy();
+    let mut code = if scope.sidecar {
+      quote!(#root::scope::ShellScopeAllowedCommandBuilder::sidecar(#command))
+    } else {
+      quote!(#root::scope::ShellScopeAllowedCommandBuilder::new(#command))
+    };
+
+    match &scope.args {
+      ShellAllowedArgs::Flag(true) => {
+        code.append_all(quote!(.allow_any_args()));
+      }
+      ShellAllowedArgs::Flag(false) => {
+        // args are disallowed by default on ShellScopeAllowedCommandBuilder
+      }
+      ShellAllowedArgs::List(list) => {
+        let args = list.iter().map(|arg| match arg {
+          ShellAllowedArg::Fixed(fixed) => {
+            quote!(#root::scope::ShellScopeAllowedArg::Fixed(#fixed.into()))
           }
-        ),
-      )
-    })
-    .collect::<Vec<_>>();
+          ShellAllowedArg::Var { validator } => {
+            let validator = match regex::Regex::new(validator) {
+              Ok(regex) => {
+                let regex = regex.as_str();
+                quote!(#root::regex::Regex::new(#regex).unwrap())
+              }
+              Err(error) => {
+                let error = error.to_string();
+                quote!({
+                  compile_error!(#error);
+                  #root::regex::Regex::new(#validator).unwrap()
+                })
+              }
+            };
+
+            quote!(#root::scope::ShellScopeAllowedArg::Var { validator: #validator })
+          }
+          _ => panic!("unknown shell scope arg, unable to prepare"),
+        });
 
-  if commands.is_empty() {
-    quote!(::std::collections::HashMap::new())
-  } else {
-    let insertions = commands
-      .iter()
-      .map(|(name, value)| quote!(hashmap.insert(#name, #value);));
+        for arg in args {
+          code.append_all(quote!(.arg(#arg)));
+        }
+      }
+      _ => panic!("unknown shell scope command, unable to prepare"),
+    }
 
-    quote!({
-      let mut hashmap = ::std::collections::HashMap::new();
-      #(#insertions)*
-      hashmap
-    })
-  }
+    (
+      quote!(#name),
+      quote!(
+        #code.build()
+      ),
+    )
+  });
+
+  let insertions = commands.map(|(name, value)| quote!(hashmap.insert(#name, #value);));
+
+  quote!({
+    let mut hashmap = ::std::collections::HashMap::new();
+    #(#insertions)*
+    hashmap
+  })
 }

+ 2 - 1
core/tauri/src/scope/mod.rs

@@ -15,7 +15,8 @@ pub use fs::{Event as FsScopeEvent, Pattern as GlobPattern, Scope as FsScope};
 #[cfg(shell_scope)]
 pub use shell::{
   ExecuteArgs, Scope as ShellScope, ScopeAllowedArg as ShellScopeAllowedArg,
-  ScopeAllowedCommand as ShellScopeAllowedCommand, ScopeConfig as ShellScopeConfig,
+  ScopeAllowedCommand as ShellScopeAllowedCommand,
+  ScopeAllowedCommandBuilder as ShellScopeAllowedCommandBuilder, ScopeConfig as ShellScopeConfig,
   ScopeError as ShellScopeError,
 };
 use std::path::Path;

+ 144 - 9
core/tauri/src/scope/shell.rs

@@ -9,8 +9,27 @@ use crate::api::shell::Program;
 
 use regex::Regex;
 use tauri_utils::{config::Config, Env, PackageInfo};
+use uuid::Uuid;
 
-use std::collections::HashMap;
+use std::{
+  collections::HashMap,
+  path::{Path, PathBuf},
+  sync::{Arc, Mutex},
+};
+
+/// Scope change event.
+#[derive(Debug, Clone)]
+pub enum Event {
+  /// A command has been allowed.
+  CommandAllowed {
+    /// The command key.
+    name: String,
+    /// The command scope definition.
+    scope: ScopeAllowedCommand,
+  },
+}
+
+type EventListener = Box<dyn Fn(&Event) + Send>;
 
 /// Allowed representation of `Execute` command arguments.
 #[derive(Debug, Clone, serde::Deserialize)]
@@ -71,13 +90,35 @@ pub struct ScopeConfig {
 #[derive(Debug, Clone)]
 pub struct ScopeAllowedCommand {
   /// The shell command to be called.
-  pub command: std::path::PathBuf,
+  command: PathBuf,
 
   /// The arguments the command is allowed to be called with.
-  pub args: Option<Vec<ScopeAllowedArg>>,
+  args: Option<Vec<ScopeAllowedArg>>,
 
   /// If this command is a sidecar command.
-  pub sidecar: bool,
+  sidecar: bool,
+}
+
+impl ScopeAllowedCommand {
+  /// The command of this scope.
+  pub fn command(&self) -> &Path {
+    &self.command
+  }
+
+  /// Whether the command is a sidecar or not.
+  pub fn sidecar(&self) -> bool {
+    self.sidecar
+  }
+
+  /// Whether this scope allows any CLI argument list or not.
+  pub fn allows_any_args(&self) -> bool {
+    self.args.is_none()
+  }
+
+  /// The CLI argument validation of this scope.
+  pub fn args(&self) -> Option<&[ScopeAllowedArg]> {
+    self.args.as_deref()
+  }
 }
 
 /// A configured argument to a scoped shell command.
@@ -105,9 +146,57 @@ impl ScopeAllowedArg {
   }
 }
 
-/// Scope for filesystem access.
+/// A builder for [`ScopeAllowedCommand`].
+#[derive(Debug)]
+pub struct ScopeAllowedCommandBuilder(ScopeAllowedCommand);
+
+impl ScopeAllowedCommandBuilder {
+  /// Prepares a new command to allow on the shell scope.
+  ///
+  /// By default CLI arguments are not allowed. Use [`Self::arg`] or [`Self::allow_any_args`] if you are going to use them.
+  pub fn new<P: AsRef<Path>>(command: P) -> Self {
+    Self(ScopeAllowedCommand {
+      command: command.as_ref().to_path_buf(),
+      args: Some(Vec::new()),
+      sidecar: false,
+    })
+  }
+
+  /// Prepares a new sidecar to allow on the shell scope.
+  ///
+  /// By default CLI arguments are not allowed. Use [`Self::arg`] or [`Self::allow_any_args`] if you are going to use them.
+  pub fn sidecar<P: AsRef<Path>>(command: P) -> Self {
+    Self(ScopeAllowedCommand {
+      command: command.as_ref().to_path_buf(),
+      args: Some(Vec::new()),
+      sidecar: true,
+    })
+  }
+
+  /// Disable CLI argument validation. If possible, prefer [`Self::arg`] for security.
+  pub fn allow_any_args(mut self) -> Self {
+    self.0.args = None;
+    self
+  }
+
+  /// Appends an argument to the command.
+  pub fn arg(mut self, arg: ScopeAllowedArg) -> Self {
+    self.0.args.get_or_insert_with(Default::default).push(arg);
+    self
+  }
+
+  /// Builds the [`ScopeAllowedCommand`] to use on [`Scope::allow`].
+  pub fn build(self) -> ScopeAllowedCommand {
+    self.0
+  }
+}
+
+/// Scope for shell access.
 #[derive(Clone)]
-pub struct Scope(ScopeConfig);
+pub struct Scope {
+  config: Arc<Mutex<ScopeConfig>>,
+  event_listeners: Arc<Mutex<HashMap<Uuid, EventListener>>>,
+}
 
 /// All errors that can happen while validating a scoped command.
 #[derive(Debug, thiserror::Error)]
@@ -205,7 +294,52 @@ impl Scope {
         cmd.command = path;
       }
     }
-    Self(scope)
+    Self {
+      config: Arc::new(Mutex::new(scope)),
+      event_listeners: Default::default(),
+    }
+  }
+
+  /// 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);
+    }
+  }
+
+  /// Allow a command to be executed.
+  ///
+  /// # Examples
+  /// ```
+  /// use tauri::{Manager, scope::ShellScopeAllowedCommandBuilder};
+  /// tauri::Builder::default()
+  ///   .setup(|app| {
+  ///     app.shell_scope().allow("java", ShellScopeAllowedCommandBuilder::new("java").build());
+  ///     app.shell_scope().allow("server-sidecar", ShellScopeAllowedCommandBuilder::sidecar("server").build());
+  ///     Ok(())
+  ///   });
+  /// ```
+  pub fn allow<S: Into<String>>(&self, name: S, command: ScopeAllowedCommand) {
+    let name = name.into();
+    self
+      .config
+      .lock()
+      .unwrap()
+      .scopes
+      .insert(name.clone(), command.clone());
+
+    self.trigger(Event::CommandAllowed {
+      name,
+      scope: command,
+    });
   }
 
   /// Validates argument inputs and creates a Tauri sidecar [`Command`].
@@ -233,7 +367,8 @@ impl Scope {
     args: ExecuteArgs,
     sidecar: Option<&str>,
   ) -> Result<Command, ScopeError> {
-    let command = match self.0.scopes.get(command_name) {
+    let scope = self.config.lock().unwrap();
+    let command = match scope.scopes.get(command_name) {
       Some(command) => command,
       None => return Err(ScopeError::NotFound(command_name.into())),
     };
@@ -305,7 +440,7 @@ impl Scope {
   #[cfg(feature = "shell-open-api")]
   pub fn open(&self, path: &str, with: Option<Program>) -> Result<(), ScopeError> {
     // ensure we pass validation if the configuration has one
-    if let Some(regex) = &self.0.open {
+    if let Some(regex) = &self.config.lock().unwrap().open {
       if !regex.is_match(path) {
         return Err(ScopeError::Validation {
           index: 0,