use std::{
collections::{BTreeMap, HashMap},
env, fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use tauri_utils::{
acl::{
ACL_MANIFESTS_FILE_NAME, APP_ACL_KEY, CAPABILITIES_FILE_NAME,
capability::Capability,
get_capabilities,
manifest::{Manifest, PermissionFile},
schema::CAPABILITIES_SCHEMA_FOLDER_PATH,
},
config::Config,
platform::Target,
write_if_changed,
};
use crate::Attributes;
#[derive(Debug, Default, Clone)]
pub struct InlinedPlugin {
commands: &'static [&'static str],
permissions_path_pattern: Option<&'static str>,
default: Option<DefaultPermissionRule>,
}
#[derive(Debug, Clone)]
pub enum DefaultPermissionRule {
AllowAllCommands,
Allow(Vec<String>),
}
impl InlinedPlugin {
pub fn new() -> Self {
Self::default()
}
pub fn commands(mut self, commands: &'static [&'static str]) -> Self {
self.commands = commands;
self
}
pub fn permissions_path_pattern(mut self, pattern: &'static str) -> Self {
self.permissions_path_pattern.replace(pattern);
self
}
pub fn default_permission(mut self, default: DefaultPermissionRule) -> Self {
self.default.replace(default);
self
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct AppManifest {
commands: &'static [&'static str],
permissions_path_pattern: Option<&'static str>,
}
impl AppManifest {
pub fn new() -> Self {
Self::default()
}
pub fn commands(mut self, commands: &'static [&'static str]) -> Self {
self.commands = commands;
self
}
pub fn permissions_path_pattern(mut self, pattern: &'static str) -> Self {
self.permissions_path_pattern.replace(pattern);
self
}
}
fn save_capabilities(capabilities: &BTreeMap<String, Capability>) -> Result<PathBuf> {
let dir = Path::new(CAPABILITIES_SCHEMA_FOLDER_PATH);
fs::create_dir_all(dir)?;
let path = dir.join(CAPABILITIES_FILE_NAME);
let json = serde_json::to_string(&capabilities)?;
write_if_changed(&path, json)?;
Ok(path)
}
fn save_acl_manifests(acl_manifests: &BTreeMap<String, Manifest>) -> Result<PathBuf> {
let dir = Path::new(CAPABILITIES_SCHEMA_FOLDER_PATH);
fs::create_dir_all(dir)?;
let path = dir.join(ACL_MANIFESTS_FILE_NAME);
let json = serde_json::to_string(&acl_manifests)?;
write_if_changed(&path, json)?;
Ok(path)
}
fn read_plugins_manifests() -> Result<BTreeMap<String, Manifest>> {
use tauri_utils::acl;
let permission_map =
acl::build::read_permissions().context("failed to read plugin permissions")?;
let mut global_scope_map =
acl::build::read_global_scope_schemas().context("failed to read global scope schemas")?;
let mut manifests = BTreeMap::new();
for (plugin_name, permission_files) in permission_map {
let global_scope_schema = global_scope_map.remove(&plugin_name);
let manifest = Manifest::new(permission_files, global_scope_schema);
manifests.insert(plugin_name, manifest);
}
Ok(manifests)
}
struct InlinedPluginsAcl {
manifests: BTreeMap<String, Manifest>,
permission_files: BTreeMap<String, Vec<PermissionFile>>,
}
fn inline_plugins(
out_dir: &Path,
inlined_plugins: HashMap<&'static str, InlinedPlugin>,
) -> Result<InlinedPluginsAcl> {
let mut acl_manifests = BTreeMap::new();
let mut permission_files_map = BTreeMap::new();
for (name, plugin) in inlined_plugins {
let plugin_out_dir = out_dir.join("plugins").join(name);
fs::create_dir_all(&plugin_out_dir)?;
let mut permission_files = if plugin.commands.is_empty() {
Vec::new()
} else {
let autogenerated = tauri_utils::acl::build::autogenerate_command_permissions(
&plugin_out_dir,
plugin.commands,
"",
false,
);
let default_permissions = plugin.default.map(|default| match default {
DefaultPermissionRule::AllowAllCommands => autogenerated.allowed,
DefaultPermissionRule::Allow(permissions) => permissions,
});
if let Some(default_permissions) = default_permissions {
let default_permissions = default_permissions
.iter()
.map(|p| format!("\"{p}\""))
.collect::<Vec<String>>()
.join(",");
let default_permission = format!(
r###"# Automatically generated - DO NOT EDIT!
[default]
permissions = [{default_permissions}]
"###
);
let default_permission_path = plugin_out_dir.join("default.toml");
write_if_changed(&default_permission_path, default_permission)
.unwrap_or_else(|_| panic!("unable to autogenerate {default_permission_path:?}"));
}
tauri_utils::acl::build::define_permissions(
&PathBuf::from(glob::Pattern::escape(&plugin_out_dir.to_string_lossy()))
.join("*")
.to_string_lossy(),
name,
&plugin_out_dir,
|_| true,
)?
};
if let Some(pattern) = plugin.permissions_path_pattern {
permission_files.extend(tauri_utils::acl::build::define_permissions(
pattern,
name,
&plugin_out_dir,
|_| true,
)?);
} else {
let default_permissions_path = Path::new("permissions").join(name);
if default_permissions_path.exists() {
println!(
"cargo:rerun-if-changed={}",
default_permissions_path.display()
);
}
permission_files.extend(tauri_utils::acl::build::define_permissions(
&PathBuf::from(glob::Pattern::escape(
&default_permissions_path.to_string_lossy(),
))
.join("**")
.join("*")
.to_string_lossy(),
name,
&plugin_out_dir,
|_| true,
)?);
}
permission_files_map.insert(name.into(), permission_files.clone());
let manifest = tauri_utils::acl::manifest::Manifest::new(permission_files, None);
acl_manifests.insert(name.into(), manifest);
}
Ok(InlinedPluginsAcl {
manifests: acl_manifests,
permission_files: permission_files_map,
})
}
#[derive(Debug)]
struct AppManifestAcl {
manifest: Manifest,
permission_files: Vec<PermissionFile>,
}
fn app_manifest_permissions(
out_dir: &Path,
manifest: AppManifest,
inlined_plugins: &HashMap<&'static str, InlinedPlugin>,
) -> Result<AppManifestAcl> {
let app_out_dir = out_dir.join("app-manifest");
fs::create_dir_all(&app_out_dir)?;
let pkg_name = "__app__";
let mut permission_files = if manifest.commands.is_empty() {
Vec::new()
} else {
let autogenerated_path = Path::new("./permissions/autogenerated");
tauri_utils::acl::build::autogenerate_command_permissions(
autogenerated_path,
manifest.commands,
"",
false,
);
tauri_utils::acl::build::define_permissions(
&autogenerated_path.join("*").to_string_lossy(),
pkg_name,
&app_out_dir,
|_| true,
)?
};
if let Some(pattern) = manifest.permissions_path_pattern {
permission_files.extend(tauri_utils::acl::build::define_permissions(
pattern,
pkg_name,
&app_out_dir,
|_| true,
)?);
} else {
let default_permissions_path = Path::new("permissions");
if default_permissions_path.exists() {
println!(
"cargo:rerun-if-changed={}",
default_permissions_path.display()
);
}
let permissions_root = env::current_dir()?.join("permissions");
let inlined_plugins_permissions: Vec<_> = inlined_plugins
.keys()
.map(|name| permissions_root.join(name))
.flat_map(|p| p.canonicalize())
.collect();
permission_files.extend(tauri_utils::acl::build::define_permissions(
&default_permissions_path
.join("**")
.join("*")
.to_string_lossy(),
pkg_name,
&app_out_dir,
|p| {
!inlined_plugins_permissions
.iter()
.any(|inlined_path| p.starts_with(inlined_path))
},
)?);
}
Ok(AppManifestAcl {
permission_files: permission_files.clone(),
manifest: tauri_utils::acl::manifest::Manifest::new(permission_files, None),
})
}
fn validate_capabilities(
acl_manifests: &BTreeMap<String, Manifest>,
capabilities: &BTreeMap<String, Capability>,
) -> Result<()> {
let target = tauri_utils::platform::Target::from_triple(&std::env::var("TARGET").unwrap());
for capability in capabilities.values() {
if !capability
.platforms
.as_ref()
.map(|platforms| platforms.contains(&target))
.unwrap_or(true)
{
continue;
}
for permission_entry in &capability.permissions {
let permission_id = permission_entry.identifier();
let key = permission_id.get_prefix().unwrap_or(APP_ACL_KEY);
let permission_name = permission_id.get_base();
let permission_exists = acl_manifests
.get(key)
.map(|manifest| {
permission_name == "default"
|| manifest.permissions.contains_key(permission_name)
|| manifest.permission_sets.contains_key(permission_name)
})
.unwrap_or(false);
if !permission_exists {
let mut available_permissions = Vec::new();
for (key, manifest) in acl_manifests {
let prefix = if key == APP_ACL_KEY {
"".to_string()
} else {
format!("{key}:")
};
if manifest.default_permission.is_some() {
available_permissions.push(format!("{prefix}default"));
}
for p in manifest.permissions.keys() {
available_permissions.push(format!("{prefix}{p}"));
}
for p in manifest.permission_sets.keys() {
available_permissions.push(format!("{prefix}{p}"));
}
}
let hint = suggest_identifier(permission_id.get(), &available_permissions)
.map(|suggestion| format!(" (did you mean \"{suggestion}\"?)"))
.unwrap_or_default();
anyhow::bail!(
"Permission {} not found{hint}, expected one of {}",
permission_id.get(),
available_permissions.join(", ")
);
}
}
}
Ok(())
}
pub fn build(
out_dir: &Path,
target: Target,
config: &Config,
attributes: &Attributes,
) -> super::Result<()> {
let mut acl_manifests = read_plugins_manifests()?;
let app_acl = app_manifest_permissions(
out_dir,
attributes.app_manifest,
&attributes.inlined_plugins,
)?;
let has_app_manifest = app_acl.manifest.default_permission.is_some()
|| !app_acl.manifest.permission_sets.is_empty()
|| !app_acl.manifest.permissions.is_empty();
if has_app_manifest {
acl_manifests.insert(APP_ACL_KEY.into(), app_acl.manifest);
}
let inline_plugins_acl = inline_plugins(out_dir, attributes.inlined_plugins.clone())?;
acl_manifests.extend(inline_plugins_acl.manifests);
let acl_manifests_path = save_acl_manifests(&acl_manifests)?;
fs::copy(acl_manifests_path, out_dir.join(ACL_MANIFESTS_FILE_NAME))?;
tauri_utils::acl::schema::generate_capability_schema(&acl_manifests, target)?;
let capabilities_from_files = if let Some(pattern) = attributes.capabilities_path_pattern {
tauri_utils::acl::build::parse_capabilities(pattern)?
} else {
let capabilities_dir = std::env::current_dir()
.context("resolve current dir for capabilities watch path")?
.join("capabilities");
println!("cargo:rerun-if-changed={}", capabilities_dir.display());
tauri_utils::acl::build::parse_capabilities("./capabilities/**/*")?
};
let capabilities = get_capabilities(config, capabilities_from_files.clone(), None)
.context("failed to resolve the capabilities from the Tauri configuration file")?;
validate_capabilities(&acl_manifests, &capabilities)?;
let capabilities_path = save_capabilities(&capabilities_from_files)?;
fs::copy(capabilities_path, out_dir.join(CAPABILITIES_FILE_NAME))?;
let mut permissions_map = inline_plugins_acl.permission_files;
if has_app_manifest {
permissions_map.insert(APP_ACL_KEY.to_string(), app_acl.permission_files);
}
tauri_utils::acl::build::generate_allowed_commands(
out_dir,
Some(capabilities_from_files),
permissions_map,
)?;
Ok(())
}
const SUGGESTION_CONFIDENCE_THRESHOLD: f64 = 0.8;
fn components_match(input: &str, candidate: &str) -> bool {
input == candidate
|| (input.chars().count().max(candidate.chars().count()) >= 4
&& strsim::osa_distance(input, candidate) <= 1)
}
fn split_identifier(identifier: &str) -> (Option<&str>, &str) {
match identifier.rsplit_once(':') {
Some((namespace, base)) => (Some(namespace), base),
None => (None, identifier),
}
}
fn base_components_match(input_parts: &[&str], candidate_base: &str) -> bool {
candidate_base.split('-').count() == input_parts.len()
&& input_parts
.iter()
.zip(candidate_base.split('-'))
.all(|(a, b)| components_match(a, b))
}
fn namespaces_match(
input_namespace: Option<&str>,
candidate_namespace: Option<&str>,
input_namespace_known: bool,
) -> bool {
match (input_namespace, candidate_namespace) {
(input, candidate) if input == candidate => true,
(Some(input), Some(candidate)) if !input_namespace_known => {
input.split(':').count() == candidate.split(':').count()
&& input
.split(':')
.zip(candidate.split(':'))
.all(|(a, b)| components_match(a, b))
}
_ => false,
}
}
fn suggest_identifier(input: &str, candidates: &[String]) -> Option<String> {
let (input_namespace, input_base) = split_identifier(input);
let input_namespace_known = candidates
.iter()
.any(|candidate| split_identifier(candidate).0 == input_namespace);
let mut queries = vec![(input.to_string(), input_base.to_string())];
if !input_base.starts_with("allow-") && !input_base.starts_with("deny-") {
let base = format!("allow-{input_base}");
let query = match input_namespace {
Some(namespace) => format!("{namespace}:{base}"),
None => base.clone(),
};
queries.push((query, base));
}
let mut best_confidence = 0.0;
let mut best_match: Option<&String> = None;
for (query, query_base) in &queries {
let query_parts: Vec<&str> = query_base.split('-').collect();
for candidate in candidates {
if candidate == input {
continue;
}
let (candidate_namespace, candidate_base) = split_identifier(candidate);
if !namespaces_match(input_namespace, candidate_namespace, input_namespace_known) {
continue;
}
if !base_components_match(&query_parts, candidate_base) {
continue;
}
let confidence = strsim::jaro_winkler(query, candidate);
let edit_similarity = strsim::normalized_levenshtein(query, candidate);
if confidence >= SUGGESTION_CONFIDENCE_THRESHOLD
&& edit_similarity >= SUGGESTION_CONFIDENCE_THRESHOLD
&& confidence > best_confidence
{
best_confidence = confidence;
best_match = Some(candidate);
}
}
}
best_match.cloned()
}
#[cfg(test)]
mod tests {
use super::suggest_identifier;
fn candidates() -> Vec<String> {
[
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"fs:default",
"core:default",
"core:event:allow-listen",
]
.into_iter()
.map(String::from)
.collect()
}
#[test]
fn suggests_on_missing_trailing_character() {
assert_eq!(
suggest_identifier("fs:allow-read-text-fil", &candidates()).as_deref(),
Some("fs:allow-read-text-file")
);
}
#[test]
fn suggests_nearest_candidate_for_typo() {
assert_eq!(
suggest_identifier("fs:allow-read-text-fiel", &candidates()).as_deref(),
Some("fs:allow-read-text-file")
);
}
#[test]
fn picks_the_closest_candidate() {
let candidates: Vec<String> = ["core:event:allow-listen", "core:event:allow-unlisten"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(
suggest_identifier("core:event:allow-listenx", &candidates).as_deref(),
Some("core:event:allow-listen")
);
}
#[test]
fn no_suggestion_for_read_write_mismatch() {
let c: Vec<String> = ["fs:allow-write-text-file"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(suggest_identifier("fs:allow-read-text-file", &c), None);
}
#[test]
fn no_suggestion_for_listen_unlisten_mismatch() {
let c: Vec<String> = ["core:event:allow-unlisten"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(suggest_identifier("core:event:allow-listen", &c), None);
}
#[test]
fn suggests_transposed_letters_in_action() {
let c: Vec<String> = ["core:event:allow-listen"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(
suggest_identifier("core:event:allow-lisetn", &c).as_deref(),
Some("core:event:allow-listen")
);
}
#[test]
fn no_suggestion_for_different_hyphenation() {
let c: Vec<String> = ["fs:allow-read-text-file"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(suggest_identifier("fs:allow-readtext-file", &c), None);
}
#[test]
fn short_action_components_must_match_exactly() {
let c: Vec<String> = ["core:window:allow-in"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(suggest_identifier("core:window:allow-on", &c), None);
}
#[test]
fn no_suggestion_for_totally_different_identifier() {
assert_eq!(suggest_identifier("totally-unrelated", &candidates()), None);
assert_eq!(suggest_identifier("", &candidates()), None);
}
#[test]
fn no_suggestion_for_incomplete_permission() {
assert_eq!(suggest_identifier("fs:allow", &candidates()), None);
assert_eq!(suggest_identifier("fs:allow-foo", &candidates()), None);
}
#[test]
fn no_suggestion_for_different_namespace() {
assert_eq!(
suggest_identifier("xx:allow-read-text-file", &candidates()),
None
);
}
#[test]
fn suggests_for_namespace_typo() {
let c: Vec<String> = ["core:window:allow-set-title", "dialog:default"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(
suggest_identifier("core:windw:allow-set-title", &c).as_deref(),
Some("core:window:allow-set-title")
);
assert_eq!(
suggest_identifier("dialgo:default", &c).as_deref(),
Some("dialog:default")
);
}
#[test]
fn no_namespace_typo_suggestion_when_namespace_exists() {
let c: Vec<String> = ["core:window:allow-close", "core:webview:allow-print"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(suggest_identifier("core:window:allow-print", &c), None);
}
#[test]
fn suggests_missing_allow_prefix() {
let c: Vec<String> = [
"core:window:allow-set-title",
"core:window:deny-set-title",
"allow-echo",
]
.into_iter()
.map(String::from)
.collect();
assert_eq!(
suggest_identifier("core:window:set-title", &c).as_deref(),
Some("core:window:allow-set-title")
);
assert_eq!(
suggest_identifier("core:window:set-titel", &c).as_deref(),
Some("core:window:allow-set-title")
);
assert_eq!(
suggest_identifier("echo", &c).as_deref(),
Some("allow-echo")
);
}
#[test]
fn no_suggestion_without_candidates() {
assert_eq!(suggest_identifier("fs:allow-read", &[]), None);
}
#[test]
fn exact_match_is_never_suggested() {
assert_eq!(
suggest_identifier("fs:default", &candidates()),
None,
"an exact match is not a misspelling, so nothing should be suggested"
);
}
}