use crate::version::Version;
use origin_manifest::{Manifest, SecurityProfile};
use std::path::{Path, PathBuf};
pub const CURRENT: Version = Version::new(0, 2, 0);
#[derive(Debug, Default)]
pub struct Steps {
pub changed: Vec<String>,
pub manual: Vec<String>,
pub skipped: Vec<String>,
}
impl Steps {
fn merge(&mut self, other: Steps) {
self.changed.extend(other.changed);
self.manual.extend(other.manual);
self.skipped.extend(other.skipped);
}
}
pub struct Context<'a> {
pub project: &'a Path,
pub manifest: &'a Manifest,
pub dry_run: bool,
}
impl Context<'_> {
fn tauri_directory(&self) -> PathBuf {
self.project.join("src-tauri")
}
}
pub struct Migration {
pub to: Version,
pub summary: &'static str,
pub apply: fn(&Context) -> Result<Steps, String>,
}
pub fn all() -> Vec<Migration> {
vec![Migration {
to: Version::new(0, 2, 0),
summary: "capability files are generated from app.toml",
apply: adopt_generated_capabilities,
}]
}
pub fn pending(from: Version) -> Vec<Migration> {
all().into_iter().filter(|m| m.to > from).collect()
}
pub fn apply(context: &Context, from: Version) -> Result<Steps, String> {
let mut steps = Steps::default();
for migration in pending(from) {
println!(" {} → {}", from, migration.to);
println!(" {}", migration.summary);
steps.merge((migration.apply)(context)?);
}
Ok(steps)
}
fn adopt_generated_capabilities(context: &Context) -> Result<Steps, String> {
let mut steps = Steps::default();
let capabilities = context.tauri_directory().join("capabilities");
if !capabilities.is_dir() {
return Ok(steps);
}
if context.manifest.has_override("hand_written_capabilities") {
steps.skipped.push(format!(
"{} — the project declares `hand_written_capabilities`",
display(context.project, &capabilities)
));
return Ok(steps);
}
for path in hand_written_files(&capabilities)? {
let contents = std::fs::read_to_string(&path)
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
let capability: serde_json::Value = serde_json::from_str(&contents)
.map_err(|error| format!("{} is not valid JSON: {error}", path.display()))?;
let granted = string_list(&capability, "permissions");
let windows = string_list(&capability, "windows");
match narrowest_profile(&granted) {
Some(profile) => {
if !context.dry_run {
std::fs::remove_file(&path)
.map_err(|error| format!("cannot remove {}: {error}", path.display()))?;
}
steps.changed.push(format!(
"{} → replaced by profile `{}` for window(s) {}",
display(context.project, &path),
profile.identifier(),
if windows.is_empty() {
"main".to_owned()
} else {
windows.join(", ")
}
));
for window in windows
.iter()
.filter(|window| !context.manifest.security.windows.contains_key(*window))
{
steps.manual.push(format!(
"add to app.toml: [security.windows] {window} = {{ profile = \"{}\" }}",
profile.identifier()
));
}
}
None => steps.manual.push(format!(
"{} grants permissions no profile covers ({}). Choose a profile in \
app.toml, or declare `hand_written_capabilities = true` under \
[origin.overrides].",
display(context.project, &path),
beyond_every_profile(&granted).join(", ")
)),
}
}
Ok(steps)
}
fn narrowest_profile(granted: &[String]) -> Option<SecurityProfile> {
let mut candidates = [
SecurityProfile::ReadonlyDashboard,
SecurityProfile::StandardDashboard,
SecurityProfile::AccountSettings,
];
candidates.sort_by_key(|profile| profile.permissions().len());
candidates.into_iter().find(|profile| {
granted
.iter()
.all(|permission| profile.permissions().contains(&permission.as_str()))
})
}
fn beyond_every_profile(granted: &[String]) -> Vec<String> {
let known: Vec<&str> = [
SecurityProfile::ReadonlyDashboard,
SecurityProfile::StandardDashboard,
SecurityProfile::AccountSettings,
]
.iter()
.flat_map(|profile| profile.permissions().iter().copied())
.collect();
granted
.iter()
.filter(|permission| !known.contains(&permission.as_str()))
.cloned()
.collect()
}
fn hand_written_files(directory: &Path) -> Result<Vec<PathBuf>, String> {
let entries = std::fs::read_dir(directory)
.map_err(|error| format!("cannot read {}: {error}", directory.display()))?;
let mut files: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.extension()
.is_some_and(|extension| extension == "json")
})
.filter(|path| {
!std::fs::read_to_string(path)
.unwrap_or_default()
.contains("Generated from app.toml")
})
.collect();
files.sort();
Ok(files)
}
fn string_list(value: &serde_json::Value, key: &str) -> Vec<String> {
value
.get(key)
.and_then(serde_json::Value::as_array)
.map(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
}
fn display(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_narrow_capability_maps_to_the_narrowest_profile() {
let granted = vec!["core:default".to_owned()];
assert_eq!(
narrowest_profile(&granted),
Some(SecurityProfile::ReadonlyDashboard)
);
}
#[test]
fn a_capability_needing_more_maps_to_a_wider_profile() {
let granted = vec![
"core:default".to_owned(),
"core:window:allow-close".to_owned(),
];
assert_eq!(
narrowest_profile(&granted),
Some(SecurityProfile::AccountSettings)
);
}
#[test]
fn filesystem_access_maps_to_no_profile_at_all() {
let granted = vec![
"core:default".to_owned(),
"fs:allow-write-text-file".to_owned(),
];
assert_eq!(narrowest_profile(&granted), None);
assert_eq!(
beyond_every_profile(&granted),
vec!["fs:allow-write-text-file"]
);
}
#[test]
fn only_migrations_newer_than_the_project_are_pending() {
assert_eq!(pending(Version::new(0, 1, 0)).len(), 1);
assert!(pending(CURRENT).is_empty());
assert!(pending(Version::new(9, 0, 0)).is_empty());
}
}