Skip to main content

tauri/ipc/
authority.rs

1// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5use 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
27/// The runtime authority used to authorize IPC execution based on the Access Control List.
28pub 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
37/// The origin trying to access the IPC.
38pub enum Origin {
39  /// Local app origin.
40  Local,
41  /// Remote origin.
42  Remote {
43    /// Remote URL.
44    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/// This is used internally by [`crate::generate_handler!`] for constructing [`RuntimeAuthority`]
70/// to only include the raw ACL when it's needed
71///
72/// ## Stability
73///
74/// The output of this macro is managed internally by Tauri,
75/// and should not be accessed directly on normal applications.
76/// It may have breaking changes in the future.
77#[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/// This is used internally by [`crate::generate_handler!`] for constructing [`RuntimeAuthority`]
87/// to only include the raw ACL when it's needed
88///
89/// ## Stability
90///
91/// The output of this macro is managed internally by Tauri,
92/// and should not be accessed directly on normal applications.
93/// It may have breaking changes in the future.
94#[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  /// Construct a new [`RuntimeAuthority`] from the ACL
105  ///
106  /// **Please prefer using the [`runtime_authority`] macro instead of calling this directly**
107  #[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  /// Adds the given capability to the runtime authority.
149  #[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    // fill global scope
183    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    // fill command scope
193    // the scope ids were assigned past the existing ones, so these are all new entries
194    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    // denied commands
207    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    // allowed commands
213    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  /// Returns the deny entries of the given command whose execution context matches the given origin.
437  ///
438  /// Deny entries carry the [`ExecutionContext`] of the capability that defined them,
439  /// so a command denied by a remote capability is not denied for the local app and vice-versa.
440  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  /// Checks if the given command is explicitly denied for the given origin.
454  fn is_denied(&self, command: &str, origin: &Origin) -> bool {
455    self.denied_on(command, origin).next().is_some()
456  }
457
458  /// Checks if the given IPC execution is allowed and returns the [`ResolvedCommand`] if it is.
459  ///
460  /// A command is denied when a capability that denies it matches the given origin
461  /// (execution context), window and webview labels are not taken into account for denial.
462  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/// List of allowed and denied objects that match either the command-specific or plugin global scope criteria.
493#[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  /// What this access scope allows.
508  pub fn allows(&self) -> &Vec<Arc<T>> {
509    &self.allow
510  }
511
512  /// What this access scope denies.
513  pub fn denies(&self) -> &Vec<Arc<T>> {
514    &self.deny
515  }
516}
517
518/// Access scope for a command that can be retrieved directly in the command function.
519#[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  /// What this access scope allows.
554  pub fn allows(&self) -> &Vec<Arc<T>> {
555    &self.allow
556  }
557
558  /// What this access scope denies.
559  pub fn denies(&self) -> &Vec<Arc<T>> {
560    &self.deny
561  }
562}
563
564impl<T: ScopeObjectMatch> CommandScope<T> {
565  /// Ensure all deny scopes were not matched and any allow scopes were.
566  ///
567  /// This **WILL** return `true` if the allow scopes are empty and the deny
568  /// scopes did not trigger. If you require at least one allow scope, then
569  /// ensure the allow scopes are not empty before calling this method.
570  ///
571  /// ```
572  /// # use tauri::ipc::CommandScope;
573  /// # fn command(scope: CommandScope<()>) -> Result<(), &'static str> {
574  /// if scope.allows().is_empty() {
575  ///   return Err("you need to specify at least 1 allow scope!");
576  /// }
577  /// # Ok(())
578  /// # }
579  /// ```
580  ///
581  /// # Example
582  ///
583  /// ```
584  /// # use serde::{Serialize, Deserialize};
585  /// # use url::Url;
586  /// # use tauri::{ipc::{CommandScope, ScopeObjectMatch}, command};
587  /// #
588  /// #[derive(Debug, Clone, Serialize, Deserialize)]
589  /// # pub struct Scope;
590  /// #
591  /// # impl ScopeObjectMatch for Scope {
592  /// #   type Input = str;
593  /// #
594  /// #   fn matches(&self, input: &str) -> bool {
595  /// #     true
596  /// #   }
597  /// # }
598  /// #
599  /// # fn do_work(_: String) -> Result<String, &'static str> {
600  /// #   Ok("Output".into())
601  /// # }
602  /// #
603  /// #[command]
604  /// fn my_command(scope: CommandScope<Scope>, input: String) -> Result<String, &'static str> {
605  ///   if scope.matches(&input) {
606  ///     do_work(input)
607  ///   } else {
608  ///     Err("Scope didn't match input")
609  ///   }
610  /// }
611  /// ```
612  pub fn matches(&self, input: &T::Input) -> bool {
613    // first make sure the input doesn't match any existing deny scope
614    if self.deny.iter().any(|s| s.matches(input)) {
615      return false;
616    }
617
618    // if there are allow scopes, ensure the input matches at least 1
619    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  /// Grabs the [`ResolvedScope`] from the [`CommandItem`] and returns the associated [`CommandScope`].
629  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/// Global access scope that can be retrieved directly in the command function.
646#[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  /// What this access scope allows.
662  pub fn allows(&self) -> &Vec<Arc<T>> {
663    &self.0.allow
664  }
665
666  /// What this access scope denies.
667  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  /// Grabs the [`ResolvedScope`] from the [`CommandItem`] and returns the associated [`GlobalScope`].
674  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
691/// Marks a type as a scope object.
692///
693/// Usually you will just rely on [`serde::de::DeserializeOwned`] instead of implementing it manually,
694/// though this is useful if you need to do some initialization logic on the type itself.
695pub trait ScopeObject: Sized + Send + Sync + Debug + 'static {
696  /// The error type.
697  type Error: std::error::Error + Send + Sync;
698  /// Deserialize the raw scope value.
699  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
709/// A [`ScopeObject`] whose validation can be represented as a `bool`.
710///
711/// # Example
712///
713/// ```
714/// # use serde::{Deserialize, Serialize};
715/// # use tauri::{ipc::ScopeObjectMatch, Url};
716/// #
717/// #[derive(Debug, Clone, Serialize, Deserialize)]
718/// #[serde(rename_all = "camelCase")]
719/// pub enum Scope {
720///   Domain(Url),
721///   StartsWith(String),
722/// }
723///
724/// impl ScopeObjectMatch for Scope {
725///   type Input = str;
726///
727///   fn matches(&self, input: &str) -> bool {
728///     match self {
729///       Scope::Domain(url) => {
730///         let parsed: Url = match input.parse() {
731///           Ok(parsed) => parsed,
732///           Err(_) => return false,
733///         };
734///
735///         let domain = parsed.domain();
736///
737///         domain.is_some() && domain == url.domain()
738///       }
739///       Scope::StartsWith(start) => input.starts_with(start),
740///     }
741///   }
742/// }
743/// ```
744pub trait ScopeObjectMatch: ScopeObject {
745  /// The type of input expected to validate against the scope.
746  ///
747  /// This will be borrowed, so if you want to match on a `&str` this type should be `str`.
748  type Input: ?Sized;
749
750  /// Check if the input matches against the scope.
751  fn matches(&self, input: &Self::Input) -> bool;
752}
753
754impl ScopeManager {
755  /// The highest assigned command scope id, or `0` if there are none.
756  #[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    // the command is allowed on both the local app and the remote URL
1080    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    // but it is only denied on the remote URL
1100    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    // the remote origin the deny entry was defined for is denied
1123    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    // the local app must not be affected by a deny entry of a remote capability
1137    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    // the command is allowed on both the local app and the remote URL
1153    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    // but it is only denied on the local app
1173    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    // the remote origin must not be affected by a deny entry of a local capability
1193    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    // the local app is denied even though it has an allow entry
1212    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    // one capability denies the command locally, another denies it on the remote URL
1262    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    // only the capability denying the local context is referenced
1297    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    // only the capability denying the remote URL is referenced
1303    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    // a remote URL that matches no deny entry is not reported as explicitly denied
1317    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    // unknown plugin
1409    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    // unknown command
1421    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    // window/webview do not match
1433    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    // window matches, but not origin
1445    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    // window/webview do not match
1459    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    // window/webview matches, but not origin
1471    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    // the scope values the command resolves to, see `CommandScope::resolve`
1521    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    // build time ACL, the opener scope gets id 1
1551    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    // a runtime capability for another plugin must not be merged into scope 1
1565    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    // fetch and fetch_send share the scope, and it must not be duplicated
1587    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    // the next runtime capability continues past the previous one
1595    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}