1use std::collections::BTreeMap;
6use std::fmt::{Debug, Display};
7use std::sync::Arc;
8
9use serde::de::DeserializeOwned;
10
11#[cfg(feature = "dynamic-acl")]
12use tauri_utils::acl::capability::CapabilityFile;
13#[cfg(any(feature = "dynamic-acl", debug_assertions))]
14use tauri_utils::acl::manifest::Manifest;
15use tauri_utils::acl::{
16 APP_ACL_KEY, ExecutionContext, Value,
17 resolved::{Resolved, ResolvedCommand, ResolvedScope, ScopeKey},
18};
19
20use url::Url;
21
22use crate::{AppHandle, Manager, StateManager, Webview};
23use crate::{Runtime, ipc::InvokeError, sealed::ManagerBase};
24
25use super::{CommandArg, CommandItem};
26
27pub struct RuntimeAuthority {
29 #[cfg(any(feature = "dynamic-acl", debug_assertions))]
30 acl: BTreeMap<String, Manifest>,
31 has_app_acl: bool,
32 allowed_commands: BTreeMap<String, Vec<ResolvedCommand>>,
33 denied_commands: BTreeMap<String, Vec<ResolvedCommand>>,
34 pub(crate) scope_manager: ScopeManager,
35}
36
37pub enum Origin {
39 Local,
41 Remote {
43 url: Url,
45 },
46}
47
48impl Display for Origin {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 Self::Local => write!(f, "local"),
52 Self::Remote { url } => write!(f, "remote: {url}"),
53 }
54 }
55}
56
57impl Origin {
58 fn matches(&self, context: &ExecutionContext) -> bool {
59 match (self, context) {
60 (Self::Local, ExecutionContext::Local) => true,
61 (Self::Remote { url }, ExecutionContext::Remote { url: url_pattern }) => {
62 url_pattern.test(url)
63 }
64 _ => false,
65 }
66 }
67}
68
69#[cfg(any(feature = "dynamic-acl", debug_assertions))]
78#[doc(hidden)]
79#[macro_export]
80macro_rules! runtime_authority {
81 ($acl:expr, $resolved_acl:expr) => {
82 $crate::ipc::RuntimeAuthority::new($acl, $resolved_acl)
83 };
84}
85
86#[cfg(not(any(feature = "dynamic-acl", debug_assertions)))]
95#[doc(hidden)]
96#[macro_export]
97macro_rules! runtime_authority {
98 ($_acl:expr, $resolved_acl:expr) => {
99 $crate::ipc::RuntimeAuthority::new($resolved_acl)
100 };
101}
102
103impl RuntimeAuthority {
104 #[doc(hidden)]
108 pub fn new(
109 #[cfg(any(feature = "dynamic-acl", debug_assertions))] acl: BTreeMap<String, Manifest>,
110 resolved_acl: Resolved,
111 ) -> Self {
112 let command_cache = resolved_acl
113 .command_scope
114 .keys()
115 .map(|key| (*key, StateManager::new()))
116 .collect();
117 Self {
118 #[cfg(any(feature = "dynamic-acl", debug_assertions))]
119 acl,
120 has_app_acl: resolved_acl.has_app_acl,
121 allowed_commands: resolved_acl.allowed_commands,
122 denied_commands: resolved_acl.denied_commands,
123 scope_manager: ScopeManager {
124 command_scope: resolved_acl.command_scope,
125 global_scope: resolved_acl.global_scope,
126 command_cache,
127 global_scope_cache: StateManager::new(),
128 },
129 }
130 }
131
132 pub(crate) fn has_app_manifest(&self) -> bool {
133 self.has_app_acl
134 }
135
136 #[doc(hidden)]
137 pub fn __allow_command(&mut self, command: String, context: ExecutionContext) {
138 self.allowed_commands.insert(
139 command,
140 vec![ResolvedCommand {
141 context,
142 windows: vec!["*".parse().unwrap()],
143 ..Default::default()
144 }],
145 );
146 }
147
148 #[cfg(feature = "dynamic-acl")]
150 pub fn add_capability(&mut self, capability: impl super::RuntimeCapability) -> crate::Result<()> {
151 self.add_capability_inner(capability.build())
152 }
153
154 #[cfg(feature = "dynamic-acl")]
155 fn add_capability_inner(&mut self, capability: CapabilityFile) -> crate::Result<()> {
156 let mut capabilities = BTreeMap::new();
157 match capability {
158 CapabilityFile::Capability(c) => {
159 capabilities.insert(c.identifier.clone(), c);
160 }
161
162 CapabilityFile::List(capabilities_list)
163 | CapabilityFile::NamedList {
164 capabilities: capabilities_list,
165 } => {
166 capabilities.extend(
167 capabilities_list
168 .into_iter()
169 .map(|c| (c.identifier.clone(), c)),
170 );
171 }
172 }
173
174 let resolved = Resolved::resolve_with_base_scope_id(
175 &self.acl,
176 capabilities,
177 tauri_utils::platform::Target::current(),
178 self.scope_manager.last_scope_id(),
179 )
180 .unwrap();
181
182 for (plugin, global_scope) in resolved.global_scope {
184 let global_scope_entry = self.scope_manager.global_scope.entry(plugin).or_default();
185
186 global_scope_entry.allow.extend(global_scope.allow);
187 global_scope_entry.deny.extend(global_scope.deny);
188
189 self.scope_manager.global_scope_cache = StateManager::new();
190 }
191
192 for (scope_id, command_scope) in resolved.command_scope {
195 self
196 .scope_manager
197 .command_cache
198 .entry(scope_id)
199 .or_insert_with(StateManager::new);
200 self
201 .scope_manager
202 .command_scope
203 .insert(scope_id, command_scope);
204 }
205
206 for (cmd_key, resolved_cmds) in resolved.denied_commands {
208 let entry = self.denied_commands.entry(cmd_key).or_default();
209 entry.extend(resolved_cmds);
210 }
211
212 for (cmd_key, resolved_cmds) in resolved.allowed_commands {
214 let entry = self.allowed_commands.entry(cmd_key).or_default();
215 entry.extend(resolved_cmds);
216 }
217
218 Ok(())
219 }
220
221 #[cfg(debug_assertions)]
222 pub(crate) fn resolve_access_message(
223 &self,
224 key: &str,
225 command_name: &str,
226 window: &str,
227 webview: &str,
228 origin: &Origin,
229 ) -> String {
230 fn print_references(resolved: &[ResolvedCommand]) -> String {
231 resolved
232 .iter()
233 .map(|r| {
234 format!(
235 "capability: {}, permission: {}",
236 r.referenced_by.capability, r.referenced_by.permission
237 )
238 })
239 .collect::<Vec<_>>()
240 .join(" || ")
241 }
242
243 fn print_allowed_on(resolved: &[ResolvedCommand]) -> String {
244 if resolved.is_empty() {
245 "command not allowed on any window/webview/URL context".to_string()
246 } else {
247 let mut s = "allowed on: ".to_string();
248
249 let last_index = resolved.len() - 1;
250 for (index, cmd) in resolved.iter().enumerate() {
251 let windows = cmd
252 .windows
253 .iter()
254 .map(|w| format!("\"{}\"", w.as_str()))
255 .collect::<Vec<_>>()
256 .join(", ");
257 let webviews = cmd
258 .webviews
259 .iter()
260 .map(|w| format!("\"{}\"", w.as_str()))
261 .collect::<Vec<_>>()
262 .join(", ");
263
264 s.push('[');
265
266 if !windows.is_empty() {
267 s.push_str(&format!("windows: {windows}, "));
268 }
269
270 if !webviews.is_empty() {
271 s.push_str(&format!("webviews: {webviews}, "));
272 }
273
274 match &cmd.context {
275 ExecutionContext::Local => s.push_str("URL: local"),
276 ExecutionContext::Remote { url } => s.push_str(&format!("URL: {}", url.as_str())),
277 }
278
279 s.push(']');
280
281 if index != last_index {
282 s.push_str(", ");
283 }
284 }
285
286 s
287 }
288 }
289
290 fn has_permissions_allowing_command(
291 manifest: &Manifest,
292 set: &crate::utils::acl::PermissionSet,
293 command: &str,
294 ) -> bool {
295 for permission_id in &set.permissions {
296 if permission_id == "default" {
297 if let Some(default) = &manifest.default_permission {
298 if has_permissions_allowing_command(manifest, default, command) {
299 return true;
300 }
301 }
302 } else if let Some(ref_set) = manifest.permission_sets.get(permission_id) {
303 if has_permissions_allowing_command(manifest, ref_set, command) {
304 return true;
305 }
306 } else if let Some(permission) = manifest.permissions.get(permission_id) {
307 if permission.commands.allow.contains(&command.into()) {
308 return true;
309 }
310 }
311 }
312 false
313 }
314
315 let command = if key == APP_ACL_KEY {
316 command_name.to_string()
317 } else {
318 format!("plugin:{key}|{command_name}")
319 };
320
321 let command_pretty_name = if key == APP_ACL_KEY {
322 command_name.to_string()
323 } else {
324 format!("{key}.{command_name}")
325 };
326
327 let denied_on_origin = self
328 .denied_on(&command, origin)
329 .cloned()
330 .collect::<Vec<_>>();
331
332 if !denied_on_origin.is_empty() {
333 format!(
334 "{command_pretty_name} explicitly denied on origin {origin}\n\nreferenced by: {}",
335 print_references(&denied_on_origin)
336 )
337 } else {
338 let command_matches = self.allowed_commands.get(&command);
339
340 if let Some(resolved) = self.allowed_commands.get(&command) {
341 let resolved_matching_origin = resolved
342 .iter()
343 .filter(|cmd| origin.matches(&cmd.context))
344 .collect::<Vec<&ResolvedCommand>>();
345 if resolved_matching_origin
346 .iter()
347 .any(|cmd| cmd.webviews.iter().any(|w| w.matches(webview)))
348 || resolved_matching_origin
349 .iter()
350 .any(|cmd| cmd.windows.iter().any(|w| w.matches(window)))
351 {
352 "allowed".to_string()
353 } else {
354 format!(
355 "{command_pretty_name} not allowed on window \"{window}\", webview \"{webview}\", URL: {}\n\n{}\n\nreferenced by: {}",
356 match origin {
357 Origin::Local => "local",
358 Origin::Remote { url } => url.as_str(),
359 },
360 print_allowed_on(resolved),
361 print_references(resolved)
362 )
363 }
364 } else {
365 let permission_error_detail = if let Some((key, manifest)) = self
366 .acl
367 .get_key_value(key)
368 .or_else(|| self.acl.get_key_value(&format!("core:{key}")))
369 {
370 let mut permissions_referencing_command = Vec::new();
371
372 if let Some(default) = &manifest.default_permission {
373 if has_permissions_allowing_command(manifest, default, command_name) {
374 permissions_referencing_command.push("default".into());
375 }
376 }
377 for set in manifest.permission_sets.values() {
378 if has_permissions_allowing_command(manifest, set, command_name) {
379 permissions_referencing_command.push(set.identifier.clone());
380 }
381 }
382 for permission in manifest.permissions.values() {
383 if permission.commands.allow.contains(&command_name.into()) {
384 permissions_referencing_command.push(permission.identifier.clone());
385 }
386 }
387
388 permissions_referencing_command.sort();
389
390 let associated_permissions = permissions_referencing_command
391 .into_iter()
392 .map(|permission| {
393 if key == APP_ACL_KEY {
394 permission
395 } else {
396 format!("{key}:{permission}")
397 }
398 })
399 .collect::<Vec<_>>()
400 .join(", ");
401
402 if associated_permissions.is_empty() {
403 "Command not found".to_string()
404 } else {
405 format!("Permissions associated with this command: {associated_permissions}")
406 }
407 } else {
408 "Plugin not found".to_string()
409 };
410
411 if let Some(resolved_cmds) = command_matches {
412 format!(
413 "{command_pretty_name} not allowed on origin [{origin}]. Please create a capability that has this origin on the context field.\n\nFound matches for: {}\n\n{permission_error_detail}",
414 resolved_cmds
415 .iter()
416 .map(|resolved| {
417 let context = match &resolved.context {
418 ExecutionContext::Local => "[local]".to_string(),
419 ExecutionContext::Remote { url } => format!("[remote: {}]", url.as_str()),
420 };
421 format!(
422 "- context: {context}, referenced by: capability: {}, permission: {}",
423 resolved.referenced_by.capability, resolved.referenced_by.permission
424 )
425 })
426 .collect::<Vec<_>>()
427 .join("\n")
428 )
429 } else {
430 format!("{command_pretty_name} not allowed. {permission_error_detail}")
431 }
432 }
433 }
434 }
435
436 fn denied_on<'a>(
441 &'a self,
442 command: &str,
443 origin: &'a Origin,
444 ) -> impl Iterator<Item = &'a ResolvedCommand> + use<'a> {
445 self
446 .denied_commands
447 .get(command)
448 .into_iter()
449 .flatten()
450 .filter(move |cmd| origin.matches(&cmd.context))
451 }
452
453 fn is_denied(&self, command: &str, origin: &Origin) -> bool {
455 self.denied_on(command, origin).next().is_some()
456 }
457
458 pub fn resolve_access(
463 &self,
464 command: &str,
465 window: &str,
466 webview: &str,
467 origin: &Origin,
468 ) -> Option<Vec<ResolvedCommand>> {
469 if self.is_denied(command, origin) {
470 None
471 } else {
472 self.allowed_commands.get(command).and_then(|resolved| {
473 let resolved_cmds = resolved
474 .iter()
475 .filter(|cmd| {
476 origin.matches(&cmd.context)
477 && (cmd.webviews.iter().any(|w| w.matches(webview))
478 || cmd.windows.iter().any(|w| w.matches(window)))
479 })
480 .cloned()
481 .collect::<Vec<_>>();
482 if resolved_cmds.is_empty() {
483 None
484 } else {
485 Some(resolved_cmds)
486 }
487 })
488 }
489 }
490}
491
492#[derive(Debug)]
494pub struct ScopeValue<T: ScopeObject> {
495 allow: Arc<Vec<Arc<T>>>,
496 deny: Arc<Vec<Arc<T>>>,
497}
498
499impl<T: ScopeObject> ScopeValue<T> {
500 fn clone(&self) -> Self {
501 Self {
502 allow: self.allow.clone(),
503 deny: self.deny.clone(),
504 }
505 }
506
507 pub fn allows(&self) -> &Vec<Arc<T>> {
509 &self.allow
510 }
511
512 pub fn denies(&self) -> &Vec<Arc<T>> {
514 &self.deny
515 }
516}
517
518#[derive(Debug)]
520pub struct CommandScope<T: ScopeObject> {
521 allow: Vec<Arc<T>>,
522 deny: Vec<Arc<T>>,
523}
524
525impl<T: ScopeObject> CommandScope<T> {
526 pub(crate) fn resolve<R: Runtime>(
527 webview: &Webview<R>,
528 scope_ids: Vec<u64>,
529 ) -> crate::Result<Self> {
530 let mut allow = Vec::new();
531 let mut deny = Vec::new();
532
533 for scope_id in scope_ids {
534 let scope = webview
535 .manager()
536 .runtime_authority
537 .lock()
538 .unwrap()
539 .scope_manager
540 .get_command_scope_typed::<R, T>(webview.app_handle(), &scope_id)?;
541
542 for s in scope.allows() {
543 allow.push(s.clone());
544 }
545 for s in scope.denies() {
546 deny.push(s.clone());
547 }
548 }
549
550 Ok(CommandScope { allow, deny })
551 }
552
553 pub fn allows(&self) -> &Vec<Arc<T>> {
555 &self.allow
556 }
557
558 pub fn denies(&self) -> &Vec<Arc<T>> {
560 &self.deny
561 }
562}
563
564impl<T: ScopeObjectMatch> CommandScope<T> {
565 pub fn matches(&self, input: &T::Input) -> bool {
613 if self.deny.iter().any(|s| s.matches(input)) {
615 return false;
616 }
617
618 if self.allow.is_empty() {
620 true
621 } else {
622 self.allow.iter().any(|s| s.matches(input))
623 }
624 }
625}
626
627impl<'a, R: Runtime, T: ScopeObject> CommandArg<'a, R> for CommandScope<T> {
628 fn from_command(command: CommandItem<'a, R>) -> Result<Self, InvokeError> {
630 if let Some(resolved) = &command.acl {
631 let scope_ids = resolved
632 .iter()
633 .filter_map(|cmd| cmd.scope_id)
634 .collect::<Vec<_>>();
635 CommandScope::resolve(&command.message.webview, scope_ids).map_err(Into::into)
636 } else {
637 Ok(CommandScope {
638 allow: Default::default(),
639 deny: Default::default(),
640 })
641 }
642 }
643}
644
645#[derive(Debug)]
647pub struct GlobalScope<T: ScopeObject>(ScopeValue<T>);
648
649impl<T: ScopeObject> GlobalScope<T> {
650 pub(crate) fn resolve<R: Runtime>(webview: &Webview<R>, plugin: &str) -> crate::Result<Self> {
651 webview
652 .manager()
653 .runtime_authority
654 .lock()
655 .unwrap()
656 .scope_manager
657 .get_global_scope_typed(webview.app_handle(), plugin)
658 .map(Self)
659 }
660
661 pub fn allows(&self) -> &Vec<Arc<T>> {
663 &self.0.allow
664 }
665
666 pub fn denies(&self) -> &Vec<Arc<T>> {
668 &self.0.deny
669 }
670}
671
672impl<'a, R: Runtime, T: ScopeObject> CommandArg<'a, R> for GlobalScope<T> {
673 fn from_command(command: CommandItem<'a, R>) -> Result<Self, InvokeError> {
675 GlobalScope::resolve(
676 &command.message.webview,
677 command.plugin.unwrap_or(APP_ACL_KEY),
678 )
679 .map_err(InvokeError::from_error)
680 }
681}
682
683#[derive(Debug)]
684pub struct ScopeManager {
685 command_scope: BTreeMap<ScopeKey, ResolvedScope>,
686 global_scope: BTreeMap<String, ResolvedScope>,
687 command_cache: BTreeMap<ScopeKey, StateManager>,
688 global_scope_cache: StateManager,
689}
690
691pub trait ScopeObject: Sized + Send + Sync + Debug + 'static {
696 type Error: std::error::Error + Send + Sync;
698 fn deserialize<R: Runtime>(app: &AppHandle<R>, raw: Value) -> Result<Self, Self::Error>;
700}
701
702impl<T: Send + Sync + Debug + DeserializeOwned + 'static> ScopeObject for T {
703 type Error = serde_json::Error;
704 fn deserialize<R: Runtime>(_app: &AppHandle<R>, raw: Value) -> Result<Self, Self::Error> {
705 serde_json::from_value(raw.into())
706 }
707}
708
709pub trait ScopeObjectMatch: ScopeObject {
745 type Input: ?Sized;
749
750 fn matches(&self, input: &Self::Input) -> bool;
752}
753
754impl ScopeManager {
755 #[cfg(feature = "dynamic-acl")]
757 fn last_scope_id(&self) -> ScopeKey {
758 self.command_scope.keys().next_back().copied().unwrap_or(0)
759 }
760
761 pub(crate) fn get_global_scope_typed<R: Runtime, T: ScopeObject>(
762 &self,
763 app: &AppHandle<R>,
764 key: &str,
765 ) -> crate::Result<ScopeValue<T>> {
766 match self.global_scope_cache.try_get::<ScopeValue<T>>() {
767 Some(cached) => Ok((*cached).clone()),
768 None => {
769 let mut allow = Vec::new();
770 let mut deny = Vec::new();
771
772 if let Some(global_scope) = self.global_scope.get(key) {
773 for allowed in &global_scope.allow {
774 allow
775 .push(Arc::new(T::deserialize(app, allowed.clone()).map_err(
776 |e| crate::Error::CannotDeserializeScope(Box::new(e)),
777 )?));
778 }
779 for denied in &global_scope.deny {
780 deny
781 .push(Arc::new(T::deserialize(app, denied.clone()).map_err(
782 |e| crate::Error::CannotDeserializeScope(Box::new(e)),
783 )?));
784 }
785 }
786
787 let scope = ScopeValue {
788 allow: Arc::new(allow),
789 deny: Arc::new(deny),
790 };
791 self.global_scope_cache.set(scope.clone());
792 Ok(scope)
793 }
794 }
795 }
796
797 fn get_command_scope_typed<R: Runtime, T: ScopeObject>(
798 &self,
799 app: &AppHandle<R>,
800 key: &ScopeKey,
801 ) -> crate::Result<ScopeValue<T>> {
802 let cache = self.command_cache.get(key).unwrap();
803 match cache.try_get::<ScopeValue<T>>() {
804 Some(cached) => Ok((*cached).clone()),
805 None => {
806 let resolved_scope = self
807 .command_scope
808 .get(key)
809 .unwrap_or_else(|| panic!("missing command scope for key {key}"));
810
811 let mut allow = Vec::new();
812 let mut deny = Vec::new();
813
814 for allowed in &resolved_scope.allow {
815 allow
816 .push(Arc::new(T::deserialize(app, allowed.clone()).map_err(
817 |e| crate::Error::CannotDeserializeScope(Box::new(e)),
818 )?));
819 }
820 for denied in &resolved_scope.deny {
821 deny
822 .push(Arc::new(T::deserialize(app, denied.clone()).map_err(
823 |e| crate::Error::CannotDeserializeScope(Box::new(e)),
824 )?));
825 }
826
827 let value = ScopeValue {
828 allow: Arc::new(allow),
829 deny: Arc::new(deny),
830 };
831
832 let _ = cache.set(value.clone());
833 Ok(value)
834 }
835 }
836 }
837}
838
839#[cfg(test)]
840mod tests {
841 use glob::Pattern;
842 use tauri_utils::acl::{
843 ExecutionContext,
844 resolved::{Resolved, ResolvedCommand},
845 };
846
847 use crate::ipc::Origin;
848
849 use super::RuntimeAuthority;
850
851 #[test]
852 fn window_glob_pattern_matches() {
853 let command = "my-command";
854 let window = "main-*";
855 let webview = "other-*";
856
857 let resolved_cmd = vec![ResolvedCommand {
858 windows: vec![Pattern::new(window).unwrap()],
859 ..Default::default()
860 }];
861 let allowed_commands = [(command.to_string(), resolved_cmd.clone())]
862 .into_iter()
863 .collect();
864
865 let authority = RuntimeAuthority::new(
866 Default::default(),
867 Resolved {
868 allowed_commands,
869 ..Default::default()
870 },
871 );
872
873 assert_eq!(
874 authority.resolve_access(
875 command,
876 &window.replace('*', "something"),
877 webview,
878 &Origin::Local
879 ),
880 Some(resolved_cmd)
881 );
882 }
883
884 #[test]
885 fn webview_glob_pattern_matches() {
886 let command = "my-command";
887 let window = "other-*";
888 let webview = "main-*";
889
890 let resolved_cmd = vec![ResolvedCommand {
891 windows: vec![Pattern::new(window).unwrap()],
892 webviews: vec![Pattern::new(webview).unwrap()],
893 ..Default::default()
894 }];
895 let allowed_commands = [(command.to_string(), resolved_cmd.clone())]
896 .into_iter()
897 .collect();
898
899 let authority = RuntimeAuthority::new(
900 Default::default(),
901 Resolved {
902 allowed_commands,
903 ..Default::default()
904 },
905 );
906
907 assert_eq!(
908 authority.resolve_access(
909 command,
910 window,
911 &webview.replace('*', "something"),
912 &Origin::Local
913 ),
914 Some(resolved_cmd)
915 );
916 }
917
918 #[test]
919 fn remote_domain_matches() {
920 let url = "https://tauri.app";
921 let command = "my-command";
922 let window = "main";
923 let webview = "main";
924
925 let resolved_cmd = vec![ResolvedCommand {
926 windows: vec![Pattern::new(window).unwrap()],
927 context: ExecutionContext::Remote {
928 url: url.parse().unwrap(),
929 },
930 ..Default::default()
931 }];
932 let allowed_commands = [(command.to_string(), resolved_cmd.clone())]
933 .into_iter()
934 .collect();
935
936 let authority = RuntimeAuthority::new(
937 Default::default(),
938 Resolved {
939 allowed_commands,
940 ..Default::default()
941 },
942 );
943
944 assert_eq!(
945 authority.resolve_access(
946 command,
947 window,
948 webview,
949 &Origin::Remote {
950 url: url.parse().unwrap()
951 }
952 ),
953 Some(resolved_cmd)
954 );
955 }
956
957 #[test]
958 fn remote_domain_glob_pattern_matches() {
959 let url = "http://tauri.*";
960 let command = "my-command";
961 let window = "main";
962 let webview = "main";
963
964 let resolved_cmd = vec![ResolvedCommand {
965 windows: vec![Pattern::new(window).unwrap()],
966 context: ExecutionContext::Remote {
967 url: url.parse().unwrap(),
968 },
969 ..Default::default()
970 }];
971 let allowed_commands = [(command.to_string(), resolved_cmd.clone())]
972 .into_iter()
973 .collect();
974
975 let authority = RuntimeAuthority::new(
976 Default::default(),
977 Resolved {
978 allowed_commands,
979 ..Default::default()
980 },
981 );
982
983 assert_eq!(
984 authority.resolve_access(
985 command,
986 window,
987 webview,
988 &Origin::Remote {
989 url: url.replace('*', "studio").parse().unwrap()
990 }
991 ),
992 Some(resolved_cmd)
993 );
994 }
995
996 #[test]
997 fn remote_context_denied() {
998 let command = "my-command";
999 let window = "main";
1000 let webview = "main";
1001
1002 let resolved_cmd = vec![ResolvedCommand {
1003 windows: vec![Pattern::new(window).unwrap()],
1004 ..Default::default()
1005 }];
1006 let allowed_commands = [(command.to_string(), resolved_cmd)].into_iter().collect();
1007
1008 let authority = RuntimeAuthority::new(
1009 Default::default(),
1010 Resolved {
1011 allowed_commands,
1012 ..Default::default()
1013 },
1014 );
1015
1016 assert!(
1017 authority
1018 .resolve_access(
1019 command,
1020 window,
1021 webview,
1022 &Origin::Remote {
1023 url: "https://tauri.app".parse().unwrap()
1024 }
1025 )
1026 .is_none()
1027 );
1028 }
1029
1030 #[test]
1031 fn denied_command_takes_precedence() {
1032 let command = "my-command";
1033 let window = "main";
1034 let webview = "main";
1035 let windows = vec![Pattern::new(window).unwrap()];
1036 let allowed_commands = [(
1037 command.to_string(),
1038 vec![ResolvedCommand {
1039 windows: windows.clone(),
1040 ..Default::default()
1041 }],
1042 )]
1043 .into_iter()
1044 .collect();
1045 let denied_commands = [(
1046 command.to_string(),
1047 vec![ResolvedCommand {
1048 windows,
1049 ..Default::default()
1050 }],
1051 )]
1052 .into_iter()
1053 .collect();
1054
1055 let authority = RuntimeAuthority::new(
1056 Default::default(),
1057 Resolved {
1058 allowed_commands,
1059 denied_commands,
1060 ..Default::default()
1061 },
1062 );
1063
1064 assert!(
1065 authority
1066 .resolve_access(command, window, webview, &Origin::Local)
1067 .is_none()
1068 );
1069 }
1070
1071 #[test]
1072 fn denied_command_is_scoped_to_the_execution_context() {
1073 let url = "https://tauri.app";
1074 let command = "my-command";
1075 let window = "main";
1076 let webview = "main";
1077 let windows = vec![Pattern::new(window).unwrap()];
1078
1079 let allowed_commands = [(
1081 command.to_string(),
1082 vec![
1083 ResolvedCommand {
1084 windows: windows.clone(),
1085 ..Default::default()
1086 },
1087 ResolvedCommand {
1088 windows: windows.clone(),
1089 context: ExecutionContext::Remote {
1090 url: url.parse().unwrap(),
1091 },
1092 ..Default::default()
1093 },
1094 ],
1095 )]
1096 .into_iter()
1097 .collect();
1098
1099 let denied_commands = [(
1101 command.to_string(),
1102 vec![ResolvedCommand {
1103 windows,
1104 context: ExecutionContext::Remote {
1105 url: url.parse().unwrap(),
1106 },
1107 ..Default::default()
1108 }],
1109 )]
1110 .into_iter()
1111 .collect();
1112
1113 let authority = RuntimeAuthority::new(
1114 Default::default(),
1115 Resolved {
1116 allowed_commands,
1117 denied_commands,
1118 ..Default::default()
1119 },
1120 );
1121
1122 assert!(
1124 authority
1125 .resolve_access(
1126 command,
1127 window,
1128 webview,
1129 &Origin::Remote {
1130 url: url.parse().unwrap()
1131 }
1132 )
1133 .is_none()
1134 );
1135
1136 let resolved = authority
1138 .resolve_access(command, window, webview, &Origin::Local)
1139 .expect("local origin must not be denied by a remote capability");
1140 assert_eq!(resolved.len(), 1);
1141 assert_eq!(resolved[0].context, ExecutionContext::Local);
1142 }
1143
1144 #[test]
1145 fn denied_command_on_local_context_does_not_deny_remote() {
1146 let url = "https://tauri.app";
1147 let command = "my-command";
1148 let window = "main";
1149 let webview = "main";
1150 let windows = vec![Pattern::new(window).unwrap()];
1151
1152 let allowed_commands = [(
1154 command.to_string(),
1155 vec![
1156 ResolvedCommand {
1157 windows: windows.clone(),
1158 ..Default::default()
1159 },
1160 ResolvedCommand {
1161 windows: windows.clone(),
1162 context: ExecutionContext::Remote {
1163 url: url.parse().unwrap(),
1164 },
1165 ..Default::default()
1166 },
1167 ],
1168 )]
1169 .into_iter()
1170 .collect();
1171
1172 let denied_commands = [(
1174 command.to_string(),
1175 vec![ResolvedCommand {
1176 windows,
1177 ..Default::default()
1178 }],
1179 )]
1180 .into_iter()
1181 .collect();
1182
1183 let authority = RuntimeAuthority::new(
1184 Default::default(),
1185 Resolved {
1186 allowed_commands,
1187 denied_commands,
1188 ..Default::default()
1189 },
1190 );
1191
1192 let resolved = authority
1194 .resolve_access(
1195 command,
1196 window,
1197 webview,
1198 &Origin::Remote {
1199 url: url.parse().unwrap(),
1200 },
1201 )
1202 .expect("remote origin must not be denied by a local capability");
1203 assert_eq!(resolved.len(), 1);
1204 assert_eq!(
1205 resolved[0].context,
1206 ExecutionContext::Remote {
1207 url: url.parse().unwrap()
1208 }
1209 );
1210
1211 assert!(
1213 authority
1214 .resolve_access(command, window, webview, &Origin::Local)
1215 .is_none()
1216 );
1217 }
1218
1219 #[cfg(debug_assertions)]
1220 #[test]
1221 fn resolve_access_message_denied_on_origin() {
1222 use tauri_utils::acl::resolved::ResolvedCommandReference;
1223
1224 let plugin_name = "myplugin";
1225 let command_name = "my-command";
1226 let command = format!("plugin:{plugin_name}|{command_name}");
1227 let window = "main";
1228 let webview = "main";
1229 let remote_url = "http://localhost:8080";
1230 let windows = vec![Pattern::new(window).unwrap()];
1231
1232 let remote_context = ExecutionContext::Remote {
1233 url: remote_url.parse().unwrap(),
1234 };
1235
1236 let allowed_commands = [(
1237 command.clone(),
1238 vec![
1239 ResolvedCommand {
1240 windows: windows.clone(),
1241 referenced_by: ResolvedCommandReference {
1242 capability: "maincap".to_string(),
1243 permission: "allow-command".to_string(),
1244 },
1245 ..Default::default()
1246 },
1247 ResolvedCommand {
1248 windows: windows.clone(),
1249 context: remote_context.clone(),
1250 referenced_by: ResolvedCommandReference {
1251 capability: "remotecap".to_string(),
1252 permission: "allow-command".to_string(),
1253 },
1254 ..Default::default()
1255 },
1256 ],
1257 )]
1258 .into_iter()
1259 .collect();
1260
1261 let denied_commands = [(
1263 command,
1264 vec![
1265 ResolvedCommand {
1266 windows: windows.clone(),
1267 referenced_by: ResolvedCommandReference {
1268 capability: "localcap".to_string(),
1269 permission: "deny-command".to_string(),
1270 },
1271 ..Default::default()
1272 },
1273 ResolvedCommand {
1274 windows,
1275 context: remote_context,
1276 referenced_by: ResolvedCommandReference {
1277 capability: "remotecap".to_string(),
1278 permission: "deny-command".to_string(),
1279 },
1280 ..Default::default()
1281 },
1282 ],
1283 )]
1284 .into_iter()
1285 .collect();
1286
1287 let authority = RuntimeAuthority::new(
1288 Default::default(),
1289 Resolved {
1290 allowed_commands,
1291 denied_commands,
1292 ..Default::default()
1293 },
1294 );
1295
1296 assert_eq!(
1298 authority.resolve_access_message(plugin_name, command_name, window, webview, &Origin::Local),
1299 "myplugin.my-command explicitly denied on origin local\n\nreferenced by: capability: localcap, permission: deny-command"
1300 );
1301
1302 assert_eq!(
1304 authority.resolve_access_message(
1305 plugin_name,
1306 command_name,
1307 window,
1308 webview,
1309 &Origin::Remote {
1310 url: remote_url.parse().unwrap()
1311 }
1312 ),
1313 "myplugin.my-command explicitly denied on origin remote: http://localhost:8080/\n\nreferenced by: capability: remotecap, permission: deny-command"
1314 );
1315
1316 let message = authority.resolve_access_message(
1318 plugin_name,
1319 command_name,
1320 window,
1321 webview,
1322 &Origin::Remote {
1323 url: "http://localhost:123".parse().unwrap(),
1324 },
1325 );
1326 assert!(
1327 !message.contains("explicitly denied"),
1328 "unexpected message: {message}"
1329 );
1330 assert!(
1331 message.starts_with("myplugin.my-command not allowed"),
1332 "unexpected message: {message}"
1333 );
1334 }
1335
1336 #[cfg(debug_assertions)]
1337 #[test]
1338 fn resolve_access_message() {
1339 use tauri_utils::acl::manifest::Manifest;
1340
1341 let plugin_name = "myplugin";
1342 let command_allowed_on_window = "my-command-window";
1343 let command_allowed_on_webview_window = "my-command-webview-window";
1344 let window = "main-*";
1345 let webview = "webview-*";
1346 let remote_url = "http://localhost:8080";
1347
1348 let referenced_by = tauri_utils::acl::resolved::ResolvedCommandReference {
1349 capability: "maincap".to_string(),
1350 permission: "allow-command".to_string(),
1351 };
1352
1353 let resolved_window_cmd = ResolvedCommand {
1354 windows: vec![Pattern::new(window).unwrap()],
1355 referenced_by: referenced_by.clone(),
1356 ..Default::default()
1357 };
1358 let resolved_webview_window_cmd = ResolvedCommand {
1359 windows: vec![Pattern::new(window).unwrap()],
1360 webviews: vec![Pattern::new(webview).unwrap()],
1361 referenced_by: referenced_by.clone(),
1362 ..Default::default()
1363 };
1364 let resolved_webview_window_remote_cmd = ResolvedCommand {
1365 windows: vec![Pattern::new(window).unwrap()],
1366 webviews: vec![Pattern::new(webview).unwrap()],
1367 referenced_by,
1368 context: ExecutionContext::Remote {
1369 url: remote_url.parse().unwrap(),
1370 },
1371 ..Default::default()
1372 };
1373
1374 let allowed_commands = [
1375 (
1376 format!("plugin:{plugin_name}|{command_allowed_on_window}"),
1377 vec![resolved_window_cmd],
1378 ),
1379 (
1380 format!("plugin:{plugin_name}|{command_allowed_on_webview_window}"),
1381 vec![
1382 resolved_webview_window_cmd,
1383 resolved_webview_window_remote_cmd,
1384 ],
1385 ),
1386 ]
1387 .into_iter()
1388 .collect();
1389
1390 let authority = RuntimeAuthority::new(
1391 [(
1392 plugin_name.to_string(),
1393 Manifest {
1394 default_permission: None,
1395 permissions: Default::default(),
1396 permission_sets: Default::default(),
1397 global_scope_schema: None,
1398 },
1399 )]
1400 .into_iter()
1401 .collect(),
1402 Resolved {
1403 allowed_commands,
1404 ..Default::default()
1405 },
1406 );
1407
1408 assert_eq!(
1410 authority.resolve_access_message(
1411 "unknown-plugin",
1412 command_allowed_on_window,
1413 window,
1414 webview,
1415 &Origin::Local
1416 ),
1417 "unknown-plugin.my-command-window not allowed. Plugin not found"
1418 );
1419
1420 assert_eq!(
1422 authority.resolve_access_message(
1423 plugin_name,
1424 "unknown-command",
1425 window,
1426 webview,
1427 &Origin::Local
1428 ),
1429 "myplugin.unknown-command not allowed. Command not found"
1430 );
1431
1432 assert_eq!(
1434 authority.resolve_access_message(
1435 plugin_name,
1436 command_allowed_on_window,
1437 "other-window",
1438 "any-webview",
1439 &Origin::Local
1440 ),
1441 "myplugin.my-command-window not allowed on window \"other-window\", webview \"any-webview\", URL: local\n\nallowed on: [windows: \"main-*\", URL: local]\n\nreferenced by: capability: maincap, permission: allow-command"
1442 );
1443
1444 assert_eq!(
1446 authority.resolve_access_message(
1447 plugin_name,
1448 command_allowed_on_window,
1449 window,
1450 "any-webview",
1451 &Origin::Remote {
1452 url: "http://localhst".parse().unwrap()
1453 }
1454 ),
1455 "myplugin.my-command-window not allowed on window \"main-*\", webview \"any-webview\", URL: http://localhst/\n\nallowed on: [windows: \"main-*\", URL: local]\n\nreferenced by: capability: maincap, permission: allow-command"
1456 );
1457
1458 assert_eq!(
1460 authority.resolve_access_message(
1461 plugin_name,
1462 command_allowed_on_webview_window,
1463 "other-window",
1464 "other-webview",
1465 &Origin::Local
1466 ),
1467 "myplugin.my-command-webview-window not allowed on window \"other-window\", webview \"other-webview\", URL: local\n\nallowed on: [windows: \"main-*\", webviews: \"webview-*\", URL: local], [windows: \"main-*\", webviews: \"webview-*\", URL: http://localhost:8080]\n\nreferenced by: capability: maincap, permission: allow-command || capability: maincap, permission: allow-command"
1468 );
1469
1470 assert_eq!(
1472 authority.resolve_access_message(
1473 plugin_name,
1474 command_allowed_on_webview_window,
1475 window,
1476 webview,
1477 &Origin::Remote {
1478 url: "http://localhost:123".parse().unwrap()
1479 }
1480 ),
1481 "myplugin.my-command-webview-window not allowed on window \"main-*\", webview \"webview-*\", URL: http://localhost:123/\n\nallowed on: [windows: \"main-*\", webviews: \"webview-*\", URL: local], [windows: \"main-*\", webviews: \"webview-*\", URL: http://localhost:8080]\n\nreferenced by: capability: maincap, permission: allow-command || capability: maincap, permission: allow-command"
1482 );
1483 }
1484
1485 #[cfg(feature = "dynamic-acl")]
1486 #[test]
1487 fn add_capability_assigns_fresh_scope_ids() {
1488 use serde_json::json;
1489 use std::collections::{BTreeMap, BTreeSet};
1490 use tauri_utils::acl::{
1491 Permission,
1492 capability::{Capability, CapabilityFile},
1493 manifest::Manifest,
1494 };
1495
1496 fn manifest(permission: serde_json::Value) -> Manifest {
1497 let permission: Permission = serde_json::from_value(permission).unwrap();
1498 Manifest {
1499 permissions: [(permission.identifier.clone(), permission)].into(),
1500 ..Default::default()
1501 }
1502 }
1503
1504 fn capability(identifier: &str, permission: &str) -> Capability {
1505 serde_json::from_value(json!({
1506 "identifier": identifier,
1507 "windows": ["main"],
1508 "permissions": [permission]
1509 }))
1510 .unwrap()
1511 }
1512
1513 fn scope_ids(authority: &RuntimeAuthority, command: &str) -> BTreeSet<u64> {
1514 authority.allowed_commands[command]
1515 .iter()
1516 .filter_map(|cmd| cmd.scope_id)
1517 .collect()
1518 }
1519
1520 fn allowed_scope(authority: &RuntimeAuthority, command: &str) -> Vec<serde_json::Value> {
1522 scope_ids(authority, command)
1523 .iter()
1524 .flat_map(|id| &authority.scope_manager.command_scope[id].allow)
1525 .cloned()
1526 .map(serde_json::Value::from)
1527 .collect()
1528 }
1529
1530 let acl: BTreeMap<String, Manifest> = [
1531 (
1532 "opener".to_string(),
1533 manifest(json!({
1534 "identifier": "allow-open-path",
1535 "commands": { "allow": ["open_path"] },
1536 "scope": { "allow": [{ "path": "/tmp/**" }] }
1537 })),
1538 ),
1539 (
1540 "http".to_string(),
1541 manifest(json!({
1542 "identifier": "allow-fetch",
1543 "commands": { "allow": ["fetch", "fetch_send"] },
1544 "scope": { "allow": [{ "url": "https://example.com" }] }
1545 })),
1546 ),
1547 ]
1548 .into();
1549
1550 let baked = Resolved::resolve(
1552 &acl,
1553 [(
1554 "baked".to_string(),
1555 capability("baked", "opener:allow-open-path"),
1556 )]
1557 .into(),
1558 tauri_utils::platform::Target::current(),
1559 )
1560 .unwrap();
1561 let mut authority = RuntimeAuthority::new(acl, baked);
1562 assert_eq!(scope_ids(&authority, "plugin:opener|open_path"), [1].into());
1563
1564 authority
1566 .add_capability_inner(CapabilityFile::Capability(capability(
1567 "runtime",
1568 "http:allow-fetch",
1569 )))
1570 .unwrap();
1571
1572 assert_eq!(
1573 authority
1574 .scope_manager
1575 .command_scope
1576 .keys()
1577 .copied()
1578 .collect::<Vec<_>>(),
1579 vec![1, 2]
1580 );
1581 assert!(authority.scope_manager.command_cache.contains_key(&2));
1582 assert_eq!(
1583 allowed_scope(&authority, "plugin:opener|open_path"),
1584 vec![json!({ "path": "/tmp/**" })]
1585 );
1586 assert_eq!(scope_ids(&authority, "plugin:http|fetch"), [2].into());
1588 assert_eq!(scope_ids(&authority, "plugin:http|fetch_send"), [2].into());
1589 assert_eq!(
1590 allowed_scope(&authority, "plugin:http|fetch"),
1591 vec![json!({ "url": "https://example.com" })]
1592 );
1593
1594 authority
1596 .add_capability_inner(CapabilityFile::Capability(capability(
1597 "runtime-2",
1598 "opener:allow-open-path",
1599 )))
1600 .unwrap();
1601 assert_eq!(
1602 authority
1603 .scope_manager
1604 .command_scope
1605 .keys()
1606 .copied()
1607 .collect::<Vec<_>>(),
1608 vec![1, 2, 3]
1609 );
1610 assert!(authority.scope_manager.command_cache.contains_key(&3));
1611 assert_eq!(
1612 scope_ids(&authority, "plugin:opener|open_path"),
1613 [1, 3].into()
1614 );
1615 assert_eq!(scope_ids(&authority, "plugin:http|fetch"), [2].into());
1616 }
1617}