1use 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#[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#[derive(Debug, Clone)]
42pub enum DefaultPermissionRule {
43 AllowAllCommands,
45 Allow(Vec<String>),
50}
51
52impl InlinedPlugin {
53 pub fn new() -> Self {
55 Self::default()
56 }
57
58 pub fn commands(mut self, commands: &'static [&'static str]) -> Self {
61 self.commands = commands;
62 self
63 }
64
65 pub fn permissions_path_pattern(mut self, pattern: &'static str) -> Self {
71 self.permissions_path_pattern.replace(pattern);
72 self
73 }
74
75 pub fn default_permission(mut self, default: DefaultPermissionRule) -> Self {
79 self.default.replace(default);
80 self
81 }
82}
83
84#[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 pub fn new() -> Self {
99 Self::default()
100 }
101
102 pub fn commands(mut self, commands: &'static [&'static str]) -> Self {
105 self.commands = commands;
106 self
107 }
108
109 pub fn permissions_path_pattern(mut self, pattern: &'static str) -> Self {
115 self.permissions_path_pattern.replace(pattern);
116 self
117 }
118}
119
120fn 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
132fn 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
144fn 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 |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 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 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 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 tauri_utils::acl::build::generate_allowed_commands(
477 out_dir,
478 Some(capabilities_from_files),
479 permissions_map,
480 )?;
481
482 Ok(())
483}
484
485const SUGGESTION_CONFIDENCE_THRESHOLD: f64 = 0.8;
491
492fn 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
499fn 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
507fn 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
516fn 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
538fn 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 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 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}