(chore): update codebase

This commit is contained in:
Alois 2026-05-01 19:57:03 +02:00
commit 4e8d46b20e
106 changed files with 29936 additions and 1023 deletions

View file

@ -0,0 +1,500 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! ACL items that are only useful inside of build script/codegen context.
use std::{
collections::{BTreeMap, HashMap},
env, fs,
path::{Path, PathBuf},
};
use crate::{
acl::{AllowedCommands, Error, has_app_manifest},
config::Config,
write_if_changed,
};
use super::{
ALLOWED_COMMANDS_FILE_NAME, PERMISSION_SCHEMA_FILE_NAME, PERMISSION_SCHEMAS_FOLDER_NAME,
REMOVE_UNUSED_COMMANDS_ENV_VAR,
capability::{Capability, CapabilityFile},
manifest::PermissionFile,
};
/// Known name of the folder containing autogenerated permissions.
pub const AUTOGENERATED_FOLDER_NAME: &str = "autogenerated";
/// Cargo cfg key for permissions file paths
pub const PERMISSION_FILES_PATH_KEY: &str = "PERMISSION_FILES_PATH";
/// Cargo cfg key for global scope schemas
pub const GLOBAL_SCOPE_SCHEMA_PATH_KEY: &str = "GLOBAL_SCOPE_SCHEMA_PATH";
/// Allowed permission file extensions
pub const PERMISSION_FILE_EXTENSIONS: &[&str] = &["json", "toml"];
/// Known filename of the permission documentation file
pub const PERMISSION_DOCS_FILE_NAME: &str = "reference.md";
/// Allowed capability file extensions
const CAPABILITY_FILE_EXTENSIONS: &[&str] = &[
"json",
#[cfg(feature = "config-json5")]
"json5",
"toml",
];
/// Known folder name of the capability schemas
const CAPABILITIES_SCHEMA_FOLDER_NAME: &str = "schemas";
const CORE_PLUGIN_PERMISSIONS_TOKEN: &str = "__CORE_PLUGIN__";
fn parse_permissions(paths: Vec<PathBuf>) -> Result<Vec<PermissionFile>, Error> {
let mut permissions = Vec::new();
for path in paths {
let ext = path.extension().unwrap().to_string_lossy().to_string();
let permission_file = fs::read_to_string(&path).map_err(|e| Error::ReadFile(e, path))?;
let permission: PermissionFile = match ext.as_str() {
"toml" => toml::from_str(&permission_file)?,
"json" => serde_json::from_str(&permission_file)?,
_ => return Err(Error::UnknownPermissionFormat(ext)),
};
permissions.push(permission);
}
Ok(permissions)
}
/// Write the permissions to a temporary directory and pass it to the immediate consuming crate.
pub fn define_permissions<F: Fn(&Path) -> bool>(
pattern: &str,
pkg_name: &str,
out_dir: &Path,
filter_fn: F,
) -> Result<Vec<PermissionFile>, Error> {
let permission_files = glob::glob(pattern)?
.flatten()
.flat_map(|p| p.canonicalize())
// filter extension
.filter(|p| {
p.extension()
.and_then(|e| e.to_str())
.map(|e| PERMISSION_FILE_EXTENSIONS.contains(&e))
.unwrap_or_default()
})
.filter(|p| filter_fn(p))
// filter schemas
.filter(|p| p.parent().unwrap().file_name().unwrap() != PERMISSION_SCHEMAS_FOLDER_NAME)
.collect::<Vec<PathBuf>>();
let pkg_name_valid_path = pkg_name.replace(':', "-");
let permission_files_path = out_dir.join(format!("{pkg_name_valid_path}-permission-files"));
let permission_files_json = serde_json::to_string(&permission_files)?;
write_if_changed(&permission_files_path, permission_files_json)
.map_err(|e| Error::WriteFile(e, permission_files_path.clone()))?;
if let Some(plugin_name) = pkg_name.strip_prefix("tauri:") {
println!(
"cargo:{plugin_name}{CORE_PLUGIN_PERMISSIONS_TOKEN}_{PERMISSION_FILES_PATH_KEY}={}",
permission_files_path.display()
);
} else {
println!(
"cargo:{PERMISSION_FILES_PATH_KEY}={}",
permission_files_path.display()
);
}
parse_permissions(permission_files)
}
/// Read all permissions listed from the defined cargo cfg key value.
pub fn read_permissions() -> Result<HashMap<String, Vec<PermissionFile>>, Error> {
let mut permissions_map = HashMap::new();
for (key, value) in env::vars_os() {
let key = key.to_string_lossy();
if let Some(plugin_crate_name_var) = key
.strip_prefix("DEP_")
.and_then(|v| v.strip_suffix(&format!("_{PERMISSION_FILES_PATH_KEY}")))
.map(|v| {
v.strip_suffix(CORE_PLUGIN_PERMISSIONS_TOKEN)
.and_then(|v| v.strip_prefix("TAURI_"))
.unwrap_or(v)
})
{
let permissions_path = PathBuf::from(value);
let permissions_str =
fs::read_to_string(&permissions_path).map_err(|e| Error::ReadFile(e, permissions_path))?;
let permissions: Vec<PathBuf> = serde_json::from_str(&permissions_str)?;
let permissions = parse_permissions(permissions)?;
let plugin_crate_name = plugin_crate_name_var.to_lowercase().replace('_', "-");
let plugin_crate_name = plugin_crate_name
.strip_prefix("tauri-plugin-")
.map(ToString::to_string)
.unwrap_or(plugin_crate_name);
permissions_map.insert(plugin_crate_name, permissions);
}
}
Ok(permissions_map)
}
/// Define the global scope schema JSON file path if it exists and pass it to the immediate consuming crate.
pub fn define_global_scope_schema(
schema: schemars::Schema,
pkg_name: &str,
out_dir: &Path,
) -> Result<(), Error> {
let path = out_dir.join("global-scope.json");
write_if_changed(&path, serde_json::to_vec(&schema)?)
.map_err(|e| Error::WriteFile(e, path.clone()))?;
if let Some(plugin_name) = pkg_name.strip_prefix("tauri:") {
println!(
"cargo:{plugin_name}{CORE_PLUGIN_PERMISSIONS_TOKEN}_{GLOBAL_SCOPE_SCHEMA_PATH_KEY}={}",
path.display()
);
} else {
println!("cargo:{GLOBAL_SCOPE_SCHEMA_PATH_KEY}={}", path.display());
}
Ok(())
}
/// Read all global scope schemas listed from the defined cargo cfg key value.
pub fn read_global_scope_schemas() -> Result<HashMap<String, serde_json::Value>, Error> {
let mut schemas_map = HashMap::new();
for (key, value) in env::vars_os() {
let key = key.to_string_lossy();
if let Some(plugin_crate_name_var) = key
.strip_prefix("DEP_")
.and_then(|v| v.strip_suffix(&format!("_{GLOBAL_SCOPE_SCHEMA_PATH_KEY}")))
.map(|v| {
v.strip_suffix(CORE_PLUGIN_PERMISSIONS_TOKEN)
.and_then(|v| v.strip_prefix("TAURI_"))
.unwrap_or(v)
})
{
let path = PathBuf::from(value);
let json = fs::read_to_string(&path).map_err(|e| Error::ReadFile(e, path))?;
let schema: serde_json::Value = serde_json::from_str(&json)?;
let plugin_crate_name = plugin_crate_name_var.to_lowercase().replace('_', "-");
let plugin_crate_name = plugin_crate_name
.strip_prefix("tauri-plugin-")
.map(ToString::to_string)
.unwrap_or(plugin_crate_name);
schemas_map.insert(plugin_crate_name, schema);
}
}
Ok(schemas_map)
}
/// Parses all capability files with the given glob pattern.
pub fn parse_capabilities(pattern: &str) -> Result<BTreeMap<String, Capability>, Error> {
let mut capabilities_map = BTreeMap::new();
for path in glob::glob(pattern)?
.flatten() // filter extension
.filter(|p| {
p.extension()
.and_then(|e| e.to_str())
.map(|e| CAPABILITY_FILE_EXTENSIONS.contains(&e))
.unwrap_or_default()
})
// filter schema files
// TODO: remove this before stable
.filter(|p| p.parent().unwrap().file_name().unwrap() != CAPABILITIES_SCHEMA_FOLDER_NAME)
{
match CapabilityFile::load(&path)? {
CapabilityFile::Capability(capability) => {
if capabilities_map.contains_key(&capability.identifier) {
return Err(Error::CapabilityAlreadyExists {
identifier: capability.identifier,
});
}
capabilities_map.insert(capability.identifier.clone(), capability);
}
CapabilityFile::List(capabilities) | CapabilityFile::NamedList { capabilities } => {
for capability in capabilities {
if capabilities_map.contains_key(&capability.identifier) {
return Err(Error::CapabilityAlreadyExists {
identifier: capability.identifier,
});
}
capabilities_map.insert(capability.identifier.clone(), capability);
}
}
}
}
Ok(capabilities_map)
}
/// Permissions that are generated from commands using [`autogenerate_command_permissions`].
pub struct AutogeneratedPermissions {
/// The allow permissions generated from commands.
pub allowed: Vec<String>,
/// The deny permissions generated from commands.
pub denied: Vec<String>,
}
/// Autogenerate permission files for a list of commands.
pub fn autogenerate_command_permissions(
path: &Path,
commands: &[&str],
license_header: &str,
schema_ref: bool,
) -> AutogeneratedPermissions {
if !path.exists() {
fs::create_dir_all(path).expect("unable to create autogenerated commands dir");
}
let schema_entry = if schema_ref {
let cwd = env::current_dir().unwrap();
let components_len = path.strip_prefix(&cwd).unwrap_or(path).components().count();
let schema_path = (1..components_len)
.map(|_| "..")
.collect::<PathBuf>()
.join(PERMISSION_SCHEMAS_FOLDER_NAME)
.join(PERMISSION_SCHEMA_FILE_NAME);
format!(
"\n\"$schema\" = \"{}\"\n",
dunce::simplified(&schema_path)
.display()
.to_string()
.replace('\\', "/")
)
} else {
"".to_string()
};
let mut autogenerated = AutogeneratedPermissions {
allowed: Vec::new(),
denied: Vec::new(),
};
for command in commands {
let slugified_command = command.replace('_', "-");
let toml = format!(
r###"{license_header}# Automatically generated - DO NOT EDIT!
{schema_entry}
[[permission]]
identifier = "allow-{slugified_command}"
description = "Enables the {command} command without any pre-configured scope."
commands.allow = ["{command}"]
[[permission]]
identifier = "deny-{slugified_command}"
description = "Denies the {command} command without any pre-configured scope."
commands.deny = ["{command}"]
"###,
);
let out_path = path.join(format!("{command}.toml"));
write_if_changed(&out_path, toml)
.unwrap_or_else(|_| panic!("unable to autogenerate {out_path:?}"));
autogenerated
.allowed
.push(format!("allow-{slugified_command}"));
autogenerated
.denied
.push(format!("deny-{slugified_command}"));
}
autogenerated
}
const PERMISSION_TABLE_HEADER: &str =
"## Permission Table\n\n<table>\n<tr>\n<th>Identifier</th>\n<th>Description</th>\n</tr>\n";
/// Generate a markdown documentation page containing the list of permissions of the plugin.
pub fn generate_docs(
permissions: &[PermissionFile],
out_dir: &Path,
plugin_identifier: &str,
) -> Result<(), Error> {
let mut default_permission = "".to_owned();
let mut permission_table = "".to_string();
fn docs_from(id: &str, description: Option<&str>, plugin_identifier: &str) -> String {
let mut docs = format!("\n<tr>\n<td>\n\n`{plugin_identifier}:{id}`\n\n</td>\n");
if let Some(d) = description {
docs.push_str(&format!("<td>\n\n{d}\n\n</td>"));
}
docs.push_str("\n</tr>");
docs
}
for permission in permissions {
for set in &permission.set {
permission_table.push_str(&docs_from(
&set.identifier,
Some(&set.description),
plugin_identifier,
));
permission_table.push('\n');
}
if let Some(default) = &permission.default {
default_permission.push_str("## Default Permission\n\n");
default_permission.push_str(default.description.as_deref().unwrap_or_default().trim());
default_permission.push('\n');
default_permission.push('\n');
if !default.permissions.is_empty() {
default_permission.push_str("#### This default permission set includes the following:\n\n");
for permission in &default.permissions {
default_permission.push_str(&format!("- `{permission}`\n"));
}
default_permission.push('\n');
}
}
for permission in &permission.permission {
permission_table.push_str(&docs_from(
&permission.identifier,
permission.description.as_deref(),
plugin_identifier,
));
permission_table.push('\n');
}
}
let docs = format!("{default_permission}{PERMISSION_TABLE_HEADER}\n{permission_table}</table>\n");
let reference_path = out_dir.join(PERMISSION_DOCS_FILE_NAME);
write_if_changed(&reference_path, docs).map_err(|e| Error::WriteFile(e, reference_path))?;
Ok(())
}
// TODO: We have way too many duplicated code around getting the config files, e.g.
// - crates/tauri-codegen/src/lib.rs (`get_config`)
// - crates/tauri-build/src/lib.rs (`try_build`)
// - crates/tauri-cli/src/helpers/config.rs (`get_internal`)
/// Generate allowed commands file for the `generate_handler` macro to remove never allowed commands
pub fn generate_allowed_commands(
out_dir: &Path,
capabilities_from_files: Option<BTreeMap<String, Capability>>,
permissions_map: BTreeMap<String, Vec<PermissionFile>>,
) -> Result<(), anyhow::Error> {
println!("cargo:rerun-if-env-changed={REMOVE_UNUSED_COMMANDS_ENV_VAR}");
let allowed_commands_file_path = out_dir.join(ALLOWED_COMMANDS_FILE_NAME);
let remove_unused_commands_env_var = std::env::var(REMOVE_UNUSED_COMMANDS_ENV_VAR);
let should_generate_allowed_commands =
remove_unused_commands_env_var.is_ok() && !permissions_map.is_empty();
if !should_generate_allowed_commands {
let _ = std::fs::remove_file(allowed_commands_file_path);
return Ok(());
}
// It's safe to `unwrap` here since we have checked if the result is ok above
let config_directory = PathBuf::from(remove_unused_commands_env_var.unwrap());
let capabilities_path = config_directory.join("capabilities");
// Cargo re-builds if the variable points to an empty path,
// so we check for exists here
// see https://github.com/rust-lang/cargo/issues/4213
if capabilities_path.exists() {
println!("cargo:rerun-if-changed={}", capabilities_path.display());
}
let target_triple = env::var("TARGET")?;
let target = crate::platform::Target::from_triple(&target_triple);
let (mut config, config_paths) = crate::config::parse::read_from(target, &config_directory)?;
for config_file_path in config_paths {
println!("cargo:rerun-if-changed={}", config_file_path.display());
}
if let Ok(env) = std::env::var("TAURI_CONFIG") {
let merge_config: serde_json::Value = serde_json::from_str(&env)?;
json_patch::merge(&mut config, &merge_config);
}
println!("cargo:rerun-if-env-changed=TAURI_CONFIG");
// Set working directory to where `tauri.config.json` is, so that relative paths in it are parsed correctly.
let old_cwd = std::env::current_dir()?;
std::env::set_current_dir(config_directory)?;
let config: Config = serde_json::from_value(config)?;
// Reset working directory.
std::env::set_current_dir(old_cwd)?;
let acl: BTreeMap<String, crate::acl::manifest::Manifest> = permissions_map
.into_iter()
.map(|(key, permissions)| {
let key = key
.strip_prefix("tauri-plugin-")
.unwrap_or(&key)
.to_string();
let manifest = crate::acl::manifest::Manifest::new(permissions, None);
(key, manifest)
})
.collect();
let capabilities_from_files = if let Some(capabilities) = capabilities_from_files {
capabilities
} else {
crate::acl::build::parse_capabilities(&format!(
"{}/**/*",
glob::Pattern::escape(&capabilities_path.to_string_lossy())
))?
};
let capabilities = crate::acl::get_capabilities(&config, capabilities_from_files, None)?;
let permission_entries = capabilities
.into_values()
.flat_map(|capabilities| capabilities.permissions);
let mut allowed_commands = AllowedCommands {
has_app_acl: has_app_manifest(&acl),
..Default::default()
};
for permission_entry in permission_entries {
let Ok(permissions) =
crate::acl::resolved::get_permissions(permission_entry.identifier(), &acl)
else {
continue;
};
for permission in permissions {
let plugin_name = permission.key;
let allowed_command_names = &permission.permission.commands.allow;
for allowed_command in allowed_command_names {
let command_name = if plugin_name == crate::acl::APP_ACL_KEY {
allowed_command.to_string()
} else if let Some(core_plugin_name) = plugin_name.strip_prefix("core:") {
format!("plugin:{core_plugin_name}|{allowed_command}")
} else {
format!("plugin:{plugin_name}|{allowed_command}")
};
allowed_commands.commands.insert(command_name);
}
}
}
write_if_changed(
allowed_commands_file_path,
serde_json::to_string(&allowed_commands)?,
)?;
Ok(())
}

View file

@ -0,0 +1,452 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! End-user abstraction for selecting permissions a window has access to.
use std::{path::Path, str::FromStr};
use crate::{acl::Identifier, platform::Target};
use serde::{
Deserialize, Deserializer, Serialize,
de::{Error, IntoDeserializer},
};
use serde_untagged::UntaggedEnumVisitor;
use super::Scopes;
/// An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`]
/// or an object that references a permission and extends its scope.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum PermissionEntry {
/// Reference a permission or permission set by identifier.
PermissionRef(Identifier),
/// Reference a permission or permission set by identifier and extends its scope.
ExtendedPermission {
/// Identifier of the permission or permission set.
identifier: Identifier,
/// Scope to append to the existing permission scope.
#[serde(default, flatten)]
scope: Scopes,
},
}
impl PermissionEntry {
/// The identifier of the permission referenced in this entry.
pub fn identifier(&self) -> &Identifier {
match self {
Self::PermissionRef(identifier) => identifier,
Self::ExtendedPermission {
identifier,
scope: _,
} => identifier,
}
}
}
impl<'de> Deserialize<'de> for PermissionEntry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct ExtendedPermissionStruct {
identifier: Identifier,
#[serde(default, flatten)]
scope: Scopes,
}
UntaggedEnumVisitor::new()
.string(|string| {
let de = string.into_deserializer();
Identifier::deserialize(de).map(Self::PermissionRef)
})
.map(|map| {
let ext_perm = map.deserialize::<ExtendedPermissionStruct>()?;
Ok(Self::ExtendedPermission {
identifier: ext_perm.identifier,
scope: ext_perm.scope,
})
})
.deserialize(deserializer)
}
}
/// A grouping and boundary mechanism developers can use to isolate access to the IPC layer.
///
/// It controls application windows' and webviews' fine grained access
/// to the Tauri core, application, or plugin commands.
/// If a webview or its window is not matching any capability then it has no access to the IPC layer at all.
///
/// This can be done to create groups of windows, based on their required system access, which can reduce
/// impact of frontend vulnerabilities in less privileged windows.
/// Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`.
/// A Window can have none, one, or multiple associated capabilities.
///
/// ## Example
///
/// ```json
/// {
/// "identifier": "main-user-files-write",
/// "description": "This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.",
/// "windows": [
/// "main"
/// ],
/// "permissions": [
/// "core:default",
/// "dialog:open",
/// {
/// "identifier": "fs:allow-write-text-file",
/// "allow": [{ "path": "$HOME/test.txt" }]
/// },
/// ],
/// "platforms": ["macOS","windows"]
/// }
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Capability {
/// Identifier of the capability.
///
/// ## Example
///
/// `main-user-files-write`
///
pub identifier: String,
/// Description of what the capability is intended to allow on associated windows.
///
/// It should contain a description of what the grouped permissions should allow.
///
/// ## Example
///
/// This capability allows the `main` window access to `filesystem` write related
/// commands and `dialog` commands to enable programmatic access to files selected by the user.
#[serde(default)]
pub description: String,
/// Configure remote URLs that can use the capability permissions.
///
/// This setting is optional and defaults to not being set, as our
/// default use case is that the content is served from our local application.
///
/// :::caution
/// Make sure you understand the security implications of providing remote
/// sources with local system access.
/// :::
///
/// ## Example
///
/// ```json
/// {
/// "urls": ["https://*.mydomain.dev"]
/// }
/// ```
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote: Option<CapabilityRemote>,
/// Whether this capability is enabled for local app URLs or not. Defaults to `true`.
#[serde(default = "default_capability_local")]
pub local: bool,
/// List of windows that are affected by this capability. Can be a glob pattern.
///
/// If a window label matches any of the patterns in this list,
/// the capability will be enabled on all the webviews of that window,
/// regardless of the value of [`Self::webviews`].
///
/// On multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`]
/// for a fine grained access control.
///
/// ## Example
///
/// `["main"]`
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub windows: Vec<String>,
/// List of webviews that are affected by this capability. Can be a glob pattern.
///
/// The capability will be enabled on all the webviews
/// whose label matches any of the patterns in this list,
/// regardless of whether the webview's window label matches a pattern in [`Self::windows`].
///
/// ## Example
///
/// `["sub-webview-one", "sub-webview-two"]`
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub webviews: Vec<String>,
/// List of permissions attached to this capability.
///
/// Must include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`.
/// For commands directly implemented in the application itself only `${permission-name}`
/// is required.
///
/// ## Example
///
/// ```json
/// [
/// "core:default",
/// "shell:allow-open",
/// "dialog:open",
/// {
/// "identifier": "fs:allow-write-text-file",
/// "allow": [{ "path": "$HOME/test.txt" }]
/// }
/// ]
/// ```
#[cfg_attr(feature = "schema", schemars(schema_with = "unique_permission"))]
pub permissions: Vec<PermissionEntry>,
/// Limit which target platforms this capability applies to.
///
/// By default all platforms are targeted.
///
/// ## Example
///
/// `["macOS","windows"]`
#[serde(skip_serializing_if = "Option::is_none")]
pub platforms: Option<Vec<Target>>,
}
impl Capability {
/// Whether this capability should be active based on the platform target or not.
pub fn is_active(&self, target: &Target) -> bool {
self
.platforms
.as_ref()
.map(|platforms| platforms.contains(target))
.unwrap_or(true)
}
}
#[cfg(feature = "schema")]
fn unique_permission(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
let items = serde_json::Value::from(generator.subschema_for::<PermissionEntry>());
schemars::json_schema!({
"type": "array",
"uniqueItems": true,
"items": items
})
}
fn default_capability_local() -> bool {
true
}
/// Configuration for remote URLs that are associated with the capability.
#[derive(Debug, Default, Clone, Serialize, Deserialize, Eq, PartialEq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct CapabilityRemote {
/// Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).
///
/// ## Examples
///
/// - "https://*.mydomain.dev": allows subdomains of mydomain.dev
/// - "https://mydomain.dev/api/*": allows any subpath of mydomain.dev/api
pub urls: Vec<String>,
}
/// Capability formats accepted in a capability file.
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schema", schemars(untagged))]
#[cfg_attr(test, derive(Debug, PartialEq))]
pub enum CapabilityFile {
/// A single capability.
Capability(Capability),
/// A list of capabilities.
List(Vec<Capability>),
/// A list of capabilities.
NamedList {
/// The list of capabilities.
capabilities: Vec<Capability>,
},
}
impl CapabilityFile {
/// Load the given capability file.
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, super::Error> {
let path = path.as_ref();
let capability_file =
std::fs::read_to_string(path).map_err(|e| super::Error::ReadFile(e, path.into()))?;
let ext = path.extension().unwrap().to_string_lossy().to_string();
let file: Self = match ext.as_str() {
"toml" => toml::from_str(&capability_file)?,
"json" => serde_json::from_str(&capability_file)?,
#[cfg(feature = "config-json5")]
"json5" => json5::from_str(&capability_file)?,
_ => return Err(super::Error::UnknownCapabilityFormat(ext)),
};
Ok(file)
}
}
impl<'de> Deserialize<'de> for CapabilityFile {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
UntaggedEnumVisitor::new()
.seq(|seq| seq.deserialize::<Vec<Capability>>().map(Self::List))
.map(|map| {
#[derive(Deserialize)]
struct CapabilityNamedList {
capabilities: Vec<Capability>,
}
let value: serde_json::Map<String, serde_json::Value> = map.deserialize()?;
if value.contains_key("capabilities") {
serde_json::from_value::<CapabilityNamedList>(value.into())
.map(|named| Self::NamedList {
capabilities: named.capabilities,
})
.map_err(|e| serde_untagged::de::Error::custom(e.to_string()))
} else {
serde_json::from_value::<Capability>(value.into())
.map(Self::Capability)
.map_err(|e| serde_untagged::de::Error::custom(e.to_string()))
}
})
.deserialize(deserializer)
}
}
impl FromStr for CapabilityFile {
type Err = super::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
serde_json::from_str(s)
.or_else(|_| toml::from_str(s))
.map_err(Into::into)
}
}
#[cfg(any(feature = "build", feature = "build-2"))]
mod build {
use std::convert::identity;
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use super::*;
use crate::{literal_struct, tokens::*};
impl ToTokens for CapabilityRemote {
fn to_tokens(&self, tokens: &mut TokenStream) {
let urls = vec_lit(&self.urls, str_lit);
literal_struct!(
tokens,
::tauri::utils::acl::capability::CapabilityRemote,
urls
);
}
}
impl ToTokens for PermissionEntry {
fn to_tokens(&self, tokens: &mut TokenStream) {
let prefix = quote! { ::tauri::utils::acl::capability::PermissionEntry };
tokens.append_all(match self {
Self::PermissionRef(id) => {
quote! { #prefix::PermissionRef(#id) }
}
Self::ExtendedPermission { identifier, scope } => {
quote! { #prefix::ExtendedPermission {
identifier: #identifier,
scope: #scope
} }
}
});
}
}
impl ToTokens for Capability {
fn to_tokens(&self, tokens: &mut TokenStream) {
let identifier = str_lit(&self.identifier);
let description = str_lit(&self.description);
let remote = opt_lit(self.remote.as_ref());
let local = self.local;
let windows = vec_lit(&self.windows, str_lit);
let webviews = vec_lit(&self.webviews, str_lit);
let permissions = vec_lit(&self.permissions, identity);
let platforms = opt_vec_lit(self.platforms.as_ref(), identity);
literal_struct!(
tokens,
::tauri::utils::acl::capability::Capability,
identifier,
description,
remote,
local,
windows,
webviews,
permissions,
platforms
);
}
}
}
#[cfg(test)]
mod tests {
use crate::acl::{Identifier, Scopes};
use super::{Capability, CapabilityFile, PermissionEntry};
#[test]
fn permission_entry_de() {
let identifier = Identifier::try_from("plugin:perm".to_string()).unwrap();
let identifier_json = serde_json::to_string(&identifier).unwrap();
assert_eq!(
serde_json::from_str::<PermissionEntry>(&identifier_json).unwrap(),
PermissionEntry::PermissionRef(identifier.clone())
);
assert_eq!(
serde_json::from_value::<PermissionEntry>(serde_json::json!({
"identifier": identifier,
"allow": [],
"deny": null
}))
.unwrap(),
PermissionEntry::ExtendedPermission {
identifier,
scope: Scopes {
allow: Some(vec![]),
deny: None
}
}
);
}
#[test]
fn capability_file_de() {
let capability = Capability {
identifier: "test".into(),
description: "".into(),
remote: None,
local: true,
windows: vec![],
webviews: vec![],
permissions: vec![],
platforms: None,
};
let capability_json = serde_json::to_string(&capability).unwrap();
assert_eq!(
serde_json::from_str::<CapabilityFile>(&capability_json).unwrap(),
CapabilityFile::Capability(capability.clone())
);
assert_eq!(
serde_json::from_str::<CapabilityFile>(&format!("[{capability_json}]")).unwrap(),
CapabilityFile::List(vec![capability.clone()])
);
assert_eq!(
serde_json::from_str::<CapabilityFile>(&format!(
"{{ \"capabilities\": [{capability_json}] }}"
))
.unwrap(),
CapabilityFile::NamedList {
capabilities: vec![capability]
}
);
}
}

View file

@ -0,0 +1,301 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! Identifier for plugins.
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::num::NonZeroU8;
use thiserror::Error;
const IDENTIFIER_SEPARATOR: u8 = b':';
const PLUGIN_PREFIX: &str = "tauri-plugin-";
const CORE_PLUGIN_IDENTIFIER_PREFIX: &str = "core:";
// <https://doc.rust-lang.org/cargo/reference/manifest.html#the-name-field>
const MAX_LEN_PREFIX: usize = 64 - PLUGIN_PREFIX.len();
const MAX_LEN_BASE: usize = 64;
const MAX_LEN_IDENTIFIER: usize = MAX_LEN_PREFIX + 1 + MAX_LEN_BASE;
/// Plugin identifier.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identifier {
inner: String,
separator: Option<NonZeroU8>,
}
#[cfg(feature = "schema")]
impl schemars::JsonSchema for Identifier {
fn schema_name() -> std::borrow::Cow<'static, str> {
"Identifier".into()
}
fn schema_id() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed(concat!(module_path!(), "::Identifier"))
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
String::json_schema(generator)
}
}
impl AsRef<str> for Identifier {
#[inline(always)]
fn as_ref(&self) -> &str {
&self.inner
}
}
impl Identifier {
/// Get the identifier str.
#[inline(always)]
pub fn get(&self) -> &str {
self.as_ref()
}
/// Get the identifier without prefix.
pub fn get_base(&self) -> &str {
match self.separator_index() {
None => self.get(),
Some(i) => &self.inner[i + 1..],
}
}
/// Get the prefix of the identifier.
pub fn get_prefix(&self) -> Option<&str> {
self.separator_index().map(|i| &self.inner[0..i])
}
/// Set the identifier prefix.
pub fn set_prefix(&mut self) -> Result<(), ParseIdentifierError> {
todo!()
}
/// Get the identifier string and its separator.
pub fn into_inner(self) -> (String, Option<NonZeroU8>) {
(self.inner, self.separator)
}
fn separator_index(&self) -> Option<usize> {
self.separator.map(|i| i.get() as usize)
}
}
#[derive(Debug)]
enum ValidByte {
Separator,
Byte(u8),
}
impl ValidByte {
fn alpha_numeric(byte: u8) -> Option<Self> {
byte.is_ascii_alphanumeric().then_some(Self::Byte(byte))
}
fn alpha_numeric_hyphen(byte: u8) -> Option<Self> {
(byte.is_ascii_alphanumeric() || byte == b'-').then_some(Self::Byte(byte))
}
fn next(&self, next: u8) -> Option<ValidByte> {
match (self, next) {
(ValidByte::Byte(b'-'), IDENTIFIER_SEPARATOR) => None,
(ValidByte::Separator, b'-') => None,
(_, IDENTIFIER_SEPARATOR) => Some(ValidByte::Separator),
(ValidByte::Separator, next) => ValidByte::alpha_numeric(next),
(ValidByte::Byte(b'-'), next) => ValidByte::alpha_numeric_hyphen(next),
(ValidByte::Byte(b'_'), next) => ValidByte::alpha_numeric_hyphen(next),
(ValidByte::Byte(_), next) => ValidByte::alpha_numeric_hyphen(next),
}
}
}
/// Errors that can happen when parsing an identifier.
#[derive(Debug, Error)]
pub enum ParseIdentifierError {
/// Identifier start with the plugin prefix.
#[error("identifiers cannot start with {}", PLUGIN_PREFIX)]
StartsWithTauriPlugin,
/// Identifier empty.
#[error("identifiers cannot be empty")]
Empty,
/// Identifier is too long.
#[error("identifiers cannot be longer than {len}, found {0}", len = MAX_LEN_IDENTIFIER)]
Humongous(usize),
/// Identifier is not in a valid format.
#[error(
"identifiers can only include lowercase ASCII, hyphens which are not leading or trailing, and a single colon if using a prefix"
)]
InvalidFormat,
/// Identifier has multiple separators.
#[error(
"identifiers can only include a single separator '{}'",
IDENTIFIER_SEPARATOR
)]
MultipleSeparators,
/// Identifier has a trailing hyphen.
#[error("identifiers cannot have a trailing hyphen")]
TrailingHyphen,
/// Identifier has a prefix without a base.
#[error("identifiers cannot have a prefix without a base")]
PrefixWithoutBase,
}
impl TryFrom<String> for Identifier {
type Error = ParseIdentifierError;
fn try_from(value: String) -> Result<Self, Self::Error> {
if value.starts_with(PLUGIN_PREFIX) {
return Err(Self::Error::StartsWithTauriPlugin);
}
if value.is_empty() {
return Err(Self::Error::Empty);
}
if value.len() > MAX_LEN_IDENTIFIER {
return Err(Self::Error::Humongous(value.len()));
}
let is_core_identifier = value.starts_with(CORE_PLUGIN_IDENTIFIER_PREFIX);
let mut bytes = value.bytes();
// grab the first byte only before parsing the rest
let mut prev = bytes
.next()
.and_then(ValidByte::alpha_numeric)
.ok_or(Self::Error::InvalidFormat)?;
let mut idx = 0;
let mut separator = None;
for byte in bytes {
idx += 1; // we already consumed first item
match prev.next(byte) {
None => return Err(Self::Error::InvalidFormat),
Some(next @ ValidByte::Byte(_)) => prev = next,
Some(ValidByte::Separator) => {
if separator.is_none() || is_core_identifier {
// safe to unwrap because idx starts at 1 and cannot go over MAX_IDENTIFIER_LEN
separator = Some(idx.try_into().unwrap());
prev = ValidByte::Separator
} else {
return Err(Self::Error::MultipleSeparators);
}
}
}
}
match prev {
// empty base
ValidByte::Separator => return Err(Self::Error::PrefixWithoutBase),
// trailing hyphen
ValidByte::Byte(b'-') => return Err(Self::Error::TrailingHyphen),
_ => (),
}
Ok(Self {
inner: value,
separator,
})
}
}
impl<'de> Deserialize<'de> for Identifier {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Self::try_from(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
}
}
impl Serialize for Identifier {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.get())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ident(s: impl Into<String>) -> Result<Identifier, ParseIdentifierError> {
Identifier::try_from(s.into())
}
#[test]
fn max_len_fits_in_u8() {
assert!(MAX_LEN_IDENTIFIER < u8::MAX as usize)
}
#[test]
fn format() {
assert!(ident("prefix:base").is_ok());
assert!(ident("prefix3:base").is_ok());
assert!(ident("preFix:base").is_ok());
// bad
assert!(ident("tauri-plugin-prefix:base").is_err());
assert!(ident("-prefix-:-base-").is_err());
assert!(ident("-prefix:base").is_err());
assert!(ident("prefix-:base").is_err());
assert!(ident("prefix:-base").is_err());
assert!(ident("prefix:base-").is_err());
assert!(ident("pre--fix:base--sep").is_ok());
assert!(ident("prefix:base--sep").is_ok());
assert!(ident("pre--fix:base").is_ok());
assert!(ident("prefix::base").is_err());
assert!(ident(":base").is_err());
assert!(ident("prefix:").is_err());
assert!(ident(":prefix:base:").is_err());
assert!(ident("base:").is_err());
assert!(ident("").is_err());
assert!(ident("💩").is_err());
assert!(ident("a".repeat(MAX_LEN_IDENTIFIER + 1)).is_err());
}
#[test]
fn base() {
assert_eq!(ident("prefix:base").unwrap().get_base(), "base");
assert_eq!(ident("base").unwrap().get_base(), "base");
}
#[test]
fn prefix() {
assert_eq!(ident("prefix:base").unwrap().get_prefix(), Some("prefix"));
assert_eq!(ident("base").unwrap().get_prefix(), None);
}
}
#[cfg(any(feature = "build", feature = "build-2"))]
mod build {
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use super::*;
impl ToTokens for Identifier {
fn to_tokens(&self, tokens: &mut TokenStream) {
let s = self.get();
tokens
.append_all(quote! { ::tauri::utils::acl::Identifier::try_from(#s.to_string()).unwrap() })
}
}
}

View file

@ -0,0 +1,196 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! Plugin ACL types.
use std::{collections::BTreeMap, num::NonZeroU64};
use super::{Permission, PermissionSet};
use serde::{Deserialize, Serialize};
/// The default permission set of the plugin.
///
/// Works similarly to a permission with the "default" identifier.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DefaultPermission {
/// The version of the permission.
pub version: Option<NonZeroU64>,
/// Human-readable description of what the permission does.
/// Tauri convention is to use `<h4>` headings in markdown content
/// for Tauri documentation generation purposes.
pub description: Option<String>,
/// All permissions this set contains.
pub permissions: Vec<String>,
}
/// Permission file that can define a default permission, a set of permissions or a list of inlined permissions.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct PermissionFile {
/// The default permission set for the plugin
pub default: Option<DefaultPermission>,
/// A list of permissions sets defined
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub set: Vec<PermissionSet>,
/// A list of inlined permissions
#[serde(default)]
pub permission: Vec<Permission>,
}
/// Plugin manifest.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Manifest {
/// Default permission.
pub default_permission: Option<PermissionSet>,
/// Plugin permissions.
pub permissions: BTreeMap<String, Permission>,
/// Plugin permission sets.
pub permission_sets: BTreeMap<String, PermissionSet>,
/// The global scope schema.
pub global_scope_schema: Option<serde_json::Value>,
}
impl Manifest {
/// Creates a new manifest from the given plugin permission files and global scope schema.
pub fn new(
permission_files: Vec<PermissionFile>,
global_scope_schema: Option<serde_json::Value>,
) -> Self {
let mut manifest = Self {
default_permission: None,
permissions: BTreeMap::new(),
permission_sets: BTreeMap::new(),
global_scope_schema,
};
for permission_file in permission_files {
if let Some(default) = permission_file.default {
manifest.default_permission.replace(PermissionSet {
identifier: "default".into(),
description: default
.description
.unwrap_or_else(|| "Default plugin permissions.".to_string()),
permissions: default.permissions,
});
}
for permission in permission_file.permission {
let key = permission.identifier.clone();
manifest.permissions.insert(key, permission);
}
for set in permission_file.set {
let key = set.identifier.clone();
manifest.permission_sets.insert(key, set);
}
}
manifest
}
}
#[cfg(feature = "schema")]
type ScopeSchema = (schemars::Schema, serde_json::Map<String, serde_json::Value>);
#[cfg(feature = "schema")]
impl Manifest {
/// Return scope schema and extra schema definitions for this plugin manifest.
pub fn global_scope_schema(&self) -> Result<Option<ScopeSchema>, super::Error> {
self
.global_scope_schema
.as_ref()
.map(|s| {
serde_json::from_value::<schemars::Schema>(s.clone()).map(|mut root| {
// Extract definitions from the schema
let definitions = root
.remove("$defs")
.or_else(|| root.remove("definitions"))
.and_then(|v| match v {
serde_json::Value::Object(m) => Some(m),
_ => None,
})
.unwrap_or_default();
// Wrap in an array schema
let items = serde_json::Value::from(root);
let scope_schema = schemars::json_schema!({
"type": "array",
"items": items
});
(scope_schema, definitions)
})
})
.transpose()
.map_err(Into::into)
}
}
#[cfg(any(feature = "build", feature = "build-2"))]
mod build {
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use std::convert::identity;
use super::*;
use crate::{literal_struct, tokens::*};
impl ToTokens for DefaultPermission {
fn to_tokens(&self, tokens: &mut TokenStream) {
let version = opt_lit_owned(self.version.as_ref().map(|v| {
let v = v.get();
quote!(::core::num::NonZeroU64::new(#v).unwrap())
}));
// Only used in build script and macros, so don't include them in runtime
let description = quote! { ::core::option::Option::None };
let permissions = vec_lit(&self.permissions, str_lit);
literal_struct!(
tokens,
::tauri::utils::acl::plugin::DefaultPermission,
version,
description,
permissions
)
}
}
impl ToTokens for Manifest {
fn to_tokens(&self, tokens: &mut TokenStream) {
let default_permission = opt_lit(self.default_permission.as_ref());
let permissions = map_lit(
quote! { ::std::collections::BTreeMap },
&self.permissions,
str_lit,
identity,
);
let permission_sets = map_lit(
quote! { ::std::collections::BTreeMap },
&self.permission_sets,
str_lit,
identity,
);
// Only used in build script and macros, so don't include them in runtime
// let global_scope_schema =
// opt_lit_owned(self.global_scope_schema.as_ref().map(json_value_lit));
let global_scope_schema = quote! { ::core::option::Option::None };
literal_struct!(
tokens,
::tauri::utils::acl::manifest::Manifest,
default_permission,
permissions,
permission_sets,
global_scope_schema
)
}
}
}

View file

@ -0,0 +1,550 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! Access Control List types.
//!
//! # Stability
//!
//! This is a core functionality that is not considered part of the stable API.
//! If you use it, note that it may include breaking changes in the future.
//!
//! These items are intended to be non-breaking from a de/serialization standpoint only.
//! Using and modifying existing config values will try to avoid breaking changes, but they are
//! free to add fields in the future - causing breaking changes for creating and full destructuring.
//!
//! To avoid this, [ignore unknown fields when destructuring] with the `{my, config, ..}` pattern.
//! If you need to create the Rust config directly without deserializing, then create the struct
//! the [Struct Update Syntax] with `..Default::default()`, which may need a
//! `#[allow(clippy::needless_update)]` attribute if you are declaring all fields.
//!
//! [ignore unknown fields when destructuring]: https://doc.rust-lang.org/book/ch18-03-pattern-syntax.html#ignoring-remaining-parts-of-a-value-with-
//! [Struct Update Syntax]: https://doc.rust-lang.org/book/ch05-01-defining-structs.html#creating-instances-from-other-instances-with-struct-update-syntax
use anyhow::Context;
use capability::{Capability, CapabilityFile};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, HashSet},
fs,
num::NonZeroU64,
path::PathBuf,
str::FromStr,
sync::Arc,
};
use thiserror::Error;
use url::Url;
use crate::{
config::{CapabilityEntry, Config},
platform::Target,
};
pub use self::{identifier::*, value::*};
/// Known foldername of the permission schema files
pub const PERMISSION_SCHEMAS_FOLDER_NAME: &str = "schemas";
/// Known filename of the permission schema JSON file
pub const PERMISSION_SCHEMA_FILE_NAME: &str = "schema.json";
/// Known ACL key for the app permissions.
pub const APP_ACL_KEY: &str = "__app-acl__";
/// Known acl manifests file
pub const ACL_MANIFESTS_FILE_NAME: &str = "acl-manifests.json";
/// Known capabilities file
pub const CAPABILITIES_FILE_NAME: &str = "capabilities.json";
/// Allowed commands file name
pub const ALLOWED_COMMANDS_FILE_NAME: &str = "allowed-commands.json";
/// Set by the CLI with when `build > removeUnusedCommands` is set for dead code elimination,
/// the value is set to the config's directory
pub const REMOVE_UNUSED_COMMANDS_ENV_VAR: &str = "REMOVE_UNUSED_COMMANDS";
#[cfg(any(feature = "build", feature = "build-2"))]
pub mod build;
pub mod capability;
pub mod identifier;
pub mod manifest;
pub mod resolved;
#[cfg(feature = "schema")]
pub mod schema;
pub mod value;
/// Possible errors while processing ACL files.
#[derive(Debug, Error)]
pub enum Error {
/// Could not find an environmental variable that is set inside of build scripts.
///
/// Whatever generated this should be called inside of a build script.
#[error(
"expected build script env var {0}, but it was not found - ensure this is called in a build script"
)]
BuildVar(&'static str),
/// The links field in the manifest **MUST** be set and match the name of the crate.
#[error(
"package.links field in the Cargo manifest is not set, it should be set to the same as package.name"
)]
LinksMissing,
/// The links field in the manifest **MUST** match the name of the crate.
#[error(
"package.links field in the Cargo manifest MUST be set to the same value as package.name"
)]
LinksName,
/// IO error while reading a file
#[error("failed to read file '{}': {}", _1.display(), _0)]
ReadFile(std::io::Error, PathBuf),
/// IO error while writing a file
#[error("failed to write file '{}': {}", _1.display(), _0)]
WriteFile(std::io::Error, PathBuf),
/// IO error while creating a file
#[error("failed to create file '{}': {}", _1.display(), _0)]
CreateFile(std::io::Error, PathBuf),
/// IO error while creating a dir
#[error("failed to create dir '{}': {}", _1.display(), _0)]
CreateDir(std::io::Error, PathBuf),
/// [`cargo_metadata`] was not able to complete successfully
#[cfg(any(feature = "build", feature = "build-2"))]
#[error("failed to execute: {0}")]
Metadata(#[from] ::cargo_metadata::Error),
/// Invalid glob
#[error("failed to run glob: {0}")]
Glob(#[from] glob::PatternError),
/// Invalid TOML encountered
#[error("failed to parse TOML: {0}")]
Toml(#[from] toml::de::Error),
/// Invalid JSON encountered
#[error("failed to parse JSON: {0}")]
Json(#[from] serde_json::Error),
/// Invalid JSON5 encountered
#[cfg(feature = "config-json5")]
#[error("failed to parse JSON5: {0}")]
Json5(#[from] json5::Error),
/// Invalid permissions file format
#[error("unknown permission format {0}")]
UnknownPermissionFormat(String),
/// Invalid capabilities file format
#[error("unknown capability format {0}")]
UnknownCapabilityFormat(String),
/// Permission referenced in set not found.
#[error("permission {permission} not found from set {set}")]
SetPermissionNotFound {
/// Permission identifier.
permission: String,
/// Set identifier.
set: String,
},
/// Unknown ACL manifest.
#[error("unknown ACL for {key}, expected one of {available}")]
UnknownManifest {
/// Manifest key.
key: String,
/// Available manifest keys.
available: String,
},
/// Unknown permission.
#[error("unknown permission {permission} for {key}")]
UnknownPermission {
/// Manifest key.
key: String,
/// Permission identifier.
permission: String,
},
/// Capability with the given identifier already exists.
#[error("capability with identifier `{identifier}` already exists")]
CapabilityAlreadyExists {
/// Capability identifier.
identifier: String,
},
}
/// Allowed and denied commands inside a permission.
///
/// If two commands clash inside of `allow` and `deny`, it should be denied by default.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Commands {
/// Allowed command.
#[serde(default)]
pub allow: Vec<String>,
/// Denied command, which takes priority.
#[serde(default)]
pub deny: Vec<String>,
}
/// An argument for fine grained behavior control of Tauri commands.
///
/// It can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command.
/// The configured scope is passed to the command and will be enforced by the command implementation.
///
/// ## Example
///
/// ```json
/// {
/// "allow": [{ "path": "$HOME/**" }],
/// "deny": [{ "path": "$HOME/secret.txt" }]
/// }
/// ```
#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Scopes {
/// Data that defines what is allowed by the scope.
#[serde(skip_serializing_if = "Option::is_none")]
pub allow: Option<Vec<Value>>,
/// Data that defines what is denied by the scope. This should be prioritized by validation logic.
#[serde(skip_serializing_if = "Option::is_none")]
pub deny: Option<Vec<Value>>,
}
impl Scopes {
fn is_empty(&self) -> bool {
self.allow.is_none() && self.deny.is_none()
}
}
/// Descriptions of explicit privileges of commands.
///
/// It can enable commands to be accessible in the frontend of the application.
///
/// If the scope is defined it can be used to fine grain control the access of individual or multiple commands.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Permission {
/// The version of the permission.
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<NonZeroU64>,
/// A unique identifier for the permission.
pub identifier: String,
/// Human-readable description of what the permission does.
/// Tauri internal convention is to use `<h4>` headings in markdown content
/// for Tauri documentation generation purposes.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Allowed or denied commands when using this permission.
#[serde(default)]
pub commands: Commands,
/// Allowed or denied scoped when using this permission.
#[serde(default, skip_serializing_if = "Scopes::is_empty")]
pub scope: Scopes,
/// Target platforms this permission applies. By default all platforms are affected by this permission.
#[serde(skip_serializing_if = "Option::is_none")]
pub platforms: Option<Vec<Target>>,
}
impl Permission {
/// Whether this permission should be active based on the platform target or not.
pub fn is_active(&self, target: &Target) -> bool {
self
.platforms
.as_ref()
.map(|platforms| platforms.contains(target))
.unwrap_or(true)
}
}
/// A set of direct permissions grouped together under a new name.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct PermissionSet {
/// A unique identifier for the permission.
pub identifier: String,
/// Human-readable description of what the permission does.
pub description: String,
/// All permissions this set contains.
pub permissions: Vec<String>,
}
/// UrlPattern for [`ExecutionContext::Remote`].
#[derive(Debug, Clone)]
pub struct RemoteUrlPattern(Arc<urlpattern::UrlPattern>, String);
impl FromStr for RemoteUrlPattern {
type Err = urlpattern::quirks::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let mut init = urlpattern::UrlPatternInit::parse_constructor_string::<regex::Regex>(s, None)?;
if init.search.as_ref().map(|p| p.is_empty()).unwrap_or(true) {
init.search.replace("*".to_string());
}
if init.hash.as_ref().map(|p| p.is_empty()).unwrap_or(true) {
init.hash.replace("*".to_string());
}
if init
.pathname
.as_ref()
.map(|p| p.is_empty() || p == "/")
.unwrap_or(true)
{
init.pathname.replace("*".to_string());
}
let pattern = urlpattern::UrlPattern::parse(init, Default::default())?;
Ok(Self(Arc::new(pattern), s.to_string()))
}
}
impl RemoteUrlPattern {
#[doc(hidden)]
pub fn as_str(&self) -> &str {
&self.1
}
/// Test if a given URL matches the pattern.
pub fn test(&self, url: &Url) -> bool {
self
.0
.test(urlpattern::UrlPatternMatchInput::Url(url.clone()))
.unwrap_or_default()
}
}
impl PartialEq for RemoteUrlPattern {
fn eq(&self, other: &Self) -> bool {
self.0.protocol() == other.0.protocol()
&& self.0.username() == other.0.username()
&& self.0.password() == other.0.password()
&& self.0.hostname() == other.0.hostname()
&& self.0.port() == other.0.port()
&& self.0.pathname() == other.0.pathname()
&& self.0.search() == other.0.search()
&& self.0.hash() == other.0.hash()
}
}
impl Eq for RemoteUrlPattern {}
/// Execution context of an IPC call.
#[derive(Debug, Default, Clone, Eq, PartialEq)]
pub enum ExecutionContext {
/// A local URL is used (the Tauri app URL).
#[default]
Local,
/// Remote URL is trying to use the IPC.
Remote {
/// The URL trying to access the IPC (URL pattern).
url: RemoteUrlPattern,
},
}
/// Test if the app has an application manifest from the ACL
pub fn has_app_manifest(acl: &BTreeMap<String, crate::acl::manifest::Manifest>) -> bool {
acl.contains_key(APP_ACL_KEY)
}
/// Get the capabilities from the config file
pub fn get_capabilities(
config: &Config,
mut capabilities_from_files: BTreeMap<String, Capability>,
additional_capability_files: Option<&[PathBuf]>,
) -> anyhow::Result<BTreeMap<String, Capability>> {
let mut capabilities = if config.app.security.capabilities.is_empty() {
capabilities_from_files
} else {
let mut capabilities = BTreeMap::new();
for capability_entry in &config.app.security.capabilities {
match capability_entry {
CapabilityEntry::Inlined(capability) => {
capabilities.insert(capability.identifier.clone(), capability.clone());
}
CapabilityEntry::Reference(id) => {
let capability = capabilities_from_files
.remove(id)
.with_context(|| format!("capability with identifier {id} not found"))?;
capabilities.insert(id.clone(), capability);
}
}
}
capabilities
};
if let Some(paths) = additional_capability_files {
for path in paths {
let capability = CapabilityFile::load(path)
.with_context(|| format!("failed to read capability {}", path.display()))?;
match capability {
CapabilityFile::Capability(c) => {
capabilities.insert(c.identifier.clone(), c);
}
CapabilityFile::List(capabilities_list)
| CapabilityFile::NamedList {
capabilities: capabilities_list,
} => {
capabilities.extend(
capabilities_list
.into_iter()
.map(|c| (c.identifier.clone(), c)),
);
}
}
}
}
Ok(capabilities)
}
/// Allowed commands used to communicate between `generate_handle` and `generate_allowed_commands` through json files
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct AllowedCommands {
/// The commands allowed
pub commands: HashSet<String>,
/// Has application ACL or not
pub has_app_acl: bool,
}
/// Try to reads allowed commands from the out dir made by our build script
pub fn read_allowed_commands() -> Option<AllowedCommands> {
let out_file = std::env::var("OUT_DIR")
.map(PathBuf::from)
.ok()?
.join(ALLOWED_COMMANDS_FILE_NAME);
let file = fs::read_to_string(&out_file).ok()?;
let json = serde_json::from_str(&file).ok()?;
Some(json)
}
#[cfg(test)]
mod tests {
use crate::acl::RemoteUrlPattern;
#[test]
fn url_pattern_domain_wildcard() {
let pattern: RemoteUrlPattern = "http://*".parse().unwrap();
assert!(pattern.test(&"http://tauri.app/path".parse().unwrap()));
assert!(pattern.test(&"http://tauri.app/path?q=1".parse().unwrap()));
assert!(pattern.test(&"http://localhost/path".parse().unwrap()));
assert!(pattern.test(&"http://localhost/path?q=1".parse().unwrap()));
let pattern: RemoteUrlPattern = "http://*.tauri.app".parse().unwrap();
assert!(!pattern.test(&"http://tauri.app/path".parse().unwrap()));
assert!(!pattern.test(&"http://tauri.app/path?q=1".parse().unwrap()));
assert!(pattern.test(&"http://api.tauri.app/path".parse().unwrap()));
assert!(pattern.test(&"http://api.tauri.app/path?q=1".parse().unwrap()));
assert!(!pattern.test(&"http://localhost/path".parse().unwrap()));
assert!(!pattern.test(&"http://localhost/path?q=1".parse().unwrap()));
}
#[test]
fn url_pattern_path_wildcard() {
let pattern: RemoteUrlPattern = "http://localhost/*".parse().unwrap();
assert!(pattern.test(&"http://localhost/path".parse().unwrap()));
assert!(pattern.test(&"http://localhost/path?q=1".parse().unwrap()));
}
#[test]
fn url_pattern_scheme_wildcard() {
let pattern: RemoteUrlPattern = "*://localhost".parse().unwrap();
assert!(pattern.test(&"http://localhost/path".parse().unwrap()));
assert!(pattern.test(&"https://localhost/path?q=1".parse().unwrap()));
assert!(pattern.test(&"custom://localhost/path".parse().unwrap()));
}
}
#[cfg(any(feature = "build", feature = "build-2"))]
mod build_ {
use std::convert::identity;
use crate::{literal_struct, tokens::*};
use super::*;
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
impl ToTokens for ExecutionContext {
fn to_tokens(&self, tokens: &mut TokenStream) {
let prefix = quote! { ::tauri::utils::acl::ExecutionContext };
tokens.append_all(match self {
Self::Local => {
quote! { #prefix::Local }
}
Self::Remote { url } => {
let url = url.as_str();
quote! { #prefix::Remote { url: #url.parse().unwrap() } }
}
});
}
}
impl ToTokens for Commands {
fn to_tokens(&self, tokens: &mut TokenStream) {
let allow = vec_lit(&self.allow, str_lit);
let deny = vec_lit(&self.deny, str_lit);
literal_struct!(tokens, ::tauri::utils::acl::Commands, allow, deny)
}
}
impl ToTokens for Scopes {
fn to_tokens(&self, tokens: &mut TokenStream) {
let allow = opt_vec_lit(self.allow.as_ref(), identity);
let deny = opt_vec_lit(self.deny.as_ref(), identity);
literal_struct!(tokens, ::tauri::utils::acl::Scopes, allow, deny)
}
}
impl ToTokens for Permission {
fn to_tokens(&self, tokens: &mut TokenStream) {
let version = opt_lit_owned(self.version.as_ref().map(|v| {
let v = v.get();
quote!(::core::num::NonZeroU64::new(#v).unwrap())
}));
let identifier = str_lit(&self.identifier);
// Only used in build script and macros, so don't include them in runtime
let description = quote! { ::core::option::Option::None };
let commands = &self.commands;
let scope = &self.scope;
let platforms = opt_vec_lit(self.platforms.as_ref(), identity);
literal_struct!(
tokens,
::tauri::utils::acl::Permission,
version,
identifier,
description,
commands,
scope,
platforms
)
}
}
impl ToTokens for PermissionSet {
fn to_tokens(&self, tokens: &mut TokenStream) {
let identifier = str_lit(&self.identifier);
// Only used in build script and macros, so don't include them in runtime
let description = quote! { "".to_string() };
let permissions = vec_lit(&self.permissions, str_lit);
literal_struct!(
tokens,
::tauri::utils::acl::PermissionSet,
identifier,
description,
permissions
)
}
}
}

View file

@ -0,0 +1,683 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! Resolved ACL for runtime usage.
use std::{collections::BTreeMap, fmt};
use crate::platform::Target;
use super::{
APP_ACL_KEY, Commands, Error, ExecutionContext, Identifier, Permission, PermissionSet, Scopes,
Value,
capability::{Capability, PermissionEntry},
has_app_manifest,
manifest::Manifest,
};
/// A key for a scope, used to link a [`ResolvedCommand#structfield.scope`] to the store [`Resolved#structfield.scopes`].
pub type ScopeKey = u64;
/// Metadata for what referenced a [`ResolvedCommand`].
#[cfg(debug_assertions)]
#[derive(Default, Clone, PartialEq, Eq)]
pub struct ResolvedCommandReference {
/// Identifier of the capability.
pub capability: String,
/// Identifier of the permission.
pub permission: String,
}
/// A resolved command permission.
#[derive(Default, Clone, PartialEq, Eq)]
pub struct ResolvedCommand {
/// The execution context of this command.
pub context: ExecutionContext,
/// The capability/permission that referenced this command.
#[cfg(debug_assertions)]
pub referenced_by: ResolvedCommandReference,
/// The list of window label patterns that was resolved for this command.
pub windows: Vec<glob::Pattern>,
/// The list of webview label patterns that was resolved for this command.
pub webviews: Vec<glob::Pattern>,
/// The reference of the scope that is associated with this command. See [`Resolved#structfield.command_scopes`].
pub scope_id: Option<ScopeKey>,
}
impl fmt::Debug for ResolvedCommand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ResolvedCommand")
.field("context", &self.context)
.field("windows", &self.windows)
.field("webviews", &self.webviews)
.field("scope_id", &self.scope_id)
.finish()
}
}
/// A resolved scope. Merges all scopes defined for a single command.
#[derive(Debug, Default, Clone)]
pub struct ResolvedScope {
/// Allows something on the command.
pub allow: Vec<Value>,
/// Denies something on the command.
pub deny: Vec<Value>,
}
/// Resolved access control list.
#[derive(Debug, Default)]
pub struct Resolved {
/// If we should check the ACL for the app commands
pub has_app_acl: bool,
/// The commands that are allowed. Map each command with its context to a [`ResolvedCommand`].
pub allowed_commands: BTreeMap<String, Vec<ResolvedCommand>>,
/// The commands that are denied. Map each command with its context to a [`ResolvedCommand`].
pub denied_commands: BTreeMap<String, Vec<ResolvedCommand>>,
/// The store of scopes referenced by a [`ResolvedCommand`].
pub command_scope: BTreeMap<ScopeKey, ResolvedScope>,
/// The global scope.
pub global_scope: BTreeMap<String, ResolvedScope>,
}
impl Resolved {
/// Resolves the ACL for the given plugin permissions and app capabilities.
pub fn resolve(
acl: &BTreeMap<String, Manifest>,
mut capabilities: BTreeMap<String, Capability>,
target: Target,
) -> Result<Self, Error> {
let mut allowed_commands = BTreeMap::new();
let mut denied_commands = BTreeMap::new();
let mut current_scope_id = 0;
let mut command_scope = BTreeMap::new();
let mut global_scope: BTreeMap<String, Vec<Scopes>> = BTreeMap::new();
// resolve commands
for capability in capabilities.values_mut().filter(|c| c.is_active(&target)) {
with_resolved_permissions(
capability,
acl,
target,
|ResolvedPermission {
key,
commands,
scope,
#[cfg_attr(not(debug_assertions), allow(unused))]
permission_name,
}| {
if commands.allow.is_empty() && commands.deny.is_empty() {
// global scope
global_scope.entry(key.to_string()).or_default().push(scope);
} else {
let scope_id = if scope.allow.is_some() || scope.deny.is_some() {
current_scope_id += 1;
command_scope.insert(
current_scope_id,
ResolvedScope {
allow: scope.allow.unwrap_or_default(),
deny: scope.deny.unwrap_or_default(),
},
);
Some(current_scope_id)
} else {
None
};
for allowed_command in &commands.allow {
resolve_command(
&mut allowed_commands,
if key == APP_ACL_KEY {
allowed_command.to_string()
} else if let Some(core_plugin_name) = key.strip_prefix("core:") {
format!("plugin:{core_plugin_name}|{allowed_command}")
} else {
format!("plugin:{key}|{allowed_command}")
},
capability,
scope_id,
#[cfg(debug_assertions)]
permission_name.to_string(),
)?;
}
for denied_command in &commands.deny {
resolve_command(
&mut denied_commands,
if key == APP_ACL_KEY {
denied_command.to_string()
} else if let Some(core_plugin_name) = key.strip_prefix("core:") {
format!("plugin:{core_plugin_name}|{denied_command}")
} else {
format!("plugin:{key}|{denied_command}")
},
capability,
scope_id,
#[cfg(debug_assertions)]
permission_name.to_string(),
)?;
}
}
Ok(())
},
)?;
}
let global_scope = global_scope
.into_iter()
.map(|(key, scopes)| {
let mut resolved_scope = ResolvedScope {
allow: Vec::new(),
deny: Vec::new(),
};
for scope in scopes {
if let Some(allow) = scope.allow {
resolved_scope.allow.extend(allow);
}
if let Some(deny) = scope.deny {
resolved_scope.deny.extend(deny);
}
}
(key, resolved_scope)
})
.collect();
let resolved = Self {
has_app_acl: has_app_manifest(acl),
allowed_commands,
denied_commands,
command_scope,
global_scope,
};
Ok(resolved)
}
}
fn parse_glob_patterns(mut raw: Vec<String>) -> Result<Vec<glob::Pattern>, Error> {
raw.sort();
let mut patterns = Vec::new();
for pattern in raw {
patterns.push(glob::Pattern::new(&pattern)?);
}
Ok(patterns)
}
fn resolve_command(
commands: &mut BTreeMap<String, Vec<ResolvedCommand>>,
command: String,
capability: &Capability,
scope_id: Option<ScopeKey>,
#[cfg(debug_assertions)] referenced_by_permission_identifier: String,
) -> Result<(), Error> {
let mut contexts = Vec::new();
if capability.local {
contexts.push(ExecutionContext::Local);
}
if let Some(remote) = &capability.remote {
contexts.extend(remote.urls.iter().map(|url| {
ExecutionContext::Remote {
url: url
.parse()
.unwrap_or_else(|e| panic!("invalid URL pattern for remote URL {url}: {e}")),
}
}));
}
for context in contexts {
let resolved_list = commands.entry(command.clone()).or_default();
resolved_list.push(ResolvedCommand {
context,
#[cfg(debug_assertions)]
referenced_by: ResolvedCommandReference {
capability: capability.identifier.clone(),
permission: referenced_by_permission_identifier.clone(),
},
windows: parse_glob_patterns(capability.windows.clone())?,
webviews: parse_glob_patterns(capability.webviews.clone())?,
scope_id,
});
}
Ok(())
}
struct ResolvedPermission<'a> {
key: &'a str,
permission_name: &'a str,
commands: Commands,
scope: Scopes,
}
/// Iterate over permissions in a capability, resolving permission sets if necessary
/// to produce a [`ResolvedPermission`] and calling the provided callback with it.
fn with_resolved_permissions<F: FnMut(ResolvedPermission<'_>) -> Result<(), Error>>(
capability: &Capability,
acl: &BTreeMap<String, Manifest>,
target: Target,
mut f: F,
) -> Result<(), Error> {
for permission_entry in &capability.permissions {
let permission_id = permission_entry.identifier();
let permissions = get_permissions(permission_id, acl)?
.into_iter()
.filter(|p| p.permission.is_active(&target));
for TraversedPermission {
key,
permission_name,
permission,
} in permissions
{
let mut resolved_scope = Scopes::default();
let mut commands = Commands::default();
if let PermissionEntry::ExtendedPermission {
identifier: _,
scope,
} = permission_entry
{
if let Some(allow) = scope.allow.clone() {
resolved_scope
.allow
.get_or_insert_with(Default::default)
.extend(allow);
}
if let Some(deny) = scope.deny.clone() {
resolved_scope
.deny
.get_or_insert_with(Default::default)
.extend(deny);
}
}
if let Some(allow) = permission.scope.allow.clone() {
resolved_scope
.allow
.get_or_insert_with(Default::default)
.extend(allow);
}
if let Some(deny) = permission.scope.deny.clone() {
resolved_scope
.deny
.get_or_insert_with(Default::default)
.extend(deny);
}
commands.allow.extend(permission.commands.allow.clone());
commands.deny.extend(permission.commands.deny.clone());
f(ResolvedPermission {
key: &key,
permission_name: &permission_name,
commands,
scope: resolved_scope,
})?;
}
}
Ok(())
}
/// Traversed permission
#[derive(Debug)]
pub struct TraversedPermission<'a> {
/// Plugin name without the tauri-plugin- prefix
pub key: String,
/// Permission's name
pub permission_name: String,
/// Permission details
pub permission: &'a Permission,
}
/// Expand a permissions id based on the ACL to get the associated permissions (e.g. expand some-plugin:default)
pub fn get_permissions<'a>(
permission_id: &Identifier,
acl: &'a BTreeMap<String, Manifest>,
) -> Result<Vec<TraversedPermission<'a>>, Error> {
let key = permission_id.get_prefix().unwrap_or(APP_ACL_KEY);
let permission_name = permission_id.get_base();
let manifest = acl.get(key).ok_or_else(|| Error::UnknownManifest {
key: display_perm_key(key).to_string(),
available: acl.keys().cloned().collect::<Vec<_>>().join(", "),
})?;
if permission_name == "default" {
manifest
.default_permission
.as_ref()
.map(|default| get_permission_set_permissions(permission_id, acl, manifest, default))
.unwrap_or_else(|| Ok(Default::default()))
} else if let Some(set) = manifest.permission_sets.get(permission_name) {
get_permission_set_permissions(permission_id, acl, manifest, set)
} else if let Some(permission) = manifest.permissions.get(permission_name) {
Ok(vec![TraversedPermission {
key: key.to_string(),
permission_name: permission_name.to_string(),
permission,
}])
} else {
Err(Error::UnknownPermission {
key: display_perm_key(key).to_string(),
permission: permission_name.to_string(),
})
}
}
// get the permissions from a permission set
fn get_permission_set_permissions<'a>(
permission_id: &Identifier,
acl: &'a BTreeMap<String, Manifest>,
manifest: &'a Manifest,
set: &'a PermissionSet,
) -> Result<Vec<TraversedPermission<'a>>, Error> {
let key = permission_id.get_prefix().unwrap_or(APP_ACL_KEY);
let mut permissions = Vec::new();
for perm in &set.permissions {
// a set could include permissions from other plugins
// for example `dialog:default`, could include `fs:default`
// in this case `perm = "fs:default"` which is not a permission
// in the dialog manifest so we check if `perm` still have a prefix (i.e `fs:`)
// and if so, we resolve this prefix from `acl` first before proceeding
let id = Identifier::try_from(perm.clone()).expect("invalid identifier in permission set?");
let (manifest, permission_id, key, permission_name) =
if let Some((new_key, manifest)) = id.get_prefix().and_then(|k| acl.get(k).map(|m| (k, m))) {
(manifest, &id, new_key, id.get_base())
} else {
(manifest, permission_id, key, perm.as_str())
};
if permission_name == "default" {
permissions.extend(
manifest
.default_permission
.as_ref()
.map(|default| get_permission_set_permissions(permission_id, acl, manifest, default))
.transpose()?
.unwrap_or_default(),
);
} else if let Some(permission) = manifest.permissions.get(permission_name) {
permissions.push(TraversedPermission {
key: key.to_string(),
permission_name: permission_name.to_string(),
permission,
});
} else if let Some(permission_set) = manifest.permission_sets.get(permission_name) {
permissions.extend(get_permission_set_permissions(
permission_id,
acl,
manifest,
permission_set,
)?);
} else {
return Err(Error::SetPermissionNotFound {
permission: permission_name.to_string(),
set: set.identifier.clone(),
});
}
}
Ok(permissions)
}
#[inline]
fn display_perm_key(prefix: &str) -> &str {
if prefix == APP_ACL_KEY {
"app manifest"
} else {
prefix
}
}
#[cfg(any(feature = "build", feature = "build-2"))]
mod build {
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use std::convert::identity;
use super::*;
use crate::{literal_struct, tokens::*};
#[cfg(debug_assertions)]
impl ToTokens for ResolvedCommandReference {
fn to_tokens(&self, tokens: &mut TokenStream) {
let capability = str_lit(&self.capability);
let permission = str_lit(&self.permission);
literal_struct!(
tokens,
::tauri::utils::acl::resolved::ResolvedCommandReference,
capability,
permission
)
}
}
impl ToTokens for ResolvedCommand {
fn to_tokens(&self, tokens: &mut TokenStream) {
#[cfg(debug_assertions)]
let referenced_by = &self.referenced_by;
let context = &self.context;
let windows = vec_lit(&self.windows, |window| {
let w = window.as_str();
quote!(#w.parse().unwrap())
});
let webviews = vec_lit(&self.webviews, |window| {
let w = window.as_str();
quote!(#w.parse().unwrap())
});
let scope_id = opt_lit(self.scope_id.as_ref());
#[cfg(debug_assertions)]
{
literal_struct!(
tokens,
::tauri::utils::acl::resolved::ResolvedCommand,
context,
referenced_by,
windows,
webviews,
scope_id
)
}
#[cfg(not(debug_assertions))]
literal_struct!(
tokens,
::tauri::utils::acl::resolved::ResolvedCommand,
context,
windows,
webviews,
scope_id
)
}
}
impl ToTokens for ResolvedScope {
fn to_tokens(&self, tokens: &mut TokenStream) {
let allow = vec_lit(&self.allow, identity);
let deny = vec_lit(&self.deny, identity);
literal_struct!(
tokens,
::tauri::utils::acl::resolved::ResolvedScope,
allow,
deny
)
}
}
impl ToTokens for Resolved {
fn to_tokens(&self, tokens: &mut TokenStream) {
let has_app_acl = self.has_app_acl;
let allowed_commands = map_lit(
quote! { ::std::collections::BTreeMap },
&self.allowed_commands,
str_lit,
|v| vec_lit(v, identity),
);
let denied_commands = map_lit(
quote! { ::std::collections::BTreeMap },
&self.denied_commands,
str_lit,
|v| vec_lit(v, identity),
);
let command_scope = map_lit(
quote! { ::std::collections::BTreeMap },
&self.command_scope,
identity,
identity,
);
let global_scope = map_lit(
quote! { ::std::collections::BTreeMap },
&self.global_scope,
str_lit,
identity,
);
literal_struct!(
tokens,
::tauri::utils::acl::resolved::Resolved,
has_app_acl,
allowed_commands,
denied_commands,
command_scope,
global_scope
)
}
}
}
#[cfg(test)]
mod tests {
use super::{Identifier, Manifest, Permission, PermissionSet, get_permissions};
fn manifest<const P: usize, const S: usize>(
name: &str,
permissions: [&str; P],
default_set: Option<&[&str]>,
sets: [(&str, &[&str]); S],
) -> (String, Manifest) {
(
name.to_string(),
Manifest {
default_permission: default_set.map(|perms| PermissionSet {
identifier: "default".to_string(),
description: "default set".to_string(),
permissions: perms.iter().map(|s| s.to_string()).collect(),
}),
permissions: permissions
.iter()
.map(|p| {
(
p.to_string(),
Permission {
identifier: p.to_string(),
..Default::default()
},
)
})
.collect(),
permission_sets: sets
.iter()
.map(|(s, perms)| {
(
s.to_string(),
PermissionSet {
identifier: s.to_string(),
description: format!("{s} set"),
permissions: perms.iter().map(|s| s.to_string()).collect(),
},
)
})
.collect(),
..Default::default()
},
)
}
fn id(id: &str) -> Identifier {
Identifier::try_from(id.to_string()).unwrap()
}
#[test]
fn resolves_permissions_from_other_plugins() {
let acl = [
manifest(
"fs",
["read", "write", "rm", "exist"],
Some(&["read", "exist"]),
[],
),
manifest(
"http",
["fetch", "fetch-cancel"],
None,
[("fetch-with-cancel", &["fetch", "fetch-cancel"])],
),
manifest(
"dialog",
["open", "save"],
None,
[(
"extra",
&[
"save",
"fs:default",
"fs:write",
"http:default",
"http:fetch-with-cancel",
],
)],
),
]
.into();
let permissions = get_permissions(&id("fs:default"), &acl).unwrap();
assert_eq!(permissions.len(), 2);
assert_eq!(permissions[0].key, "fs");
assert_eq!(permissions[0].permission_name, "read");
assert_eq!(permissions[1].key, "fs");
assert_eq!(permissions[1].permission_name, "exist");
let permissions = get_permissions(&id("fs:rm"), &acl).unwrap();
assert_eq!(permissions.len(), 1);
assert_eq!(permissions[0].key, "fs");
assert_eq!(permissions[0].permission_name, "rm");
let permissions = get_permissions(&id("http:fetch-with-cancel"), &acl).unwrap();
assert_eq!(permissions.len(), 2);
assert_eq!(permissions[0].key, "http");
assert_eq!(permissions[0].permission_name, "fetch");
assert_eq!(permissions[1].key, "http");
assert_eq!(permissions[1].permission_name, "fetch-cancel");
let permissions = get_permissions(&id("dialog:extra"), &acl).unwrap();
assert_eq!(permissions.len(), 6);
assert_eq!(permissions[0].key, "dialog");
assert_eq!(permissions[0].permission_name, "save");
assert_eq!(permissions[1].key, "fs");
assert_eq!(permissions[1].permission_name, "read");
assert_eq!(permissions[2].key, "fs");
assert_eq!(permissions[2].permission_name, "exist");
assert_eq!(permissions[3].key, "fs");
assert_eq!(permissions[3].permission_name, "write");
assert_eq!(permissions[4].key, "http");
assert_eq!(permissions[4].permission_name, "fetch");
assert_eq!(permissions[5].key, "http");
assert_eq!(permissions[5].permission_name, "fetch-cancel");
}
}

View file

@ -0,0 +1,445 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! Schema generation for ACL items.
use std::{
collections::{BTreeMap, btree_map::Values},
fs,
path::{Path, PathBuf},
slice::Iter,
};
use schemars::Schema;
use super::{Error, PERMISSION_SCHEMAS_FOLDER_NAME};
use crate::{platform::Target, write_if_changed};
use super::{
PERMISSION_SCHEMA_FILE_NAME, Permission, PermissionSet,
capability::CapabilityFile,
manifest::{Manifest, PermissionFile},
};
/// Capability schema file name.
pub const CAPABILITIES_SCHEMA_FILE_NAME: &str = "schema.json";
/// Path of the folder where schemas are saved.
pub const CAPABILITIES_SCHEMA_FOLDER_PATH: &str = "gen/schemas";
// TODO: once MSRV is high enough, remove generic and use impl <trait>
// see https://github.com/tauri-apps/tauri/commit/b5561d74aee431f93c0c5b0fa6784fc0a956effe#diff-7c31d393f83cae149122e74ad44ac98e7d70ffb45c9e5b0a94ec52881b6f1cebR30-R42
/// Permission schema generator trait
pub trait PermissionSchemaGenerator<
'a,
Ps: Iterator<Item = &'a PermissionSet>,
P: Iterator<Item = &'a Permission>,
>
{
/// Whether has a default permission set or not.
fn has_default_permission_set(&self) -> bool;
/// Default permission set description if any.
fn default_set_description(&self) -> Option<&str>;
/// Default permission set's permissions if any.
fn default_set_permissions(&self) -> Option<&Vec<String>>;
/// Permissions sets to generate schema for.
fn permission_sets(&'a self) -> Ps;
/// Permissions to generate schema for.
fn permissions(&'a self) -> P;
/// A utility function to generate a schema for a permission identifier
fn perm_id_schema(name: Option<&str>, id: &str, description: Option<&str>) -> Schema {
let command_name = match name {
Some(name) if name == super::APP_ACL_KEY => id.to_string(),
Some(name) => format!("{name}:{id}"),
_ => id.to_string(),
};
let mut schema = schemars::json_schema!({
"type": "string",
"const": command_name
});
if let Some(description) = description {
schema.insert(
"description".to_string(),
serde_json::Value::String(description.to_string()),
);
// Non-standard, used by vscode for rich hover tooltips
schema.insert(
"markdownDescription".to_string(),
serde_json::Value::String(description.to_string()),
);
}
schema
}
/// Generate schemas for all possible permissions.
fn gen_possible_permission_schemas(&'a self, name: Option<&str>) -> Vec<Schema> {
let mut permission_schemas = Vec::new();
// schema for default set
if self.has_default_permission_set() {
let description = self.default_set_description().unwrap_or_default();
let description = if let Some(permissions) = self.default_set_permissions() {
add_permissions_to_description(description, permissions, true)
} else {
description.to_string()
};
if !description.is_empty() {
let default = Self::perm_id_schema(name, "default", Some(&description));
permission_schemas.push(default);
}
}
// schema for each permission set
for set in self.permission_sets() {
let description = add_permissions_to_description(&set.description, &set.permissions, false);
let schema = Self::perm_id_schema(name, &set.identifier, Some(&description));
permission_schemas.push(schema);
}
// schema for each permission
for perm in self.permissions() {
let schema = Self::perm_id_schema(name, &perm.identifier, perm.description.as_deref());
permission_schemas.push(schema);
}
permission_schemas
}
}
fn add_permissions_to_description(
description: &str,
permissions: &[String],
is_default: bool,
) -> String {
if permissions.is_empty() {
return description.to_string();
}
let permissions_list = permissions
.iter()
.map(|permission| format!("- `{permission}`"))
.collect::<Vec<_>>()
.join("\n");
let default_permission_set = if is_default {
"default permission set"
} else {
"permission set"
};
format!("{description}\n#### This {default_permission_set} includes:\n\n{permissions_list}")
}
impl<'a>
PermissionSchemaGenerator<
'a,
Values<'a, std::string::String, PermissionSet>,
Values<'a, std::string::String, Permission>,
> for Manifest
{
fn has_default_permission_set(&self) -> bool {
self.default_permission.is_some()
}
fn default_set_description(&self) -> Option<&str> {
self
.default_permission
.as_ref()
.map(|d| d.description.as_str())
}
fn default_set_permissions(&self) -> Option<&Vec<String>> {
self.default_permission.as_ref().map(|d| &d.permissions)
}
fn permission_sets(&'a self) -> Values<'a, std::string::String, PermissionSet> {
self.permission_sets.values()
}
fn permissions(&'a self) -> Values<'a, std::string::String, Permission> {
self.permissions.values()
}
}
impl<'a> PermissionSchemaGenerator<'a, Iter<'a, PermissionSet>, Iter<'a, Permission>>
for PermissionFile
{
fn has_default_permission_set(&self) -> bool {
self.default.is_some()
}
fn default_set_description(&self) -> Option<&str> {
self.default.as_ref().and_then(|d| d.description.as_deref())
}
fn default_set_permissions(&self) -> Option<&Vec<String>> {
self.default.as_ref().map(|d| &d.permissions)
}
fn permission_sets(&'a self) -> Iter<'a, PermissionSet> {
self.set.iter()
}
fn permissions(&'a self) -> Iter<'a, Permission> {
self.permission.iter()
}
}
/// Collect and include all possible identifiers in `Identifier` definition in the schema
fn extend_identifier_schema(schema: &mut Schema, acl: &BTreeMap<String, Manifest>) {
let permission_schemas: Vec<serde_json::Value> = acl
.iter()
.flat_map(|(name, manifest)| manifest.gen_possible_permission_schemas(Some(name)))
.map(serde_json::Value::from)
.collect();
if let Some(identifier_schema) = schema
.pointer_mut("/$defs/Identifier")
.and_then(|v| v.as_object_mut())
{
identifier_schema.insert(
"oneOf".to_string(),
serde_json::Value::Array(permission_schemas),
);
identifier_schema.remove("properties");
identifier_schema.remove("type");
identifier_schema.insert(
"description".to_string(),
serde_json::Value::String("Permission identifier".to_string()),
);
}
}
/// Collect permission schemas and its associated scope schema and schema definitions from plugins
/// and replace `PermissionEntry` extend object syntax with a new schema that does conditional
/// checks to serve the relevant scope schema for the right permissions schema, in a nutshell, it
/// will look something like this:
/// ```text
/// PermissionEntry {
/// anyOf {
/// String, // default string syntax
/// Object { // extended object syntax
/// allOf { // JSON allOf is used but actually means anyOf
/// {
/// "if": "identifier" property anyOf "fs" plugin permission,
/// "then": add "allow" and "deny" properties that match "fs" plugin scope schema
/// },
/// {
/// "if": "identifier" property anyOf "http" plugin permission,
/// "then": add "allow" and "deny" properties that match "http" plugin scope schema
/// },
/// ...etc,
/// {
/// No "if" or "then", just "allow" and "deny" properties with default "#/defs/Value"
/// },
/// }
/// }
/// }
/// }
/// ```
fn extend_permission_entry_schema(root_schema: &mut Schema, acl: &BTreeMap<String, Manifest>) {
const IDENTIFIER: &str = "identifier";
const ALLOW: &str = "allow";
const DENY: &str = "deny";
let mut collected_defs: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
// Scope the mutable borrow of root_schema
{
let defs = match root_schema.get_mut("$defs").and_then(|v| v.as_object_mut()) {
Some(d) => d,
None => return,
};
let perm_entry = match defs
.get_mut("PermissionEntry")
.and_then(|v| v.as_object_mut())
{
Some(p) => p,
None => return,
};
let any_of = match perm_entry.get_mut("anyOf").and_then(|v| v.as_array_mut()) {
Some(a) => a,
None => return,
};
let extend_perm_entry = match any_of.last_mut().and_then(|v| v.as_object_mut()) {
Some(e) => e,
None => return,
};
// Remove default properties and save to be added later as a fallback
let default_properties = extend_perm_entry
.remove("properties")
.and_then(|v| match v {
serde_json::Value::Object(m) => Some(m),
_ => None,
})
.unwrap_or_default();
let default_identifier = default_properties.get(IDENTIFIER).cloned().unwrap();
let mut all_of: Vec<serde_json::Value> = vec![];
let schemas = acl.iter().filter_map(|(name, manifest)| {
manifest
.global_scope_schema()
.unwrap_or_else(|e| panic!("invalid JSON schema for plugin {name}: {e}"))
.map(|s| (s, manifest.gen_possible_permission_schemas(Some(name))))
});
for ((scope_schema, defs), acl_perm_schema) in schemas {
let perm_schema_values: Vec<serde_json::Value> = acl_perm_schema
.into_iter()
.map(serde_json::Value::from)
.collect();
let scope_value = serde_json::Value::from(scope_schema);
let obj = serde_json::json!({
"properties": {
IDENTIFIER: default_identifier.clone()
},
"if": {
"properties": {
IDENTIFIER: { "anyOf": perm_schema_values }
}
},
"then": {
"properties": {
ALLOW: scope_value.clone(),
DENY: scope_value,
}
}
});
all_of.push(obj);
collected_defs.extend(defs);
}
// Add back default properties as a fallback
all_of.push(serde_json::json!({
"properties": serde_json::Value::Object(default_properties)
}));
// Replace extended PermissionEntry with the new schema
extend_perm_entry.insert("allOf".to_string(), serde_json::Value::Array(all_of));
}
// Extend root schema with definitions collected from plugins
if !collected_defs.is_empty() {
let root_defs = root_schema
.ensure_object()
.entry("$defs")
.or_insert(serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
.unwrap();
root_defs.extend(collected_defs);
}
}
/// Generate schema for CapabilityFile with all possible plugins permissions
pub fn generate_capability_schema(
acl: &BTreeMap<String, Manifest>,
target: Target,
) -> crate::Result<()> {
let mut schema = schemars::SchemaGenerator::new(schemars::generate::SchemaSettings::draft07())
.into_root_schema_for::<CapabilityFile>();
extend_identifier_schema(&mut schema, acl);
extend_permission_entry_schema(&mut schema, acl);
let schema_str = serde_json::to_string_pretty(&schema).unwrap();
let out_dir = PathBuf::from(CAPABILITIES_SCHEMA_FOLDER_PATH);
fs::create_dir_all(&out_dir)?;
let schema_path = out_dir.join(format!("{target}-{CAPABILITIES_SCHEMA_FILE_NAME}"));
if schema_str != fs::read_to_string(&schema_path).unwrap_or_default() {
fs::write(&schema_path, schema_str)?;
fs::copy(
schema_path,
out_dir.join(format!(
"{}-{CAPABILITIES_SCHEMA_FILE_NAME}",
if target.is_desktop() {
"desktop"
} else {
"mobile"
}
)),
)?;
}
Ok(())
}
/// Extend schema with collected permissions from the passed [`PermissionFile`]s.
fn extend_permission_file_schema(schema: &mut Schema, permissions: &[PermissionFile]) {
// Collect possible permissions
let permission_schemas: Vec<serde_json::Value> = permissions
.iter()
.flat_map(|p| p.gen_possible_permission_schemas(None))
.map(serde_json::Value::from)
.collect();
// Update the permissions property to reference PermissionKind
let updated = if let Some(permissions_obj) = schema
.pointer_mut("/$defs/PermissionSet/properties/permissions")
.and_then(|v| v.as_object_mut())
{
permissions_obj.insert(
"items".to_string(),
serde_json::json!({ "$ref": "#/$defs/PermissionKind" }),
);
true
} else {
false
};
// Add the new PermissionKind definition
if updated {
let defs = schema
.ensure_object()
.entry("$defs")
.or_insert(serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
.unwrap();
defs.insert(
"PermissionKind".into(),
serde_json::json!({
"type": "string",
"oneOf": permission_schemas,
}),
);
}
}
/// Generate and write a schema based on the format of a [`PermissionFile`].
pub fn generate_permissions_schema<P: AsRef<Path>>(
permissions: &[PermissionFile],
out_dir: P,
) -> Result<(), Error> {
let mut schema = schemars::SchemaGenerator::new(schemars::generate::SchemaSettings::draft07())
.into_root_schema_for::<PermissionFile>();
extend_permission_file_schema(&mut schema, permissions);
let schema_str = serde_json::to_string_pretty(&schema)?;
let out_dir = out_dir.as_ref().join(PERMISSION_SCHEMAS_FOLDER_NAME);
fs::create_dir_all(&out_dir).map_err(|e| Error::CreateDir(e, out_dir.clone()))?;
let schema_path = out_dir.join(PERMISSION_SCHEMA_FILE_NAME);
write_if_changed(&schema_path, schema_str).map_err(|e| Error::WriteFile(e, schema_path))?;
Ok(())
}

View file

@ -0,0 +1,201 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! A [`Value`] that is used instead of [`toml::Value`] or [`serde_json::Value`]
//! to support both formats.
use std::collections::BTreeMap;
use std::fmt::Debug;
use serde::{Deserialize, Serialize};
/// A valid ACL number.
#[derive(Debug, PartialEq, Serialize, Deserialize, Copy, Clone, PartialOrd)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum Number {
/// Represents an [`i64`].
Int(i64),
/// Represents a [`f64`].
Float(f64),
}
impl From<i64> for Number {
#[inline(always)]
fn from(value: i64) -> Self {
Self::Int(value)
}
}
impl From<f64> for Number {
#[inline(always)]
fn from(value: f64) -> Self {
Self::Float(value)
}
}
/// All supported ACL values.
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, PartialOrd)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum Value {
/// Represents a null JSON value.
Null,
/// Represents a [`bool`].
Bool(bool),
/// Represents a valid ACL [`Number`].
Number(Number),
/// Represents a [`String`].
String(String),
/// Represents a list of other [`Value`]s.
List(Vec<Value>),
/// Represents a map of [`String`] keys to [`Value`]s.
Map(BTreeMap<String, Value>),
}
impl From<Value> for serde_json::Value {
fn from(value: Value) -> Self {
match value {
Value::Null => serde_json::Value::Null,
Value::Bool(b) => serde_json::Value::Bool(b),
Value::Number(Number::Float(f)) => {
serde_json::Value::Number(serde_json::Number::from_f64(f).unwrap())
}
Value::Number(Number::Int(i)) => serde_json::Value::Number(i.into()),
Value::String(s) => serde_json::Value::String(s),
Value::List(list) => serde_json::Value::Array(list.into_iter().map(Into::into).collect()),
Value::Map(map) => serde_json::Value::Object(
map
.into_iter()
.map(|(key, value)| (key, value.into()))
.collect(),
),
}
}
}
impl From<serde_json::Value> for Value {
fn from(value: serde_json::Value) -> Self {
match value {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(b),
serde_json::Value::Number(n) => Value::Number(if let Some(f) = n.as_f64() {
Number::Float(f)
} else if let Some(n) = n.as_u64() {
Number::Int(n as i64)
} else if let Some(n) = n.as_i64() {
Number::Int(n)
} else {
Number::Int(0)
}),
serde_json::Value::String(s) => Value::String(s),
serde_json::Value::Array(list) => Value::List(list.into_iter().map(Into::into).collect()),
serde_json::Value::Object(map) => Value::Map(
map
.into_iter()
.map(|(key, value)| (key, value.into()))
.collect(),
),
}
}
}
impl From<bool> for Value {
#[inline(always)]
fn from(value: bool) -> Self {
Self::Bool(value)
}
}
impl<T: Into<Number>> From<T> for Value {
#[inline(always)]
fn from(value: T) -> Self {
Self::Number(value.into())
}
}
impl From<String> for Value {
#[inline(always)]
fn from(value: String) -> Self {
Value::String(value)
}
}
impl From<toml::Value> for Value {
#[inline(always)]
fn from(value: toml::Value) -> Self {
use toml::Value as Toml;
match value {
Toml::String(s) => s.into(),
Toml::Integer(i) => i.into(),
Toml::Float(f) => f.into(),
Toml::Boolean(b) => b.into(),
Toml::Datetime(d) => d.to_string().into(),
Toml::Array(a) => Value::List(a.into_iter().map(Value::from).collect()),
Toml::Table(t) => Value::Map(t.into_iter().map(|(k, v)| (k, v.into())).collect()),
}
}
}
#[cfg(any(feature = "build", feature = "build-2"))]
mod build {
use std::convert::identity;
use crate::tokens::*;
use super::*;
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
impl ToTokens for Number {
fn to_tokens(&self, tokens: &mut TokenStream) {
let prefix = quote! { ::tauri::utils::acl::Number };
tokens.append_all(match self {
Self::Int(i) => {
quote! { #prefix::Int(#i) }
}
Self::Float(f) => {
quote! { #prefix::Float (#f) }
}
});
}
}
impl ToTokens for Value {
fn to_tokens(&self, tokens: &mut TokenStream) {
let prefix = quote! { ::tauri::utils::acl::Value };
tokens.append_all(match self {
Value::Null => quote! { #prefix::Null },
Value::Bool(bool) => quote! { #prefix::Bool(#bool) },
Value::Number(number) => quote! { #prefix::Number(#number) },
Value::String(str) => {
let s = str_lit(str);
quote! { #prefix::String(#s) }
}
Value::List(vec) => {
let items = vec_lit(vec, identity);
quote! { #prefix::List(#items) }
}
Value::Map(map) => {
let map = map_lit(
quote! { ::std::collections::BTreeMap },
map,
str_lit,
identity,
);
quote! { #prefix::Map(#map) }
}
});
}
}
}

View file

@ -0,0 +1,212 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! The Assets module allows you to read files that have been bundled by tauri
//! during both compile time and runtime.
#[doc(hidden)]
pub use phf;
use std::{
borrow::Cow,
path::{Component, Path},
};
/// The token used for script nonces.
pub const SCRIPT_NONCE_TOKEN: &str = "__TAURI_SCRIPT_NONCE__";
/// The token used for style nonces.
pub const STYLE_NONCE_TOKEN: &str = "__TAURI_STYLE_NONCE__";
/// Assets iterator.
pub type AssetsIter<'a> = dyn Iterator<Item = (Cow<'a, str>, Cow<'a, [u8]>)> + 'a;
/// Represent an asset file path in a normalized way.
///
/// The following rules are enforced and added if needed:
/// * Unix path component separators
/// * Has a root directory
/// * No trailing slash - directories are not included in assets
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct AssetKey(String);
impl From<AssetKey> for String {
fn from(key: AssetKey) -> Self {
key.0
}
}
impl AsRef<str> for AssetKey {
fn as_ref(&self) -> &str {
&self.0
}
}
impl<P: AsRef<Path>> From<P> for AssetKey {
fn from(path: P) -> Self {
// TODO: change this to utilize `Cow` to prevent allocating an intermediate `PathBuf` when not necessary
let path = path.as_ref().to_owned();
// add in root to mimic how it is used from a server url
let path = if path.has_root() {
path
} else {
Path::new(&Component::RootDir).join(path)
};
let buf = if cfg!(windows) {
let mut buf = String::new();
for component in path.components() {
match component {
Component::RootDir => buf.push('/'),
Component::CurDir => buf.push_str("./"),
Component::ParentDir => buf.push_str("../"),
Component::Prefix(prefix) => buf.push_str(&prefix.as_os_str().to_string_lossy()),
Component::Normal(s) => {
buf.push_str(&s.to_string_lossy());
buf.push('/')
}
}
}
// remove the last slash
if buf != "/" {
buf.pop();
}
buf
} else {
path.to_string_lossy().to_string()
};
AssetKey(buf)
}
}
/// A Content-Security-Policy hash value for a specific directive.
/// For more information see [the MDN page](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy#directives).
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub enum CspHash<'a> {
/// The `script-src` directive.
Script(&'a str),
/// The `style-src` directive.
Style(&'a str),
}
impl CspHash<'_> {
/// The Content-Security-Policy directive this hash applies to.
pub fn directive(&self) -> &'static str {
match self {
Self::Script(_) => "script-src",
Self::Style(_) => "style-src",
}
}
/// The value of the Content-Security-Policy hash.
pub fn hash(&self) -> &str {
match self {
Self::Script(hash) => hash,
Self::Style(hash) => hash,
}
}
}
/// [`Assets`] implementation that only contains compile-time compressed and embedded assets.
pub struct EmbeddedAssets {
assets: phf::Map<&'static str, &'static [u8]>,
// Hashes that must be injected to the CSP of every HTML file.
global_hashes: &'static [CspHash<'static>],
// Hashes that are associated to the CSP of the HTML file identified by the map key (the HTML asset key).
html_hashes: phf::Map<&'static str, &'static [CspHash<'static>]>,
}
/// Temporary struct that overrides the Debug formatting for the `assets` field.
///
/// It reduces the output size compared to the default, as that would format the binary
/// data as a slice of numbers like `[65, 66, 67]` for "ABC". This instead shows the length
/// of the slice.
///
/// For example: `{"/index.html": [u8; 1835], "/index.js": [u8; 212]}`
struct DebugAssetMap<'a>(&'a phf::Map<&'static str, &'static [u8]>);
impl std::fmt::Debug for DebugAssetMap<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut map = f.debug_map();
for (k, v) in self.0.entries() {
map.key(k);
map.value(&format_args!("[u8; {}]", v.len()));
}
map.finish()
}
}
impl std::fmt::Debug for EmbeddedAssets {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EmbeddedAssets")
.field("assets", &DebugAssetMap(&self.assets))
.field("global_hashes", &self.global_hashes)
.field("html_hashes", &self.html_hashes)
.finish()
}
}
impl EmbeddedAssets {
/// Creates a new instance from the given asset map and script hash list.
pub const fn new(
map: phf::Map<&'static str, &'static [u8]>,
global_hashes: &'static [CspHash<'static>],
html_hashes: phf::Map<&'static str, &'static [CspHash<'static>]>,
) -> Self {
Self {
assets: map,
global_hashes,
html_hashes,
}
}
/// Get an asset by key.
#[cfg(feature = "compression")]
pub fn get(&self, key: &AssetKey) -> Option<Cow<'_, [u8]>> {
let &(mut asdf) = self.assets.get(key.as_ref())?;
// with the exception of extremely small files, output should usually be
// at least as large as the compressed version.
let mut buf = Vec::with_capacity(asdf.len());
brotli::BrotliDecompress(&mut asdf, &mut buf).ok()?;
Some(Cow::Owned(buf))
}
/// Get an asset by key.
#[cfg(not(feature = "compression"))]
pub fn get(&self, key: &AssetKey) -> Option<Cow<'_, [u8]>> {
Some(Cow::Borrowed(self.assets.get(key.as_ref())?))
}
/// Iterate on the assets.
pub fn iter(&self) -> Box<AssetsIter<'_>> {
Box::new(
self
.assets
.into_iter()
.map(|(k, b)| (Cow::Borrowed(*k), Cow::Borrowed(*b))),
)
}
/// CSP hashes for the given asset.
pub fn csp_hashes(&self, html_path: &AssetKey) -> Box<dyn Iterator<Item = CspHash<'_>> + '_> {
Box::new(
self
.global_hashes
.iter()
.chain(
self
.html_hashes
.get(html_path.as_ref())
.copied()
.into_iter()
.flatten(),
)
.copied(),
)
}
}

View file

@ -0,0 +1,171 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! Build script utilities.
/// Link a Swift library.
#[cfg(target_os = "macos")]
pub fn link_apple_library(name: &str, source: impl AsRef<std::path::Path>) {
if source.as_ref().join("Package.swift").exists() {
link_swift_library(name, source);
} else {
link_xcode_library(name, source);
}
}
/// Link a Swift library.
#[cfg(target_os = "macos")]
fn link_swift_library(name: &str, source: impl AsRef<std::path::Path>) {
let source = source.as_ref();
let sdk_root = std::env::var_os("SDKROOT");
unsafe {
std::env::remove_var("SDKROOT");
}
swift_rs::SwiftLinker::new(
&std::env::var("MACOSX_DEPLOYMENT_TARGET").unwrap_or_else(|_| "10.13".into()),
)
.with_ios(&std::env::var("IPHONEOS_DEPLOYMENT_TARGET").unwrap_or_else(|_| "13.0".into()))
.with_package(name, source)
.link();
if let Some(root) = sdk_root {
unsafe {
std::env::set_var("SDKROOT", root);
}
}
}
/// Link a Xcode library.
#[cfg(target_os = "macos")]
fn link_xcode_library(name: &str, source: impl AsRef<std::path::Path>) {
use std::{path::PathBuf, process::Command};
let source = source.as_ref();
let configuration = if std::env::var("DEBUG")
.map(|v| v == "true")
.unwrap_or_default()
{
"Debug"
} else {
"Release"
};
let (sdk, arch) = match std::env::var("TARGET").unwrap().as_str() {
"aarch64-apple-ios" => ("iphoneos", "arm64"),
"aarch64-apple-ios-sim" => ("iphonesimulator", "arm64"),
"x86_64-apple-ios" => ("iphonesimulator", "x86_64"),
_ => return,
};
let out_dir = std::env::var_os("OUT_DIR").map(PathBuf::from).unwrap();
let derived_data_path = out_dir.join(format!("derivedData-{name}"));
let status = Command::new("xcodebuild")
.arg("build")
.arg("-scheme")
.arg(name)
.arg("-configuration")
.arg(configuration)
.arg("-sdk")
.arg(sdk)
.arg("-arch")
.arg(arch)
.arg("-derivedDataPath")
.arg(&derived_data_path)
.arg("BUILD_LIBRARY_FOR_DISTRIBUTION=YES")
.arg("OTHER_SWIFT_FLAGS=-no-verify-emitted-module-interface")
.current_dir(source)
.env_clear()
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.status()
.unwrap();
assert!(status.success());
let lib_out_dir = derived_data_path
.join("Build")
.join("Products")
.join(format!("{configuration}-{sdk}"));
println!(
"cargo::rustc-link-search=framework={}",
lib_out_dir.display()
);
println!("cargo:rerun-if-changed={}", source.display());
println!("cargo:rustc-link-search=native={}", lib_out_dir.display());
println!("cargo:rustc-link-lib=static={name}");
}
/// Updates the Android manifest by inserting XML content into a specified parent tag.
///
/// The content is wrapped in auto-generated comments and will replace any existing
/// content with the same block identifier.
///
/// # Arguments
///
/// * `block_identifier` - A unique identifier for the block (used in comments)
/// * `parent` - The parent XML tag name (e.g., "activity", "application")
/// * `insert` - The XML content to insert
pub fn update_android_manifest(
block_identifier: &str,
parent: &str,
insert: String,
) -> anyhow::Result<()> {
use std::{
env::var_os,
fs::{read_to_string, write},
path::PathBuf,
};
if let Some(project_path) = var_os("TAURI_ANDROID_PROJECT_PATH").map(PathBuf::from) {
let manifest_path = project_path.join("app/src/main/AndroidManifest.xml");
if !manifest_path.exists() {
return Ok(());
}
let manifest = read_to_string(&manifest_path)?;
let rewritten = insert_into_xml(&manifest, block_identifier, parent, &insert);
if rewritten != manifest {
write(&manifest_path, rewritten)?;
}
}
Ok(())
}
fn xml_block_comment(id: &str) -> String {
format!("<!-- {id}. AUTO-GENERATED. DO NOT REMOVE. -->")
}
fn insert_into_xml(xml: &str, block_identifier: &str, parent_tag: &str, contents: &str) -> String {
let block_comment = xml_block_comment(block_identifier);
let mut rewritten = Vec::new();
let mut found_block = false;
let parent_closing_tag = format!("</{parent_tag}>");
for line in xml.split('\n') {
if line.contains(&block_comment) {
found_block = !found_block;
continue;
}
// found previous block which should be removed
if found_block {
continue;
}
if let Some(index) = line.find(&parent_closing_tag) {
let indentation = " ".repeat(index + 4);
rewritten.push(format!("{indentation}{block_comment}"));
for l in contents.split('\n') {
rewritten.push(format!("{indentation}{l}"));
}
rewritten.push(format!("{indentation}{block_comment}"));
}
rewritten.push(line.to_string());
}
rewritten.join("\n")
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,398 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::config::Config;
use crate::platform::Target;
use json_patch::merge;
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use thiserror::Error;
/// All extensions that are possibly supported, but perhaps not enabled.
pub const EXTENSIONS_SUPPORTED: &[&str] = &["json", "json5", "toml"];
/// All configuration formats that are possibly supported, but perhaps not enabled.
pub const SUPPORTED_FORMATS: &[ConfigFormat] =
&[ConfigFormat::Json, ConfigFormat::Json5, ConfigFormat::Toml];
/// All configuration formats that are currently enabled.
pub const ENABLED_FORMATS: &[ConfigFormat] = &[
ConfigFormat::Json,
#[cfg(feature = "config-json5")]
ConfigFormat::Json5,
#[cfg(feature = "config-toml")]
ConfigFormat::Toml,
];
/// The available configuration formats.
#[derive(Debug, Copy, Clone)]
pub enum ConfigFormat {
/// The default JSON (tauri.conf.json) format.
Json,
/// The JSON5 (tauri.conf.json5) format.
Json5,
/// The TOML (Tauri.toml file) format.
Toml,
}
impl ConfigFormat {
/// Maps the config format to its file name.
pub fn into_file_name(self) -> &'static str {
match self {
Self::Json => "tauri.conf.json",
Self::Json5 => "tauri.conf.json5",
Self::Toml => "Tauri.toml",
}
}
fn into_platform_file_name(self, target: Target) -> &'static str {
match self {
Self::Json => match target {
Target::MacOS => "tauri.macos.conf.json",
Target::Windows => "tauri.windows.conf.json",
Target::Linux => "tauri.linux.conf.json",
Target::Android => "tauri.android.conf.json",
Target::Ios => "tauri.ios.conf.json",
},
Self::Json5 => match target {
Target::MacOS => "tauri.macos.conf.json5",
Target::Windows => "tauri.windows.conf.json5",
Target::Linux => "tauri.linux.conf.json5",
Target::Android => "tauri.android.conf.json5",
Target::Ios => "tauri.ios.conf.json5",
},
Self::Toml => match target {
Target::MacOS => "Tauri.macos.toml",
Target::Windows => "Tauri.windows.toml",
Target::Linux => "Tauri.linux.toml",
Target::Android => "Tauri.android.toml",
Target::Ios => "Tauri.ios.toml",
},
}
}
}
/// Represents all the errors that can happen while reading the config.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ConfigError {
/// Failed to parse from JSON.
#[error("unable to parse JSON Tauri config file at {path} because {error}")]
FormatJson {
/// The path that failed to parse into JSON.
path: PathBuf,
/// The parsing [`serde_json::Error`].
error: serde_json::Error,
},
/// Failed to parse from JSON5.
#[cfg(feature = "config-json5")]
#[error("unable to parse JSON5 Tauri config file at {path} because {error}")]
FormatJson5 {
/// The path that failed to parse into JSON5.
path: PathBuf,
/// The parsing [`json5::Error`].
error: ::json5::Error,
},
/// Failed to parse from TOML.
#[cfg(feature = "config-toml")]
#[error("unable to parse toml Tauri config file at {path} because {error}")]
FormatToml {
/// The path that failed to parse into TOML.
path: PathBuf,
/// The parsing [`toml::Error`].
error: Box<::toml::de::Error>,
},
/// Unknown config file name encountered.
#[error("unsupported format encountered {0}")]
UnsupportedFormat(String),
/// Known file extension encountered, but corresponding parser is not enabled (cargo features).
#[error("supported (but disabled) format encountered {extension} - try enabling `{feature}` ")]
DisabledFormat {
/// The extension encountered.
extension: String,
/// The cargo feature to enable it.
feature: String,
},
/// A generic IO error with context of what caused it.
#[error("unable to read Tauri config file at {path} because {error}")]
Io {
/// The path the IO error occurred on.
path: PathBuf,
/// The [`std::io::Error`].
error: std::io::Error,
},
}
/// Determines if the given folder has a configuration file.
pub fn folder_has_configuration_file(target: Target, folder: &Path) -> bool {
folder.join(ConfigFormat::Json.into_file_name()).exists()
|| folder.join(ConfigFormat::Json5.into_file_name()).exists()
|| folder.join(ConfigFormat::Toml.into_file_name()).exists()
// platform file names
|| folder.join(ConfigFormat::Json.into_platform_file_name(target)).exists()
|| folder.join(ConfigFormat::Json5.into_platform_file_name(target)).exists()
|| folder.join(ConfigFormat::Toml.into_platform_file_name(target)).exists()
}
/// Determines if the given file path represents a Tauri configuration file.
pub fn is_configuration_file(target: Target, path: &Path) -> bool {
path
.file_name()
.map(|file_name| {
file_name == OsStr::new(ConfigFormat::Json.into_file_name())
|| file_name == OsStr::new(ConfigFormat::Json5.into_file_name())
|| file_name == OsStr::new(ConfigFormat::Toml.into_file_name())
// platform file names
|| file_name == OsStr::new(ConfigFormat::Json.into_platform_file_name(target))
|| file_name == OsStr::new(ConfigFormat::Json5.into_platform_file_name(target))
|| file_name == OsStr::new(ConfigFormat::Toml.into_platform_file_name(target))
})
.unwrap_or_default()
}
/// Reads the configuration from the given root directory.
///
/// It first looks for a `tauri.conf.json[5]` or `Tauri.toml` file on the given directory. The file must exist.
/// Then it looks for a platform-specific configuration file:
/// - `tauri.macos.conf.json[5]` or `Tauri.macos.toml` on macOS
/// - `tauri.linux.conf.json[5]` or `Tauri.linux.toml` on Linux
/// - `tauri.windows.conf.json[5]` or `Tauri.windows.toml` on Windows
/// - `tauri.android.conf.json[5]` or `Tauri.android.toml` on Android
/// - `tauri.ios.conf.json[5]` or `Tauri.ios.toml` on iOS
/// Merging the configurations using [JSON Merge Patch (RFC 7396)].
///
/// Returns the raw configuration and used config paths.
///
/// [JSON Merge Patch (RFC 7396)]: https://datatracker.ietf.org/doc/html/rfc7396.
pub fn read_from(target: Target, root_dir: &Path) -> Result<(Value, Vec<PathBuf>), ConfigError> {
let (mut config, config_file_path) = parse_value(target, root_dir.join("tauri.conf.json"))?;
let mut config_paths = vec![config_file_path];
if let Some((platform_config, path)) = read_platform(target, root_dir)? {
config_paths.push(path);
merge(&mut config, &platform_config);
}
Ok((config, config_paths))
}
/// Reads the platform-specific configuration file from the given root directory if it exists.
///
/// Check [`read_from`] for more information.
pub fn read_platform(
target: Target,
root_dir: &Path,
) -> Result<Option<(Value, PathBuf)>, ConfigError> {
let platform_config_path = root_dir.join(ConfigFormat::Json.into_platform_file_name(target));
if does_supported_file_name_exist(target, &platform_config_path) {
let (platform_config, path): (Value, PathBuf) = parse_value(target, platform_config_path)?;
Ok(Some((platform_config, path)))
} else {
Ok(None)
}
}
/// Check if a supported config file exists at path.
///
/// The passed path is expected to be the path to the "default" configuration format, in this case
/// JSON with `.json`.
pub fn does_supported_file_name_exist(target: Target, path: impl Into<PathBuf>) -> bool {
let path = path.into();
let source_file_name = path.file_name().unwrap();
let lookup_platform_config = ENABLED_FORMATS
.iter()
.any(|format| source_file_name == format.into_platform_file_name(target));
ENABLED_FORMATS.iter().any(|format| {
path
.with_file_name(if lookup_platform_config {
format.into_platform_file_name(target)
} else {
format.into_file_name()
})
.exists()
})
}
/// Parse the config from path, including alternative formats.
///
/// Hierarchy:
/// 1. Check if `tauri.conf.json` exists
/// a. Parse it with `serde_json`
/// b. Parse it with `json5` if `serde_json` fails
/// c. Return original `serde_json` error if all above steps failed
/// 2. Check if `tauri.conf.json5` exists
/// a. Parse it with `json5`
/// b. Return error if all above steps failed
/// 3. Check if `Tauri.json` exists
/// a. Parse it with `toml`
/// b. Return error if all above steps failed
/// 4. Return error if all above steps failed
pub fn parse(target: Target, path: impl Into<PathBuf>) -> Result<(Config, PathBuf), ConfigError> {
do_parse(target, path.into())
}
/// See [`parse`] for specifics, returns a JSON [`Value`] instead of [`Config`].
pub fn parse_value(
target: Target,
path: impl Into<PathBuf>,
) -> Result<(Value, PathBuf), ConfigError> {
do_parse(target, path.into())
}
fn do_parse<D: DeserializeOwned>(
target: Target,
path: PathBuf,
) -> Result<(D, PathBuf), ConfigError> {
let file_name = path
.file_name()
.map(OsStr::to_string_lossy)
.unwrap_or_default();
let lookup_platform_config = ENABLED_FORMATS
.iter()
.any(|format| file_name == format.into_platform_file_name(target));
let json5 = path.with_file_name(if lookup_platform_config {
ConfigFormat::Json5.into_platform_file_name(target)
} else {
ConfigFormat::Json5.into_file_name()
});
let toml = path.with_file_name(if lookup_platform_config {
ConfigFormat::Toml.into_platform_file_name(target)
} else {
ConfigFormat::Toml.into_file_name()
});
let path_ext = path
.extension()
.map(OsStr::to_string_lossy)
.unwrap_or_default();
if path.exists() {
let raw = read_to_string(&path)?;
// to allow us to easily use the compile-time #[cfg], we always bind
#[allow(clippy::let_and_return)]
let json = do_parse_json(&raw, &path);
// we also want to support **valid** json5 in the .json extension if the feature is enabled.
// if the json5 is not valid the serde_json error for regular json will be returned.
// this could be a bit confusing, so we may want to encourage users using json5 to use the
// .json5 extension instead of .json
#[cfg(feature = "config-json5")]
let json = {
match do_parse_json5(&raw, &path) {
json5 @ Ok(_) => json5,
// assume any errors from json5 in a .json file is because it's not json5
Err(_) => json,
}
};
json.map(|j| (j, path))
} else if json5.exists() {
#[cfg(feature = "config-json5")]
{
let raw = read_to_string(&json5)?;
do_parse_json5(&raw, &json5).map(|config| (config, json5))
}
#[cfg(not(feature = "config-json5"))]
Err(ConfigError::DisabledFormat {
extension: ".json5".into(),
feature: "config-json5".into(),
})
} else if toml.exists() {
#[cfg(feature = "config-toml")]
{
let raw = read_to_string(&toml)?;
do_parse_toml(&raw, &toml).map(|config| (config, toml))
}
#[cfg(not(feature = "config-toml"))]
Err(ConfigError::DisabledFormat {
extension: ".toml".into(),
feature: "config-toml".into(),
})
} else if !EXTENSIONS_SUPPORTED.contains(&path_ext.as_ref()) {
Err(ConfigError::UnsupportedFormat(path_ext.to_string()))
} else {
Err(ConfigError::Io {
path,
error: std::io::ErrorKind::NotFound.into(),
})
}
}
/// "Low-level" helper to parse JSON into a [`Config`].
///
/// `raw` should be the contents of the file that is represented by `path`.
pub fn parse_json(raw: &str, path: &Path) -> Result<Config, ConfigError> {
do_parse_json(raw, path)
}
/// "Low-level" helper to parse JSON into a JSON [`Value`].
///
/// `raw` should be the contents of the file that is represented by `path`.
pub fn parse_json_value(raw: &str, path: &Path) -> Result<Value, ConfigError> {
do_parse_json(raw, path)
}
fn do_parse_json<D: DeserializeOwned>(raw: &str, path: &Path) -> Result<D, ConfigError> {
serde_json::from_str(raw).map_err(|error| ConfigError::FormatJson {
path: path.into(),
error,
})
}
/// "Low-level" helper to parse JSON5 into a [`Config`].
///
/// `raw` should be the contents of the file that is represented by `path`. This function requires
/// the `config-json5` feature to be enabled.
#[cfg(feature = "config-json5")]
pub fn parse_json5(raw: &str, path: &Path) -> Result<Config, ConfigError> {
do_parse_json5(raw, path)
}
/// "Low-level" helper to parse JSON5 into a JSON [`Value`].
///
/// `raw` should be the contents of the file that is represented by `path`. This function requires
/// the `config-json5` feature to be enabled.
#[cfg(feature = "config-json5")]
pub fn parse_json5_value(raw: &str, path: &Path) -> Result<Value, ConfigError> {
do_parse_json5(raw, path)
}
#[cfg(feature = "config-json5")]
fn do_parse_json5<D: DeserializeOwned>(raw: &str, path: &Path) -> Result<D, ConfigError> {
::json5::from_str(raw).map_err(|error| ConfigError::FormatJson5 {
path: path.into(),
error,
})
}
#[cfg(feature = "config-toml")]
fn do_parse_toml<D: DeserializeOwned>(raw: &str, path: &Path) -> Result<D, ConfigError> {
::toml::from_str(raw).map_err(|error| ConfigError::FormatToml {
path: path.into(),
error: Box::new(error),
})
}
/// Helper function to wrap IO errors from [`std::fs::read_to_string`] into a [`ConfigError`].
fn read_to_string(path: &Path) -> Result<String, ConfigError> {
std::fs::read_to_string(path).map_err(|error| ConfigError::Io {
path: path.into(),
error,
})
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,255 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
#![allow(clippy::result_large_err)]
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use thiserror::Error;
/// All extensions that are possibly supported, but perhaps not enabled.
const EXTENSIONS_SUPPORTED: &[&str] = &["json", "json5", "toml"];
/// All configuration formats that are currently enabled.
const ENABLED_FORMATS: &[ConfigFormat] = &[
ConfigFormat::Json,
#[cfg(feature = "config-json5")]
ConfigFormat::Json5,
#[cfg(feature = "config-toml")]
ConfigFormat::Toml,
];
/// The available configuration formats.
#[derive(Debug, Copy, Clone)]
enum ConfigFormat {
/// The default JSON (tauri.conf.json) format.
Json,
/// The JSON5 (tauri.conf.json5) format.
Json5,
/// The TOML (Tauri.toml file) format.
Toml,
}
impl ConfigFormat {
/// Maps the config format to its file name.
fn into_file_name(self) -> &'static str {
match self {
Self::Json => "tauri.conf.json",
Self::Json5 => "tauri.conf.json5",
Self::Toml => "Tauri.toml",
}
}
fn into_platform_file_name(self) -> &'static str {
match self {
Self::Json => {
if cfg!(target_os = "macos") {
"tauri.macos.conf.json"
} else if cfg!(windows) {
"tauri.windows.conf.json"
} else {
"tauri.linux.conf.json"
}
}
Self::Json5 => {
if cfg!(target_os = "macos") {
"tauri.macos.conf.json5"
} else if cfg!(windows) {
"tauri.windows.conf.json5"
} else {
"tauri.linux.conf.json5"
}
}
Self::Toml => {
if cfg!(target_os = "macos") {
"Tauri.macos.toml"
} else if cfg!(windows) {
"Tauri.windows.toml"
} else {
"Tauri.linux.toml"
}
}
}
}
}
/// Represents all the errors that can happen while reading the config.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ConfigError {
/// Failed to parse from JSON.
#[error("unable to parse JSON Tauri config file at {path} because {error}")]
FormatJson {
/// The path that failed to parse into JSON.
path: PathBuf,
/// The parsing [`serde_json::Error`].
error: serde_json::Error,
},
/// Failed to parse from JSON5.
#[cfg(feature = "config-json5")]
#[error("unable to parse JSON5 Tauri config file at {path} because {error}")]
FormatJson5 {
/// The path that failed to parse into JSON5.
path: PathBuf,
/// The parsing [`json5::Error`].
error: ::json5::Error,
},
/// Failed to parse from TOML.
#[cfg(feature = "config-toml")]
#[error("unable to parse toml Tauri config file at {path} because {error}")]
FormatToml {
/// The path that failed to parse into TOML.
path: PathBuf,
/// The parsing [`toml::Error`].
error: ::toml::de::Error,
},
/// Unknown config file name encountered.
#[error("unsupported format encountered {0}")]
UnsupportedFormat(String),
/// Known file extension encountered, but corresponding parser is not enabled (cargo features).
#[error("supported (but disabled) format encountered {extension} - try enabling `{feature}` ")]
DisabledFormat {
/// The extension encountered.
extension: String,
/// The cargo feature to enable it.
feature: String,
},
/// A generic IO error with context of what caused it.
#[error("unable to read Tauri config file at {path} because {error}")]
Io {
/// The path the IO error occurred on.
path: PathBuf,
/// The [`std::io::Error`].
error: std::io::Error,
},
}
/// See [`parse`] for specifics, returns a JSON [`Value`] instead of [`Config`].
pub fn parse_value(path: impl Into<PathBuf>) -> Result<(Value, PathBuf), ConfigError> {
do_parse(path.into())
}
fn do_parse<D: DeserializeOwned>(path: PathBuf) -> Result<(D, PathBuf), ConfigError> {
let file_name = path
.file_name()
.map(OsStr::to_string_lossy)
.unwrap_or_default();
let lookup_platform_config = ENABLED_FORMATS
.iter()
.any(|format| file_name == format.into_platform_file_name());
let json5 = path.with_file_name(if lookup_platform_config {
ConfigFormat::Json5.into_platform_file_name()
} else {
ConfigFormat::Json5.into_file_name()
});
let toml = path.with_file_name(if lookup_platform_config {
ConfigFormat::Toml.into_platform_file_name()
} else {
ConfigFormat::Toml.into_file_name()
});
let path_ext = path
.extension()
.map(OsStr::to_string_lossy)
.unwrap_or_default();
if path.exists() {
let raw = read_to_string(&path)?;
// to allow us to easily use the compile-time #[cfg], we always bind
#[allow(clippy::let_and_return)]
let json = do_parse_json(&raw, &path);
// we also want to support **valid** json5 in the .json extension if the feature is enabled.
// if the json5 is not valid the serde_json error for regular json will be returned.
// this could be a bit confusing, so we may want to encourage users using json5 to use the
// .json5 extension instead of .json
#[cfg(feature = "config-json5")]
let json = {
match do_parse_json5(&raw, &path) {
json5 @ Ok(_) => json5,
// assume any errors from json5 in a .json file is because it's not json5
Err(_) => json,
}
};
json.map(|j| (j, path))
} else if json5.exists() {
#[cfg(feature = "config-json5")]
{
let raw = read_to_string(&json5)?;
do_parse_json5(&raw, &path).map(|config| (config, json5))
}
#[cfg(not(feature = "config-json5"))]
Err(ConfigError::DisabledFormat {
extension: ".json5".into(),
feature: "config-json5".into(),
})
} else if toml.exists() {
#[cfg(feature = "config-toml")]
{
let raw = read_to_string(&toml)?;
do_parse_toml(&raw, &path).map(|config| (config, toml))
}
#[cfg(not(feature = "config-toml"))]
Err(ConfigError::DisabledFormat {
extension: ".toml".into(),
feature: "config-toml".into(),
})
} else if !EXTENSIONS_SUPPORTED.contains(&path_ext.as_ref()) {
Err(ConfigError::UnsupportedFormat(path_ext.to_string()))
} else {
Err(ConfigError::Io {
path,
error: std::io::ErrorKind::NotFound.into(),
})
}
}
fn do_parse_json<D: DeserializeOwned>(raw: &str, path: &Path) -> Result<D, ConfigError> {
serde_json::from_str(raw).map_err(|error| ConfigError::FormatJson {
path: path.into(),
error,
})
}
#[cfg(feature = "config-json5")]
fn do_parse_json5<D: DeserializeOwned>(raw: &str, path: &Path) -> Result<D, ConfigError> {
::json5::from_str(raw).map_err(|error| ConfigError::FormatJson5 {
path: path.into(),
error,
})
}
#[cfg(feature = "config-toml")]
fn do_parse_toml<D: DeserializeOwned>(raw: &str, path: &Path) -> Result<D, ConfigError> {
::toml::from_str(raw).map_err(|error| ConfigError::FormatToml {
path: path.into(),
error,
})
}
/// Helper function to wrap IO errors from [`std::fs::read_to_string`] into a [`ConfigError`].
fn read_to_string(path: &Path) -> Result<String, ConfigError> {
std::fs::read_to_string(path).map_err(|error| ConfigError::Io {
path: path.into(),
error,
})
}

View file

@ -0,0 +1,442 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! The module to process HTML in Tauri.
use std::path::{Path, PathBuf};
use html5ever::{
LocalName,
interface::QualName,
namespace_url, ns,
serialize::{HtmlSerializer, SerializeOpts, Serializer, TraversalScope},
tendril::TendrilSink,
};
pub use kuchiki::NodeRef;
use kuchiki::{Attribute, ExpandedName, NodeData};
use serde::Serialize;
#[cfg(feature = "isolation")]
use serialize_to_javascript::DefaultTemplate;
#[cfg(feature = "isolation")]
use crate::pattern::isolation::IsolationJavascriptCodegen;
use crate::{
assets::{SCRIPT_NONCE_TOKEN, STYLE_NONCE_TOKEN},
config::{DisabledCspModificationKind, PatternKind},
};
// taken from <https://github.com/kuchiki-rs/kuchiki/blob/57ee6920d835315a498e748ba4b07a851ae5e498/src/serializer.rs#L12>
fn serialize_node_ref_internal<S: Serializer>(
node: &NodeRef,
serializer: &mut S,
traversal_scope: TraversalScope,
) -> crate::Result<()> {
match (traversal_scope, node.data()) {
(ref scope, NodeData::Element(element)) => {
if *scope == TraversalScope::IncludeNode {
let attrs = element.attributes.borrow();
// Unfortunately we need to allocate something to hold these &'a QualName
let attrs = attrs
.map
.iter()
.map(|(name, attr)| {
(
QualName::new(attr.prefix.clone(), name.ns.clone(), name.local.clone()),
&attr.value,
)
})
.collect::<Vec<_>>();
serializer.start_elem(
element.name.clone(),
attrs.iter().map(|&(ref name, value)| (name, &**value)),
)?
}
let children = match element.template_contents.as_ref() {
Some(template_root) => template_root.children(),
None => node.children(),
};
for child in children {
serialize_node_ref_internal(&child, serializer, TraversalScope::IncludeNode)?
}
if *scope == TraversalScope::IncludeNode {
serializer.end_elem(element.name.clone())?
}
Ok(())
}
(_, &NodeData::DocumentFragment) | (_, &NodeData::Document(_)) => {
for child in node.children() {
serialize_node_ref_internal(&child, serializer, TraversalScope::IncludeNode)?
}
Ok(())
}
(TraversalScope::ChildrenOnly(_), _) => Ok(()),
(TraversalScope::IncludeNode, NodeData::Doctype(doctype)) => {
serializer.write_doctype(&doctype.name).map_err(Into::into)
}
(TraversalScope::IncludeNode, NodeData::Text(text)) => {
serializer.write_text(&text.borrow()).map_err(Into::into)
}
(TraversalScope::IncludeNode, NodeData::Comment(text)) => {
serializer.write_comment(&text.borrow()).map_err(Into::into)
}
(TraversalScope::IncludeNode, NodeData::ProcessingInstruction(contents)) => {
let contents = contents.borrow();
serializer
.write_processing_instruction(&contents.0, &contents.1)
.map_err(Into::into)
}
}
}
/// Serializes the node to HTML.
pub fn serialize_node(node: &NodeRef) -> Vec<u8> {
let mut u8_vec = Vec::new();
let mut ser = HtmlSerializer::new(
&mut u8_vec,
SerializeOpts {
traversal_scope: TraversalScope::IncludeNode,
..Default::default()
},
);
serialize_node_ref_internal(node, &mut ser, TraversalScope::IncludeNode).unwrap();
u8_vec
}
/// Parses the given HTML string.
pub fn parse(html: String) -> NodeRef {
kuchiki::parse_html().one(html).document_node
}
fn with_head<F: FnOnce(&NodeRef)>(document: &NodeRef, f: F) {
if let Ok(ref node) = document.select_first("head") {
f(node.as_node())
} else {
let node = NodeRef::new_element(
QualName::new(None, ns!(html), LocalName::from("head")),
None,
);
f(&node);
document.prepend(node)
}
}
fn inject_nonce(document: &NodeRef, selector: &str, token: &str) {
if let Ok(elements) = document.select(selector) {
for target in elements {
let node = target.as_node();
let element = node.as_element().unwrap();
let mut attrs = element.attributes.borrow_mut();
// if the node already has the `nonce` attribute, skip it
if attrs.get("nonce").is_some() {
continue;
}
attrs.insert("nonce", token.into());
}
}
}
/// Inject nonce tokens to all scripts and styles.
pub fn inject_nonce_token(
document: &NodeRef,
dangerous_disable_asset_csp_modification: &DisabledCspModificationKind,
) {
if dangerous_disable_asset_csp_modification.can_modify("script-src") {
inject_nonce(document, "script[src^='http']", SCRIPT_NONCE_TOKEN);
}
if dangerous_disable_asset_csp_modification.can_modify("style-src") {
inject_nonce(document, "style", STYLE_NONCE_TOKEN);
}
}
/// Injects a content security policy to the HTML.
pub fn inject_csp(document: &NodeRef, csp: &str) {
with_head(document, |head| {
head.append(create_csp_meta_tag(csp));
});
}
fn create_csp_meta_tag(csp: &str) -> NodeRef {
NodeRef::new_element(
QualName::new(None, ns!(html), LocalName::from("meta")),
vec![
(
ExpandedName::new(ns!(), LocalName::from("http-equiv")),
Attribute {
prefix: None,
value: "Content-Security-Policy".into(),
},
),
(
ExpandedName::new(ns!(), LocalName::from("content")),
Attribute {
prefix: None,
value: csp.into(),
},
),
],
)
}
/// The shape of the JavaScript Pattern config
#[derive(Debug, Serialize)]
#[serde(rename_all = "lowercase", tag = "pattern")]
pub enum PatternObject {
/// Brownfield pattern.
Brownfield,
/// Isolation pattern. Recommended for security purposes.
Isolation {
/// Which `IsolationSide` this `PatternObject` is getting injected into
side: IsolationSide,
},
}
impl From<&PatternKind> for PatternObject {
fn from(pattern_kind: &PatternKind) -> Self {
match pattern_kind {
PatternKind::Brownfield => Self::Brownfield,
PatternKind::Isolation { .. } => Self::Isolation {
side: IsolationSide::default(),
},
}
}
}
/// Where the JavaScript is injected to
#[derive(Debug, Serialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum IsolationSide {
/// Original frame, the Brownfield application
#[default]
Original,
/// Secure frame, the isolation security application
Secure,
}
/// Injects the Isolation JavaScript to a codegen time document.
///
/// Note: This function is not considered part of the stable API.
#[cfg(feature = "isolation")]
pub fn inject_codegen_isolation_script(document: &NodeRef) {
with_head(document, |head| {
let script = NodeRef::new_element(
QualName::new(None, ns!(html), "script".into()),
vec![(
ExpandedName::new(ns!(), LocalName::from("nonce")),
Attribute {
prefix: None,
value: SCRIPT_NONCE_TOKEN.into(),
},
)],
);
script.append(NodeRef::new_text(
IsolationJavascriptCodegen {}
.render_default(&Default::default())
.expect("unable to render codegen isolation script template")
.into_string(),
));
head.prepend(script);
});
}
/// Temporary workaround for Windows not allowing requests
///
/// Note: this does not prevent path traversal due to the isolation application expectation that it
/// is secure.
pub fn inline_isolation(document: &NodeRef, dir: &Path) {
for script in document
.select("script[src]")
.expect("unable to parse document for scripts")
{
let src = {
let attributes = script.attributes.borrow();
attributes
.get(LocalName::from("src"))
.expect("script with src attribute has no src value")
.to_string()
};
let mut path = PathBuf::from(src);
if path.has_root() {
path = path
.strip_prefix("/")
.expect("Tauri \"Isolation\" Pattern only supports relative or absolute (`/`) paths.")
.into();
}
let file = std::fs::read_to_string(dir.join(path)).expect("unable to find isolation file");
script.as_node().append(NodeRef::new_text(file));
let mut attributes = script.attributes.borrow_mut();
attributes.remove(LocalName::from("src"));
}
}
// TODO: Verify this, this is not found in the HTML spec, see https://github.com/tauri-apps/tauri/pull/14265#discussion_r2415396842
/// Normalize line endings in script content to match what the browser uses for CSP hashing.
///
/// According to the HTML spec, browsers normalize:
/// - `\r\n` → `\n`
/// - `\r` → `\n`
pub fn normalize_script_for_csp(input: &[u8]) -> Vec<u8> {
let mut output = Vec::with_capacity(input.len());
let mut i = 0;
while i < input.len() {
match input[i] {
b'\r' => {
if i + 1 < input.len() && input[i + 1] == b'\n' {
// CRLF → LF
output.push(b'\n');
i += 2;
} else {
// Lone CR → LF
output.push(b'\n');
i += 1;
}
}
_ => {
output.push(input[i]);
i += 1;
}
}
}
output
}
#[cfg(test)]
mod tests {
use std::io::Write;
use super::*;
use crate::{
assets::{SCRIPT_NONCE_TOKEN, STYLE_NONCE_TOKEN},
config,
};
#[test]
fn csp() {
let htmls = vec![
"<html><head></head></html>".to_string(),
"<html></html>".to_string(),
];
for html in htmls {
let document = parse(html);
let csp = "csp-string";
inject_csp(&document, csp);
assert_eq!(
String::from_utf8(serialize_node(&document)).unwrap(),
format!(
r#"<html><head><meta http-equiv="Content-Security-Policy" content="{csp}"></head><body></body></html>"#,
)
);
}
}
#[test]
fn normalize_script_for_csp_test() {
let js = "// Copyright 2019-2024 Tauri Programme within The Commons Conservancy\r// SPDX-License-Identifier: Apache-2.0\n// SPDX-License-Identifier: MIT\r\n\r\nwindow.__TAURI_ISOLATION_HOOK__ = (payload, options) => {\r\n return payload\r\n}\r\n";
let expected = "// Copyright 2019-2024 Tauri Programme within The Commons Conservancy\n// SPDX-License-Identifier: Apache-2.0\n// SPDX-License-Identifier: MIT\n\nwindow.__TAURI_ISOLATION_HOOK__ = (payload, options) => {\n return payload\n}\n";
assert_eq!(normalize_script_for_csp(js.as_bytes()), expected.as_bytes())
}
#[test]
fn parse_and_serialize_roundtrips() {
let htmls = [
"<html><head><title>Test</title></head><body><h1>Hello</h1></body></html>",
"<!DOCTYPE html><html><head></head><body></body></html>",
];
for html in htmls {
let parsed = parse(html.to_string());
let serialized = serialize_node(&parsed);
let result = String::from_utf8(serialized).unwrap();
assert_eq!(result, html);
}
}
#[test]
fn inject_nonce_to_scripts() {
let html = r#"<html><head><script src="http://example.com/script.js"></script></head><body></body></html>"#;
let document = parse(html.to_string());
inject_nonce_token(&document, &config::DisabledCspModificationKind::Flag(false));
assert_eq!(
String::from_utf8(serialize_node(&document)).unwrap(),
format!(
r#"<html><head><script src="http://example.com/script.js" nonce="{SCRIPT_NONCE_TOKEN}"></script></head><body></body></html>"#
)
);
}
#[test]
fn inject_nonce_to_styles() {
let html = r#"<html><head><style>body { color: red; }</style></head><body></body></html>"#;
let document = parse(html.to_string());
inject_nonce_token(&document, &config::DisabledCspModificationKind::Flag(false));
assert_eq!(
String::from_utf8(serialize_node(&document)).unwrap(),
format!(
r#"<html><head><style nonce="{STYLE_NONCE_TOKEN}">body {{ color: red; }}</style></head><body></body></html>"#
)
);
}
#[test]
fn inject_nonce_skips_existing() {
let html = r#"<html><head><script src="http://example.com/script.js" nonce="existing"></script></head><body></body></html>"#;
let document = parse(html.to_string());
inject_nonce_token(&document, &config::DisabledCspModificationKind::Flag(false));
assert_eq!(String::from_utf8(serialize_node(&document)).unwrap(), html);
}
#[test]
fn inject_nonce_respects_disabled_modification() {
let html = r#"<html><head><script src="http://example.com/script.js"></script></head><body></body></html>"#;
let document = parse(html.to_string());
inject_nonce_token(&document, &config::DisabledCspModificationKind::Flag(true));
assert_eq!(
String::from_utf8(serialize_node(&document)).unwrap(),
r#"<html><head><script src="http://example.com/script.js"></script></head><body></body></html>"#
);
}
#[test]
fn inline_isolation_replaces_src_with_content() {
let temp_dir = tempfile::tempdir().unwrap();
let mut file = tempfile::NamedTempFile::with_suffix_in(".js", &temp_dir).unwrap();
file.write_all(b"console.log('test');").unwrap();
let file_name = file.path().file_name().unwrap().to_str().unwrap();
let html =
format!(r#"<html><head><script src="/{file_name}"></script></head><body></body></html>"#);
let document = parse(html);
inline_isolation(&document, temp_dir.path());
assert_eq!(
String::from_utf8(serialize_node(&document)).unwrap(),
r#"<html><head><script>console.log('test');</script></head><body></body></html>"#
);
}
}

View file

@ -0,0 +1,335 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! The module to process HTML in Tauri.
//!
//! # Stability
//!
//! This is utility used in Tauri internally and not considered part of the stable API.
//! If you use it, note that it may include breaking changes in the future.
use dom_query::NodeRef;
use crate::{
assets::{SCRIPT_NONCE_TOKEN, STYLE_NONCE_TOKEN},
config::DisabledCspModificationKind,
};
/// # Stability
///
/// This dependency might receive updates in minor releases.
pub use dom_query::Document;
/// Serializes the document to HTML.
///
/// # Stability
///
/// This dependency [`dom_query`] for [`Document`] might receive updates in minor releases.
pub fn serialize_doc(document: &Document) -> Vec<u8> {
document.html().as_bytes().to_vec()
}
/// Parses the given HTML string.
///
/// # Stability
///
/// This dependency [`dom_query`] for [`Document`] might receive updates in minor releases.
pub fn parse_doc(html: String) -> Document {
Document::from(html)
}
fn ensure_head(document: &Document) -> NodeRef<'_> {
document.head().unwrap_or_else(|| {
let html = document.html_root();
let head = document.tree.new_element("head");
html.prepend_child(&head);
head
})
}
fn inject_nonce(document: &Document, selector: &str, token: &str) {
let elements = document.select(selector);
for elem in elements.nodes() {
// if the node already has the `nonce` attribute, skip it
if elem.attr("nonce").is_some() {
continue;
}
elem.set_attr("nonce", token);
}
}
/// Inject nonce tokens to all scripts and styles.
///
/// # Stability
///
/// This dependency [`dom_query`] for [`Document`] might receive updates in minor releases.
pub fn inject_nonce_token(
document: &Document,
dangerous_disable_asset_csp_modification: &DisabledCspModificationKind,
) {
if dangerous_disable_asset_csp_modification.can_modify("script-src") {
inject_nonce(document, "script[src^='http']", SCRIPT_NONCE_TOKEN);
}
if dangerous_disable_asset_csp_modification.can_modify("style-src") {
inject_nonce(document, "style", STYLE_NONCE_TOKEN);
}
}
/// Injects a content security policy to the HTML.
///
/// # Stability
///
/// This dependency [`dom_query`] for [`Document`] might receive updates in minor releases.
pub fn inject_csp(document: &Document, csp: &str) {
let head = ensure_head(document);
let meta_tag = document.tree.new_element("meta");
meta_tag.set_attr("http-equiv", "Content-Security-Policy");
meta_tag.set_attr("content", csp);
head.append_child(&meta_tag);
}
/// Injects a content security policy to the HTML.
///
/// # Stability
///
/// This dependency [`dom_query`] for [`Document`] might receive updates in minor releases.
pub fn append_script_to_head(document: &Document, script: &str) {
let head = ensure_head(document);
let script_tag = document.tree.new_element("script");
script_tag.set_text(script);
head.prepend_child(&script_tag);
}
/// Injects the Isolation JavaScript to a codegen time document.
///
/// Note: This function is not considered part of the stable API.
///
/// # Stability
///
/// This dependency [`dom_query`] for [`Document`] might receive updates in minor releases.
#[cfg(feature = "isolation")]
pub fn inject_codegen_isolation_script(document: &Document) {
use crate::pattern::isolation::IsolationJavascriptCodegen;
use serialize_to_javascript::DefaultTemplate;
let head = ensure_head(document);
let script_content = IsolationJavascriptCodegen {}
.render_default(&Default::default())
.expect("unable to render codegen isolation script template")
.into_string();
let script_tag = document.tree.new_element("script");
script_tag.set_attr("nonce", SCRIPT_NONCE_TOKEN);
script_tag.set_text(script_content);
head.prepend_child(&script_tag);
}
/// Temporary workaround for Windows not allowing requests
///
/// Note: this does not prevent path traversal due to the isolation application expectation that it
/// is secure.
///
/// # Stability
///
/// This dependency [`dom_query`] for [`Document`] might receive updates in minor releases.
#[cfg(feature = "isolation")]
pub fn inline_isolation(document: &Document, dir: &std::path::Path) {
let scripts = document.select("script[src]");
for script in scripts.nodes() {
let src = match script.attr("src") {
Some(s) => s.to_string(),
None => continue,
};
let mut path = std::path::PathBuf::from(src);
if path.has_root() {
path = path
.strip_prefix("/")
.expect("Tauri \"Isolation\" Pattern only supports relative or absolute (`/`) paths.")
.into();
}
let file = std::fs::read_to_string(dir.join(path)).expect("unable to find isolation file");
script.set_text(file);
script.remove_attr("src");
}
}
// TODO: Verify this, this is not found in the HTML spec, see https://github.com/tauri-apps/tauri/pull/14265#discussion_r2415396842
/// Normalize line endings in script content to match what the browser uses for CSP hashing.
///
/// According to the HTML spec, browsers normalize:
/// - `\r\n` → `\n`
/// - `\r` → `\n`
pub fn normalize_script_for_csp(input: &[u8]) -> Vec<u8> {
let mut output = Vec::with_capacity(input.len());
let mut i = 0;
while i < input.len() {
match input[i] {
b'\r' => {
if i + 1 < input.len() && input[i + 1] == b'\n' {
// CRLF → LF
output.push(b'\n');
i += 2;
} else {
// Lone CR → LF
output.push(b'\n');
i += 1;
}
}
_ => {
output.push(input[i]);
i += 1;
}
}
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
assets::{SCRIPT_NONCE_TOKEN, STYLE_NONCE_TOKEN},
config,
};
#[test]
fn csp() {
let htmls = vec![
"<html><head></head></html>".to_string(),
"<html></html>".to_string(),
];
for html in htmls {
let document = parse_doc(html);
let csp = "csp-string";
inject_csp(&document, csp);
assert_eq!(
String::from_utf8(serialize_doc(&document)).unwrap(),
format!(
r#"<html><head><meta http-equiv="Content-Security-Policy" content="{csp}"></head><body></body></html>"#
)
);
}
}
#[test]
fn normalize_script_for_csp_test() {
let js = "// Copyright 2019-2024 Tauri Programme within The Commons Conservancy\r// SPDX-License-Identifier: Apache-2.0\n// SPDX-License-Identifier: MIT\r\n\r\nwindow.__TAURI_ISOLATION_HOOK__ = (payload, options) => {\r\n return payload\r\n}\r\n";
let expected = "// Copyright 2019-2024 Tauri Programme within The Commons Conservancy\n// SPDX-License-Identifier: Apache-2.0\n// SPDX-License-Identifier: MIT\n\nwindow.__TAURI_ISOLATION_HOOK__ = (payload, options) => {\n return payload\n}\n";
assert_eq!(normalize_script_for_csp(js.as_bytes()), expected.as_bytes())
}
#[test]
fn parse_and_serialize_roundtrips() {
let htmls = [
"<html><head><title>Test</title></head><body><h1>Hello</h1></body></html>",
"<!DOCTYPE html><html><head></head><body></body></html>",
];
for html in htmls {
let parsed = parse_doc(html.to_string());
let serialized = serialize_doc(&parsed);
let result = String::from_utf8(serialized).unwrap();
assert_eq!(result, html);
}
}
#[test]
fn inject_nonce_to_scripts() {
let html = r#"<html><head><script src="http://example.com/script.js"></script></head><body></body></html>"#;
let document = parse_doc(html.to_string());
inject_nonce_token(&document, &config::DisabledCspModificationKind::Flag(false));
assert_eq!(
String::from_utf8(serialize_doc(&document)).unwrap(),
format!(
r#"<html><head><script src="http://example.com/script.js" nonce="{SCRIPT_NONCE_TOKEN}"></script></head><body></body></html>"#
)
);
}
#[test]
fn inject_nonce_to_styles() {
let html = r#"<html><head><style>body { color: red; }</style></head><body></body></html>"#;
let document = parse_doc(html.to_string());
inject_nonce_token(&document, &config::DisabledCspModificationKind::Flag(false));
assert_eq!(
String::from_utf8(serialize_doc(&document)).unwrap(),
format!(
r#"<html><head><style nonce="{STYLE_NONCE_TOKEN}">body {{ color: red; }}</style></head><body></body></html>"#
)
);
}
#[test]
fn append_script_to_head_test() {
let html = r#"<html><head></head><body></body></html>"#;
let document = parse_doc(html.to_string());
append_script_to_head(&document, r#"console.log('Test')"#);
assert_eq!(
String::from_utf8(serialize_doc(&document)).unwrap(),
format!(r#"<html><head><script>console.log('Test')</script></head><body></body></html>"#)
);
}
#[test]
fn inject_nonce_skips_existing() {
let html = r#"<html><head><script src="http://example.com/script.js" nonce="existing"></script></head><body></body></html>"#;
let document = parse_doc(html.to_string());
inject_nonce_token(&document, &config::DisabledCspModificationKind::Flag(false));
assert_eq!(String::from_utf8(serialize_doc(&document)).unwrap(), html);
}
#[test]
fn inject_nonce_respects_disabled_modification() {
let html = r#"<html><head><script src="http://example.com/script.js"></script></head><body></body></html>"#;
let document = parse_doc(html.to_string());
inject_nonce_token(&document, &config::DisabledCspModificationKind::Flag(true));
assert_eq!(
String::from_utf8(serialize_doc(&document)).unwrap(),
r#"<html><head><script src="http://example.com/script.js"></script></head><body></body></html>"#
);
}
#[test]
#[cfg(feature = "isolation")]
fn inline_isolation_replaces_src_with_content() {
use std::io::Write;
let temp_dir = tempfile::tempdir().unwrap();
let mut file = tempfile::NamedTempFile::with_suffix_in(".js", &temp_dir).unwrap();
file.write_all(b"console.log('test');").unwrap();
let file_name = file.path().file_name().unwrap().to_str().unwrap();
let html =
format!(r#"<html><head><script src="/{file_name}"></script></head><body></body></html>"#);
let document = parse_doc(html);
inline_isolation(&document, temp_dir.path());
assert_eq!(
String::from_utf8(serialize_doc(&document)).unwrap(),
r#"<html><head><script>console.log('test');</script></head><body></body></html>"#
);
}
}

View file

@ -0,0 +1,47 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! IO helpers.
use std::io::BufRead;
/// Read all bytes until a newline (the `0xA` byte) or a carriage return (`\r`) is reached, and append them to the provided buffer.
///
/// Adapted from <https://doc.rust-lang.org/std/io/trait.BufRead.html#method.read_line>.
pub fn read_line<R: BufRead + ?Sized>(r: &mut R, buf: &mut Vec<u8>) -> std::io::Result<usize> {
let mut read = 0;
loop {
let (done, used) = {
let available = match r.fill_buf() {
Ok(n) => n,
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
};
match memchr::memchr(b'\n', available) {
Some(i) => {
let end = i + 1;
buf.extend_from_slice(&available[..end]);
(true, end)
}
None => match memchr::memchr(b'\r', available) {
Some(i) => {
let end = i + 1;
buf.extend_from_slice(&available[..end]);
(true, end)
}
None => {
buf.extend_from_slice(available);
(false, available.len())
}
},
}
};
r.consume(used);
read += used;
if done || used == 0 {
return Ok(read);
}
}
}

View file

@ -0,0 +1,404 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! This crate contains common code that is reused in many places and offers useful utilities like parsing configuration files, detecting platform triples, injecting the CSP, and managing assets.
#![doc(
html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/.github/icon.png",
html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/.github/icon.png"
)]
#![warn(missing_docs, rust_2018_idioms)]
#![allow(clippy::deprecated_semver)]
use std::{
ffi::OsString,
fmt::Display,
path::{Path, PathBuf},
};
use semver::Version;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub mod acl;
pub mod assets;
pub mod config;
pub mod config_v1;
#[cfg(feature = "html-manipulation")]
pub mod html;
#[cfg(feature = "html-manipulation-2")]
pub mod html2;
pub mod io;
pub mod mime_type;
pub mod platform;
pub mod plugin;
/// Prepare application resources and sidecars.
#[cfg(feature = "resources")]
pub mod resources;
#[cfg(any(feature = "build", feature = "build-2"))]
pub mod tokens;
#[cfg(any(feature = "build", feature = "build-2"))]
pub mod build;
/// Application pattern.
pub mod pattern;
/// `tauri::App` package information.
#[derive(Debug, Clone)]
pub struct PackageInfo {
/// App name
pub name: String,
/// App version
pub version: Version,
/// The crate authors.
pub authors: &'static str,
/// The crate description.
pub description: &'static str,
/// The crate name.
pub crate_name: &'static str,
}
#[allow(deprecated)]
mod window_effects {
use super::*;
#[derive(Debug, PartialEq, Eq, Clone, Copy, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
/// Platform-specific window effects
pub enum WindowEffect {
/// A default material appropriate for the view's effectiveAppearance. **macOS 10.14-**
#[deprecated(
since = "macOS 10.14",
note = "You should instead choose an appropriate semantic material."
)]
AppearanceBased,
/// **macOS 10.14-**
#[deprecated(since = "macOS 10.14", note = "Use a semantic material instead.")]
Light,
/// **macOS 10.14-**
#[deprecated(since = "macOS 10.14", note = "Use a semantic material instead.")]
Dark,
/// **macOS 10.14-**
#[deprecated(since = "macOS 10.14", note = "Use a semantic material instead.")]
MediumLight,
/// **macOS 10.14-**
#[deprecated(since = "macOS 10.14", note = "Use a semantic material instead.")]
UltraDark,
/// **macOS 10.10+**
Titlebar,
/// **macOS 10.10+**
Selection,
/// **macOS 10.11+**
Menu,
/// **macOS 10.11+**
Popover,
/// **macOS 10.11+**
Sidebar,
/// **macOS 10.14+**
HeaderView,
/// **macOS 10.14+**
Sheet,
/// **macOS 10.14+**
WindowBackground,
/// **macOS 10.14+**
HudWindow,
/// **macOS 10.14+**
FullScreenUI,
/// **macOS 10.14+**
Tooltip,
/// **macOS 10.14+**
ContentBackground,
/// **macOS 10.14+**
UnderWindowBackground,
/// **macOS 10.14+**
UnderPageBackground,
/// Mica effect that matches the system dark preference **Windows 11 Only**
Mica,
/// Mica effect with dark mode but only if dark mode is enabled on the system **Windows 11 Only**
MicaDark,
/// Mica effect with light mode **Windows 11 Only**
MicaLight,
/// Tabbed effect that matches the system dark preference **Windows 11 Only**
Tabbed,
/// Tabbed effect with dark mode but only if dark mode is enabled on the system **Windows 11 Only**
TabbedDark,
/// Tabbed effect with light mode **Windows 11 Only**
TabbedLight,
/// **Windows 7/10/11(22H1) Only**
///
/// ## Notes
///
/// This effect has bad performance when resizing/dragging the window on Windows 11 build 22621.
Blur,
/// **Windows 10/11 Only**
///
/// ## Notes
///
/// This effect has bad performance when resizing/dragging the window on Windows 10 v1903+ and Windows 11 build 22000.
Acrylic,
}
/// Window effect state **macOS only**
///
/// <https://developer.apple.com/documentation/appkit/nsvisualeffectview/state>
#[derive(Debug, PartialEq, Eq, Clone, Copy, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
pub enum WindowEffectState {
/// Make window effect state follow the window's active state
FollowsWindowActiveState,
/// Make window effect state always active
Active,
/// Make window effect state always inactive
Inactive,
}
}
pub use window_effects::{WindowEffect, WindowEffectState};
/// How the window title bar should be displayed on macOS.
#[derive(Debug, Clone, PartialEq, Eq, Copy, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub enum TitleBarStyle {
/// A normal title bar.
#[default]
Visible,
/// Makes the title bar transparent, so the window background color is shown instead.
///
/// Useful if you don't need to have actual HTML under the title bar. This lets you avoid the caveats of using `TitleBarStyle::Overlay`. Will be more useful when Tauri lets you set a custom window background color.
Transparent,
/// Shows the title bar as a transparent overlay over the window's content.
///
/// Keep in mind:
/// - The height of the title bar is different on different OS versions, which can lead to window the controls and title not being where you don't expect.
/// - You need to define a custom drag region to make your window draggable, however due to a limitation you can't drag the window when it's not in focus <https://github.com/tauri-apps/tauri/issues/4316>.
/// - The color of the window title depends on the system theme.
Overlay,
}
impl Serialize for TitleBarStyle {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
impl<'de> Deserialize<'de> for TitleBarStyle {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(match s.to_lowercase().as_str() {
"transparent" => Self::Transparent,
"overlay" => Self::Overlay,
_ => Self::Visible,
})
}
}
impl Display for TitleBarStyle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
Self::Visible => "Visible",
Self::Transparent => "Transparent",
Self::Overlay => "Overlay",
}
)
}
}
/// System theme.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub enum Theme {
/// Light theme.
Light,
/// Dark theme.
Dark,
}
impl Serialize for Theme {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
impl<'de> Deserialize<'de> for Theme {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(match s.to_lowercase().as_str() {
"dark" => Self::Dark,
_ => Self::Light,
})
}
}
impl Display for Theme {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
Self::Light => "light",
Self::Dark => "dark",
}
)
}
}
/// Information about environment variables.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Env {
/// The APPIMAGE environment variable.
#[cfg(target_os = "linux")]
pub appimage: Option<std::ffi::OsString>,
/// The APPDIR environment variable.
#[cfg(target_os = "linux")]
pub appdir: Option<std::ffi::OsString>,
/// The command line arguments of the current process.
pub args_os: Vec<OsString>,
}
#[allow(clippy::derivable_impls)]
impl Default for Env {
fn default() -> Self {
let args_os = std::env::args_os().collect();
#[cfg(target_os = "linux")]
{
let env = Self {
#[cfg(target_os = "linux")]
appimage: std::env::var_os("APPIMAGE"),
#[cfg(target_os = "linux")]
appdir: std::env::var_os("APPDIR"),
args_os,
};
if env.appimage.is_some() || env.appdir.is_some() {
// validate that we're actually running on an AppImage
// an AppImage is mounted to `/$TEMPDIR/.mount_${appPrefix}${hash}`
// see <https://github.com/AppImage/AppImageKit/blob/1681fd84dbe09c7d9b22e13cdb16ea601aa0ec47/src/runtime.c#L501>
// note that it is safe to use `std::env::current_exe` here since we just loaded an AppImage.
let is_temp = std::env::current_exe()
.map(|p| {
p.display()
.to_string()
.starts_with(&format!("{}/.mount_", std::env::temp_dir().display()))
})
.unwrap_or(true);
if !is_temp {
log::warn!(
"`APPDIR` or `APPIMAGE` environment variable found but this application was not detected as an AppImage; this might be a security issue."
);
}
}
env
}
#[cfg(not(target_os = "linux"))]
{
Self { args_os }
}
}
}
/// The result type of `tauri-utils`.
pub type Result<T> = std::result::Result<T, Error>;
/// The error type of `tauri-utils`.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
/// Target triple architecture error
#[error("Unable to determine target-architecture")]
Architecture,
/// Target triple OS error
#[error("Unable to determine target-os")]
Os,
/// Target triple environment error
#[error("Unable to determine target-environment")]
Environment,
/// Tried to get resource on an unsupported platform
#[error("Unsupported platform for reading resources")]
UnsupportedPlatform,
/// Get parent process error
#[error("Could not get parent process")]
ParentProcess,
/// Get parent process PID error
#[error("Could not get parent PID")]
ParentPid,
/// Get child process error
#[error("Could not get child process")]
ChildProcess,
/// IO error
#[error("{0}")]
Io(#[from] std::io::Error),
/// Invalid pattern.
#[error("invalid pattern `{0}`. Expected either `brownfield` or `isolation`.")]
InvalidPattern(String),
/// Invalid glob pattern.
#[cfg(feature = "resources")]
#[error("{0}")]
GlobPattern(#[from] glob::PatternError),
/// Failed to use glob pattern.
#[cfg(feature = "resources")]
#[error("`{0}`")]
Glob(#[from] glob::GlobError),
/// Glob pattern did not find any results.
#[cfg(feature = "resources")]
#[error("glob pattern {0} path not found or didn't match any files.")]
GlobPathNotFound(String),
/// Error walking directory.
#[cfg(feature = "resources")]
#[error("{0}")]
WalkdirError(#[from] walkdir::Error),
/// Not allowed to walk dir.
#[cfg(feature = "resources")]
#[error(
"could not walk directory `{0}`, try changing `allow_walk` to true on the `ResourcePaths` constructor."
)]
NotAllowedToWalkDir(std::path::PathBuf),
/// Resource path doesn't exist
#[cfg(feature = "resources")]
#[error("resource path `{0}` doesn't exist")]
ResourcePathNotFound(std::path::PathBuf),
}
/// Reconstructs a path from its components using the platform separator then converts it to String and removes UNC prefixes on Windows if it exists.
pub fn display_path<P: AsRef<Path>>(p: P) -> String {
dunce::simplified(&p.as_ref().components().collect::<PathBuf>())
.display()
.to_string()
}
/// Write the file only if the content of the existing file (if any) is different.
///
/// This will always write unless the file exists with identical content.
pub fn write_if_changed<P, C>(path: P, content: C) -> std::io::Result<()>
where
P: AsRef<Path>,
C: AsRef<[u8]>,
{
if let Ok(existing) = std::fs::read(&path)
&& existing == content.as_ref()
{
return Ok(());
}
std::fs::write(path, content)
}

View file

@ -0,0 +1,154 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! Determine a mime type from a URI or file contents.
use std::fmt;
const MIMETYPE_PLAIN: &str = "text/plain";
/// [Web Compatible MimeTypes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#important_mime_types_for_web_developers)
#[allow(missing_docs)]
pub enum MimeType {
Css,
Csv,
Html,
Ico,
Js,
Json,
Jsonld,
Mp4,
OctetStream,
Rtf,
Svg,
Txt,
}
impl std::fmt::Display for MimeType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mime = match self {
MimeType::Css => "text/css",
MimeType::Csv => "text/csv",
MimeType::Html => "text/html",
MimeType::Ico => "image/vnd.microsoft.icon",
MimeType::Js => "text/javascript",
MimeType::Json => "application/json",
MimeType::Jsonld => "application/ld+json",
MimeType::Mp4 => "video/mp4",
MimeType::OctetStream => "application/octet-stream",
MimeType::Rtf => "application/rtf",
MimeType::Svg => "image/svg+xml",
MimeType::Txt => MIMETYPE_PLAIN,
};
write!(f, "{mime}")
}
}
impl MimeType {
/// parse a URI suffix to convert text/plain mimeType to their actual web compatible mimeType.
pub fn parse_from_uri(uri: &str) -> MimeType {
Self::parse_from_uri_with_fallback(uri, Self::Html)
}
/// parse a URI suffix to convert text/plain mimeType to their actual web compatible mimeType with specified fallback for unknown file extensions.
pub fn parse_from_uri_with_fallback(uri: &str, fallback: MimeType) -> MimeType {
let suffix = uri.split('.').next_back();
match suffix {
Some("bin") => Self::OctetStream,
Some("css" | "less" | "sass" | "styl") => Self::Css,
Some("csv") => Self::Csv,
Some("html") => Self::Html,
Some("ico") => Self::Ico,
Some("js") => Self::Js,
Some("json") => Self::Json,
Some("jsonld") => Self::Jsonld,
Some("mjs") => Self::Js,
Some("mp4") => Self::Mp4,
Some("rtf") => Self::Rtf,
Some("svg") => Self::Svg,
Some("txt") => Self::Txt,
// Assume HTML when a TLD is found for eg. `wry:://tauri.app` | `wry://hello.com`
Some(_) => fallback,
// using octet stream according to this:
// <https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types>
None => Self::OctetStream,
}
}
/// infer mimetype from content (or) URI if needed.
pub fn parse(content: &[u8], uri: &str) -> String {
Self::parse_with_fallback(content, uri, Self::Html)
}
/// infer mimetype from content (or) URI if needed with specified fallback for unknown file extensions.
pub fn parse_with_fallback(content: &[u8], uri: &str, fallback: MimeType) -> String {
let mime = if uri.ends_with(".svg") {
// when reading svg, we can't use `infer`
None
} else {
infer::get(content).map(|info| info.mime_type())
};
match mime {
Some(mime) if mime == MIMETYPE_PLAIN => {
Self::parse_from_uri_with_fallback(uri, fallback).to_string()
}
None => Self::parse_from_uri_with_fallback(uri, fallback).to_string(),
Some(mime) => mime.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_parse_mimetype_from_uri() {
let css = MimeType::parse_from_uri(
"https://unpkg.com/browse/bootstrap@4.1.0/dist/css/bootstrap-grid.css",
)
.to_string();
assert_eq!(css, "text/css".to_string());
let csv: String = MimeType::parse_from_uri("https://example.com/random.csv").to_string();
assert_eq!(csv, "text/csv".to_string());
let ico: String =
MimeType::parse_from_uri("https://icons.duckduckgo.com/ip3/microsoft.com.ico").to_string();
assert_eq!(ico, String::from("image/vnd.microsoft.icon"));
let html: String = MimeType::parse_from_uri("https://tauri.app/index.html").to_string();
assert_eq!(html, String::from("text/html"));
let js: String =
MimeType::parse_from_uri("https://unpkg.com/react@17.0.1/umd/react.production.min.js")
.to_string();
assert_eq!(js, "text/javascript".to_string());
let json: String =
MimeType::parse_from_uri("https://unpkg.com/browse/react@17.0.1/build-info.json").to_string();
assert_eq!(json, String::from("application/json"));
let jsonld: String = MimeType::parse_from_uri("https:/example.com/hello.jsonld").to_string();
assert_eq!(jsonld, String::from("application/ld+json"));
let mjs: String = MimeType::parse_from_uri("https://example.com/bundled.mjs").to_string();
assert_eq!(mjs, String::from("text/javascript"));
let mp4: String = MimeType::parse_from_uri("https://example.com/video.mp4").to_string();
assert_eq!(mp4, String::from("video/mp4"));
let rtf: String = MimeType::parse_from_uri("https://example.com/document.rtf").to_string();
assert_eq!(rtf, String::from("application/rtf"));
let svg: String = MimeType::parse_from_uri("https://example.com/picture.svg").to_string();
assert_eq!(svg, String::from("image/svg+xml"));
let txt: String = MimeType::parse_from_uri("https://example.com/file.txt").to_string();
assert_eq!(txt, String::from("text/plain"));
let custom_scheme = MimeType::parse_from_uri("wry://tauri.app").to_string();
assert_eq!(custom_scheme, String::from("text/html"));
}
}

View file

@ -0,0 +1,154 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
/**
* IMPORTANT: See ipc.js for the main frame implementation.
* main frame -> isolation frame = isolation payload
* isolation frame -> main frame = isolation message
*/
;(async function () {
/**
* Sends the message to the isolation frame.
* @param {any} message
*/
function sendMessage(message) {
window.parent.postMessage(message, '*')
}
/**
* @type {string} - The main frame origin.
*/
const origin = __TEMPLATE_origin__
/**
* @type {Uint8Array} - Injected by Tauri during runtime
*/
const aesGcmKeyRaw = new Uint8Array(__TEMPLATE_runtime_aes_gcm_key__)
/**
* @type {CryptoKey}
*/
const aesGcmKey = await window.crypto.subtle.importKey(
'raw',
aesGcmKeyRaw,
'AES-GCM',
false,
['encrypt']
)
/**
* @param {object} data
* @return {Promise<{nonce: number[], payload: number[]}>}
*/
async function encrypt(payload) {
const algorithm = Object.create(null)
algorithm.name = 'AES-GCM'
algorithm.iv = window.crypto.getRandomValues(new Uint8Array(12))
const { contentType, data } = __RAW_process_ipc_message_fn__(payload)
const message =
typeof data === 'string'
? new TextEncoder().encode(data)
: ArrayBuffer.isView(data) || data instanceof ArrayBuffer
? data
: new Uint8Array(data)
return window.crypto.subtle
.encrypt(algorithm, aesGcmKey, message)
.then((payload) => {
const result = Object.create(null)
result.nonce = Array.from(new Uint8Array(algorithm.iv))
result.payload = Array.from(new Uint8Array(payload))
result.contentType = contentType
return result
})
}
/**
* Detects if a message event is a valid isolation message.
*
* @param {MessageEvent<object>} event - a message event that is expected to be an isolation message
* @return {boolean} - if the event was a valid isolation message
*/
function isIsolationMessage(data) {
if (typeof data === 'object' && typeof data.payload === 'object') {
const keys = data.payload ? Object.keys(data.payload) : []
return (
keys.length > 0
&& keys.every(
(key) => key === 'nonce' || key === 'payload' || key === 'contentType'
)
)
}
return false
}
/**
* Detect if a message event is a valid isolation payload.
*
* @param {MessageEvent<object>} event - a message event that is expected to be an isolation payload
* @return boolean
*/
function isIsolationPayload(data) {
return (
typeof data === 'object'
&& 'callback' in data
&& 'error' in data
&& !isIsolationMessage(data)
)
}
/**
* Handle incoming payload events.
* @param {MessageEvent<any>} event
*/
async function payloadHandler(event) {
if (event.origin !== origin || !isIsolationPayload(event.data)) {
return
}
let data = event.data
if (typeof window.__TAURI_ISOLATION_HOOK__ === 'function') {
// await even if it's not async so that we can support async ones
data = await window.__TAURI_ISOLATION_HOOK__(data)
}
const message = Object.create(null)
message.cmd = data.cmd
message.callback = data.callback
message.error = data.error
message.options = data.options
message.payload = await encrypt(data.payload)
sendMessage(message)
}
window.addEventListener('message', payloadHandler, false)
/**
* @type {number} - How many milliseconds to wait between ready checks
*/
const readyIntervalMs = 50
/**
* Wait until this Isolation context is ready to receive messages, and let the main frame know.
*/
function waitUntilReady() {
// consider either a function or an explicitly set null value as the ready signal
if (
typeof window.__TAURI_ISOLATION_HOOK__ === 'function'
|| window.__TAURI_ISOLATION_HOOK__ === null
) {
sendMessage('__TAURI_ISOLATION_READY__')
} else {
setTimeout(waitUntilReady, readyIntervalMs)
}
}
setTimeout(waitUntilReady, readyIntervalMs)
})()
document.currentScript?.remove()

View file

@ -0,0 +1,171 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::array::TryFromSliceError;
use std::borrow::Cow;
use std::fmt::{Debug, Formatter};
use std::string::FromUtf8Error;
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use getrandom::Error as CsprngError;
use serialize_to_javascript::{Template, default_template};
/// The style for the isolation iframe.
pub const IFRAME_STYLE: &str = "#__tauri_isolation__ { display: none !important }";
/// Errors that can occur during Isolation keys generation.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
/// Something went wrong with the CSPRNG.
#[error("CSPRNG error")]
Csprng(#[from] CsprngError),
/// Something went wrong with decrypting an AES-GCM payload
#[error("AES-GCM")]
Aes,
/// Nonce was not 96 bits
#[error("Nonce: {0}")]
NonceSize(#[from] TryFromSliceError),
/// Payload was not valid utf8
#[error("{0}")]
Utf8(#[from] FromUtf8Error),
/// Invalid json format
#[error("{0}")]
Json(#[from] serde_json::Error),
}
/// A formatted AES-GCM cipher instance along with the key used to initialize it.
#[derive(Clone)]
pub struct AesGcmPair {
raw: [u8; 32],
key: Aes256Gcm,
}
impl Debug for AesGcmPair {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "AesGcmPair(...)")
}
}
impl AesGcmPair {
fn new() -> Result<Self, Error> {
let mut raw = [0u8; 32];
getrandom::fill(&mut raw)?;
let key = aes_gcm::Key::<Aes256Gcm>::from_slice(&raw);
Ok(Self {
raw,
key: Aes256Gcm::new(key),
})
}
/// The raw value used to create the AES-GCM key
pub fn raw(&self) -> &[u8; 32] {
&self.raw
}
/// The formatted AES-GCM key
pub fn key(&self) -> &Aes256Gcm {
&self.key
}
#[doc(hidden)]
pub fn encrypt(&self, nonce: &[u8; 12], payload: &[u8]) -> Result<Vec<u8>, Error> {
self
.key
.encrypt(nonce.into(), payload)
.map_err(|_| self::Error::Aes)
}
}
/// All cryptographic keys required for Isolation encryption
#[derive(Debug, Clone)]
pub struct Keys {
/// AES-GCM key
aes_gcm: AesGcmPair,
}
impl Keys {
/// Securely generate required keys for Isolation encryption.
pub fn new() -> Result<Self, Error> {
AesGcmPair::new().map(|aes_gcm| Self { aes_gcm })
}
/// The AES-GCM data (and raw data).
pub fn aes_gcm(&self) -> &AesGcmPair {
&self.aes_gcm
}
/// Decrypts a message using the generated keys.
pub fn decrypt(&self, raw: RawIsolationPayload<'_>) -> Result<Vec<u8>, Error> {
let RawIsolationPayload { nonce, payload, .. } = raw;
let nonce: [u8; 12] = nonce.as_ref().try_into()?;
self
.aes_gcm
.key
.decrypt(Nonce::from_slice(&nonce), payload.as_ref())
.map_err(|_| self::Error::Aes)
}
}
/// Raw representation of
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawIsolationPayload<'a> {
nonce: Cow<'a, [u8]>,
payload: Cow<'a, [u8]>,
content_type: Cow<'a, str>,
}
impl<'a> RawIsolationPayload<'a> {
/// Content type of this payload.
pub fn content_type(&self) -> &Cow<'a, str> {
&self.content_type
}
}
impl<'a> TryFrom<&'a Vec<u8>> for RawIsolationPayload<'a> {
type Error = Error;
fn try_from(value: &'a Vec<u8>) -> Result<Self, Self::Error> {
serde_json::from_slice(value).map_err(Into::into)
}
}
/// The Isolation JavaScript template meant to be injected during codegen.
///
/// Note: This struct is not considered part of the stable API
#[derive(Template)]
#[default_template("isolation.js")]
pub struct IsolationJavascriptCodegen {
// this template intentionally does not include the runtime field
}
/// The Isolation JavaScript template meant to be injected during runtime.
///
/// Note: This struct is not considered part of the stable API
#[derive(Template)]
#[default_template("isolation.js")]
pub struct IsolationJavascriptRuntime<'a> {
/// The key used on the Rust backend and the Isolation Javascript
pub runtime_aes_gcm_key: &'a [u8; 32],
/// The origin the isolation application is expecting messages from.
pub origin: String,
/// The function that processes the IPC message.
#[raw]
pub process_ipc_message_fn: &'a str,
}
#[cfg(test)]
mod test {
#[test]
fn create_keys() -> Result<(), Box<dyn std::error::Error>> {
let _ = super::Keys::new()?;
Ok(())
}
}

View file

@ -0,0 +1,7 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
/// Handling the Tauri "Isolation" Pattern.
#[cfg(feature = "isolation")]
pub mod isolation;

View file

@ -0,0 +1,434 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! Platform helper functions.
use std::{fmt::Display, path::PathBuf};
use serde::{Deserialize, Serialize};
use crate::{Env, PackageInfo, config::BundleType};
mod starting_binary;
/// URI prefix of a Tauri asset.
///
/// This is referenced in the Tauri Android library,
/// which resolves these assets to a file descriptor.
#[cfg(target_os = "android")]
pub const ANDROID_ASSET_PROTOCOL_URI_PREFIX: &str = "asset://localhost/";
/// Platform target.
#[derive(PartialEq, Eq, Copy, Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum Target {
/// MacOS.
#[serde(rename = "macOS")]
MacOS,
/// Windows.
Windows,
/// Linux.
Linux,
/// Android.
Android,
/// iOS.
#[serde(rename = "iOS")]
Ios,
}
impl Display for Target {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
Self::MacOS => "macOS",
Self::Windows => "windows",
Self::Linux => "linux",
Self::Android => "android",
Self::Ios => "iOS",
}
)
}
}
impl Target {
/// Parses the target from the given target triple.
pub fn from_triple(target: &str) -> Self {
if target.contains("darwin") {
Self::MacOS
} else if target.contains("windows") {
Self::Windows
} else if target.contains("android") {
Self::Android
} else if target.contains("ios") {
Self::Ios
} else {
Self::Linux
}
}
/// Gets the current build target.
pub fn current() -> Self {
if cfg!(target_os = "macos") {
Self::MacOS
} else if cfg!(target_os = "windows") {
Self::Windows
} else if cfg!(target_os = "ios") {
Self::Ios
} else if cfg!(target_os = "android") {
Self::Android
} else {
Self::Linux
}
}
/// Whether the target is mobile or not.
pub fn is_mobile(&self) -> bool {
matches!(self, Target::Android | Target::Ios)
}
/// Whether the target is desktop or not.
pub fn is_desktop(&self) -> bool {
!self.is_mobile()
}
}
/// Retrieves the currently running binary's path, taking into account security considerations.
///
/// The path is cached as soon as possible (before even `main` runs) and that value is returned
/// repeatedly instead of fetching the path every time. It is possible for the path to not be found,
/// or explicitly disabled (see following macOS specific behavior).
///
/// # Platform-specific behavior
///
/// On `macOS`, this function will return an error if the original path contained any symlinks
/// due to less protection on macOS regarding symlinks. This behavior can be disabled by setting the
/// `process-relaunch-dangerous-allow-symlink-macos` feature, although it is *highly discouraged*.
///
/// # Security
///
/// If the above platform-specific behavior does **not** take place, this function uses the
/// following resolution.
///
/// We canonicalize the path we received from [`std::env::current_exe`] to resolve any soft links.
/// This avoids the usual issue of needing the file to exist at the passed path because a valid
/// current executable result for our purpose should always exist. Notably,
/// [`std::env::current_exe`] also has a security section that goes over a theoretical attack using
/// hard links. Let's cover some specific topics that relate to different ways an attacker might
/// try to trick this function into returning the wrong binary path.
///
/// ## Symlinks ("Soft Links")
///
/// [`std::path::Path::canonicalize`] is used to resolve symbolic links to the original path,
/// including nested symbolic links (`link2 -> link1 -> bin`). On macOS, any results that include
/// a symlink are rejected by default due to lesser symlink protections. This can be disabled,
/// **although discouraged**, with the `process-relaunch-dangerous-allow-symlink-macos` feature.
///
/// ## Hard Links
///
/// A [Hard Link] is a named entry that points to a file in the file system.
/// On most systems, this is what you would think of as a "file". The term is
/// used on filesystems that allow multiple entries to point to the same file.
/// The linked [Hard Link] Wikipedia page provides a decent overview.
///
/// In short, unless the attacker was able to create the link with elevated
/// permissions, it should generally not be possible for them to hard link
/// to a file they do not have permissions to - with exception to possible
/// operating system exploits.
///
/// There are also some platform-specific information about this below.
///
/// ### Windows
///
/// Windows requires a permission to be set for the user to create a symlink
/// or a hard link, regardless of ownership status of the target. Elevated
/// permissions users have the ability to create them.
///
/// ### macOS
///
/// macOS allows for the creation of symlinks and hard links to any file.
/// Accessing through those links will fail if the user who owns the links
/// does not have the proper permissions on the original file.
///
/// ### Linux
///
/// Linux allows for the creation of symlinks to any file. Accessing the
/// symlink will fail if the user who owns the symlink does not have the
/// proper permissions on the original file.
///
/// Linux additionally provides a kernel hardening feature since version
/// 3.6 (30 September 2012). Most distributions since then have enabled
/// the protection (setting `fs.protected_hardlinks = 1`) by default, which
/// means that a vast majority of desktop Linux users should have it enabled.
/// **The feature prevents the creation of hardlinks that the user does not own
/// or have read/write access to.** [See the patch that enabled this].
///
/// [Hard Link]: https://en.wikipedia.org/wiki/Hard_link
/// [See the patch that enabled this]: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=800179c9b8a1e796e441674776d11cd4c05d61d7
pub fn current_exe() -> std::io::Result<PathBuf> {
self::starting_binary::STARTING_BINARY.cloned()
}
/// Try to determine the current target triple.
///
/// Returns a target triple (e.g. `x86_64-unknown-linux-gnu` or `i686-pc-windows-msvc`) or an
/// `Error::Config` if the current config cannot be determined or is not some combination of the
/// following values:
/// `linux, mac, windows` -- `i686, x86, armv7` -- `gnu, musl, msvc`
///
/// * Errors:
/// * Unexpected system config
pub fn target_triple() -> crate::Result<String> {
let arch = if cfg!(target_arch = "x86") {
"i686"
} else if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "arm") {
"armv7"
} else if cfg!(target_arch = "aarch64") {
"aarch64"
} else if cfg!(target_arch = "riscv64") {
"riscv64"
} else {
return Err(crate::Error::Architecture);
};
let os = if cfg!(target_os = "linux") {
"unknown-linux"
} else if cfg!(target_os = "macos") {
"apple-darwin"
} else if cfg!(target_os = "windows") {
"pc-windows"
} else if cfg!(target_os = "freebsd") {
"unknown-freebsd"
} else {
return Err(crate::Error::Os);
};
let os = if cfg!(target_os = "macos") || cfg!(target_os = "freebsd") {
String::from(os)
} else {
let env = if cfg!(target_env = "gnu") {
"gnu"
} else if cfg!(target_env = "musl") {
"musl"
} else if cfg!(target_env = "msvc") {
"msvc"
} else {
return Err(crate::Error::Environment);
};
format!("{os}-{env}")
};
Ok(format!("{arch}-{os}"))
}
#[cfg(all(not(test), not(target_os = "android")))]
fn is_cargo_output_directory(path: &std::path::Path) -> bool {
path.join(".cargo-lock").exists()
}
#[cfg(test)]
const CARGO_OUTPUT_DIRECTORIES: &[&str] = &["debug", "release", "custom-profile"];
#[cfg(test)]
fn is_cargo_output_directory(path: &std::path::Path) -> bool {
let Some(last_component) = path.components().next_back() else {
return false;
};
CARGO_OUTPUT_DIRECTORIES
.iter()
.any(|dirname| &last_component.as_os_str() == dirname)
}
/// Computes the resource directory of the current environment.
///
/// ## Platform-specific
///
/// - **Windows:** Resolves to the directory that contains the main executable.
/// - **Linux:** When running in an AppImage, the `APPDIR` variable will be set to
/// the mounted location of the app, and the resource dir will be `${APPDIR}/usr/lib/${exe_name}`.
/// If not running in an AppImage, the path is `/usr/lib/${exe_name}`.
/// When running the app from `src-tauri/target/(debug|release)/`, the path is `${exe_dir}/../lib/${exe_name}`.
/// - **macOS:** Resolves to `${exe_dir}/../Resources` (inside .app).
/// - **iOS:** Resolves to `${exe_dir}/assets`.
/// - **Android:** Currently the resources are stored in the APK as assets so it's not a normal file system path,
/// we return a special URI prefix `asset://localhost/` here that can be used with the [file system plugin](https://tauri.app/plugin/file-system/),
/// with that, you can read the files through [`FsExt::fs`](https://docs.rs/tauri-plugin-fs/latest/tauri_plugin_fs/trait.FsExt.html#tymethod.fs)
/// like this: `app.fs().read_to_string(app.path().resource_dir().unwrap().join("resource"));`
pub fn resource_dir(package_info: &PackageInfo, env: &Env) -> crate::Result<PathBuf> {
#[cfg(target_os = "android")]
return resource_dir_android(package_info, env);
#[cfg(not(target_os = "android"))]
{
let exe = current_exe()?;
resource_dir_from(exe, package_info, env)
}
}
#[cfg(target_os = "android")]
fn resource_dir_android(_package_info: &PackageInfo, _env: &Env) -> crate::Result<PathBuf> {
Ok(PathBuf::from(ANDROID_ASSET_PROTOCOL_URI_PREFIX))
}
#[cfg(not(target_os = "android"))]
#[allow(unused_variables)]
fn resource_dir_from<P: AsRef<std::path::Path>>(
exe: P,
package_info: &PackageInfo,
env: &Env,
) -> crate::Result<PathBuf> {
let exe_dir = exe.as_ref().parent().expect("failed to get exe directory");
let curr_dir = exe_dir.display().to_string();
let parts: Vec<&str> = curr_dir.split(std::path::MAIN_SEPARATOR).collect();
let len = parts.len();
// Check if running from the Cargo output directory, which means it's an executable in a development machine
// We check if the binary is inside a `target` folder which can be either `target/$profile` or `target/$triple/$profile`
// and see if there's a .cargo-lock file along the executable
// This ensures the check is safer so it doesn't affect apps in production
// Windows also includes the resources in the executable folder so we check that too
if cfg!(target_os = "windows")
|| ((len >= 2 && parts[len - 2] == "target") || (len >= 3 && parts[len - 3] == "target"))
&& is_cargo_output_directory(exe_dir)
{
return Ok(exe_dir.to_path_buf());
}
#[allow(unused_mut, unused_assignments)]
let mut res = Err(crate::Error::UnsupportedPlatform);
#[cfg(target_os = "linux")]
{
// (canonicalize checks for existence, so there's no need for an extra check)
res = if let Ok(bundle_dir) = exe_dir
.join(format!("../lib/{}", package_info.name))
.canonicalize()
{
Ok(bundle_dir)
} else if let Some(appdir) = &env.appdir {
let appdir: &std::path::Path = appdir.as_ref();
Ok(PathBuf::from(format!(
"{}/usr/lib/{}",
appdir.display(),
package_info.name
)))
} else {
// running bundle
Ok(PathBuf::from(format!("/usr/lib/{}", package_info.name)))
};
}
#[cfg(target_os = "macos")]
{
res = exe_dir
.join("../Resources")
.canonicalize()
.map_err(Into::into);
}
#[cfg(target_os = "ios")]
{
res = exe_dir.join("assets").canonicalize().map_err(Into::into);
}
res
}
// Variable holding the type of bundle the executable is stored in. This is modified by binary
// patching during build
#[used]
// Marked as `mut` because it could get optimized away without it,
// see https://github.com/tauri-apps/tauri/pull/13812
static mut __TAURI_BUNDLE_TYPE: &str = "__TAURI_BUNDLE_TYPE_VAR_UNK";
/// Get the type of the bundle current binary is packaged in.
/// If the bundle type is unknown, it returns [`Option::None`].
pub fn bundle_type() -> Option<BundleType> {
unsafe {
match __TAURI_BUNDLE_TYPE {
"__TAURI_BUNDLE_TYPE_VAR_DEB" => Some(BundleType::Deb),
"__TAURI_BUNDLE_TYPE_VAR_RPM" => Some(BundleType::Rpm),
"__TAURI_BUNDLE_TYPE_VAR_APP" => Some(BundleType::AppImage),
"__TAURI_BUNDLE_TYPE_VAR_MSI" => Some(BundleType::Msi),
"__TAURI_BUNDLE_TYPE_VAR_NSS" => Some(BundleType::Nsis),
_ => {
if cfg!(target_os = "macos") {
Some(BundleType::App)
} else {
None
}
}
}
}
}
#[cfg(any(feature = "build", feature = "build-2"))]
mod build {
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use super::*;
impl ToTokens for Target {
fn to_tokens(&self, tokens: &mut TokenStream) {
let prefix = quote! { ::tauri::utils::platform::Target };
tokens.append_all(match self {
Self::MacOS => quote! { #prefix::MacOS },
Self::Linux => quote! { #prefix::Linux },
Self::Windows => quote! { #prefix::Windows },
Self::Android => quote! { #prefix::Android },
Self::Ios => quote! { #prefix::Ios },
});
}
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::{Env, PackageInfo};
#[test]
#[cfg(not(target_os = "android"))]
fn resolve_resource_dir() {
let package_info = PackageInfo {
name: "MyApp".into(),
version: "1.0.0".parse().unwrap(),
authors: "",
description: "",
crate_name: "my-app",
};
let env = Env::default();
let path = PathBuf::from("/path/to/target/aarch64-apple-darwin/debug/app");
let resource_dir = super::resource_dir_from(&path, &package_info, &env).unwrap();
assert_eq!(resource_dir, path.parent().unwrap());
let path = PathBuf::from("/path/to/target/custom-profile/app");
let resource_dir = super::resource_dir_from(&path, &package_info, &env).unwrap();
assert_eq!(resource_dir, path.parent().unwrap());
let path = PathBuf::from("/path/to/target/release/app");
let resource_dir = super::resource_dir_from(&path, &package_info, &env).unwrap();
assert_eq!(resource_dir, path.parent().unwrap());
let path = PathBuf::from("/path/to/target/unknown-profile/app");
#[allow(clippy::needless_borrows_for_generic_args)]
let resource_dir = super::resource_dir_from(&path, &package_info, &env);
#[cfg(target_os = "macos")]
assert!(resource_dir.is_err());
#[cfg(target_os = "linux")]
assert_eq!(resource_dir.unwrap(), PathBuf::from("/usr/lib/MyApp"));
#[cfg(windows)]
assert_eq!(resource_dir.unwrap(), path.parent().unwrap());
}
}

View file

@ -0,0 +1,83 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use ctor::ctor;
use std::{
io::{Error, ErrorKind, Result},
path::{Path, PathBuf},
};
/// A cached version of the current binary using [`ctor`] to cache it before even `main` runs.
#[ctor]
#[used]
pub(super) static STARTING_BINARY: StartingBinary = unsafe { StartingBinary::new() };
/// Represents a binary path that was cached when the program was loaded.
pub(super) struct StartingBinary(std::io::Result<PathBuf>);
impl StartingBinary {
/// Find the starting executable as safely as possible.
fn new() -> Self {
// see notes on current_exe() for security implications
let dangerous_path = match std::env::current_exe() {
Ok(dangerous_path) => dangerous_path,
error @ Err(_) => return Self(error),
};
// note: this only checks symlinks on problematic platforms, see implementation below
if let Some(symlink) = Self::has_symlink(&dangerous_path) {
return Self(Err(Error::new(
ErrorKind::InvalidData,
format!(
"StartingBinary found current_exe() that contains a symlink on a non-allowed platform: {}",
symlink.display()
),
)));
}
// we canonicalize the path to resolve any symlinks to the real exe path
Self(dangerous_path.canonicalize())
}
/// A clone of the [`PathBuf`] found to be the starting path.
///
/// Because [`Error`] is not clone-able, it is recreated instead.
pub(super) fn cloned(&self) -> Result<PathBuf> {
// false positive
#[allow(clippy::useless_asref)]
self
.0
.as_ref()
.map(Clone::clone)
.map_err(|e| Error::new(e.kind(), e.to_string()))
}
/// We only care about checking this on macOS currently, as it has the least symlink protections.
#[cfg(any(
not(target_os = "macos"),
feature = "process-relaunch-dangerous-allow-symlink-macos"
))]
fn has_symlink(_: &Path) -> Option<&Path> {
None
}
/// We only care about checking this on macOS currently, as it has the least symlink protections.
#[cfg(all(
target_os = "macos",
not(feature = "process-relaunch-dangerous-allow-symlink-macos")
))]
fn has_symlink(path: &Path) -> Option<&Path> {
path.ancestors().find(|ancestor| {
matches!(
ancestor
.symlink_metadata()
.as_ref()
.map(std::fs::Metadata::file_type)
.as_ref()
.map(std::fs::FileType::is_symlink),
Ok(true)
)
})
}
}

View file

@ -0,0 +1,88 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! Compile-time and runtime types for Tauri plugins.
#[cfg(any(feature = "build", feature = "build-2"))]
pub use build::*;
#[cfg(any(feature = "build", feature = "build-2"))]
mod build {
use std::{
env::vars_os,
fs,
path::{Path, PathBuf},
};
const GLOBAL_API_SCRIPT_PATH_KEY: &str = "GLOBAL_API_SCRIPT_PATH";
/// Known file name of the file that contains an array with the path of all API scripts defined with [`define_global_api_script_path`].
pub const GLOBAL_API_SCRIPT_FILE_LIST_PATH: &str = "__global-api-script.js";
/// Defines the path to the global API script using Cargo instructions.
pub fn define_global_api_script_path(path: &Path) {
println!(
"cargo:{GLOBAL_API_SCRIPT_PATH_KEY}={}",
path
.canonicalize()
.expect("failed to canonicalize global API script path")
.display()
)
}
/// Collects the path of all the global API scripts defined with [`define_global_api_script_path`]
/// and saves them to the out dir with filename [`GLOBAL_API_SCRIPT_FILE_LIST_PATH`].
///
/// `tauri_global_scripts` is only used in Tauri's monorepo for the examples to work
/// since they don't have a build script to run `tauri-build` and pull in the deps env vars
pub fn save_global_api_scripts_paths(out_dir: &Path, mut tauri_global_scripts: Option<PathBuf>) {
let mut scripts = Vec::new();
for (key, value) in vars_os() {
let key = key.to_string_lossy();
if key == format!("DEP_TAURI_{GLOBAL_API_SCRIPT_PATH_KEY}") {
tauri_global_scripts = Some(PathBuf::from(value));
} else if key.starts_with("DEP_") && key.ends_with(GLOBAL_API_SCRIPT_PATH_KEY) {
let script_path = PathBuf::from(value);
scripts.push(script_path);
}
}
if let Some(tauri_global_scripts) = tauri_global_scripts {
scripts.insert(0, tauri_global_scripts);
}
fs::write(
out_dir.join(GLOBAL_API_SCRIPT_FILE_LIST_PATH),
serde_json::to_string(&scripts).expect("failed to serialize global API script paths"),
)
.expect("failed to write global API script");
}
/// Read global api scripts from [`GLOBAL_API_SCRIPT_FILE_LIST_PATH`]
pub fn read_global_api_scripts(out_dir: &Path) -> Option<Vec<String>> {
let global_scripts_path = out_dir.join(GLOBAL_API_SCRIPT_FILE_LIST_PATH);
if !global_scripts_path.exists() {
return None;
}
let global_scripts_str = fs::read_to_string(global_scripts_path)
.expect("failed to read plugin global API script paths");
let global_scripts = serde_json::from_str::<Vec<PathBuf>>(&global_scripts_str)
.expect("failed to parse plugin global API script paths");
Some(
global_scripts
.into_iter()
.map(|p| {
fs::read_to_string(&p).unwrap_or_else(|e| {
panic!(
"failed to read plugin global API script {}: {e}",
p.display()
)
})
})
.collect(),
)
}
}

View file

@ -0,0 +1,643 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::{
collections::HashMap,
path::{Component, Path, PathBuf},
};
use walkdir::WalkDir;
use crate::platform::Target as TargetPlatform;
/// Given a path (absolute or relative) to a resource file, returns the
/// relative path from the bundle resources directory where that resource
/// should be stored.
pub fn resource_relpath(path: &Path) -> PathBuf {
let mut dest = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(_) => {}
Component::RootDir => dest.push("_root_"),
Component::CurDir => {}
Component::ParentDir => dest.push("_up_"),
Component::Normal(string) => dest.push(string),
}
}
dest
}
fn normalize(path: &Path) -> PathBuf {
let mut dest = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(_) => {}
Component::RootDir => dest.push("/"),
Component::CurDir => {}
Component::ParentDir => dest.push(".."),
Component::Normal(string) => dest.push(string),
}
}
dest
}
/// Parses the external binaries to bundle, adding the target triple suffix to each of them.
pub fn external_binaries(
external_binaries: &[String],
target_triple: &str,
target_platform: &TargetPlatform,
) -> Vec<String> {
let mut paths = Vec::new();
for curr_path in external_binaries {
let extension = if matches!(target_platform, TargetPlatform::Windows) {
".exe"
} else {
""
};
paths.push(format!("{curr_path}-{target_triple}{extension}"));
}
paths
}
/// Information for a resource.
#[derive(Debug)]
pub struct Resource {
path: PathBuf,
target: PathBuf,
}
impl Resource {
/// The path of the resource.
pub fn path(&self) -> &Path {
&self.path
}
/// The target location of the resource.
pub fn target(&self) -> &Path {
&self.target
}
}
#[derive(Debug)]
enum PatternIter<'a> {
Slice(std::slice::Iter<'a, String>),
Map(std::collections::hash_map::Iter<'a, String, String>),
}
/// A helper to iterate through resources.
pub struct ResourcePaths<'a> {
iter: ResourcePathsIter<'a>,
}
impl<'a> ResourcePaths<'a> {
/// Creates a new ResourcePaths from a slice of patterns to iterate
pub fn new(patterns: &'a [String], allow_walk: bool) -> ResourcePaths<'a> {
ResourcePaths {
iter: ResourcePathsIter {
pattern_iter: PatternIter::Slice(patterns.iter()),
allow_walk,
current_dest: None,
current_iter: None,
},
}
}
/// Creates a new ResourcePaths from a slice of patterns to iterate
pub fn from_map(patterns: &'a HashMap<String, String>, allow_walk: bool) -> ResourcePaths<'a> {
ResourcePaths {
iter: ResourcePathsIter {
pattern_iter: PatternIter::Map(patterns.iter()),
allow_walk,
current_dest: None,
current_iter: None,
},
}
}
/// Returns the resource iterator that yields the source and target paths.
/// Needed when using [`Self::from_map`].
pub fn iter(self) -> ResourcePathsIter<'a> {
self.iter
}
}
/// Iterator of a [`ResourcePaths`].
#[derive(Debug)]
pub struct ResourcePathsIter<'a> {
/// the patterns to iterate.
pattern_iter: PatternIter<'a>,
/// whether the resource paths allows directories or not.
allow_walk: bool,
/// The value of map when [`Self::pattern_iter`] is a [`PatternIter::Map`],
/// used for determining [`Resource::target`]
current_dest: Option<PathBuf>,
/// The iter for the current pattern. The cycle goes like this:
/// [`ResourcePaths::next`] -> [`Self::next`] -> [`Self::pattern_iter::next`] -> [`Self::current_iter::next`]
current_iter: Option<ResourcePathsInnerIter>,
}
#[derive(Debug)]
enum ResourcePathsInnerIter {
Walk {
iter: walkdir::IntoIter,
/// The key of map when [`ResourcePathsIter::pattern_iter`] is a [`PatternIter::Map`],
/// used for determining [`Resource::target`]
current_pattern: Option<PathBuf>,
},
Glob {
iter: glob::Paths,
},
}
impl Iterator for ResourcePathsInnerIter {
type Item = crate::Result<PathBuf>;
fn next(&mut self) -> Option<crate::Result<PathBuf>> {
match self {
ResourcePathsInnerIter::Walk { iter, .. } => Some(
iter
.next()?
.map(|entry| entry.into_path())
.map_err(Into::into),
),
ResourcePathsInnerIter::Glob { iter } => Some(iter.next()?.map_err(Into::into)),
}
}
}
impl ResourcePathsIter<'_> {
fn next_current_iter(&mut self) -> Option<crate::Result<Resource>> {
let current_iter = self.current_iter.as_mut().unwrap();
let entry = current_iter.next()?;
Some(match entry {
Ok(entry) => {
// Skip directories
if entry.is_dir() {
self.next_current_iter()?
} else {
self.resource_from_path(normalize(&entry))
}
}
Err(error) => Err(error),
})
}
fn resource_from_path(&self, path: PathBuf) -> crate::Result<Resource> {
if !path.exists() {
return Err(crate::Error::ResourcePathNotFound(path));
}
Ok(Resource {
target: if let Some(dest) = &self.current_dest {
match &self.current_iter {
Some(current_iter) => match current_iter {
// if processing a directory, preserve directory structure under current_dest
ResourcePathsInnerIter::Walk {
current_pattern, ..
} => {
if let Some(pattern) = current_pattern {
dest.join(path.strip_prefix(pattern).unwrap_or(&path))
} else {
dest.join(&path)
}
}
// if processing a glob and current_dest is not empty
// we put all globbed paths under current_dest
// preserving the file name as it is
ResourcePathsInnerIter::Glob { .. } => dest.join(path.file_name().unwrap()),
},
None => dest.clone(),
}
} else {
// If [`ResourcePathsIter::pattern_iter`] is a [`PatternIter::Slice`]
resource_relpath(&path)
},
path,
})
}
fn next_pattern(&mut self) -> Option<crate::Result<Resource>> {
self.current_dest = None;
let pattern = match &mut self.pattern_iter {
PatternIter::Slice(iter) => iter.next()?,
PatternIter::Map(iter) => {
let (pattern, dest) = iter.next()?;
self.current_dest = Some(resource_relpath(Path::new(dest)));
pattern
}
};
if pattern.contains('*') {
self.current_iter = match glob::glob(pattern) {
Ok(glob) => Some(ResourcePathsInnerIter::Glob { iter: glob }),
Err(error) => return Some(Err(error.into())),
};
match self.next_current_iter() {
Some(r) => return Some(r),
None => {
self.current_iter = None;
return Some(Err(crate::Error::GlobPathNotFound(pattern.clone())));
}
}
} else {
let path = normalize(Path::new(pattern));
if path.is_dir() {
if !self.allow_walk {
return Some(Err(crate::Error::NotAllowedToWalkDir(path)));
}
self.current_iter = Some(ResourcePathsInnerIter::Walk {
iter: WalkDir::new(&path).into_iter(),
current_pattern: if matches!(self.pattern_iter, PatternIter::Map(_)) {
Some(path)
} else {
None
},
});
} else {
return Some(self.resource_from_path(path));
}
}
self.next_current_iter()
}
}
impl Iterator for ResourcePaths<'_> {
type Item = crate::Result<PathBuf>;
fn next(&mut self) -> Option<crate::Result<PathBuf>> {
self.iter.next().map(|r| r.map(|res| res.path))
}
}
impl Iterator for ResourcePathsIter<'_> {
type Item = crate::Result<Resource>;
fn next(&mut self) -> Option<crate::Result<Resource>> {
if self.current_iter.is_some() {
match self.next_current_iter() {
Some(r) => return Some(r),
None => self.current_iter = None,
}
}
self.next_pattern()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
impl PartialEq for Resource {
fn eq(&self, other: &Self) -> bool {
self.path == other.path && self.target == other.target
}
}
fn expected_resources(resources: &[(&str, &str)]) -> Vec<Resource> {
resources
.iter()
.map(|(path, target)| Resource {
path: Path::new(path).components().collect(),
target: Path::new(target).components().collect(),
})
.collect()
}
fn setup_test_dirs() {
let mut random = [0; 1];
getrandom::fill(&mut random).unwrap();
let temp = std::env::temp_dir();
let temp = temp.join(format!("tauri_resource_paths_iter_test_{}", random[0]));
let _ = fs::remove_dir_all(&temp);
fs::create_dir_all(&temp).unwrap();
std::env::set_current_dir(&temp).unwrap();
let paths = [
"src-tauri/tauri.conf.json",
"src-tauri/some-other-json.json",
"src-tauri/Cargo.toml",
"src-tauri/Tauri.toml",
"src-tauri/build.rs",
"src-tauri/some-folder/some-file.txt",
"src/assets/javascript.svg",
"src/assets/tauri.svg",
"src/assets/rust.svg",
"src/assets/lang/en.json",
"src/assets/lang/ar.json",
"src/sounds/lang/es.wav",
"src/sounds/lang/fr.wav",
"src/textures/ground/earth.tex",
"src/textures/ground/sand.tex",
"src/textures/water.tex",
"src/textures/fire.tex",
"src/tiles/sky/grey.tile",
"src/tiles/sky/yellow.tile",
"src/tiles/grass.tile",
"src/tiles/stones.tile",
"src/index.html",
"src/style.css",
"src/script.js",
"src/dir/another-dir/file1.txt",
"src/dir/another-dir2/file2.txt",
];
for path in paths {
let path = Path::new(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, "").unwrap();
}
}
fn resources_map(literal: &[(&str, &str)]) -> HashMap<String, String> {
literal
.iter()
.map(|(from, to)| (from.to_string(), to.to_string()))
.collect()
}
#[test]
#[serial_test::serial(resources)]
fn resource_paths_iter_slice_allow_walk() {
setup_test_dirs();
let dir = std::env::current_dir().unwrap().join("src-tauri");
let _ = std::env::set_current_dir(dir);
let resources = ResourcePaths::new(
&[
"../src/script.js".into(),
"../src/assets".into(),
"../src/index.html".into(),
"../src/sounds".into(),
// Should be the same as `../src/textures/` or `../src/textures`
"../src/textures/**/*".into(),
"*.toml".into(),
"*.conf.json".into(),
],
true,
)
.iter()
.flatten()
.collect::<Vec<_>>();
let expected = expected_resources(&[
// From `../src/script.js`
("../src/script.js", "_up_/src/script.js"),
// From `../src/assets`
(
"../src/assets/javascript.svg",
"_up_/src/assets/javascript.svg",
),
("../src/assets/tauri.svg", "_up_/src/assets/tauri.svg"),
("../src/assets/rust.svg", "_up_/src/assets/rust.svg"),
("../src/assets/lang/en.json", "_up_/src/assets/lang/en.json"),
("../src/assets/lang/ar.json", "_up_/src/assets/lang/ar.json"),
// From `../src/index.html`
("../src/index.html", "_up_/src/index.html"),
// From `../src/sounds`
("../src/sounds/lang/es.wav", "_up_/src/sounds/lang/es.wav"),
("../src/sounds/lang/fr.wav", "_up_/src/sounds/lang/fr.wav"),
// From `../src/textures/**/*`
(
"../src/textures/ground/earth.tex",
"_up_/src/textures/ground/earth.tex",
),
(
"../src/textures/ground/sand.tex",
"_up_/src/textures/ground/sand.tex",
),
("../src/textures/water.tex", "_up_/src/textures/water.tex"),
("../src/textures/fire.tex", "_up_/src/textures/fire.tex"),
// From `*.toml`
("Cargo.toml", "Cargo.toml"),
("Tauri.toml", "Tauri.toml"),
// From `*.conf.json`
("tauri.conf.json", "tauri.conf.json"),
]);
assert_eq!(resources.len(), expected.len());
for resource in expected {
if !resources.contains(&resource) {
panic!("{resource:?} was expected but not found in {resources:?}");
}
}
}
#[test]
#[serial_test::serial(resources)]
fn resource_paths_iter_slice_no_walk() {
setup_test_dirs();
let dir = std::env::current_dir().unwrap().join("src-tauri");
let _ = std::env::set_current_dir(dir);
let resources = ResourcePaths::new(
&[
"../src/script.js".into(),
"../src/assets".into(),
"../src/index.html".into(),
"../src/sounds".into(),
"*.toml".into(),
"*.conf.json".into(),
],
false,
)
.iter()
.flatten()
.collect::<Vec<_>>();
let expected = expected_resources(&[
("../src/script.js", "_up_/src/script.js"),
("../src/index.html", "_up_/src/index.html"),
("Cargo.toml", "Cargo.toml"),
("Tauri.toml", "Tauri.toml"),
("tauri.conf.json", "tauri.conf.json"),
]);
assert_eq!(resources.len(), expected.len());
for resource in expected {
if !resources.contains(&resource) {
panic!("{resource:?} was expected but not found in {resources:?}");
}
}
}
#[test]
#[serial_test::serial(resources)]
fn resource_paths_iter_map_allow_walk() {
setup_test_dirs();
let dir = std::env::current_dir().unwrap().join("src-tauri");
let _ = std::env::set_current_dir(dir);
let resources = ResourcePaths::from_map(
&resources_map(&[
("../src/script.js", "main.js"),
("../src/assets", ""),
("../src/index.html", "frontend/index.html"),
("../src/sounds", "voices"),
("../src/textures/*", "textures"),
("../src/tiles/**/*", "tiles"),
("*.toml", ""),
("*.conf.json", "json"),
("./some-folder/", "some-target-folder/"),
("../non-existent-file", "asd"), // invalid case
("../non/*", "asd"), // invalid case
]),
true,
)
.iter()
.flatten()
.collect::<Vec<_>>();
let expected = expected_resources(&[
("../src/script.js", "main.js"),
("../src/assets/javascript.svg", "javascript.svg"),
("../src/assets/tauri.svg", "tauri.svg"),
("../src/assets/rust.svg", "rust.svg"),
("../src/assets/lang/en.json", "lang/en.json"),
("../src/assets/lang/ar.json", "lang/ar.json"),
("../src/index.html", "frontend/index.html"),
("../src/sounds/lang/es.wav", "voices/lang/es.wav"),
("../src/sounds/lang/fr.wav", "voices/lang/fr.wav"),
("../src/textures/water.tex", "textures/water.tex"),
("../src/textures/fire.tex", "textures/fire.tex"),
("../src/tiles/grass.tile", "tiles/grass.tile"),
("../src/tiles/stones.tile", "tiles/stones.tile"),
("../src/tiles/sky/grey.tile", "tiles/grey.tile"),
("../src/tiles/sky/yellow.tile", "tiles/yellow.tile"),
("Cargo.toml", "Cargo.toml"),
("Tauri.toml", "Tauri.toml"),
("tauri.conf.json", "json/tauri.conf.json"),
(
"some-folder/some-file.txt",
"some-target-folder/some-file.txt",
),
]);
assert_eq!(resources.len(), expected.len());
for resource in expected {
if !resources.contains(&resource) {
panic!("{resource:?} was expected but not found in {resources:?}");
}
}
}
#[test]
#[serial_test::serial(resources)]
fn resource_paths_iter_map_no_walk() {
setup_test_dirs();
let dir = std::env::current_dir().unwrap().join("src-tauri");
let _ = std::env::set_current_dir(dir);
let resources = ResourcePaths::from_map(
&resources_map(&[
("../src/script.js", "main.js"),
("../src/assets", ""),
("../src/index.html", "frontend/index.html"),
("../src/sounds", "voices"),
("*.toml", ""),
("*.conf.json", "json"),
]),
false,
)
.iter()
.flatten()
.collect::<Vec<_>>();
let expected = expected_resources(&[
("../src/script.js", "main.js"),
("../src/index.html", "frontend/index.html"),
("Cargo.toml", "Cargo.toml"),
("Tauri.toml", "Tauri.toml"),
("tauri.conf.json", "json/tauri.conf.json"),
]);
assert_eq!(resources.len(), expected.len());
for resource in expected {
if !resources.contains(&resource) {
panic!("{resource:?} was expected but not found in {resources:?}");
}
}
}
#[test]
#[serial_test::serial(resources)]
fn resource_paths_errors() {
setup_test_dirs();
let dir = std::env::current_dir().unwrap().join("src-tauri");
let _ = std::env::set_current_dir(dir);
let resources = ResourcePaths::from_map(
&resources_map(&[
("../non-existent-file", "file"),
("../non-existent-dir", "dir"),
// exists but not allowed to walk
("../src", "dir2"),
// doesn't exist but it is a glob and will return an error
("../non-existent-glob-dir/*", "glob"),
// exists but only contains directories and will not produce any values
("../src/dir/*", "dir3"),
]),
false,
)
.iter()
.collect::<Vec<_>>();
assert_eq!(resources.len(), 5);
assert!(resources.iter().all(|r| r.is_err()));
// hashmap order is not guaranteed so we check the error variant exists and how many
assert!(
resources
.iter()
.any(|r| matches!(r, Err(crate::Error::ResourcePathNotFound(_))))
);
assert_eq!(
resources
.iter()
.filter(|r| matches!(r, Err(crate::Error::ResourcePathNotFound(_))))
.count(),
2
);
assert!(
resources
.iter()
.any(|r| matches!(r, Err(crate::Error::NotAllowedToWalkDir(_))))
);
assert_eq!(
resources
.iter()
.filter(|r| matches!(r, Err(crate::Error::NotAllowedToWalkDir(_))))
.count(),
1
);
assert!(
resources
.iter()
.any(|r| matches!(r, Err(crate::Error::GlobPathNotFound(_))))
);
assert_eq!(
resources
.iter()
.filter(|r| matches!(r, Err(crate::Error::GlobPathNotFound(_))))
.count(),
2
);
}
}

View file

@ -0,0 +1,175 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! Utilities to implement [`ToTokens`] for a type.
use std::path::Path;
use proc_macro2::TokenStream;
use quote::{ToTokens, quote};
use serde_json::Value as JsonValue;
use url::Url;
/// Write a `TokenStream` of the `$struct`'s fields to the `$tokens`.
///
/// All fields must represent a binding of the same name that implements `ToTokens`.
#[macro_export]
macro_rules! literal_struct {
($tokens:ident, $struct:path, $($field:ident),+) => {
$tokens.append_all(quote! {
$struct {
$($field: #$field),+
}
})
};
}
/// Create a `String` constructor `TokenStream`.
///
/// e.g. `"Hello World"` -> `String::from("Hello World")`.
/// This takes a `&String` to reduce casting all the `&String` -> `&str` manually.
pub fn str_lit(s: impl AsRef<str>) -> TokenStream {
let s = s.as_ref();
quote! { #s.into() }
}
/// Create an `Option` constructor `TokenStream`.
pub fn opt_lit(item: Option<&impl ToTokens>) -> TokenStream {
match item {
None => quote! { ::core::option::Option::None },
Some(item) => quote! { ::core::option::Option::Some(#item) },
}
}
/// Create an `Option` constructor `TokenStream` over an owned [`ToTokens`] impl type.
pub fn opt_lit_owned(item: Option<impl ToTokens>) -> TokenStream {
match item {
None => quote! { ::core::option::Option::None },
Some(item) => quote! { ::core::option::Option::Some(#item) },
}
}
/// Helper function to combine an `opt_lit` with `str_lit`.
pub fn opt_str_lit(item: Option<impl AsRef<str>>) -> TokenStream {
opt_lit(item.map(str_lit).as_ref())
}
/// Helper function to combine an `opt_lit` with a list of `str_lit`
pub fn opt_vec_lit<Raw, Tokens>(
item: Option<impl IntoIterator<Item = Raw>>,
map: impl Fn(Raw) -> Tokens,
) -> TokenStream
where
Tokens: ToTokens,
{
opt_lit(item.map(|list| vec_lit(list, map)).as_ref())
}
/// Create a `Vec` constructor, mapping items with a function that spits out `TokenStream`s.
pub fn vec_lit<Raw, Tokens>(
list: impl IntoIterator<Item = Raw>,
map: impl Fn(Raw) -> Tokens,
) -> TokenStream
where
Tokens: ToTokens,
{
let items = list.into_iter().map(map);
quote! { vec![#(#items),*] }
}
/// Create a `PathBuf` constructor `TokenStream`.
///
/// e.g. `"Hello World" -> String::from("Hello World").
pub fn path_buf_lit(s: impl AsRef<Path>) -> TokenStream {
let s = s.as_ref().to_string_lossy().into_owned();
quote! { ::std::path::PathBuf::from(#s) }
}
/// Creates a `Url` constructor `TokenStream`.
pub fn url_lit(url: &Url) -> TokenStream {
let url = url.as_str();
quote! { #url.parse().unwrap() }
}
/// Create a map constructor, mapping keys and values with other `TokenStream`s.
///
/// This function is pretty generic because the types of keys AND values get transformed.
pub fn map_lit<Map, Key, Value, TokenStreamKey, TokenStreamValue, FuncKey, FuncValue>(
map_type: TokenStream,
map: Map,
map_key: FuncKey,
map_value: FuncValue,
) -> TokenStream
where
<Map as IntoIterator>::IntoIter: ExactSizeIterator,
Map: IntoIterator<Item = (Key, Value)>,
TokenStreamKey: ToTokens,
TokenStreamValue: ToTokens,
FuncKey: Fn(Key) -> TokenStreamKey,
FuncValue: Fn(Value) -> TokenStreamValue,
{
let ident = quote::format_ident!("map");
let map = map.into_iter();
if map.len() > 0 {
let items = map.map(|(key, value)| {
let key = map_key(key);
let value = map_value(value);
quote! { #ident.insert(#key, #value); }
});
quote! {{
let mut #ident = #map_type::new();
#(#items)*
#ident
}}
} else {
quote! { #map_type::new() }
}
}
/// Create a `serde_json::Value` variant `TokenStream` for a number
pub fn json_value_number_lit(num: &serde_json::Number) -> TokenStream {
// See <https://docs.rs/serde_json/1/serde_json/struct.Number.html> for guarantees
let prefix = quote! { ::serde_json::Value };
if num.is_u64() {
// guaranteed u64
let num = num.as_u64().unwrap();
quote! { #prefix::Number(#num.into()) }
} else if num.is_i64() {
// guaranteed i64
let num = num.as_i64().unwrap();
quote! { #prefix::Number(#num.into()) }
} else if num.is_f64() {
// guaranteed f64
let num = num.as_f64().unwrap();
quote! { #prefix::Number(::serde_json::Number::from_f64(#num).unwrap(/* safe to unwrap, guaranteed f64 */)) }
} else {
// invalid number
quote! { #prefix::Null }
}
}
/// Create a `serde_json::Value` constructor `TokenStream`
pub fn json_value_lit(jv: &JsonValue) -> TokenStream {
let prefix = quote! { ::serde_json::Value };
match jv {
JsonValue::Null => quote! { #prefix::Null },
JsonValue::Bool(bool) => quote! { #prefix::Bool(#bool) },
JsonValue::Number(number) => json_value_number_lit(number),
JsonValue::String(str) => {
let s = str_lit(str);
quote! { #prefix::String(#s) }
}
JsonValue::Array(vec) => {
let items = vec.iter().map(json_value_lit);
quote! { #prefix::Array(vec![#(#items),*]) }
}
JsonValue::Object(map) => {
let map = map_lit(quote! { ::serde_json::Map }, map, str_lit, json_value_lit);
quote! { #prefix::Object(#map) }
}
}
}