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  pub fn resolve(
129    acl: &BTreeMap<String, Manifest>,
130    mut capabilities: BTreeMap<String, Capability>,
131    target: Target,
132  ) -> Result<Self, Error> {
133    let mut allowed_commands = BTreeMap::new();
134    let mut denied_commands = BTreeMap::new();
135
136    let mut current_scope_id = 0;
137    let mut command_scope = BTreeMap::new();
138    let mut global_scope: BTreeMap<String, Vec<Scopes>> = BTreeMap::new();
139
140    // resolve commands
141    for capability in capabilities.values_mut().filter(|c| c.is_active(&target)) {
142      with_resolved_permissions(
143        capability,
144        acl,
145        target,
146        |ResolvedPermission {
147           key,
148           commands,
149           scope,
150           #[cfg_attr(not(debug_assertions), allow(unused))]
151           permission_name,
152         }| {
153          if commands.allow.is_empty() && commands.deny.is_empty() {
154            // global scope
155            global_scope.entry(key).or_default().push(scope);
156          } else {
157            let scope_id = if scope.allow.is_some() || scope.deny.is_some() {
158              current_scope_id += 1;
159              command_scope.insert(
160                current_scope_id,
161                ResolvedScope {
162                  allow: scope.allow.unwrap_or_default(),
163                  deny: scope.deny.unwrap_or_default(),
164                },
165              );
166              Some(current_scope_id)
167            } else {
168              None
169            };
170
171            for allowed_command in &commands.allow {
172              resolve_command(
173                &mut allowed_commands,
174                if key == APP_ACL_KEY {
175                  allowed_command.to_string()
176                } else if let Some(core_plugin_name) = key.strip_prefix("core:") {
177                  format!("plugin:{core_plugin_name}|{allowed_command}")
178                } else {
179                  format!("plugin:{key}|{allowed_command}")
180                },
181                capability,
182                scope_id,
183                #[cfg(debug_assertions)]
184                permission_name,
185              )?;
186            }
187
188            for denied_command in &commands.deny {
189              resolve_command(
190                &mut denied_commands,
191                if key == APP_ACL_KEY {
192                  denied_command.to_string()
193                } else if let Some(core_plugin_name) = key.strip_prefix("core:") {
194                  format!("plugin:{core_plugin_name}|{denied_command}")
195                } else {
196                  format!("plugin:{key}|{denied_command}")
197                },
198                capability,
199                scope_id,
200                #[cfg(debug_assertions)]
201                permission_name,
202              )?;
203            }
204          }
205
206          Ok(())
207        },
208      )?;
209    }
210
211    let global_scope = global_scope
212      .into_iter()
213      .map(|(key, scopes)| {
214        let mut resolved_scope = ResolvedScope {
215          allow: Vec::new(),
216          deny: Vec::new(),
217        };
218        for scope in scopes {
219          if let Some(allow) = scope.allow {
220            resolved_scope.allow.extend(allow);
221          }
222          if let Some(deny) = scope.deny {
223            resolved_scope.deny.extend(deny);
224          }
225        }
226        (key, resolved_scope)
227      })
228      .collect();
229
230    let resolved = Self {
231      has_app_acl: has_app_manifest(acl),
232      allowed_commands,
233      denied_commands,
234      command_scope,
235      global_scope,
236    };
237
238    Ok(resolved)
239  }
240}
241
242fn parse_glob_patterns(mut raw: Vec<String>) -> Result<Vec<glob::Pattern>, Error> {
243  raw.sort();
244
245  let mut patterns = Vec::new();
246  for pattern in raw {
247    patterns.push(glob::Pattern::new(&pattern)?);
248  }
249
250  Ok(patterns)
251}
252
253fn resolve_command(
254  commands: &mut BTreeMap<String, Vec<ResolvedCommand>>,
255  command: String,
256  capability: &Capability,
257  scope_id: Option<ScopeKey>,
258  #[cfg(debug_assertions)] referenced_by_permission_identifier: &str,
259) -> Result<(), Error> {
260  let mut contexts = Vec::new();
261  if capability.local {
262    contexts.push(ExecutionContext::Local);
263  }
264  if let Some(remote) = &capability.remote {
265    contexts.extend(remote.urls.iter().map(|url| {
266      ExecutionContext::Remote {
267        url: url
268          .parse()
269          .unwrap_or_else(|e| panic!("invalid URL pattern for remote URL {url}: {e}")),
270      }
271    }));
272  }
273
274  for context in contexts {
275    let resolved_list = commands.entry(command.clone()).or_default();
276
277    resolved_list.push(ResolvedCommand {
278      context,
279      #[cfg(debug_assertions)]
280      referenced_by: ResolvedCommandReference {
281        capability: capability.identifier.clone(),
282        permission: referenced_by_permission_identifier.to_owned(),
283      },
284      windows: parse_glob_patterns(capability.windows.clone())?,
285      webviews: parse_glob_patterns(capability.webviews.clone())?,
286      scope_id,
287    });
288  }
289
290  Ok(())
291}
292
293struct ResolvedPermission<'a> {
294  key: String,
295  permission_name: &'a str,
296  commands: Commands,
297  scope: Scopes,
298}
299
300/// Iterate over permissions in a capability, resolving permission sets if necessary
301/// to produce a [`ResolvedPermission`] and calling the provided callback with it.
302fn with_resolved_permissions<F: FnMut(ResolvedPermission<'_>) -> Result<(), Error>>(
303  capability: &Capability,
304  acl: &BTreeMap<String, Manifest>,
305  target: Target,
306  mut f: F,
307) -> Result<(), Error> {
308  for permission_entry in &capability.permissions {
309    let permission_id = permission_entry.identifier();
310
311    let permissions = get_permissions(permission_id, acl)?
312      .into_iter()
313      .filter(|p| p.permission.is_active(&target));
314
315    for TraversedPermission {
316      key,
317      permission_name,
318      permission,
319    } in permissions
320    {
321      let mut resolved_scope = Scopes::default();
322      let mut commands = Commands::default();
323
324      if let PermissionEntry::ExtendedPermission {
325        identifier: _,
326        scope,
327      } = permission_entry
328      {
329        if let Some(allow) = scope.allow.clone() {
330          resolved_scope
331            .allow
332            .get_or_insert_with(Default::default)
333            .extend(allow);
334        }
335        if let Some(deny) = scope.deny.clone() {
336          resolved_scope
337            .deny
338            .get_or_insert_with(Default::default)
339            .extend(deny);
340        }
341      }
342
343      if let Some(allow) = permission.scope.allow.clone() {
344        resolved_scope
345          .allow
346          .get_or_insert_with(Default::default)
347          .extend(allow);
348      }
349      if let Some(deny) = permission.scope.deny.clone() {
350        resolved_scope
351          .deny
352          .get_or_insert_with(Default::default)
353          .extend(deny);
354      }
355
356      commands.allow.extend(permission.commands.allow.clone());
357      commands.deny.extend(permission.commands.deny.clone());
358
359      f(ResolvedPermission {
360        key,
361        permission_name: &permission_name,
362        commands,
363        scope: resolved_scope,
364      })?;
365    }
366  }
367
368  Ok(())
369}
370
371/// Traversed permission
372#[derive(Debug)]
373pub struct TraversedPermission<'a> {
374  /// Plugin name without the tauri-plugin- prefix
375  pub key: String,
376  /// Permission's name
377  pub permission_name: String,
378  /// Permission details.
379  ///
380  /// This is borrowed for permissions stored in the [`Manifest`], or owned for the `allow-$command`
381  /// and `deny-$command` permissions [materialized on demand](Manifest::command_permission).
382  pub permission: Cow<'a, Permission>,
383}
384
385/// Expand a permissions id based on the ACL to get the associated permissions (e.g. expand some-plugin:default)
386pub fn get_permissions<'a>(
387  permission_id: &Identifier,
388  acl: &'a BTreeMap<String, Manifest>,
389) -> Result<Vec<TraversedPermission<'a>>, Error> {
390  let key = permission_id.get_prefix().unwrap_or(APP_ACL_KEY);
391  let permission_name = permission_id.get_base();
392
393  let manifest = acl.get(key).ok_or_else(|| Error::UnknownManifest {
394    key: display_perm_key(key).to_string(),
395    available: acl.keys().cloned().collect::<Vec<_>>().join(", "),
396  })?;
397
398  if permission_name == "default" {
399    manifest
400      .default_permission
401      .as_ref()
402      .map(|default| get_permission_set_permissions(permission_id, acl, manifest, default))
403      .unwrap_or_else(|| Ok(Default::default()))
404  } else if let Some(set) = manifest.permission_sets.get(permission_name) {
405    get_permission_set_permissions(permission_id, acl, manifest, set)
406  } else if let Some(permission) = manifest.permissions.get(permission_name) {
407    Ok(vec![TraversedPermission {
408      key: key.to_string(),
409      permission_name: permission_name.to_string(),
410      permission: Cow::Borrowed(permission),
411    }])
412  } else if let Some(permission) = manifest.command_permission(permission_name, key == APP_ACL_KEY)
413  {
414    Ok(vec![TraversedPermission {
415      key: key.to_string(),
416      permission_name: permission_name.to_string(),
417      permission: Cow::Owned(permission),
418    }])
419  } else {
420    Err(Error::UnknownPermission {
421      key: display_perm_key(key).to_string(),
422      permission: permission_name.to_string(),
423    })
424  }
425}
426
427// get the permissions from a permission set
428fn get_permission_set_permissions<'a>(
429  permission_id: &Identifier,
430  acl: &'a BTreeMap<String, Manifest>,
431  manifest: &'a Manifest,
432  set: &'a PermissionSet,
433) -> Result<Vec<TraversedPermission<'a>>, Error> {
434  let key = permission_id.get_prefix().unwrap_or(APP_ACL_KEY);
435
436  let mut permissions = Vec::new();
437
438  for perm in &set.permissions {
439    // a set could include permissions from other plugins
440    // for example `dialog:default`, could include `fs:default`
441    // in this case `perm = "fs:default"` which is not a permission
442    // in the dialog manifest so we check if `perm` still have a prefix (i.e `fs:`)
443    // and if so, we resolve this prefix from `acl` first before proceeding
444    let id = Identifier::try_from(perm.clone()).expect("invalid identifier in permission set?");
445    let (manifest, permission_id, key, permission_name) =
446      if let Some((new_key, manifest)) = id.get_prefix().and_then(|k| acl.get(k).map(|m| (k, m))) {
447        (manifest, &id, new_key, id.get_base())
448      } else {
449        (manifest, permission_id, key, perm.as_str())
450      };
451
452    if permission_name == "default" {
453      permissions.extend(
454        manifest
455          .default_permission
456          .as_ref()
457          .map(|default| get_permission_set_permissions(permission_id, acl, manifest, default))
458          .transpose()?
459          .unwrap_or_default(),
460      );
461    } else if let Some(permission) = manifest.permissions.get(permission_name) {
462      permissions.push(TraversedPermission {
463        key: key.to_string(),
464        permission_name: permission_name.to_string(),
465        permission: Cow::Borrowed(permission),
466      });
467    } else if let Some(permission_set) = manifest.permission_sets.get(permission_name) {
468      permissions.extend(get_permission_set_permissions(
469        permission_id,
470        acl,
471        manifest,
472        permission_set,
473      )?);
474    } else if let Some(permission) =
475      manifest.command_permission(permission_name, key == APP_ACL_KEY)
476    {
477      permissions.push(TraversedPermission {
478        key: key.to_string(),
479        permission_name: permission_name.to_string(),
480        permission: Cow::Owned(permission),
481      });
482    } else {
483      return Err(Error::SetPermissionNotFound {
484        permission: permission_name.to_string(),
485        set: set.identifier.clone(),
486      });
487    }
488  }
489
490  Ok(permissions)
491}
492
493#[inline]
494fn display_perm_key(prefix: &str) -> &str {
495  if prefix == APP_ACL_KEY {
496    "app manifest"
497  } else {
498    prefix
499  }
500}
501
502#[cfg(any(feature = "build", feature = "build-2"))]
503mod build {
504  use proc_macro2::TokenStream;
505  use quote::{ToTokens, TokenStreamExt, quote};
506  use std::convert::identity;
507
508  use super::*;
509  use crate::{literal_struct, tokens::*};
510
511  #[cfg(debug_assertions)]
512  impl ToTokens for ResolvedCommandReference {
513    fn to_tokens(&self, tokens: &mut TokenStream) {
514      let capability = str_lit(&self.capability);
515      let permission = str_lit(&self.permission);
516      tokens.append_all(quote! {
517        ::tauri::utils::acl::resolved::ResolvedCommandReference::new(#capability, #permission)
518      });
519    }
520  }
521
522  impl ToTokens for ResolvedCommand {
523    fn to_tokens(&self, tokens: &mut TokenStream) {
524      #[cfg(debug_assertions)]
525      let referenced_by = &self.referenced_by;
526      #[cfg(not(debug_assertions))]
527      let referenced_by =
528        quote!(::tauri::utils::acl::resolved::ResolvedCommandReference::default());
529
530      let context = &self.context;
531
532      let windows = vec_lit(&self.windows, |window| {
533        let w = window.as_str();
534        quote!(#w.parse().unwrap())
535      });
536      let webviews = vec_lit(&self.webviews, |window| {
537        let w = window.as_str();
538        quote!(#w.parse().unwrap())
539      });
540      let scope_id = opt_lit(self.scope_id.as_ref());
541
542      tokens.append_all(quote! {
543        ::tauri::utils::acl::resolved::ResolvedCommand::new(
544          #context,
545          #referenced_by,
546          #windows,
547          #webviews,
548          #scope_id
549        )
550      })
551    }
552  }
553
554  impl ToTokens for ResolvedScope {
555    fn to_tokens(&self, tokens: &mut TokenStream) {
556      let allow = vec_lit(&self.allow, identity);
557      let deny = vec_lit(&self.deny, identity);
558      literal_struct!(
559        tokens,
560        ::tauri::utils::acl::resolved::ResolvedScope,
561        allow,
562        deny
563      )
564    }
565  }
566
567  impl ToTokens for Resolved {
568    fn to_tokens(&self, tokens: &mut TokenStream) {
569      let has_app_acl = self.has_app_acl;
570
571      let allowed_commands = map_lit(
572        quote! { ::std::collections::BTreeMap },
573        &self.allowed_commands,
574        str_lit,
575        |v| vec_lit(v, identity),
576      );
577
578      let denied_commands = map_lit(
579        quote! { ::std::collections::BTreeMap },
580        &self.denied_commands,
581        str_lit,
582        |v| vec_lit(v, identity),
583      );
584
585      let command_scope = map_lit(
586        quote! { ::std::collections::BTreeMap },
587        &self.command_scope,
588        identity,
589        identity,
590      );
591
592      let global_scope = map_lit(
593        quote! { ::std::collections::BTreeMap },
594        &self.global_scope,
595        str_lit,
596        identity,
597      );
598
599      literal_struct!(
600        tokens,
601        ::tauri::utils::acl::resolved::Resolved,
602        has_app_acl,
603        allowed_commands,
604        denied_commands,
605        command_scope,
606        global_scope
607      )
608    }
609  }
610}
611
612#[cfg(test)]
613mod tests {
614
615  use super::{
616    APP_ACL_KEY, Identifier, Manifest, Permission, PermissionSet, Resolved, get_permissions,
617  };
618  use crate::platform::Target;
619
620  fn manifest<const P: usize, const S: usize>(
621    name: &str,
622    permissions: [&str; P],
623    default_set: Option<&[&str]>,
624    sets: [(&str, &[&str]); S],
625  ) -> (String, Manifest) {
626    (
627      name.to_string(),
628      Manifest {
629        default_permission: default_set.map(|perms| PermissionSet {
630          identifier: "default".to_string(),
631          description: "default set".to_string(),
632          permissions: perms.iter().map(|s| s.to_string()).collect(),
633        }),
634        permissions: permissions
635          .iter()
636          .map(|p| {
637            (
638              p.to_string(),
639              Permission {
640                identifier: p.to_string(),
641                ..Default::default()
642              },
643            )
644          })
645          .collect(),
646        permission_sets: sets
647          .iter()
648          .map(|(s, perms)| {
649            (
650              s.to_string(),
651              PermissionSet {
652                identifier: s.to_string(),
653                description: format!("{s} set"),
654                permissions: perms.iter().map(|s| s.to_string()).collect(),
655              },
656            )
657          })
658          .collect(),
659        ..Default::default()
660      },
661    )
662  }
663
664  fn id(id: &str) -> Identifier {
665    Identifier::try_from(id.to_string()).unwrap()
666  }
667
668  #[test]
669  fn resolves_permissions_from_other_plugins() {
670    let acl = [
671      manifest(
672        "fs",
673        ["read", "write", "rm", "exist"],
674        Some(&["read", "exist"]),
675        [],
676      ),
677      manifest(
678        "http",
679        ["fetch", "fetch-cancel"],
680        None,
681        [("fetch-with-cancel", &["fetch", "fetch-cancel"])],
682      ),
683      manifest(
684        "dialog",
685        ["open", "save"],
686        None,
687        [(
688          "extra",
689          &[
690            "save",
691            "fs:default",
692            "fs:write",
693            "http:default",
694            "http:fetch-with-cancel",
695          ],
696        )],
697      ),
698    ]
699    .into();
700
701    let permissions = get_permissions(&id("fs:default"), &acl).unwrap();
702    assert_eq!(permissions.len(), 2);
703    assert_eq!(permissions[0].key, "fs");
704    assert_eq!(permissions[0].permission_name, "read");
705    assert_eq!(permissions[1].key, "fs");
706    assert_eq!(permissions[1].permission_name, "exist");
707
708    let permissions = get_permissions(&id("fs:rm"), &acl).unwrap();
709    assert_eq!(permissions.len(), 1);
710    assert_eq!(permissions[0].key, "fs");
711    assert_eq!(permissions[0].permission_name, "rm");
712
713    let permissions = get_permissions(&id("http:fetch-with-cancel"), &acl).unwrap();
714    assert_eq!(permissions.len(), 2);
715    assert_eq!(permissions[0].key, "http");
716    assert_eq!(permissions[0].permission_name, "fetch");
717    assert_eq!(permissions[1].key, "http");
718    assert_eq!(permissions[1].permission_name, "fetch-cancel");
719
720    let permissions = get_permissions(&id("dialog:extra"), &acl).unwrap();
721    assert_eq!(permissions.len(), 6);
722    assert_eq!(permissions[0].key, "dialog");
723    assert_eq!(permissions[0].permission_name, "save");
724    assert_eq!(permissions[1].key, "fs");
725    assert_eq!(permissions[1].permission_name, "read");
726    assert_eq!(permissions[2].key, "fs");
727    assert_eq!(permissions[2].permission_name, "exist");
728    assert_eq!(permissions[3].key, "fs");
729    assert_eq!(permissions[3].permission_name, "write");
730    assert_eq!(permissions[4].key, "http");
731    assert_eq!(permissions[4].permission_name, "fetch");
732    assert_eq!(permissions[5].key, "http");
733    assert_eq!(permissions[5].permission_name, "fetch-cancel");
734  }
735
736  fn manifest_with_commands(name: &str, commands: &[&str]) -> (String, Manifest) {
737    (
738      name.to_string(),
739      Manifest {
740        commands: commands.iter().map(|c| c.to_string()).collect(),
741        ..Default::default()
742      },
743    )
744  }
745
746  #[test]
747  fn resolves_implicit_command_permissions() {
748    let acl = [manifest_with_commands("fs", &["read_file", "write_file"])].into();
749
750    // `allow-$command` resolves to the command with the original (snake_case) name
751    let permissions = get_permissions(&id("fs:allow-read-file"), &acl).unwrap();
752    assert_eq!(permissions.len(), 1);
753    assert_eq!(permissions[0].key, "fs");
754    assert_eq!(permissions[0].permission.commands.allow, ["read_file"]);
755    assert!(permissions[0].permission.commands.deny.is_empty());
756
757    // `deny-$command` resolves to the deny side
758    let permissions = get_permissions(&id("fs:deny-write-file"), &acl).unwrap();
759    assert_eq!(permissions.len(), 1);
760    assert_eq!(permissions[0].permission.commands.deny, ["write_file"]);
761
762    // unknown command is still an error
763    assert!(get_permissions(&id("fs:allow-unknown"), &acl).is_err());
764  }
765
766  #[test]
767  fn resolves_wildcard_command_permission() {
768    let acl = [(
769      APP_ACL_KEY.to_string(),
770      manifest_with_commands("__app__", &["read_file", "write_file"]).1,
771    )]
772    .into();
773
774    // the wildcard resolves to a single `*` command instead of one entry per command
775    let permissions = get_permissions(&id("allow-*"), &acl).unwrap();
776    assert_eq!(permissions.len(), 1);
777    assert_eq!(permissions[0].permission.commands.allow, ["*"]);
778
779    let permissions = get_permissions(&id("deny-*"), &acl).unwrap();
780    assert_eq!(permissions.len(), 1);
781    assert_eq!(permissions[0].permission.commands.deny, ["*"]);
782
783    // the wildcard is only available for the app manifest, not for plugins
784    let plugin_acl = [manifest_with_commands("fs", &["read_file", "write_file"])].into();
785    assert!(get_permissions(&id("fs:allow-*"), &plugin_acl).is_err());
786    // but specific command permissions still work for plugins
787    assert!(get_permissions(&id("fs:allow-read-file"), &plugin_acl).is_ok());
788
789    // the wildcard is not a valid permission when the app manifest has no commands
790    let empty = [(APP_ACL_KEY.to_string(), manifest("__app__", [], None, []).1)].into();
791    assert!(get_permissions(&id("allow-*"), &empty).is_err());
792  }
793
794  #[test]
795  fn resolve_wildcard_uses_single_entry() {
796    use crate::acl::{Capability, capability::PermissionEntry};
797
798    let acl = [(
799      APP_ACL_KEY.to_string(),
800      Manifest {
801        commands: ["echo", "ping", "spam"]
802          .iter()
803          .map(|c| c.to_string())
804          .collect(),
805        ..Default::default()
806      },
807    )]
808    .into();
809
810    let capability = Capability {
811      identifier: "main".to_string(),
812      description: String::new(),
813      remote: None,
814      local: true,
815      windows: vec!["main".to_string()],
816      webviews: Vec::new(),
817      permissions: vec![PermissionEntry::PermissionRef(id("allow-*"))],
818      platforms: None,
819    };
820
821    let resolved = Resolved::resolve(
822      &acl,
823      [("main".to_string(), capability)].into(),
824      Target::Linux,
825    )
826    .unwrap();
827
828    // the whole app is allowed through a single resolved entry keyed by `*`
829    assert_eq!(resolved.allowed_commands.len(), 1);
830    assert!(resolved.allowed_commands.contains_key("*"));
831    assert!(resolved.denied_commands.is_empty());
832  }
833}