Skip to main content

tauri_build/
acl.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::{
6  collections::{BTreeMap, HashMap},
7  env, fs,
8  path::{Path, PathBuf},
9};
10
11use anyhow::{Context, Result};
12use tauri_utils::{
13  acl::{
14    ACL_MANIFESTS_FILE_NAME, APP_ACL_KEY, CAPABILITIES_FILE_NAME,
15    capability::Capability,
16    get_capabilities,
17    manifest::{Manifest, PermissionFile},
18    schema::CAPABILITIES_SCHEMA_FOLDER_PATH,
19  },
20  config::Config,
21  platform::Target,
22  write_if_changed,
23};
24
25use crate::Attributes;
26
27/// Definition of a plugin that is part of the Tauri application instead of having its own crate.
28///
29/// By default it generates a plugin manifest that parses permissions from the `permissions/$plugin-name` directory.
30/// To change the glob pattern that is used to find permissions, use [`Self::permissions_path_pattern`].
31///
32/// To autogenerate permissions for each of the plugin commands, see [`Self::commands`].
33#[derive(Debug, Default, Clone)]
34pub struct InlinedPlugin {
35  commands: &'static [&'static str],
36  permissions_path_pattern: Option<&'static str>,
37  default: Option<DefaultPermissionRule>,
38}
39
40/// Variants of a generated default permission that can be used on an [`InlinedPlugin`].
41#[derive(Debug, Clone)]
42pub enum DefaultPermissionRule {
43  /// Allow all commands from [`InlinedPlugin::commands`].
44  AllowAllCommands,
45  /// Allow the given list of permissions.
46  ///
47  /// Note that the list refers to permissions instead of command names,
48  /// so for example a command called `execute` would need to be allowed as `allow-execute`.
49  Allow(Vec<String>),
50}
51
52impl InlinedPlugin {
53  /// Creates a new inlined plugin definition with the default options.
54  pub fn new() -> Self {
55    Self::default()
56  }
57
58  /// Define a list of commands that gets permissions autogenerated in the format of `allow-$command` and `deny-$command`
59  /// where $command is the command name in snake_case.
60  pub fn commands(mut self, commands: &'static [&'static str]) -> Self {
61    self.commands = commands;
62    self
63  }
64
65  /// Sets a glob pattern that is used to find the permissions of this inlined plugin.
66  ///
67  /// **Note:** You must emit [rerun-if-changed] instructions for the plugin permissions directory.
68  ///
69  /// By default it is `./permissions/$plugin-name/**/*`
70  pub fn permissions_path_pattern(mut self, pattern: &'static str) -> Self {
71    self.permissions_path_pattern.replace(pattern);
72    self
73  }
74
75  /// Creates a default permission for the plugin using the given rule.
76  ///
77  /// Alternatively you can pull a permission in the filesystem in the permissions directory, see [`Self::permissions_path_pattern`].
78  pub fn default_permission(mut self, default: DefaultPermissionRule) -> Self {
79    self.default.replace(default);
80    self
81  }
82}
83
84/// Tauri application permission manifest.
85///
86/// By default it generates a manifest that parses permissions from the `permissions` directory.
87/// To change the glob pattern that is used to find permissions, use [`Self::permissions_path_pattern`].
88///
89/// To autogenerate permissions for each of the app commands, see [`Self::commands`].
90#[derive(Debug, Default, Clone, Copy)]
91pub struct AppManifest {
92  commands: &'static [&'static str],
93  permissions_path_pattern: Option<&'static str>,
94}
95
96impl AppManifest {
97  /// Creates a new application manifest with the default options.
98  pub fn new() -> Self {
99    Self::default()
100  }
101
102  /// Define a list of commands that gets permissions autogenerated in the format of `allow-$command` and `deny-$command`
103  /// where $command is the command name in snake_case.
104  pub fn commands(mut self, commands: &'static [&'static str]) -> Self {
105    self.commands = commands;
106    self
107  }
108
109  /// Sets a glob pattern that is used to find the permissions of the app.
110  ///
111  /// **Note:** You must emit [rerun-if-changed] instructions for the permissions directory.
112  ///
113  /// By default it is `./permissions/**/*` ignoring any [`InlinedPlugin`].
114  pub fn permissions_path_pattern(mut self, pattern: &'static str) -> Self {
115    self.permissions_path_pattern.replace(pattern);
116    self
117  }
118}
119
120/// Saves capabilities in a file inside the project, mainly to be read by tauri-cli.
121fn save_capabilities(capabilities: &BTreeMap<String, Capability>) -> Result<PathBuf> {
122  let dir = Path::new(CAPABILITIES_SCHEMA_FOLDER_PATH);
123  fs::create_dir_all(dir)?;
124
125  let path = dir.join(CAPABILITIES_FILE_NAME);
126  let json = serde_json::to_string(&capabilities)?;
127  write_if_changed(&path, json)?;
128
129  Ok(path)
130}
131
132/// Saves ACL manifests in a file inside the project, mainly to be read by tauri-cli.
133fn save_acl_manifests(acl_manifests: &BTreeMap<String, Manifest>) -> Result<PathBuf> {
134  let dir = Path::new(CAPABILITIES_SCHEMA_FOLDER_PATH);
135  fs::create_dir_all(dir)?;
136
137  let path = dir.join(ACL_MANIFESTS_FILE_NAME);
138  let json = serde_json::to_string(&acl_manifests)?;
139  write_if_changed(&path, json)?;
140
141  Ok(path)
142}
143
144/// Read plugin permissions and scope schema from env vars
145fn read_plugins_manifests() -> Result<BTreeMap<String, Manifest>> {
146  use tauri_utils::acl;
147
148  let permission_map =
149    acl::build::read_permissions().context("failed to read plugin permissions")?;
150  let mut global_scope_map =
151    acl::build::read_global_scope_schemas().context("failed to read global scope schemas")?;
152
153  let mut manifests = BTreeMap::new();
154
155  for (plugin_name, permission_files) in permission_map {
156    let global_scope_schema = global_scope_map.remove(&plugin_name);
157    let manifest = Manifest::new(permission_files, global_scope_schema);
158    manifests.insert(plugin_name, manifest);
159  }
160
161  Ok(manifests)
162}
163
164struct InlinedPluginsAcl {
165  manifests: BTreeMap<String, Manifest>,
166  permission_files: BTreeMap<String, Vec<PermissionFile>>,
167}
168
169fn inline_plugins(
170  out_dir: &Path,
171  inlined_plugins: HashMap<&'static str, InlinedPlugin>,
172) -> Result<InlinedPluginsAcl> {
173  let mut acl_manifests = BTreeMap::new();
174  let mut permission_files_map = BTreeMap::new();
175
176  for (name, plugin) in inlined_plugins {
177    let plugin_out_dir = out_dir.join("plugins").join(name);
178    fs::create_dir_all(&plugin_out_dir)?;
179
180    let mut permission_files = if plugin.commands.is_empty() {
181      Vec::new()
182    } else {
183      let autogenerated = tauri_utils::acl::build::autogenerate_command_permissions(
184        &plugin_out_dir,
185        plugin.commands,
186        "",
187        false,
188      );
189
190      let default_permissions = plugin.default.map(|default| match default {
191        DefaultPermissionRule::AllowAllCommands => autogenerated.allowed,
192        DefaultPermissionRule::Allow(permissions) => permissions,
193      });
194      if let Some(default_permissions) = default_permissions {
195        let default_permissions = default_permissions
196          .iter()
197          .map(|p| format!("\"{p}\""))
198          .collect::<Vec<String>>()
199          .join(",");
200        let default_permission = format!(
201          r###"# Automatically generated - DO NOT EDIT!
202[default]
203permissions = [{default_permissions}]
204"###
205        );
206
207        let default_permission_path = plugin_out_dir.join("default.toml");
208
209        write_if_changed(&default_permission_path, default_permission)
210          .unwrap_or_else(|_| panic!("unable to autogenerate {default_permission_path:?}"));
211      }
212
213      tauri_utils::acl::build::define_permissions(
214        &PathBuf::from(glob::Pattern::escape(&plugin_out_dir.to_string_lossy()))
215          .join("*")
216          .to_string_lossy(),
217        name,
218        &plugin_out_dir,
219        |_| true,
220      )?
221    };
222
223    if let Some(pattern) = plugin.permissions_path_pattern {
224      permission_files.extend(tauri_utils::acl::build::define_permissions(
225        pattern,
226        name,
227        &plugin_out_dir,
228        |_| true,
229      )?);
230    } else {
231      let default_permissions_path = Path::new("permissions").join(name);
232      if default_permissions_path.exists() {
233        println!(
234          "cargo:rerun-if-changed={}",
235          default_permissions_path.display()
236        );
237      }
238      permission_files.extend(tauri_utils::acl::build::define_permissions(
239        &PathBuf::from(glob::Pattern::escape(
240          &default_permissions_path.to_string_lossy(),
241        ))
242        .join("**")
243        .join("*")
244        .to_string_lossy(),
245        name,
246        &plugin_out_dir,
247        |_| true,
248      )?);
249    }
250
251    permission_files_map.insert(name.into(), permission_files.clone());
252
253    let manifest = tauri_utils::acl::manifest::Manifest::new(permission_files, None);
254    acl_manifests.insert(name.into(), manifest);
255  }
256
257  Ok(InlinedPluginsAcl {
258    manifests: acl_manifests,
259    permission_files: permission_files_map,
260  })
261}
262
263#[derive(Debug)]
264struct AppManifestAcl {
265  manifest: Manifest,
266  permission_files: Vec<PermissionFile>,
267}
268
269fn app_manifest_permissions(
270  out_dir: &Path,
271  manifest: AppManifest,
272  inlined_plugins: &HashMap<&'static str, InlinedPlugin>,
273) -> Result<AppManifestAcl> {
274  let app_out_dir = out_dir.join("app-manifest");
275  fs::create_dir_all(&app_out_dir)?;
276  let pkg_name = "__app__";
277
278  let mut permission_files = if manifest.commands.is_empty() {
279    Vec::new()
280  } else {
281    let autogenerated_path = Path::new("./permissions/autogenerated");
282    tauri_utils::acl::build::autogenerate_command_permissions(
283      autogenerated_path,
284      manifest.commands,
285      "",
286      false,
287    );
288    tauri_utils::acl::build::define_permissions(
289      &autogenerated_path.join("*").to_string_lossy(),
290      pkg_name,
291      &app_out_dir,
292      |_| true,
293    )?
294  };
295
296  if let Some(pattern) = manifest.permissions_path_pattern {
297    permission_files.extend(tauri_utils::acl::build::define_permissions(
298      pattern,
299      pkg_name,
300      &app_out_dir,
301      |_| true,
302    )?);
303  } else {
304    let default_permissions_path = Path::new("permissions");
305    if default_permissions_path.exists() {
306      println!(
307        "cargo:rerun-if-changed={}",
308        default_permissions_path.display()
309      );
310    }
311
312    let permissions_root = env::current_dir()?.join("permissions");
313    let inlined_plugins_permissions: Vec<_> = inlined_plugins
314      .keys()
315      .map(|name| permissions_root.join(name))
316      .flat_map(|p| p.canonicalize())
317      .collect();
318
319    permission_files.extend(tauri_utils::acl::build::define_permissions(
320      &default_permissions_path
321        .join("**")
322        .join("*")
323        .to_string_lossy(),
324      pkg_name,
325      &app_out_dir,
326      // filter out directories containing inlined plugins
327      |p| {
328        !inlined_plugins_permissions
329          .iter()
330          .any(|inlined_path| p.starts_with(inlined_path))
331      },
332    )?);
333  }
334
335  Ok(AppManifestAcl {
336    permission_files: permission_files.clone(),
337    manifest: tauri_utils::acl::manifest::Manifest::new(permission_files, None),
338  })
339}
340
341fn validate_capabilities(
342  acl_manifests: &BTreeMap<String, Manifest>,
343  capabilities: &BTreeMap<String, Capability>,
344) -> Result<()> {
345  let target = tauri_utils::platform::Target::from_triple(&std::env::var("TARGET").unwrap());
346
347  for capability in capabilities.values() {
348    if !capability
349      .platforms
350      .as_ref()
351      .map(|platforms| platforms.contains(&target))
352      .unwrap_or(true)
353    {
354      continue;
355    }
356
357    for permission_entry in &capability.permissions {
358      let permission_id = permission_entry.identifier();
359
360      let key = permission_id.get_prefix().unwrap_or(APP_ACL_KEY);
361      let permission_name = permission_id.get_base();
362
363      let permission_exists = acl_manifests
364        .get(key)
365        .map(|manifest| {
366          // the default permission is always treated as valid, the CLI automatically adds it on the `tauri add` command
367          permission_name == "default"
368            || manifest.permissions.contains_key(permission_name)
369            || manifest.permission_sets.contains_key(permission_name)
370        })
371        .unwrap_or(false);
372
373      if !permission_exists {
374        let mut available_permissions = Vec::new();
375        for (key, manifest) in acl_manifests {
376          let prefix = if key == APP_ACL_KEY {
377            "".to_string()
378          } else {
379            format!("{key}:")
380          };
381          if manifest.default_permission.is_some() {
382            available_permissions.push(format!("{prefix}default"));
383          }
384          for p in manifest.permissions.keys() {
385            available_permissions.push(format!("{prefix}{p}"));
386          }
387          for p in manifest.permission_sets.keys() {
388            available_permissions.push(format!("{prefix}{p}"));
389          }
390        }
391
392        let hint = suggest_identifier(permission_id.get(), &available_permissions)
393          .map(|suggestion| format!(" (did you mean \"{suggestion}\"?)"))
394          .unwrap_or_default();
395
396        anyhow::bail!(
397          "Permission {} not found{hint}, expected one of {}",
398          permission_id.get(),
399          available_permissions.join(", ")
400        );
401      }
402    }
403  }
404
405  Ok(())
406}
407
408pub fn build(
409  out_dir: &Path,
410  target: Target,
411  config: &Config,
412  attributes: &Attributes,
413) -> super::Result<()> {
414  let mut acl_manifests = read_plugins_manifests()?;
415
416  let app_acl = app_manifest_permissions(
417    out_dir,
418    attributes.app_manifest,
419    &attributes.inlined_plugins,
420  )?;
421  let has_app_manifest = app_acl.manifest.default_permission.is_some()
422    || !app_acl.manifest.permission_sets.is_empty()
423    || !app_acl.manifest.permissions.is_empty();
424  if has_app_manifest {
425    acl_manifests.insert(APP_ACL_KEY.into(), app_acl.manifest);
426  }
427
428  let inline_plugins_acl = inline_plugins(out_dir, attributes.inlined_plugins.clone())?;
429
430  acl_manifests.extend(inline_plugins_acl.manifests);
431
432  let acl_manifests_path = save_acl_manifests(&acl_manifests)?;
433  fs::copy(acl_manifests_path, out_dir.join(ACL_MANIFESTS_FILE_NAME))?;
434
435  tauri_utils::acl::schema::generate_capability_schema(&acl_manifests, target)?;
436
437  let capabilities_from_files = if let Some(pattern) = attributes.capabilities_path_pattern {
438    tauri_utils::acl::build::parse_capabilities(pattern)?
439  } else {
440    // Emit an absolute watch path: cargo resolves a relative
441    // `rerun-if-changed` against the package that owns the build script, while
442    // the glob below is resolved against the process working directory.
443    // Callers that `set_current_dir` before `try_build[_context]` (for example
444    // a crate generating a context byte-identical to the app's) would
445    // otherwise watch a `<caller-manifest-dir>/capabilities` that usually does
446    // not exist — and a missing watched path is dirty on every fingerprint
447    // check, re-running the build script and recompiling the whole downstream
448    // chain on every build.
449    let capabilities_dir = std::env::current_dir()
450      .context("resolve current dir for capabilities watch path")?
451      .join("capabilities");
452    println!("cargo:rerun-if-changed={}", capabilities_dir.display());
453    tauri_utils::acl::build::parse_capabilities("./capabilities/**/*")?
454  };
455  // the capabilities that are actually resolved at compile time are the ones returned by
456  // `get_capabilities`: when the configuration file defines `app > security > capabilities`,
457  // the capabilities parsed from the filesystem are only used to resolve the entries that
458  // reference them by identifier, and the inlined ones are never seen by this build script
459  // otherwise. we must validate that same set so a typo in an inlined permission identifier
460  // is reported here instead of panicking later on tauri-codegen's ACL resolution
461  let capabilities = get_capabilities(config, capabilities_from_files.clone(), None)
462    .context("failed to resolve the capabilities from the Tauri configuration file")?;
463
464  validate_capabilities(&acl_manifests, &capabilities)?;
465
466  let capabilities_path = save_capabilities(&capabilities_from_files)?;
467  fs::copy(capabilities_path, out_dir.join(CAPABILITIES_FILE_NAME))?;
468
469  let mut permissions_map = inline_plugins_acl.permission_files;
470  if has_app_manifest {
471    permissions_map.insert(APP_ACL_KEY.to_string(), app_acl.permission_files);
472  }
473
474  // note: `generate_allowed_commands` merges the configuration capabilities itself
475  // so it must receive the capabilities parsed from the filesystem
476  tauri_utils::acl::build::generate_allowed_commands(
477    out_dir,
478    Some(capabilities_from_files),
479    permissions_map,
480  )?;
481
482  Ok(())
483}
484
485/// Minimum [`strsim::jaro_winkler`] similarity for a candidate to be suggested as a
486/// "did you mean" hint.
487///
488/// Matches the threshold used by the application-category parser in `tauri-bundler`,
489/// so the two suggestion surfaces behave consistently.
490const SUGGESTION_CONFIDENCE_THRESHOLD: f64 = 0.8;
491
492/// Returns whether two components match exactly or with one edit or adjacent transposition.
493fn components_match(input: &str, candidate: &str) -> bool {
494  input == candidate
495    || (input.chars().count().max(candidate.chars().count()) >= 4
496      && strsim::osa_distance(input, candidate) <= 1)
497}
498
499/// Splits an identifier into its namespace (everything before the last `:`) and its base.
500fn split_identifier(identifier: &str) -> (Option<&str>, &str) {
501  match identifier.rsplit_once(':') {
502    Some((namespace, base)) => (Some(namespace), base),
503    None => (None, identifier),
504  }
505}
506
507/// Checks the `-` separated components of two identifier bases.
508fn base_components_match(input_parts: &[&str], candidate_base: &str) -> bool {
509  candidate_base.split('-').count() == input_parts.len()
510    && input_parts
511      .iter()
512      .zip(candidate_base.split('-'))
513      .all(|(a, b)| components_match(a, b))
514}
515
516/// Checks whether a candidate namespace is acceptable for the rejected identifier's namespace.
517///
518/// The namespace must match exactly, unless no candidate uses it (an unknown plugin or a typo in
519/// its name), in which case each `:` separated component may contain a single typo.
520fn namespaces_match(
521  input_namespace: Option<&str>,
522  candidate_namespace: Option<&str>,
523  input_namespace_known: bool,
524) -> bool {
525  match (input_namespace, candidate_namespace) {
526    (input, candidate) if input == candidate => true,
527    (Some(input), Some(candidate)) if !input_namespace_known => {
528      input.split(':').count() == candidate.split(':').count()
529        && input
530          .split(':')
531          .zip(candidate.split(':'))
532          .all(|(a, b)| components_match(a, b))
533    }
534    _ => false,
535  }
536}
537
538/// Returns the identifier from `candidates` that most closely resembles `input`, if any
539/// is similar enough to be worth suggesting.
540///
541/// Used to turn a rejected permission identifier into an actionable build error. Returns
542/// `None` when no candidate clears [`SUGGESTION_CONFIDENCE_THRESHOLD`], so unrelated typos
543/// do not get a misleading hint.
544fn suggest_identifier(input: &str, candidates: &[String]) -> Option<String> {
545  let (input_namespace, input_base) = split_identifier(input);
546  let input_namespace_known = candidates
547    .iter()
548    .any(|candidate| split_identifier(candidate).0 == input_namespace);
549
550  // Also try the input with the `allow-` prefix, which is easy to forget.
551  let mut queries = vec![(input.to_string(), input_base.to_string())];
552  if !input_base.starts_with("allow-") && !input_base.starts_with("deny-") {
553    let base = format!("allow-{input_base}");
554    let query = match input_namespace {
555      Some(namespace) => format!("{namespace}:{base}"),
556      None => base.clone(),
557    };
558    queries.push((query, base));
559  }
560
561  let mut best_confidence = 0.0;
562  let mut best_match: Option<&String> = None;
563
564  for (query, query_base) in &queries {
565    let query_parts: Vec<&str> = query_base.split('-').collect();
566
567    for candidate in candidates {
568      if candidate == input {
569        continue;
570      }
571
572      let (candidate_namespace, candidate_base) = split_identifier(candidate);
573
574      if !namespaces_match(input_namespace, candidate_namespace, input_namespace_known) {
575        continue;
576      }
577
578      if !base_components_match(&query_parts, candidate_base) {
579        continue;
580      }
581
582      let confidence = strsim::jaro_winkler(query, candidate);
583      let edit_similarity = strsim::normalized_levenshtein(query, candidate);
584
585      // Jaro-Winkler alone overvalues shared prefixes.
586      if confidence >= SUGGESTION_CONFIDENCE_THRESHOLD
587        && edit_similarity >= SUGGESTION_CONFIDENCE_THRESHOLD
588        && confidence > best_confidence
589      {
590        best_confidence = confidence;
591        best_match = Some(candidate);
592      }
593    }
594  }
595
596  best_match.cloned()
597}
598
599#[cfg(test)]
600mod tests {
601  use super::suggest_identifier;
602
603  fn candidates() -> Vec<String> {
604    [
605      "fs:allow-read-text-file",
606      "fs:allow-write-text-file",
607      "fs:default",
608      "core:default",
609      "core:event:allow-listen",
610    ]
611    .into_iter()
612    .map(String::from)
613    .collect()
614  }
615
616  #[test]
617  fn suggests_on_missing_trailing_character() {
618    assert_eq!(
619      suggest_identifier("fs:allow-read-text-fil", &candidates()).as_deref(),
620      Some("fs:allow-read-text-file")
621    );
622  }
623
624  #[test]
625  fn suggests_nearest_candidate_for_typo() {
626    assert_eq!(
627      suggest_identifier("fs:allow-read-text-fiel", &candidates()).as_deref(),
628      Some("fs:allow-read-text-file")
629    );
630  }
631
632  #[test]
633  fn picks_the_closest_candidate() {
634    let candidates: Vec<String> = ["core:event:allow-listen", "core:event:allow-unlisten"]
635      .into_iter()
636      .map(String::from)
637      .collect();
638    assert_eq!(
639      suggest_identifier("core:event:allow-listenx", &candidates).as_deref(),
640      Some("core:event:allow-listen")
641    );
642  }
643
644  #[test]
645  fn no_suggestion_for_read_write_mismatch() {
646    let c: Vec<String> = ["fs:allow-write-text-file"]
647      .into_iter()
648      .map(String::from)
649      .collect();
650    assert_eq!(suggest_identifier("fs:allow-read-text-file", &c), None);
651  }
652
653  #[test]
654  fn no_suggestion_for_listen_unlisten_mismatch() {
655    let c: Vec<String> = ["core:event:allow-unlisten"]
656      .into_iter()
657      .map(String::from)
658      .collect();
659    assert_eq!(suggest_identifier("core:event:allow-listen", &c), None);
660  }
661
662  #[test]
663  fn suggests_transposed_letters_in_action() {
664    let c: Vec<String> = ["core:event:allow-listen"]
665      .into_iter()
666      .map(String::from)
667      .collect();
668    assert_eq!(
669      suggest_identifier("core:event:allow-lisetn", &c).as_deref(),
670      Some("core:event:allow-listen")
671    );
672  }
673
674  #[test]
675  fn no_suggestion_for_different_hyphenation() {
676    let c: Vec<String> = ["fs:allow-read-text-file"]
677      .into_iter()
678      .map(String::from)
679      .collect();
680    assert_eq!(suggest_identifier("fs:allow-readtext-file", &c), None);
681  }
682
683  #[test]
684  fn short_action_components_must_match_exactly() {
685    let c: Vec<String> = ["core:window:allow-in"]
686      .into_iter()
687      .map(String::from)
688      .collect();
689    assert_eq!(suggest_identifier("core:window:allow-on", &c), None);
690  }
691
692  #[test]
693  fn no_suggestion_for_totally_different_identifier() {
694    assert_eq!(suggest_identifier("totally-unrelated", &candidates()), None);
695    assert_eq!(suggest_identifier("", &candidates()), None);
696  }
697
698  #[test]
699  fn no_suggestion_for_incomplete_permission() {
700    assert_eq!(suggest_identifier("fs:allow", &candidates()), None);
701    assert_eq!(suggest_identifier("fs:allow-foo", &candidates()), None);
702  }
703
704  #[test]
705  fn no_suggestion_for_different_namespace() {
706    assert_eq!(
707      suggest_identifier("xx:allow-read-text-file", &candidates()),
708      None
709    );
710  }
711
712  #[test]
713  fn suggests_for_namespace_typo() {
714    let c: Vec<String> = ["core:window:allow-set-title", "dialog:default"]
715      .into_iter()
716      .map(String::from)
717      .collect();
718    assert_eq!(
719      suggest_identifier("core:windw:allow-set-title", &c).as_deref(),
720      Some("core:window:allow-set-title")
721    );
722    assert_eq!(
723      suggest_identifier("dialgo:default", &c).as_deref(),
724      Some("dialog:default")
725    );
726  }
727
728  #[test]
729  fn no_namespace_typo_suggestion_when_namespace_exists() {
730    let c: Vec<String> = ["core:window:allow-close", "core:webview:allow-print"]
731      .into_iter()
732      .map(String::from)
733      .collect();
734    assert_eq!(suggest_identifier("core:window:allow-print", &c), None);
735  }
736
737  #[test]
738  fn suggests_missing_allow_prefix() {
739    let c: Vec<String> = [
740      "core:window:allow-set-title",
741      "core:window:deny-set-title",
742      "allow-echo",
743    ]
744    .into_iter()
745    .map(String::from)
746    .collect();
747    assert_eq!(
748      suggest_identifier("core:window:set-title", &c).as_deref(),
749      Some("core:window:allow-set-title")
750    );
751    assert_eq!(
752      suggest_identifier("core:window:set-titel", &c).as_deref(),
753      Some("core:window:allow-set-title")
754    );
755    assert_eq!(
756      suggest_identifier("echo", &c).as_deref(),
757      Some("allow-echo")
758    );
759  }
760
761  #[test]
762  fn no_suggestion_without_candidates() {
763    assert_eq!(suggest_identifier("fs:allow-read", &[]), None);
764  }
765
766  #[test]
767  fn exact_match_is_never_suggested() {
768    assert_eq!(
769      suggest_identifier("fs:default", &candidates()),
770      None,
771      "an exact match is not a misspelling, so nothing should be suggested"
772    );
773  }
774}