Skip to main content

tauri_utils/acl/
resolved.rs

1// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5//! Resolved ACL for runtime usage.
6
7use std::{borrow::Cow, collections::BTreeMap, fmt};
8
9use crate::platform::Target;
10
11use super::{
12  APP_ACL_KEY, Commands, Error, ExecutionContext, Identifier, Permission, PermissionSet, Scopes,
13  Value,
14  capability::{Capability, PermissionEntry},
15  has_app_manifest,
16  manifest::Manifest,
17};
18
19/// A key for a scope, used to link a [`ResolvedCommand#structfield.scope`] to the store [`Resolved#structfield.scopes`].
20pub type ScopeKey = u64;
21
22// All the fields are marked with `#[cfg(debug_assertions)]` but not the struct itself is because
23// we want to avoid compilation errors on different `debug_assertions` settings,
24// see https://github.com/tauri-apps/tauri/issues/13865
25/// Metadata for what referenced a [`ResolvedCommand`].
26#[derive(Default, Clone, PartialEq, Eq)]
27pub struct ResolvedCommandReference {
28  /// Identifier of the capability.
29  #[cfg(debug_assertions)]
30  pub capability: String,
31  /// Identifier of the permission.
32  #[cfg(debug_assertions)]
33  pub permission: String,
34}
35
36impl ResolvedCommandReference {
37  /// Internal helper for tauri-macros to avoid compilation errors on different `debug_assertions` settings,
38  /// see https://github.com/tauri-apps/tauri/issues/13865
39  #[doc(hidden)]
40  pub fn new(
41    #[cfg_attr(not(debug_assertions), allow(unused))] capability: String,
42    #[cfg_attr(not(debug_assertions), allow(unused))] permission: String,
43  ) -> Self {
44    Self {
45      #[cfg(debug_assertions)]
46      capability,
47      #[cfg(debug_assertions)]
48      permission,
49    }
50  }
51}
52
53/// A resolved command permission.
54#[derive(Default, Clone, PartialEq, Eq)]
55pub struct ResolvedCommand {
56  /// The execution context of this command.
57  pub context: ExecutionContext,
58  /// The capability/permission that referenced this command.
59  #[cfg(debug_assertions)]
60  pub referenced_by: ResolvedCommandReference,
61  /// The list of window label patterns that was resolved for this command.
62  pub windows: Vec<glob::Pattern>,
63  /// The list of webview label patterns that was resolved for this command.
64  pub webviews: Vec<glob::Pattern>,
65  /// The reference of the scope that is associated with this command. See [`Resolved#structfield.command_scopes`].
66  pub scope_id: Option<ScopeKey>,
67}
68
69impl ResolvedCommand {
70  /// Internal helper for tauri-macros to avoid compilation errors on different `debug_assertions` settings,
71  /// see https://github.com/tauri-apps/tauri/issues/13865
72  #[doc(hidden)]
73  pub fn new(
74    context: ExecutionContext,
75    #[cfg_attr(not(debug_assertions), allow(unused))] referenced_by: ResolvedCommandReference,
76    windows: Vec<glob::Pattern>,
77    webviews: Vec<glob::Pattern>,
78    scope_id: Option<ScopeKey>,
79  ) -> Self {
80    Self {
81      context,
82      #[cfg(debug_assertions)]
83      referenced_by,
84      windows,
85      webviews,
86      scope_id,
87    }
88  }
89}
90
91impl fmt::Debug for ResolvedCommand {
92  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93    f.debug_struct("ResolvedCommand")
94      .field("context", &self.context)
95      .field("windows", &self.windows)
96      .field("webviews", &self.webviews)
97      .field("scope_id", &self.scope_id)
98      .finish()
99  }
100}
101
102/// A resolved scope. Merges all scopes defined for a single command.
103#[derive(Debug, Default, Clone)]
104pub struct ResolvedScope {
105  /// Allows something on the command.
106  pub allow: Vec<Value>,
107  /// Denies something on the command.
108  pub deny: Vec<Value>,
109}
110
111/// Resolved access control list.
112#[derive(Debug, Default)]
113pub struct Resolved {
114  /// If we should check the ACL for the app commands
115  pub has_app_acl: bool,
116  /// The commands that are allowed. Map each command with its context to a [`ResolvedCommand`].
117  pub allowed_commands: BTreeMap<String, Vec<ResolvedCommand>>,
118  /// The commands that are denied. Map each command with its context to a [`ResolvedCommand`].
119  pub denied_commands: BTreeMap<String, Vec<ResolvedCommand>>,
120  /// The store of scopes referenced by a [`ResolvedCommand`].
121  pub command_scope: BTreeMap<ScopeKey, ResolvedScope>,
122  /// The global scope.
123  pub global_scope: BTreeMap<String, ResolvedScope>,
124}
125
126impl Resolved {
127  /// Resolves the ACL for the given plugin permissions and app capabilities.
128  ///
129  /// Command scope ids are assigned sequentially starting from `1`.
130  /// See [`Self::resolve_with_base_scope_id`] to assign them past a different id.
131  // TODO: Take `base_scope_id` here and remove `resolve_with_base_scope_id` in v3,
132  // so that callers merging into an already resolved ACL cannot forget to offset the scope ids.
133  pub fn resolve(
134    acl: &BTreeMap<String, Manifest>,
135    capabilities: BTreeMap<String, Capability>,
136    target: Target,
137  ) -> Result<Self, Error> {
138    Self::resolve_with_base_scope_id(acl, capabilities, target, 0)
139  }
140
141  /// Resolves the ACL for the given plugin permissions and app capabilities,
142  /// assigning command scope ids sequentially after `base_scope_id` (starting from `base_scope_id + 1`).
143  ///
144  /// This is useful when the result is merged into an already resolved ACL:
145  /// pass its highest scope id so the new [`Self::command_scope`] keys do not collide.
146  pub fn resolve_with_base_scope_id(
147    acl: &BTreeMap<String, Manifest>,
148    mut capabilities: BTreeMap<String, Capability>,
149    target: Target,
150    base_scope_id: ScopeKey,
151  ) -> Result<Self, Error> {
152    let mut allowed_commands = BTreeMap::new();
153    let mut denied_commands = BTreeMap::new();
154
155    let mut current_scope_id = base_scope_id;
156    let mut command_scope = BTreeMap::new();
157    let mut global_scope: BTreeMap<String, Vec<Scopes>> = BTreeMap::new();
158
159    // resolve commands
160    for capability in capabilities.values_mut().filter(|c| c.is_active(&target)) {
161      with_resolved_permissions(
162        capability,
163        acl,
164        target,
165        |ResolvedPermission {
166           key,
167           commands,
168           scope,
169           #[cfg_attr(not(debug_assertions), allow(unused))]
170           permission_name,
171         }| {
172          if commands.allow.is_empty() && commands.deny.is_empty() {
173            // global scope
174            global_scope.entry(key).or_default().push(scope);
175          } else {
176            let scope_id = if scope.allow.is_some() || scope.deny.is_some() {
177              current_scope_id += 1;
178              command_scope.insert(
179                current_scope_id,
180                ResolvedScope {
181                  allow: scope.allow.unwrap_or_default(),
182                  deny: scope.deny.unwrap_or_default(),
183                },
184              );
185              Some(current_scope_id)
186            } else {
187              None
188            };
189
190            for allowed_command in &commands.allow {
191              resolve_command(
192                &mut allowed_commands,
193                if key == APP_ACL_KEY {
194                  allowed_command.to_string()
195                } else if let Some(core_plugin_name) = key.strip_prefix("core:") {
196                  format!("plugin:{core_plugin_name}|{allowed_command}")
197                } else {
198                  format!("plugin:{key}|{allowed_command}")
199                },
200                capability,
201                scope_id,
202                #[cfg(debug_assertions)]
203                permission_name,
204              )?;
205            }
206
207            for denied_command in &commands.deny {
208              resolve_command(
209                &mut denied_commands,
210                if key == APP_ACL_KEY {
211                  denied_command.to_string()
212                } else if let Some(core_plugin_name) = key.strip_prefix("core:") {
213                  format!("plugin:{core_plugin_name}|{denied_command}")
214                } else {
215                  format!("plugin:{key}|{denied_command}")
216                },
217                capability,
218                scope_id,
219                #[cfg(debug_assertions)]
220                permission_name,
221              )?;
222            }
223          }
224
225          Ok(())
226        },
227      )?;
228    }
229
230    let global_scope = global_scope
231      .into_iter()
232      .map(|(key, scopes)| {
233        let mut resolved_scope = ResolvedScope {
234          allow: Vec::new(),
235          deny: Vec::new(),
236        };
237        for scope in scopes {
238          if let Some(allow) = scope.allow {
239            resolved_scope.allow.extend(allow);
240          }
241          if let Some(deny) = scope.deny {
242            resolved_scope.deny.extend(deny);
243          }
244        }
245        (key, resolved_scope)
246      })
247      .collect();
248
249    let resolved = Self {
250      has_app_acl: has_app_manifest(acl),
251      allowed_commands,
252      denied_commands,
253      command_scope,
254      global_scope,
255    };
256
257    Ok(resolved)
258  }
259}
260
261fn parse_glob_patterns(mut raw: Vec<String>) -> Result<Vec<glob::Pattern>, Error> {
262  raw.sort();
263
264  let mut patterns = Vec::new();
265  for pattern in raw {
266    patterns.push(glob::Pattern::new(&pattern)?);
267  }
268
269  Ok(patterns)
270}
271
272fn resolve_command(
273  commands: &mut BTreeMap<String, Vec<ResolvedCommand>>,
274  command: String,
275  capability: &Capability,
276  scope_id: Option<ScopeKey>,
277  #[cfg(debug_assertions)] referenced_by_permission_identifier: &str,
278) -> Result<(), Error> {
279  let mut contexts = Vec::new();
280  if capability.local {
281    contexts.push(ExecutionContext::Local);
282  }
283  if let Some(remote) = &capability.remote {
284    contexts.extend(remote.urls.iter().map(|url| {
285      ExecutionContext::Remote {
286        url: url
287          .parse()
288          .unwrap_or_else(|e| panic!("invalid URL pattern for remote URL {url}: {e}")),
289      }
290    }));
291  }
292
293  for context in contexts {
294    let resolved_list = commands.entry(command.clone()).or_default();
295
296    resolved_list.push(ResolvedCommand {
297      context,
298      #[cfg(debug_assertions)]
299      referenced_by: ResolvedCommandReference {
300        capability: capability.identifier.clone(),
301        permission: referenced_by_permission_identifier.to_owned(),
302      },
303      windows: parse_glob_patterns(capability.windows.clone())?,
304      webviews: parse_glob_patterns(capability.webviews.clone())?,
305      scope_id,
306    });
307  }
308
309  Ok(())
310}
311
312struct ResolvedPermission<'a> {
313  key: String,
314  permission_name: &'a str,
315  commands: Commands,
316  scope: Scopes,
317}
318
319/// Iterate over permissions in a capability, resolving permission sets if necessary
320/// to produce a [`ResolvedPermission`] and calling the provided callback with it.
321fn with_resolved_permissions<F: FnMut(ResolvedPermission<'_>) -> Result<(), Error>>(
322  capability: &Capability,
323  acl: &BTreeMap<String, Manifest>,
324  target: Target,
325  mut f: F,
326) -> Result<(), Error> {
327  for permission_entry in &capability.permissions {
328    let permission_id = permission_entry.identifier();
329
330    let permissions = get_permissions(permission_id, acl)?
331      .into_iter()
332      .filter(|p| p.permission.is_active(&target));
333
334    for TraversedPermission {
335      key,
336      permission_name,
337      permission,
338    } in permissions
339    {
340      let mut resolved_scope = Scopes::default();
341      let mut commands = Commands::default();
342
343      if let PermissionEntry::ExtendedPermission {
344        identifier: _,
345        scope,
346      } = permission_entry
347      {
348        if let Some(allow) = scope.allow.clone() {
349          resolved_scope
350            .allow
351            .get_or_insert_with(Default::default)
352            .extend(allow);
353        }
354        if let Some(deny) = scope.deny.clone() {
355          resolved_scope
356            .deny
357            .get_or_insert_with(Default::default)
358            .extend(deny);
359        }
360      }
361
362      if let Some(allow) = permission.scope.allow.clone() {
363        resolved_scope
364          .allow
365          .get_or_insert_with(Default::default)
366          .extend(allow);
367      }
368      if let Some(deny) = permission.scope.deny.clone() {
369        resolved_scope
370          .deny
371          .get_or_insert_with(Default::default)
372          .extend(deny);
373      }
374
375      commands.allow.extend(permission.commands.allow.clone());
376      commands.deny.extend(permission.commands.deny.clone());
377
378      f(ResolvedPermission {
379        key,
380        permission_name: &permission_name,
381        commands,
382        scope: resolved_scope,
383      })?;
384    }
385  }
386
387  Ok(())
388}
389
390/// Traversed permission
391#[derive(Debug)]
392pub struct TraversedPermission<'a> {
393  /// Plugin name without the tauri-plugin- prefix
394  pub key: String,
395  /// Permission's name
396  pub permission_name: String,
397  /// Permission details.
398  ///
399  /// This is borrowed for permissions stored in the [`Manifest`], or owned for the `allow-$command`
400  /// and `deny-$command` permissions [materialized on demand](Manifest::command_permission).
401  pub permission: Cow<'a, Permission>,
402}
403
404/// Expand a permissions id based on the ACL to get the associated permissions (e.g. expand some-plugin:default)
405pub fn get_permissions<'a>(
406  permission_id: &Identifier,
407  acl: &'a BTreeMap<String, Manifest>,
408) -> Result<Vec<TraversedPermission<'a>>, Error> {
409  let key = permission_id.get_prefix().unwrap_or(APP_ACL_KEY);
410  let permission_name = permission_id.get_base();
411
412  let manifest = acl.get(key).ok_or_else(|| Error::UnknownManifest {
413    key: display_perm_key(key).to_string(),
414    available: acl.keys().cloned().collect::<Vec<_>>().join(", "),
415  })?;
416
417  if permission_name == "default" {
418    manifest
419      .default_permission
420      .as_ref()
421      .map(|default| get_permission_set_permissions(permission_id, acl, manifest, default))
422      .unwrap_or_else(|| Ok(Default::default()))
423  } else if let Some(set) = manifest.permission_sets.get(permission_name) {
424    get_permission_set_permissions(permission_id, acl, manifest, set)
425  } else if let Some(permission) = manifest.permissions.get(permission_name) {
426    Ok(vec![TraversedPermission {
427      key: key.to_string(),
428      permission_name: permission_name.to_string(),
429      permission: Cow::Borrowed(permission),
430    }])
431  } else if let Some(permission) = manifest.command_permission(permission_name, key == APP_ACL_KEY)
432  {
433    Ok(vec![TraversedPermission {
434      key: key.to_string(),
435      permission_name: permission_name.to_string(),
436      permission: Cow::Owned(permission),
437    }])
438  } else {
439    Err(Error::UnknownPermission {
440      key: display_perm_key(key).to_string(),
441      permission: permission_name.to_string(),
442    })
443  }
444}
445
446// get the permissions from a permission set
447fn get_permission_set_permissions<'a>(
448  permission_id: &Identifier,
449  acl: &'a BTreeMap<String, Manifest>,
450  manifest: &'a Manifest,
451  set: &'a PermissionSet,
452) -> Result<Vec<TraversedPermission<'a>>, Error> {
453  let key = permission_id.get_prefix().unwrap_or(APP_ACL_KEY);
454
455  let mut permissions = Vec::new();
456
457  for perm in &set.permissions {
458    // a set could include permissions from other plugins
459    // for example `dialog:default`, could include `fs:default`
460    // in this case `perm = "fs:default"` which is not a permission
461    // in the dialog manifest so we check if `perm` still have a prefix (i.e `fs:`)
462    // and if so, we resolve this prefix from `acl` first before proceeding
463    let id = Identifier::try_from(perm.clone()).expect("invalid identifier in permission set?");
464    let (manifest, permission_id, key, permission_name) =
465      if let Some((new_key, manifest)) = id.get_prefix().and_then(|k| acl.get(k).map(|m| (k, m))) {
466        (manifest, &id, new_key, id.get_base())
467      } else {
468        (manifest, permission_id, key, perm.as_str())
469      };
470
471    if permission_name == "default" {
472      permissions.extend(
473        manifest
474          .default_permission
475          .as_ref()
476          .map(|default| get_permission_set_permissions(permission_id, acl, manifest, default))
477          .transpose()?
478          .unwrap_or_default(),
479      );
480    } else if let Some(permission) = manifest.permissions.get(permission_name) {
481      permissions.push(TraversedPermission {
482        key: key.to_string(),
483        permission_name: permission_name.to_string(),
484        permission: Cow::Borrowed(permission),
485      });
486    } else if let Some(permission_set) = manifest.permission_sets.get(permission_name) {
487      permissions.extend(get_permission_set_permissions(
488        permission_id,
489        acl,
490        manifest,
491        permission_set,
492      )?);
493    } else if let Some(permission) =
494      manifest.command_permission(permission_name, key == APP_ACL_KEY)
495    {
496      permissions.push(TraversedPermission {
497        key: key.to_string(),
498        permission_name: permission_name.to_string(),
499        permission: Cow::Owned(permission),
500      });
501    } else {
502      return Err(Error::SetPermissionNotFound {
503        permission: permission_name.to_string(),
504        set: set.identifier.clone(),
505      });
506    }
507  }
508
509  Ok(permissions)
510}
511
512#[inline]
513fn display_perm_key(prefix: &str) -> &str {
514  if prefix == APP_ACL_KEY {
515    "app manifest"
516  } else {
517    prefix
518  }
519}
520
521#[cfg(any(feature = "build", feature = "build-2"))]
522mod build {
523  use proc_macro2::TokenStream;
524  use quote::{ToTokens, TokenStreamExt, quote};
525  use std::convert::identity;
526
527  use super::*;
528  use crate::{literal_struct, tokens::*};
529
530  #[cfg(debug_assertions)]
531  impl ToTokens for ResolvedCommandReference {
532    fn to_tokens(&self, tokens: &mut TokenStream) {
533      let capability = str_lit(&self.capability);
534      let permission = str_lit(&self.permission);
535      tokens.append_all(quote! {
536        ::tauri::utils::acl::resolved::ResolvedCommandReference::new(#capability, #permission)
537      });
538    }
539  }
540
541  impl ToTokens for ResolvedCommand {
542    fn to_tokens(&self, tokens: &mut TokenStream) {
543      #[cfg(debug_assertions)]
544      let referenced_by = &self.referenced_by;
545      #[cfg(not(debug_assertions))]
546      let referenced_by =
547        quote!(::tauri::utils::acl::resolved::ResolvedCommandReference::default());
548
549      let context = &self.context;
550
551      let windows = vec_lit(&self.windows, |window| {
552        let w = window.as_str();
553        quote!(#w.parse().unwrap())
554      });
555      let webviews = vec_lit(&self.webviews, |window| {
556        let w = window.as_str();
557        quote!(#w.parse().unwrap())
558      });
559      let scope_id = opt_lit(self.scope_id.as_ref());
560
561      tokens.append_all(quote! {
562        ::tauri::utils::acl::resolved::ResolvedCommand::new(
563          #context,
564          #referenced_by,
565          #windows,
566          #webviews,
567          #scope_id
568        )
569      })
570    }
571  }
572
573  impl ToTokens for ResolvedScope {
574    fn to_tokens(&self, tokens: &mut TokenStream) {
575      let allow = vec_lit(&self.allow, identity);
576      let deny = vec_lit(&self.deny, identity);
577      literal_struct!(
578        tokens,
579        ::tauri::utils::acl::resolved::ResolvedScope,
580        allow,
581        deny
582      )
583    }
584  }
585
586  impl ToTokens for Resolved {
587    fn to_tokens(&self, tokens: &mut TokenStream) {
588      let has_app_acl = self.has_app_acl;
589
590      let allowed_commands = map_lit(
591        quote! { ::std::collections::BTreeMap },
592        &self.allowed_commands,
593        str_lit,
594        |v| vec_lit(v, identity),
595      );
596
597      let denied_commands = map_lit(
598        quote! { ::std::collections::BTreeMap },
599        &self.denied_commands,
600        str_lit,
601        |v| vec_lit(v, identity),
602      );
603
604      let command_scope = map_lit(
605        quote! { ::std::collections::BTreeMap },
606        &self.command_scope,
607        identity,
608        identity,
609      );
610
611      let global_scope = map_lit(
612        quote! { ::std::collections::BTreeMap },
613        &self.global_scope,
614        str_lit,
615        identity,
616      );
617
618      literal_struct!(
619        tokens,
620        ::tauri::utils::acl::resolved::Resolved,
621        has_app_acl,
622        allowed_commands,
623        denied_commands,
624        command_scope,
625        global_scope
626      )
627    }
628  }
629}
630
631#[cfg(test)]
632mod tests {
633
634  use super::{
635    APP_ACL_KEY, Identifier, Manifest, Permission, PermissionSet, Resolved, get_permissions,
636  };
637  use crate::platform::Target;
638
639  fn manifest<const P: usize, const S: usize>(
640    name: &str,
641    permissions: [&str; P],
642    default_set: Option<&[&str]>,
643    sets: [(&str, &[&str]); S],
644  ) -> (String, Manifest) {
645    (
646      name.to_string(),
647      Manifest {
648        default_permission: default_set.map(|perms| PermissionSet {
649          identifier: "default".to_string(),
650          description: "default set".to_string(),
651          permissions: perms.iter().map(|s| s.to_string()).collect(),
652        }),
653        permissions: permissions
654          .iter()
655          .map(|p| {
656            (
657              p.to_string(),
658              Permission {
659                identifier: p.to_string(),
660                ..Default::default()
661              },
662            )
663          })
664          .collect(),
665        permission_sets: sets
666          .iter()
667          .map(|(s, perms)| {
668            (
669              s.to_string(),
670              PermissionSet {
671                identifier: s.to_string(),
672                description: format!("{s} set"),
673                permissions: perms.iter().map(|s| s.to_string()).collect(),
674              },
675            )
676          })
677          .collect(),
678        ..Default::default()
679      },
680    )
681  }
682
683  fn id(id: &str) -> Identifier {
684    Identifier::try_from(id.to_string()).unwrap()
685  }
686
687  #[test]
688  fn resolves_permissions_from_other_plugins() {
689    let acl = [
690      manifest(
691        "fs",
692        ["read", "write", "rm", "exist"],
693        Some(&["read", "exist"]),
694        [],
695      ),
696      manifest(
697        "http",
698        ["fetch", "fetch-cancel"],
699        None,
700        [("fetch-with-cancel", &["fetch", "fetch-cancel"])],
701      ),
702      manifest(
703        "dialog",
704        ["open", "save"],
705        None,
706        [(
707          "extra",
708          &[
709            "save",
710            "fs:default",
711            "fs:write",
712            "http:default",
713            "http:fetch-with-cancel",
714          ],
715        )],
716      ),
717    ]
718    .into();
719
720    let permissions = get_permissions(&id("fs:default"), &acl).unwrap();
721    assert_eq!(permissions.len(), 2);
722    assert_eq!(permissions[0].key, "fs");
723    assert_eq!(permissions[0].permission_name, "read");
724    assert_eq!(permissions[1].key, "fs");
725    assert_eq!(permissions[1].permission_name, "exist");
726
727    let permissions = get_permissions(&id("fs:rm"), &acl).unwrap();
728    assert_eq!(permissions.len(), 1);
729    assert_eq!(permissions[0].key, "fs");
730    assert_eq!(permissions[0].permission_name, "rm");
731
732    let permissions = get_permissions(&id("http:fetch-with-cancel"), &acl).unwrap();
733    assert_eq!(permissions.len(), 2);
734    assert_eq!(permissions[0].key, "http");
735    assert_eq!(permissions[0].permission_name, "fetch");
736    assert_eq!(permissions[1].key, "http");
737    assert_eq!(permissions[1].permission_name, "fetch-cancel");
738
739    let permissions = get_permissions(&id("dialog:extra"), &acl).unwrap();
740    assert_eq!(permissions.len(), 6);
741    assert_eq!(permissions[0].key, "dialog");
742    assert_eq!(permissions[0].permission_name, "save");
743    assert_eq!(permissions[1].key, "fs");
744    assert_eq!(permissions[1].permission_name, "read");
745    assert_eq!(permissions[2].key, "fs");
746    assert_eq!(permissions[2].permission_name, "exist");
747    assert_eq!(permissions[3].key, "fs");
748    assert_eq!(permissions[3].permission_name, "write");
749    assert_eq!(permissions[4].key, "http");
750    assert_eq!(permissions[4].permission_name, "fetch");
751    assert_eq!(permissions[5].key, "http");
752    assert_eq!(permissions[5].permission_name, "fetch-cancel");
753  }
754
755  fn manifest_with_commands(name: &str, commands: &[&str]) -> (String, Manifest) {
756    (
757      name.to_string(),
758      Manifest {
759        commands: commands.iter().map(|c| c.to_string()).collect(),
760        ..Default::default()
761      },
762    )
763  }
764
765  #[test]
766  fn resolves_implicit_command_permissions() {
767    let acl = [manifest_with_commands("fs", &["read_file", "write_file"])].into();
768
769    // `allow-$command` resolves to the command with the original (snake_case) name
770    let permissions = get_permissions(&id("fs:allow-read-file"), &acl).unwrap();
771    assert_eq!(permissions.len(), 1);
772    assert_eq!(permissions[0].key, "fs");
773    assert_eq!(permissions[0].permission.commands.allow, ["read_file"]);
774    assert!(permissions[0].permission.commands.deny.is_empty());
775
776    // `deny-$command` resolves to the deny side
777    let permissions = get_permissions(&id("fs:deny-write-file"), &acl).unwrap();
778    assert_eq!(permissions.len(), 1);
779    assert_eq!(permissions[0].permission.commands.deny, ["write_file"]);
780
781    // unknown command is still an error
782    assert!(get_permissions(&id("fs:allow-unknown"), &acl).is_err());
783  }
784
785  #[test]
786  fn resolves_wildcard_command_permission() {
787    let acl = [(
788      APP_ACL_KEY.to_string(),
789      manifest_with_commands("__app__", &["read_file", "write_file"]).1,
790    )]
791    .into();
792
793    // the wildcard resolves to a single `*` command instead of one entry per command
794    let permissions = get_permissions(&id("allow-*"), &acl).unwrap();
795    assert_eq!(permissions.len(), 1);
796    assert_eq!(permissions[0].permission.commands.allow, ["*"]);
797
798    let permissions = get_permissions(&id("deny-*"), &acl).unwrap();
799    assert_eq!(permissions.len(), 1);
800    assert_eq!(permissions[0].permission.commands.deny, ["*"]);
801
802    // the wildcard is only available for the app manifest, not for plugins
803    let plugin_acl = [manifest_with_commands("fs", &["read_file", "write_file"])].into();
804    assert!(get_permissions(&id("fs:allow-*"), &plugin_acl).is_err());
805    // but specific command permissions still work for plugins
806    assert!(get_permissions(&id("fs:allow-read-file"), &plugin_acl).is_ok());
807
808    // the wildcard is not a valid permission when the app manifest has no commands
809    let empty = [(APP_ACL_KEY.to_string(), manifest("__app__", [], None, []).1)].into();
810    assert!(get_permissions(&id("allow-*"), &empty).is_err());
811  }
812
813  #[test]
814  fn resolve_wildcard_uses_single_entry() {
815    use crate::acl::{Capability, capability::PermissionEntry};
816
817    let acl = [(
818      APP_ACL_KEY.to_string(),
819      Manifest {
820        commands: ["echo", "ping", "spam"]
821          .iter()
822          .map(|c| c.to_string())
823          .collect(),
824        ..Default::default()
825      },
826    )]
827    .into();
828
829    let capability = Capability {
830      identifier: "main".to_string(),
831      description: String::new(),
832      remote: None,
833      local: true,
834      windows: vec!["main".to_string()],
835      webviews: Vec::new(),
836      permissions: vec![PermissionEntry::PermissionRef(id("allow-*"))],
837      platforms: None,
838    };
839
840    let resolved = Resolved::resolve(
841      &acl,
842      [("main".to_string(), capability)].into(),
843      Target::Linux,
844    )
845    .unwrap();
846
847    // the whole app is allowed through a single resolved entry keyed by `*`
848    assert_eq!(resolved.allowed_commands.len(), 1);
849    assert!(resolved.allowed_commands.contains_key("*"));
850    assert!(resolved.denied_commands.is_empty());
851  }
852
853  #[test]
854  fn resolve_assigns_scope_ids_from_base() {
855    use super::{Capability, Resolved, Target};
856    use std::collections::BTreeMap;
857
858    let acl: BTreeMap<String, Manifest> = [(
859      "http".to_string(),
860      Manifest {
861        permissions: [(
862          "allow-fetch".to_string(),
863          serde_json::from_value::<Permission>(serde_json::json!({
864            "identifier": "allow-fetch",
865            "commands": { "allow": ["fetch"] },
866            "scope": { "allow": [{ "url": "https://example.com" }] }
867          }))
868          .unwrap(),
869        )]
870        .into(),
871        ..Default::default()
872      },
873    )]
874    .into();
875    let capabilities: BTreeMap<String, Capability> = [(
876      "main".to_string(),
877      serde_json::from_value(serde_json::json!({
878        "identifier": "main",
879        "windows": ["main"],
880        "permissions": ["http:allow-fetch"]
881      }))
882      .unwrap(),
883    )]
884    .into();
885
886    let resolved = Resolved::resolve(&acl, capabilities.clone(), Target::current()).unwrap();
887    assert_eq!(
888      resolved.command_scope.keys().copied().collect::<Vec<_>>(),
889      vec![1]
890    );
891    assert_eq!(
892      resolved.allowed_commands["plugin:http|fetch"][0].scope_id,
893      Some(1)
894    );
895
896    let resolved =
897      Resolved::resolve_with_base_scope_id(&acl, capabilities, Target::current(), 10).unwrap();
898    assert_eq!(
899      resolved.command_scope.keys().copied().collect::<Vec<_>>(),
900      vec![11]
901    );
902    assert_eq!(
903      resolved.allowed_commands["plugin:http|fetch"][0].scope_id,
904      Some(11)
905    );
906  }
907}