diff --git a/.changes/config.json b/.changes/config.json index 19006ae4..5792535f 100644 --- a/.changes/config.json +++ b/.changes/config.json @@ -71,7 +71,8 @@ "dialog": { "path": "./plugins/dialog", - "manager": "rust-disabled" + "manager": "rust-disabled", + "dependencies": ["fs"] }, "dialog-js": { "path": "./plugins/dialog", @@ -107,7 +108,8 @@ "http": { "path": "./plugins/http", - "manager": "rust-disabled" + "manager": "rust-disabled", + "dependencies": ["fs"] }, "http-js": { "path": "./plugins/http", @@ -139,7 +141,8 @@ "persisted-scope": { "path": "./plugins/persisted-scope", - "manager": "rust" + "manager": "rust", + "dependencies": ["fs"] }, "positioner": { diff --git a/Cargo.lock b/Cargo.lock index cfda4c2c..0662e024 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4960,7 +4960,7 @@ dependencies = [ [[package]] name = "tauri" version = "2.0.0-alpha.8" -source = "git+https://github.com/tauri-apps/tauri?branch=next#9a79dc085870e0c1a5df13481ff271b8c6cc3b78" +source = "git+https://github.com/tauri-apps/tauri?branch=refactor/cleanup#e36a8e070da414e3a2f57411951f2a38b470cf9d" dependencies = [ "anyhow", "bytes 1.4.0", @@ -4968,7 +4968,6 @@ dependencies = [ "dirs-next", "embed_plist", "encoding_rs", - "flate2", "futures-util", "glib", "glob", @@ -4976,7 +4975,6 @@ dependencies = [ "heck 0.4.1", "http", "ico 0.2.0", - "ignore", "infer 0.9.0", "jni", "libc", @@ -4995,7 +4993,6 @@ dependencies = [ "serialize-to-javascript", "state", "swift-rs", - "tar", "tauri-build", "tauri-macros", "tauri-runtime", @@ -5003,20 +5000,18 @@ dependencies = [ "tauri-utils", "tempfile", "thiserror", - "time 0.3.20", "tokio", "url", "uuid", "webkit2gtk", "webview2-com", "windows 0.44.0", - "zip", ] [[package]] name = "tauri-build" version = "2.0.0-alpha.4" -source = "git+https://github.com/tauri-apps/tauri?branch=next#9a79dc085870e0c1a5df13481ff271b8c6cc3b78" +source = "git+https://github.com/tauri-apps/tauri?branch=refactor/cleanup#e36a8e070da414e3a2f57411951f2a38b470cf9d" dependencies = [ "anyhow", "cargo_toml", @@ -5037,7 +5032,7 @@ dependencies = [ [[package]] name = "tauri-codegen" version = "2.0.0-alpha.4" -source = "git+https://github.com/tauri-apps/tauri?branch=next#9a79dc085870e0c1a5df13481ff271b8c6cc3b78" +source = "git+https://github.com/tauri-apps/tauri?branch=refactor/cleanup#e36a8e070da414e3a2f57411951f2a38b470cf9d" dependencies = [ "base64 0.21.0", "brotli", @@ -5062,7 +5057,7 @@ dependencies = [ [[package]] name = "tauri-macros" version = "2.0.0-alpha.4" -source = "git+https://github.com/tauri-apps/tauri?branch=next#9a79dc085870e0c1a5df13481ff271b8c6cc3b78" +source = "git+https://github.com/tauri-apps/tauri?branch=refactor/cleanup#e36a8e070da414e3a2f57411951f2a38b470cf9d" dependencies = [ "heck 0.4.1", "proc-macro2", @@ -5147,6 +5142,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-fs", "thiserror", ] @@ -5155,9 +5151,11 @@ name = "tauri-plugin-fs" version = "0.0.0" dependencies = [ "anyhow", + "glob", "serde", "tauri", "thiserror", + "uuid", ] [[package]] @@ -5198,6 +5196,7 @@ dependencies = [ "serde_json", "serde_repr", "tauri", + "tauri-plugin-fs", "thiserror", ] @@ -5273,6 +5272,7 @@ dependencies = [ "serde", "serde_json", "tauri", + "tauri-plugin-fs", "thiserror", ] @@ -5373,8 +5373,10 @@ version = "0.0.0" dependencies = [ "base64 0.21.0", "dirs-next", + "flate2", "futures-util", "http", + "ignore", "minisign-verify", "mockito", "percent-encoding", @@ -5382,6 +5384,7 @@ dependencies = [ "semver", "serde", "serde_json", + "tar", "tauri", "tempfile", "thiserror", @@ -5389,6 +5392,7 @@ dependencies = [ "tokio", "tokio-test", "url", + "zip", ] [[package]] @@ -5447,7 +5451,7 @@ dependencies = [ [[package]] name = "tauri-runtime" version = "0.13.0-alpha.4" -source = "git+https://github.com/tauri-apps/tauri?branch=next#9a79dc085870e0c1a5df13481ff271b8c6cc3b78" +source = "git+https://github.com/tauri-apps/tauri?branch=refactor/cleanup#e36a8e070da414e3a2f57411951f2a38b470cf9d" dependencies = [ "gtk", "http", @@ -5468,7 +5472,7 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" version = "0.13.0-alpha.4" -source = "git+https://github.com/tauri-apps/tauri?branch=next#9a79dc085870e0c1a5df13481ff271b8c6cc3b78" +source = "git+https://github.com/tauri-apps/tauri?branch=refactor/cleanup#e36a8e070da414e3a2f57411951f2a38b470cf9d" dependencies = [ "cocoa", "gtk", @@ -5488,7 +5492,7 @@ dependencies = [ [[package]] name = "tauri-utils" version = "2.0.0-alpha.4" -source = "git+https://github.com/tauri-apps/tauri?branch=next#9a79dc085870e0c1a5df13481ff271b8c6cc3b78" +source = "git+https://github.com/tauri-apps/tauri?branch=refactor/cleanup#e36a8e070da414e3a2f57411951f2a38b470cf9d" dependencies = [ "aes-gcm 0.10.1", "brotli", diff --git a/Cargo.toml b/Cargo.toml index c70e53f1..8766a2fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,8 +5,8 @@ resolver = "2" [workspace.dependencies] serde = { version = "1", features = ["derive"] } log = "0.4" -tauri = { git = "https://github.com/tauri-apps/tauri", branch = "next" } -tauri-build = { git = "https://github.com/tauri-apps/tauri", branch = "next" } +tauri = { git = "https://github.com/tauri-apps/tauri", branch = "refactor/cleanup" } +tauri-build = { git = "https://github.com/tauri-apps/tauri", branch = "refactor/cleanup" } serde_json = "1" thiserror = "1" diff --git a/examples/api/src-tauri/Cargo.toml b/examples/api/src-tauri/Cargo.toml index e64d84b8..4bd7ce49 100644 --- a/examples/api/src-tauri/Cargo.toml +++ b/examples/api/src-tauri/Cargo.toml @@ -33,13 +33,12 @@ tauri-plugin-window = { path = "../../../plugins/window" } [dependencies.tauri] workspace = true features = [ - "api-all", "icon-ico", "icon-png", "isolation", "macos-private-api", "system-tray", - "updater" + "protocol-asset" ] [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"openbsd\", target_os = \"netbsd\"))".dependencies] diff --git a/examples/api/src-tauri/tauri.conf.json b/examples/api/src-tauri/tauri.conf.json index 741a7e1f..b1aa782f 100644 --- a/examples/api/src-tauri/tauri.conf.json +++ b/examples/api/src-tauri/tauri.conf.json @@ -46,47 +46,8 @@ ] } } - } - }, - "tauri": { - "pattern": { - "use": "isolation", - "options": { - "dir": "../isolation-dist/" - } }, - "macOSPrivateApi": true, - "bundle": { - "active": true, - "identifier": "com.tauri.api", - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ], - "windows": { - "wix": { - "language": { - "en-US": {}, - "pt-BR": { - "localePath": "locales/pt-BR.wxl" - } - } - } - } - }, - "updater": { - "active": true, - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5QzMxNjYwNTM5OEUwNTgKUldSWTRKaFRZQmJER1h4d1ZMYVA3dnluSjdpN2RmMldJR09hUFFlZDY0SlFqckkvRUJhZDJVZXAK", - "endpoints": [ - "https://tauri-update-server.vercel.app/update/{{target}}/{{current_version}}" - ] - }, - "allowlist": { - "all": true, - "fs": { + "fs": { "scope": { "allow": ["$APPDATA/db/**", "$DOWNLOAD/**", "$RESOURCE/**"], "deny": ["$APPDATA/db/*.stronghold"] @@ -117,15 +78,55 @@ } ] }, + "http": { + "scope": ["http://localhost:3003"] + }, + "updater": { + "endpoints": [ + "https://tauri-update-server.vercel.app/update/{{target}}/{{current_version}}" + ] + } + }, + "tauri": { + "pattern": { + "use": "isolation", + "options": { + "dir": "../isolation-dist/" + } + }, + "macOSPrivateApi": true, + "bundle": { + "active": true, + "identifier": "com.tauri.api", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "windows": { + "wix": { + "language": { + "en-US": {}, + "pt-BR": { + "localePath": "locales/pt-BR.wxl" + } + } + } + }, + "updater": { + "active": true, + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5QzMxNjYwNTM5OEUwNTgKUldSWTRKaFRZQmJER1h4d1ZMYVA3dnluSjdpN2RmMldJR09hUFFlZDY0SlFqckkvRUJhZDJVZXAK" + } + }, + "allowlist": { "protocol": { "asset": true, "assetScope": { "allow": ["$APPDATA/db/**", "$RESOURCE/**"], "deny": ["$APPDATA/db/*.stronghold"] } - }, - "http": { - "scope": ["http://localhost:3003"] } }, "windows": [], diff --git a/plugins/dialog/Cargo.toml b/plugins/dialog/Cargo.toml index 905de823..1882da38 100644 --- a/plugins/dialog/Cargo.toml +++ b/plugins/dialog/Cargo.toml @@ -13,6 +13,7 @@ serde_json.workspace = true tauri.workspace = true log.workspace = true thiserror.workspace = true +tauri-plugin-fs = { path = "../fs", version = "0.0.0" } [target."cfg(any(target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"openbsd\", target_os = \"netbsd\"))".dependencies] glib = "0.16" diff --git a/plugins/dialog/src/commands.rs b/plugins/dialog/src/commands.rs index bc68f39e..d2a1697a 100644 --- a/plugins/dialog/src/commands.rs +++ b/plugins/dialog/src/commands.rs @@ -5,7 +5,8 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; -use tauri::{command, Manager, Runtime, State, Window}; +use tauri::{command, Runtime, State, Window}; +use tauri_plugin_fs::FsExt; use crate::{Dialog, FileDialogBuilder, FileResponse, MessageDialogKind, Result}; @@ -177,7 +178,9 @@ pub(crate) async fn save( let path = dialog_builder.blocking_save_file(); if let Some(p) = &path { - window.fs_scope().allow_file(p)?; + if let Some(s) = window.try_fs_scope() { + s.allow_file(p)?; + } } Ok(path) diff --git a/plugins/dialog/src/error.rs b/plugins/dialog/src/error.rs index 7aa9804b..069cd55c 100644 --- a/plugins/dialog/src/error.rs +++ b/plugins/dialog/src/error.rs @@ -21,6 +21,8 @@ pub enum Error { #[cfg(mobile)] #[error("File save dialog is not implemented on mobile")] FileSaveDialogNotImplemented, + #[error(transparent)] + Fs(#[from] tauri_plugin_fs::Error), } impl Serialize for Error { diff --git a/plugins/fs/Cargo.toml b/plugins/fs/Cargo.toml index a9549994..bb3980b6 100644 --- a/plugins/fs/Cargo.toml +++ b/plugins/fs/Cargo.toml @@ -14,3 +14,5 @@ serde.workspace = true tauri.workspace = true thiserror.workspace = true anyhow = "1" +uuid = { version = "1", features = ["v4"] } +glob = "0.3" diff --git a/plugins/fs/src/commands.rs b/plugins/fs/src/commands.rs index cd1fc4e0..168f9629 100644 --- a/plugins/fs/src/commands.rs +++ b/plugins/fs/src/commands.rs @@ -1,8 +1,9 @@ +use crate::Scope; use anyhow::Context; use serde::{Deserialize, Serialize, Serializer}; use tauri::{ path::{BaseDirectory, SafePathBuf}, - FsScope, Manager, Runtime, Window, + Manager, Runtime, Window, }; #[cfg(unix)] @@ -16,7 +17,7 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; -use crate::{Error, Result}; +use crate::{Error, FsExt, Result}; #[derive(Debug, thiserror::Error)] pub enum CommandError { @@ -120,7 +121,7 @@ pub fn write_file( #[derive(Clone, Copy)] struct ReadDirOptions<'a> { - pub scope: Option<&'a FsScope>, + pub scope: Option<&'a Scope>, } #[derive(Debug, Serialize)] @@ -189,7 +190,7 @@ pub fn read_dir( &resolved_path, recursive, ReadDirOptions { - scope: Some(&window.fs_scope()), + scope: Some(window.fs_scope()), }, ) .with_context(|| format!("path: {}", resolved_path.display())) diff --git a/plugins/fs/src/config.rs b/plugins/fs/src/config.rs new file mode 100644 index 00000000..a1dbc30c --- /dev/null +++ b/plugins/fs/src/config.rs @@ -0,0 +1,7 @@ +use serde::Deserialize; +use tauri::utils::config::FsAllowlistScope; + +#[derive(Debug, Deserialize)] +pub struct Config { + pub scope: FsAllowlistScope, +} diff --git a/plugins/fs/src/error.rs b/plugins/fs/src/error.rs index df109d43..9af56db3 100644 --- a/plugins/fs/src/error.rs +++ b/plugins/fs/src/error.rs @@ -10,6 +10,9 @@ pub enum Error { PathForbidden(PathBuf), #[error("failed to resolve path: {0}")] CannotResolvePath(tauri::path::Error), + /// Invalid glob pattern. + #[error("invalid glob pattern: {0}")] + GlobPattern(#[from] glob::PatternError), } impl Serialize for Error { diff --git a/plugins/fs/src/lib.rs b/plugins/fs/src/lib.rs index 5c1b2658..bf78f3b4 100644 --- a/plugins/fs/src/lib.rs +++ b/plugins/fs/src/lib.rs @@ -4,18 +4,38 @@ use tauri::{ plugin::{Builder as PluginBuilder, TauriPlugin}, - Runtime, + utils::config::FsAllowlistScope, + Manager, Runtime, }; mod commands; +mod config; mod error; +mod scope; +pub use config::Config; pub use error::Error; +pub use scope::{Event as ScopeEvent, Scope}; type Result = std::result::Result; -pub fn init() -> TauriPlugin { - PluginBuilder::new("fs") +pub trait FsExt { + fn fs_scope(&self) -> &Scope; + fn try_fs_scope(&self) -> Option<&Scope>; +} + +impl> FsExt for T { + fn fs_scope(&self) -> &Scope { + self.state::().inner() + } + + fn try_fs_scope(&self) -> Option<&Scope> { + self.try_state::().map(|s| s.inner()) + } +} + +pub fn init() -> TauriPlugin> { + PluginBuilder::>::new("fs") .invoke_handler(tauri::generate_handler![ commands::read_file, commands::read_text_file, @@ -29,5 +49,16 @@ pub fn init() -> TauriPlugin { commands::exists, commands::metadata ]) + .setup(|app: &tauri::AppHandle, api| { + let default_scope = FsAllowlistScope::default(); + app.manage(Scope::new( + app, + api.config() + .as_ref() + .map(|c| &c.scope) + .unwrap_or(&default_scope), + )); + Ok(()) + }) .build() } diff --git a/plugins/fs/src/scope.rs b/plugins/fs/src/scope.rs new file mode 100644 index 00000000..773dabe5 --- /dev/null +++ b/plugins/fs/src/scope.rs @@ -0,0 +1,368 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ + collections::{HashMap, HashSet}, + fmt, + path::{Path, PathBuf, MAIN_SEPARATOR}, + sync::{Arc, Mutex}, +}; + +pub use glob::Pattern; +use tauri::utils::config::FsAllowlistScope; +use uuid::Uuid; + +use crate::{Manager, Runtime}; + +/// Scope change event. +#[derive(Debug, Clone)] +pub enum Event { + /// A path has been allowed. + PathAllowed(PathBuf), + /// A path has been forbidden. + PathForbidden(PathBuf), +} + +type EventListener = Box; + +/// Scope for filesystem access. +#[derive(Clone)] +pub struct Scope { + allowed_patterns: Arc>>, + forbidden_patterns: Arc>>, + event_listeners: Arc>>, +} + +impl fmt::Debug for Scope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Scope") + .field( + "allowed_patterns", + &self + .allowed_patterns + .lock() + .unwrap() + .iter() + .map(|p| p.as_str()) + .collect::>(), + ) + .field( + "forbidden_patterns", + &self + .forbidden_patterns + .lock() + .unwrap() + .iter() + .map(|p| p.as_str()) + .collect::>(), + ) + .finish() + } +} + +fn push_pattern, F: Fn(&str) -> Result>( + list: &mut HashSet, + pattern: P, + f: F, +) -> crate::Result<()> { + let path: PathBuf = pattern.as_ref().components().collect(); + list.insert(f(&path.to_string_lossy())?); + #[cfg(windows)] + { + if let Ok(p) = std::fs::canonicalize(&path) { + list.insert(f(&p.to_string_lossy())?); + } else { + list.insert(f(&format!("\\\\?\\{}", path.display()))?); + } + } + Ok(()) +} + +impl Scope { + /// Creates a new scope from a `FsAllowlistScope` configuration. + pub(crate) fn new>( + manager: &M, + scope: &FsAllowlistScope, + ) -> crate::Result { + let mut allowed_patterns = HashSet::new(); + for path in scope.allowed_paths() { + if let Ok(path) = manager.path().parse(path) { + push_pattern(&mut allowed_patterns, path, Pattern::new)?; + } + } + + let mut forbidden_patterns = HashSet::new(); + if let Some(forbidden_paths) = scope.forbidden_paths() { + for path in forbidden_paths { + if let Ok(path) = manager.path().parse(path) { + push_pattern(&mut forbidden_patterns, path, Pattern::new)?; + } + } + } + + Ok(Self { + allowed_patterns: Arc::new(Mutex::new(allowed_patterns)), + forbidden_patterns: Arc::new(Mutex::new(forbidden_patterns)), + event_listeners: Default::default(), + }) + } + + /// The list of allowed patterns. + pub fn allowed_patterns(&self) -> HashSet { + self.allowed_patterns.lock().unwrap().clone() + } + + /// The list of forbidden patterns. + pub fn forbidden_patterns(&self) -> HashSet { + self.forbidden_patterns.lock().unwrap().clone() + } + + /// Listen to an event on this scope. + pub fn listen(&self, f: F) -> Uuid { + let id = Uuid::new_v4(); + self.event_listeners.lock().unwrap().insert(id, Box::new(f)); + id + } + + fn trigger(&self, event: Event) { + let listeners = self.event_listeners.lock().unwrap(); + let handlers = listeners.values(); + for listener in handlers { + listener(&event); + } + } + + /// Extend the allowed patterns with the given directory. + /// + /// After this function has been called, the frontend will be able to use the Tauri API to read + /// the directory and all of its files. If `recursive` is `true`, subdirectories will be accessible too. + pub fn allow_directory>(&self, path: P, recursive: bool) -> crate::Result<()> { + let path = path.as_ref(); + { + let mut list = self.allowed_patterns.lock().unwrap(); + + // allow the directory to be read + push_pattern(&mut list, path, escaped_pattern)?; + // allow its files and subdirectories to be read + push_pattern(&mut list, path, |p| { + escaped_pattern_with(p, if recursive { "**" } else { "*" }) + })?; + } + self.trigger(Event::PathAllowed(path.to_path_buf())); + Ok(()) + } + + /// Extend the allowed patterns with the given file path. + /// + /// After this function has been called, the frontend will be able to use the Tauri API to read the contents of this file. + pub fn allow_file>(&self, path: P) -> crate::Result<()> { + let path = path.as_ref(); + push_pattern( + &mut self.allowed_patterns.lock().unwrap(), + path, + escaped_pattern, + )?; + self.trigger(Event::PathAllowed(path.to_path_buf())); + Ok(()) + } + + /// Set the given directory path to be forbidden by this scope. + /// + /// **Note:** this takes precedence over allowed paths, so its access gets denied **always**. + pub fn forbid_directory>(&self, path: P, recursive: bool) -> crate::Result<()> { + let path = path.as_ref(); + { + let mut list = self.forbidden_patterns.lock().unwrap(); + + // allow the directory to be read + push_pattern(&mut list, path, escaped_pattern)?; + // allow its files and subdirectories to be read + push_pattern(&mut list, path, |p| { + escaped_pattern_with(p, if recursive { "**" } else { "*" }) + })?; + } + self.trigger(Event::PathForbidden(path.to_path_buf())); + Ok(()) + } + + /// Set the given file path to be forbidden by this scope. + /// + /// **Note:** this takes precedence over allowed paths, so its access gets denied **always**. + pub fn forbid_file>(&self, path: P) -> crate::Result<()> { + let path = path.as_ref(); + push_pattern( + &mut self.forbidden_patterns.lock().unwrap(), + path, + escaped_pattern, + )?; + self.trigger(Event::PathForbidden(path.to_path_buf())); + Ok(()) + } + + /// Determines if the given path is allowed on this scope. + pub fn is_allowed>(&self, path: P) -> bool { + let path = path.as_ref(); + let path = if !path.exists() { + crate::Result::Ok(path.to_path_buf()) + } else { + std::fs::canonicalize(path).map_err(Into::into) + }; + + if let Ok(path) = path { + let path: PathBuf = path.components().collect(); + let options = glob::MatchOptions { + // this is needed so `/dir/*` doesn't match files within subdirectories such as `/dir/subdir/file.txt` + // see: https://github.com/tauri-apps/tauri/security/advisories/GHSA-6mv3-wm7j-h4w5 + require_literal_separator: true, + // dotfiles are not supposed to be exposed by default + #[cfg(unix)] + require_literal_leading_dot: true, + ..Default::default() + }; + + let forbidden = self + .forbidden_patterns + .lock() + .unwrap() + .iter() + .any(|p| p.matches_path_with(&path, options)); + + if forbidden { + false + } else { + let allowed = self + .allowed_patterns + .lock() + .unwrap() + .iter() + .any(|p| p.matches_path_with(&path, options)); + allowed + } + } else { + false + } + } +} + +fn escaped_pattern(p: &str) -> Result { + Pattern::new(&glob::Pattern::escape(p)) +} + +fn escaped_pattern_with(p: &str, append: &str) -> Result { + Pattern::new(&format!( + "{}{}{append}", + glob::Pattern::escape(p), + MAIN_SEPARATOR + )) +} + +#[cfg(test)] +mod tests { + use super::Scope; + + fn new_scope() -> Scope { + Scope { + allowed_patterns: Default::default(), + forbidden_patterns: Default::default(), + event_listeners: Default::default(), + } + } + + #[test] + fn path_is_escaped() { + let scope = new_scope(); + #[cfg(unix)] + { + scope.allow_directory("/home/tauri/**", false).unwrap(); + assert!(scope.is_allowed("/home/tauri/**")); + assert!(scope.is_allowed("/home/tauri/**/file")); + assert!(!scope.is_allowed("/home/tauri/anyfile")); + } + #[cfg(windows)] + { + scope.allow_directory("C:\\home\\tauri\\**", false).unwrap(); + assert!(scope.is_allowed("C:\\home\\tauri\\**")); + assert!(scope.is_allowed("C:\\home\\tauri\\**\\file")); + assert!(!scope.is_allowed("C:\\home\\tauri\\anyfile")); + } + + let scope = new_scope(); + #[cfg(unix)] + { + scope.allow_file("/home/tauri/**").unwrap(); + assert!(scope.is_allowed("/home/tauri/**")); + assert!(!scope.is_allowed("/home/tauri/**/file")); + assert!(!scope.is_allowed("/home/tauri/anyfile")); + } + #[cfg(windows)] + { + scope.allow_file("C:\\home\\tauri\\**").unwrap(); + assert!(scope.is_allowed("C:\\home\\tauri\\**")); + assert!(!scope.is_allowed("C:\\home\\tauri\\**\\file")); + assert!(!scope.is_allowed("C:\\home\\tauri\\anyfile")); + } + + let scope = new_scope(); + #[cfg(unix)] + { + scope.allow_directory("/home/tauri", true).unwrap(); + scope.forbid_directory("/home/tauri/**", false).unwrap(); + assert!(!scope.is_allowed("/home/tauri/**")); + assert!(!scope.is_allowed("/home/tauri/**/file")); + assert!(scope.is_allowed("/home/tauri/**/inner/file")); + assert!(scope.is_allowed("/home/tauri/inner/folder/anyfile")); + assert!(scope.is_allowed("/home/tauri/anyfile")); + } + #[cfg(windows)] + { + scope.allow_directory("C:\\home\\tauri", true).unwrap(); + scope + .forbid_directory("C:\\home\\tauri\\**", false) + .unwrap(); + assert!(!scope.is_allowed("C:\\home\\tauri\\**")); + assert!(!scope.is_allowed("C:\\home\\tauri\\**\\file")); + assert!(scope.is_allowed("C:\\home\\tauri\\**\\inner\\file")); + assert!(scope.is_allowed("C:\\home\\tauri\\inner\\folder\\anyfile")); + assert!(scope.is_allowed("C:\\home\\tauri\\anyfile")); + } + + let scope = new_scope(); + #[cfg(unix)] + { + scope.allow_directory("/home/tauri", true).unwrap(); + scope.forbid_file("/home/tauri/**").unwrap(); + assert!(!scope.is_allowed("/home/tauri/**")); + assert!(scope.is_allowed("/home/tauri/**/file")); + assert!(scope.is_allowed("/home/tauri/**/inner/file")); + assert!(scope.is_allowed("/home/tauri/anyfile")); + } + #[cfg(windows)] + { + scope.allow_directory("C:\\home\\tauri", true).unwrap(); + scope.forbid_file("C:\\home\\tauri\\**").unwrap(); + assert!(!scope.is_allowed("C:\\home\\tauri\\**")); + assert!(scope.is_allowed("C:\\home\\tauri\\**\\file")); + assert!(scope.is_allowed("C:\\home\\tauri\\**\\inner\\file")); + assert!(scope.is_allowed("C:\\home\\tauri\\anyfile")); + } + + let scope = new_scope(); + #[cfg(unix)] + { + scope.allow_directory("/home/tauri", false).unwrap(); + assert!(scope.is_allowed("/home/tauri/**")); + assert!(!scope.is_allowed("/home/tauri/**/file")); + assert!(!scope.is_allowed("/home/tauri/**/inner/file")); + assert!(scope.is_allowed("/home/tauri/anyfile")); + } + #[cfg(windows)] + { + scope.allow_directory("C:\\home\\tauri", false).unwrap(); + assert!(scope.is_allowed("C:\\home\\tauri\\**")); + assert!(!scope.is_allowed("C:\\home\\tauri\\**\\file")); + assert!(!scope.is_allowed("C:\\home\\tauri\\**\\inner\\file")); + assert!(scope.is_allowed("C:\\home\\tauri\\anyfile")); + } + } +} diff --git a/plugins/http/Cargo.toml b/plugins/http/Cargo.toml index 3272ad39..b0e6ab32 100644 --- a/plugins/http/Cargo.toml +++ b/plugins/http/Cargo.toml @@ -10,6 +10,7 @@ serde.workspace = true serde_json.workspace = true tauri.workspace = true thiserror.workspace = true +tauri-plugin-fs = { path = "../fs", version = "0.0.0" } glob = "0.3" rand = "0.8" bytes = { version = "1", features = [ "serde" ] } diff --git a/plugins/http/src/commands/mod.rs b/plugins/http/src/commands/mod.rs index f38d6eac..5e9d4c58 100644 --- a/plugins/http/src/commands/mod.rs +++ b/plugins/http/src/commands/mod.rs @@ -1,4 +1,5 @@ use tauri::{path::SafePathBuf, AppHandle, Runtime, State}; +use tauri_plugin_fs::FsExt; use crate::{ClientId, Http}; @@ -38,7 +39,6 @@ pub async fn request( client_id: ClientId, options: Box, ) -> super::Result { - use crate::Manager; if http.scope.is_allowed(&options.url) { let client = http .clients @@ -55,7 +55,12 @@ pub async fn request( .. } = value { - if SafePathBuf::new(path.clone()).is_err() || !app.fs_scope().is_allowed(path) { + if SafePathBuf::new(path.clone()).is_err() + || !app + .try_fs_scope() + .map(|s| s.is_allowed(path)) + .unwrap_or_default() + { return Err(crate::Error::PathNotAllowed(path.clone())); } } diff --git a/plugins/http/src/config.rs b/plugins/http/src/config.rs new file mode 100644 index 00000000..d6b5eba0 --- /dev/null +++ b/plugins/http/src/config.rs @@ -0,0 +1,19 @@ +use reqwest::Url; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct Config { + pub scope: HttpAllowlistScope, +} + +/// HTTP API scope definition. +/// It is a list of URLs that can be accessed by the webview when using the HTTP APIs. +/// The scoped URL is matched against the request URL using a glob pattern. +/// +/// Examples: +/// - "https://**": allows all HTTPS urls +/// - "https://*.github.com/tauri-apps/tauri": allows any subdomain of "github.com" with the "tauri-apps/api" path +/// - "https://myapi.service.com/users/*": allows access to any URLs that begins with "https://myapi.service.com/users/" +#[allow(rustdoc::bare_urls)] +#[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize)] +pub struct HttpAllowlistScope(pub Vec); diff --git a/plugins/http/src/lib.rs b/plugins/http/src/lib.rs index 5f905c54..348d26a2 100644 --- a/plugins/http/src/lib.rs +++ b/plugins/http/src/lib.rs @@ -1,3 +1,4 @@ +use config::{Config, HttpAllowlistScope}; pub use reqwest as client; use tauri::{ plugin::{Builder, TauriPlugin}, @@ -7,6 +8,7 @@ use tauri::{ use std::{collections::HashMap, sync::Mutex}; mod commands; +mod config; mod error; mod scope; @@ -33,18 +35,24 @@ impl> HttpExt for T { } } -pub fn init() -> TauriPlugin { - Builder::new("http") +pub fn init() -> TauriPlugin> { + Builder::>::new("http") .invoke_handler(tauri::generate_handler![ commands::create_client, commands::drop_client, commands::request ]) - .setup(|app, _api| { + .setup(|app, api| { + let default_scope = HttpAllowlistScope::default(); app.manage(Http { app: app.clone(), clients: Default::default(), - scope: scope::Scope::new(&app.config().tauri.allowlist.http.scope), + scope: scope::Scope::new( + api.config() + .as_ref() + .map(|c| &c.scope) + .unwrap_or(&default_scope), + ), }); Ok(()) }) diff --git a/plugins/http/src/scope.rs b/plugins/http/src/scope.rs index 241c2816..0c620a9b 100644 --- a/plugins/http/src/scope.rs +++ b/plugins/http/src/scope.rs @@ -2,9 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +use crate::config::HttpAllowlistScope; use glob::Pattern; use reqwest::Url; -use tauri::utils::config::HttpAllowlistScope; /// Scope for filesystem access. #[derive(Debug, Clone)] @@ -38,7 +38,7 @@ impl Scope { #[cfg(test)] mod tests { - use tauri::utils::config::HttpAllowlistScope; + use crate::config::HttpAllowlistScope; #[test] fn is_allowed() { diff --git a/plugins/persisted-scope/Cargo.toml b/plugins/persisted-scope/Cargo.toml index 4e1f24d0..b8e25427 100644 --- a/plugins/persisted-scope/Cargo.toml +++ b/plugins/persisted-scope/Cargo.toml @@ -17,6 +17,7 @@ log.workspace = true thiserror.workspace = true aho-corasick = "1.0" bincode = "1" +tauri-plugin-fs = { path = "../fs", version = "0.0.0" } [features] protocol-asset = [ "tauri/protocol-asset" ] \ No newline at end of file diff --git a/plugins/persisted-scope/src/lib.rs b/plugins/persisted-scope/src/lib.rs index 1473056c..7c594e71 100644 --- a/plugins/persisted-scope/src/lib.rs +++ b/plugins/persisted-scope/src/lib.rs @@ -6,8 +6,9 @@ use aho_corasick::AhoCorasick; use serde::{Deserialize, Serialize}; use tauri::{ plugin::{Builder, TauriPlugin}, - AppHandle, FsScopeEvent, Manager, Runtime, + AppHandle, Manager, Runtime, }; +use tauri_plugin_fs::{FsExt, ScopeEvent as FsScopeEvent}; use std::{ fs::{create_dir_all, File}, @@ -59,28 +60,28 @@ fn fix_pattern(ac: &AhoCorasick, s: &str) -> String { } fn save_scopes(app: &AppHandle, app_dir: &Path, scope_state_path: &Path) { - let fs_scope = app.fs_scope(); - - let scope = Scope { - allowed_paths: fs_scope - .allowed_patterns() - .into_iter() - .map(|p| p.to_string()) - .collect(), - forbidden_patterns: fs_scope - .forbidden_patterns() - .into_iter() - .map(|p| p.to_string()) - .collect(), - }; - - let _ = create_dir_all(app_dir) - .and_then(|_| File::create(scope_state_path)) - .map_err(Error::Io) - .and_then(|mut f| { - f.write_all(&bincode::serialize(&scope).map_err(Error::from)?) - .map_err(Into::into) - }); + if let Some(fs_scope) = app.try_fs_scope() { + let scope = Scope { + allowed_paths: fs_scope + .allowed_patterns() + .into_iter() + .map(|p| p.to_string()) + .collect(), + forbidden_patterns: fs_scope + .forbidden_patterns() + .into_iter() + .map(|p| p.to_string()) + .collect(), + }; + + let _ = create_dir_all(app_dir) + .and_then(|_| File::create(scope_state_path)) + .map_err(Error::Io) + .and_then(|mut f| { + f.write_all(&bincode::serialize(&scope).map_err(Error::from)?) + .map_err(Into::into) + }); + } } pub fn init() -> TauriPlugin { diff --git a/plugins/shell/src/config.rs b/plugins/shell/src/config.rs new file mode 100644 index 00000000..1a35df4e --- /dev/null +++ b/plugins/shell/src/config.rs @@ -0,0 +1,148 @@ +use std::path::PathBuf; + +use serde::{de::Error as DeError, Deserialize, Deserializer}; + +/// Allowlist for the shell APIs. +/// +/// See more: https://tauri.app/v1/api/config#shellallowlistconfig +#[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Config { + /// Access scope for the binary execution APIs. + /// Sidecars are automatically enabled. + #[serde(default)] + pub scope: ShellAllowlistScope, + /// Open URL with the user's default application. + #[serde(default)] + pub open: ShellAllowlistOpen, +} + +/// A command allowed to be executed by the webview API. +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ShellAllowedCommand { + /// The name for this allowed shell command configuration. + /// + /// This name will be used inside of the webview API to call this command along with + /// any specified arguments. + pub name: String, + + /// The command name. + /// It can start with a variable that resolves to a system base directory. + /// The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, + /// `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, + /// `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, + /// `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`. + // use default just so the schema doesn't flag it as required + pub command: PathBuf, + + /// The allowed arguments for the command execution. + pub args: ShellAllowedArgs, + + /// If this command is a sidecar command. + pub sidecar: bool, +} + +impl<'de> Deserialize<'de> for ShellAllowedCommand { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct InnerShellAllowedCommand { + name: String, + #[serde(rename = "cmd")] + command: Option, + #[serde(default)] + args: ShellAllowedArgs, + #[serde(default)] + sidecar: bool, + } + + let config = InnerShellAllowedCommand::deserialize(deserializer)?; + + if !config.sidecar && config.command.is_none() { + return Err(DeError::custom( + "The shell scope `command` value is required.", + )); + } + + Ok(ShellAllowedCommand { + name: config.name, + command: config.command.unwrap_or_default(), + args: config.args, + sidecar: config.sidecar, + }) + } +} + +/// A set of command arguments allowed to be executed by the webview API. +/// +/// A value of `true` will allow any arguments to be passed to the command. `false` will disable all +/// arguments. A list of [`ShellAllowedArg`] will set those arguments as the only valid arguments to +/// be passed to the attached command configuration. +#[derive(Debug, PartialEq, Eq, Clone, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +#[non_exhaustive] +pub enum ShellAllowedArgs { + /// Use a simple boolean to allow all or disable all arguments to this command configuration. + Flag(bool), + + /// A specific set of [`ShellAllowedArg`] that are valid to call for the command configuration. + List(Vec), +} + +impl Default for ShellAllowedArgs { + fn default() -> Self { + Self::Flag(false) + } +} + +/// A command argument allowed to be executed by the webview API. +#[derive(Debug, PartialEq, Eq, Clone, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +#[non_exhaustive] +pub enum ShellAllowedArg { + /// A non-configurable argument that is passed to the command in the order it was specified. + Fixed(String), + + /// A variable that is set while calling the command from the webview API. + /// + Var { + /// [regex] validator to require passed values to conform to an expected input. + /// + /// This will require the argument value passed to this variable to match the `validator` regex + /// before it will be executed. + /// + /// [regex]: https://docs.rs/regex/latest/regex/#syntax + validator: String, + }, +} + +/// Shell scope definition. +/// It is a list of command names and associated CLI arguments that restrict the API access from the webview. +#[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize)] + +pub struct ShellAllowlistScope(pub Vec); + +/// Defines the `shell > open` api scope. +#[derive(Debug, PartialEq, Eq, Clone, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +#[non_exhaustive] +pub enum ShellAllowlistOpen { + /// If the shell open API should be enabled. + /// + /// If enabled, the default validation regex (`^((mailto:\w+)|(tel:\w+)|(https?://\w+)).+`) is used. + Flag(bool), + + /// Enable the shell open API, with a custom regex that the opened path must match against. + /// + /// If using a custom regex to support a non-http(s) schema, care should be used to prevent values + /// that allow flag-like strings to pass validation. e.g. `--enable-debugging`, `-i`, `/R`. + Validate(String), +} + +impl Default for ShellAllowlistOpen { + fn default() -> Self { + Self::Flag(false) + } +} diff --git a/plugins/shell/src/lib.rs b/plugins/shell/src/lib.rs index 70c98dd9..f9ffd1e7 100644 --- a/plugins/shell/src/lib.rs +++ b/plugins/shell/src/lib.rs @@ -8,16 +8,17 @@ use regex::Regex; use scope::{Scope, ScopeAllowedCommand, ScopeConfig}; use tauri::{ plugin::{Builder, TauriPlugin}, - utils::config::{ShellAllowedArg, ShellAllowedArgs, ShellAllowlistOpen, ShellAllowlistScope}, AppHandle, Manager, RunEvent, Runtime, }; mod commands; +mod config; mod error; mod open; pub mod process; mod scope; +use config::{Config, ShellAllowedArg, ShellAllowedArgs, ShellAllowlistOpen, ShellAllowlistScope}; pub use error::Error; type Result = std::result::Result; type ChildStore = Arc>>; @@ -61,25 +62,21 @@ impl> ShellExt for T { } } -pub fn init() -> TauriPlugin { - Builder::new("shell") +pub fn init() -> TauriPlugin> { + Builder::>::new("shell") .invoke_handler(tauri::generate_handler![ commands::execute, commands::stdin_write, commands::kill, commands::open ]) - .setup(|app, _api| { + .setup(|app, api| { + let default_config = Config::default(); + let config = api.config().as_ref().unwrap_or(&default_config); app.manage(Shell { app: app.clone(), children: Default::default(), - scope: Scope::new( - app, - shell_scope( - app.config().tauri.allowlist.shell.scope.clone(), - &app.config().tauri.allowlist.shell.open, - ), - ), + scope: Scope::new(app, shell_scope(config.scope.clone(), &config.open)), }); Ok(()) }) @@ -111,7 +108,6 @@ fn shell_scope(scope: ShellAllowlistScope, open: &ShellAllowlistOpen) -> ScopeCo Regex::new(validator).unwrap_or_else(|e| panic!("invalid regex {validator}: {e}")); Some(validator) } - _ => panic!("unknown shell open format, unable to prepare"), }; ScopeConfig { @@ -136,11 +132,9 @@ fn get_allowed_clis(scope: ShellAllowlistScope) -> HashMap panic!("unknown shell scope arg, unable to prepare"), }); Some(list.collect()) } - _ => panic!("unknown shell scope command, unable to prepare"), }; ( diff --git a/plugins/single-instance/examples/vanilla/src-tauri/tauri.conf.json b/plugins/single-instance/examples/vanilla/src-tauri/tauri.conf.json index de4bdb81..a0593744 100644 --- a/plugins/single-instance/examples/vanilla/src-tauri/tauri.conf.json +++ b/plugins/single-instance/examples/vanilla/src-tauri/tauri.conf.json @@ -40,12 +40,6 @@ "timestampUrl": "" } }, - "updater": { - "active": false - }, - "allowlist": { - "all": true - }, "windows": [ { "title": "app", diff --git a/plugins/updater/Cargo.toml b/plugins/updater/Cargo.toml index ab74fd5d..33e726ac 100644 --- a/plugins/updater/Cargo.toml +++ b/plugins/updater/Cargo.toml @@ -6,7 +6,7 @@ authors.workspace = true license.workspace = true [dependencies] -tauri = { workspace = true, features = ["updater", "fs-extract-api"] } +tauri.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true @@ -23,6 +23,10 @@ percent-encoding = "2" semver = { version = "1", features = [ "serde" ] } futures-util = "0.3" tempfile = "3" +flate2 = "1" +tar = "0.4" +ignore = "0.4" +zip = { version = "0.6", default-features = false } [dev-dependencies] mockito = "0.31" diff --git a/plugins/updater/src/config.rs b/plugins/updater/src/config.rs new file mode 100644 index 00000000..458cc58d --- /dev/null +++ b/plugins/updater/src/config.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Deserializer}; +use url::Url; + +/// Updater configuration. +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + #[serde(default)] + pub endpoints: Vec, + /// Additional arguments given to the NSIS or WiX installer. + #[serde(default, alias = "installer-args")] + pub installer_args: Vec, +} + +/// A URL to an updater server. +/// +/// The URL must use the `https` scheme on production. +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct UpdaterEndpoint(pub Url); + +impl std::fmt::Display for UpdaterEndpoint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl<'de> Deserialize<'de> for UpdaterEndpoint { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let url = Url::deserialize(deserializer)?; + #[cfg(all(not(debug_assertions), not(feature = "schema")))] + { + if url.scheme() != "https" { + return Err(serde::de::Error::custom( + "The configured updater endpoint must use the `https` protocol.", + )); + } + } + Ok(Self(url)) + } +} diff --git a/plugins/updater/src/error.rs b/plugins/updater/src/error.rs index 509c9504..8f0b9c74 100644 --- a/plugins/updater/src/error.rs +++ b/plugins/updater/src/error.rs @@ -78,6 +78,12 @@ pub enum Error { #[cfg(target_os = "linux")] #[error("temp directory is not on the same mount point as the AppImage")] TempDirNotOnSameMountPoint, + /// The path StripPrefixError error. + #[error("Path Error: {0}")] + PathPrefix(#[from] std::path::StripPrefixError), + /// Ignore error. + #[error("failed to walkdir: {0}")] + Ignore(#[from] ignore::Error), } impl Serialize for Error { diff --git a/plugins/updater/src/lib.rs b/plugins/updater/src/lib.rs index ec55cb5b..ae30a372 100644 --- a/plugins/updater/src/lib.rs +++ b/plugins/updater/src/lib.rs @@ -6,15 +6,18 @@ use tauri::{ use tokio::sync::Mutex; mod commands; +mod config; mod error; mod updater; +pub use config::Config; pub use error::Error; pub use updater::*; pub type Result = std::result::Result; struct UpdaterState { target: Option, + config: Config, } struct PendingUpdate(Mutex>>); @@ -60,11 +63,14 @@ impl Builder { self } - pub fn build(self) -> TauriPlugin { + pub fn build(self) -> TauriPlugin { let target = self.target; - PluginBuilder::::new("updater") - .setup(move |app, _api| { - app.manage(UpdaterState { target }); + PluginBuilder::::new("updater") + .setup(move |app, api| { + app.manage(UpdaterState { + target, + config: api.config().clone(), + }); app.manage(PendingUpdate::(Default::default())); Ok(()) }) diff --git a/plugins/updater/src/updater/core.rs b/plugins/updater/src/updater/core.rs index b89adc84..920b507a 100644 --- a/plugins/updater/src/updater/core.rs +++ b/plugins/updater/src/updater/core.rs @@ -2,6 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +#[cfg(desktop)] +use super::{ + extract::{ArchiveFormat, Extract}, + move_file::Move, +}; use crate::{Error, Result}; use base64::Engine; use futures_util::StreamExt; @@ -13,8 +18,6 @@ use minisign_verify::{PublicKey, Signature}; use reqwest::ClientBuilder; use semver::Version; use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize}; -#[cfg(desktop)] -use tauri::api::file::{ArchiveFormat, Extract, Move}; use tauri::utils::{platform::current_exe, Env}; use tauri::{AppHandle, Manager, Runtime}; use time::OffsetDateTime; @@ -36,7 +39,7 @@ use std::{ use std::ffi::OsStr; #[cfg(all(desktop, not(target_os = "windows")))] -use tauri::api::file::Compression; +use super::extract::Compression; #[cfg(target_os = "windows")] use std::{ @@ -607,6 +610,7 @@ impl Update { &self.extract_path, self.with_elevated_task, &self.app.config(), + &self.app.state::().config, )?; #[cfg(not(target_os = "windows"))] copy_files_and_run(archive_buffer, &self.extract_path)?; @@ -706,6 +710,7 @@ fn copy_files_and_run( _extract_path: &Path, with_elevated_task: bool, config: &tauri::Config, + updater_config: &crate::Config, ) -> Result<()> { // FIXME: We need to create a memory buffer with the MSI and then run it. // (instead of extracting the MSI to a temp path) @@ -733,11 +738,11 @@ fn copy_files_and_run( // Run the EXE let mut installer = Command::new(found_path); if tauri::utils::config::WindowsUpdateInstallMode::Quiet - == config.tauri.updater.windows.install_mode + == config.tauri.bundle.updater.install_mode { installer.arg("/S"); } - installer.args(&config.tauri.updater.windows.installer_args); + installer.args(&updater_config.installer_args); installer.spawn().expect("installer failed to start"); @@ -793,17 +798,17 @@ fn copy_files_and_run( msi_path_arg.push(&found_path); msi_path_arg.push("\"\"\""); - let mut msiexec_args = config + let mut msiexec_args = updater_config .tauri + .bundle .updater - .windows .install_mode .clone() .msiexec_args() .iter() .map(|p| p.to_string()) .collect::>(); - msiexec_args.extend(config.tauri.updater.windows.installer_args.clone()); + msiexec_args.extend(updater_config.installer_args.clone()); // run the installer and relaunch the application let system_root = std::env::var("SYSTEMROOT"); @@ -890,7 +895,7 @@ fn copy_files_and_run(archive_buffer: R, extract_path: &Path) -> } } Move::from_source(tmp_dir.path()).to_dest(extract_path)?; - return Err(tauri::api::Error::Extract(err.to_string())); + return Err(err); } extracted_files.push(extraction_path); diff --git a/plugins/updater/src/updater/extract.rs b/plugins/updater/src/updater/extract.rs new file mode 100644 index 00000000..143c4d3f --- /dev/null +++ b/plugins/updater/src/updater/extract.rs @@ -0,0 +1,336 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ + borrow::Cow, + fs, + io::{self, Cursor, Read, Seek}, + path::{self, Path, PathBuf}, +}; + +use crate::{Error, Result}; + +/// The archive reader. +#[derive(Debug)] +pub enum ArchiveReader { + /// A plain reader. + Plain(R), + /// A GZ- compressed reader (decoder). + GzCompressed(Box>), +} + +impl Read for ArchiveReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + Self::Plain(r) => r.read(buf), + Self::GzCompressed(decoder) => decoder.read(buf), + } + } +} + +impl ArchiveReader { + #[allow(dead_code)] + fn get_mut(&mut self) -> &mut R { + match self { + Self::Plain(r) => r, + Self::GzCompressed(decoder) => decoder.get_mut(), + } + } +} + +/// The supported archive formats. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ArchiveFormat { + /// Tar archive. + Tar(Option), + /// Zip archive. + #[allow(dead_code)] + Zip, +} + +/// The supported compression types. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Compression { + /// Gz compression (e.g. `.tar.gz` archives) + Gz, +} + +/// The zip entry. +pub struct ZipEntry { + path: PathBuf, + is_dir: bool, + file_contents: Vec, +} + +/// A read-only view into an entry of an archive. +#[non_exhaustive] +pub enum Entry<'a, R: Read> { + /// An entry of a tar archive. + #[non_exhaustive] + Tar(Box>), + /// An entry of a zip archive. + #[non_exhaustive] + #[allow(dead_code)] + Zip(ZipEntry), +} + +impl<'a, R: Read> Entry<'a, R> { + /// The entry path. + pub fn path(&self) -> Result> { + match self { + Self::Tar(e) => e.path().map_err(Into::into), + Self::Zip(e) => Ok(Cow::Borrowed(&e.path)), + } + } + + /// Extract this entry into `into_path`. + /// If it's a directory, the target will be created, if it's a file, it'll be extracted at this location. + /// Note: You need to include the complete path, with file name and extension. + pub fn extract(self, into_path: &path::Path) -> Result<()> { + match self { + Self::Tar(mut entry) => { + // determine if it's a file or a directory + if entry.header().entry_type() == tar::EntryType::Directory { + // this is a directory, lets create it + match fs::create_dir_all(into_path) { + Ok(_) => (), + Err(e) => { + if e.kind() != io::ErrorKind::AlreadyExists { + return Err(e.into()); + } + } + } + } else { + let mut out_file = fs::File::create(into_path)?; + io::copy(&mut entry, &mut out_file)?; + + // make sure we set permissions + if let Ok(mode) = entry.header().mode() { + set_perms(into_path, Some(&mut out_file), mode, true)?; + } + } + } + Self::Zip(entry) => { + if entry.is_dir { + // this is a directory, lets create it + match fs::create_dir_all(into_path) { + Ok(_) => (), + Err(e) => { + if e.kind() != io::ErrorKind::AlreadyExists { + return Err(e.into()); + } + } + } + } else { + let mut out_file = fs::File::create(into_path)?; + io::copy(&mut Cursor::new(entry.file_contents), &mut out_file)?; + } + } + } + + Ok(()) + } +} + +/// The extract manager to retrieve files from archives. +pub struct Extract<'a, R: Read + Seek> { + reader: ArchiveReader, + archive_format: ArchiveFormat, + tar_archive: Option>>, +} + +impl<'a, R: std::fmt::Debug + Read + Seek> std::fmt::Debug for Extract<'a, R> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Extract") + .field("reader", &self.reader) + .field("archive_format", &self.archive_format) + .finish() + } +} + +impl<'a, R: Read + Seek> Extract<'a, R> { + /// Create archive from reader. + pub fn from_cursor(mut reader: R, archive_format: ArchiveFormat) -> Extract<'a, R> { + if reader.rewind().is_err() { + #[cfg(debug_assertions)] + eprintln!("Could not seek to start of the file"); + } + let compression = if let ArchiveFormat::Tar(compression) = archive_format { + compression + } else { + None + }; + Extract { + reader: match compression { + Some(Compression::Gz) => { + ArchiveReader::GzCompressed(Box::new(flate2::read::GzDecoder::new(reader))) + } + _ => ArchiveReader::Plain(reader), + }, + archive_format, + tar_archive: None, + } + } + + /// Reads the archive content. + pub fn with_files< + E: Into, + F: FnMut(Entry<'_, &mut ArchiveReader>) -> std::result::Result, + >( + &'a mut self, + mut f: F, + ) -> Result<()> { + match self.archive_format { + ArchiveFormat::Tar(_) => { + let archive = tar::Archive::new(&mut self.reader); + self.tar_archive.replace(archive); + for entry in self.tar_archive.as_mut().unwrap().entries()? { + let entry = entry?; + if entry.path().is_ok() { + let stop = f(Entry::Tar(Box::new(entry))).map_err(Into::into)?; + if stop { + break; + } + } + } + } + + ArchiveFormat::Zip => { + #[cfg(feature = "fs-extract-api")] + { + let mut archive = zip::ZipArchive::new(self.reader.get_mut())?; + let file_names = archive + .file_names() + .map(|f| f.to_string()) + .collect::>(); + for path in file_names { + let mut zip_file = archive.by_name(&path)?; + let is_dir = zip_file.is_dir(); + let mut file_contents = Vec::new(); + zip_file.read_to_end(&mut file_contents)?; + let stop = f(Entry::Zip(ZipEntry { + path: path.into(), + is_dir, + file_contents, + })) + .map_err(Into::into)?; + if stop { + break; + } + } + } + } + } + + Ok(()) + } + + /// Extract an entire source archive into a specified path. If the source is a single compressed + /// file and not an archive, it will be extracted into a file with the same name inside of + /// `into_dir`. + #[allow(dead_code)] + pub fn extract_into(&mut self, into_dir: &path::Path) -> Result<()> { + match self.archive_format { + ArchiveFormat::Tar(_) => { + let mut archive = tar::Archive::new(&mut self.reader); + archive.unpack(into_dir)?; + } + + ArchiveFormat::Zip => { + #[cfg(feature = "fs-extract-api")] + { + let mut archive = zip::ZipArchive::new(self.reader.get_mut())?; + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + // Decode the file name from raw bytes instead of using file.name() directly. + // file.name() uses String::from_utf8_lossy() which may return messy characters + // such as: 爱交易.app/, that does not work as expected. + // Here we require the file name must be a valid UTF-8. + let file_name = String::from_utf8(file.name_raw().to_vec())?; + let out_path = into_dir.join(file_name); + if file.is_dir() { + fs::create_dir_all(&out_path)?; + } else { + if let Some(out_path_parent) = out_path.parent() { + fs::create_dir_all(out_path_parent)?; + } + let mut out_file = fs::File::create(&out_path)?; + io::copy(&mut file, &mut out_file)?; + } + // Get and Set permissions + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Some(mode) = file.unix_mode() { + fs::set_permissions(&out_path, fs::Permissions::from_mode(mode))?; + } + } + } + } + } + } + Ok(()) + } +} + +fn set_perms( + dst: &Path, + f: Option<&mut std::fs::File>, + mode: u32, + preserve: bool, +) -> io::Result<()> { + _set_perms(dst, f, mode, preserve).map_err(|_| { + io::Error::new( + io::ErrorKind::Other, + format!( + "failed to set permissions to {mode:o} \ + for `{}`", + dst.display() + ), + ) + }) +} + +#[cfg(unix)] +fn _set_perms( + dst: &Path, + f: Option<&mut std::fs::File>, + mode: u32, + preserve: bool, +) -> io::Result<()> { + use std::os::unix::prelude::*; + + let mode = if preserve { mode } else { mode & 0o777 }; + let perm = fs::Permissions::from_mode(mode as _); + match f { + Some(f) => f.set_permissions(perm), + None => fs::set_permissions(dst, perm), + } +} + +#[cfg(windows)] +fn _set_perms( + dst: &Path, + f: Option<&mut std::fs::File>, + mode: u32, + _preserve: bool, +) -> io::Result<()> { + if mode & 0o200 == 0o200 { + return Ok(()); + } + match f { + Some(f) => { + let mut perm = f.metadata()?.permissions(); + perm.set_readonly(true); + f.set_permissions(perm) + } + None => { + let mut perm = fs::metadata(dst)?.permissions(); + perm.set_readonly(true); + fs::set_permissions(dst, perm) + } + } +} diff --git a/plugins/updater/src/updater/mod.rs b/plugins/updater/src/updater/mod.rs index c0252205..913148c9 100644 --- a/plugins/updater/src/updater/mod.rs +++ b/plugins/updater/src/updater/mod.rs @@ -12,6 +12,8 @@ //! ``` mod core; +mod extract; +mod move_file; use std::time::Duration; @@ -23,7 +25,7 @@ pub use self::core::{DownloadEvent, RemoteRelease}; use tauri::{AppHandle, Manager, Runtime}; -use crate::Result; +use crate::{Result, UpdaterState}; /// Gets the target string used on the updater. pub fn target() -> Option { @@ -276,7 +278,7 @@ impl UpdateResponse { // Linux we replace the AppImage by launching a new install, it start a new AppImage instance, so we're closing the previous. (the process stop here) self.update .download_and_install( - self.update.app.config().tauri.updater.pubkey.clone(), + self.update.app.config().tauri.bundle.updater.pubkey.clone(), on_event, ) .await @@ -285,14 +287,13 @@ impl UpdateResponse { /// Initializes the [`UpdateBuilder`] using the app configuration. pub fn builder(handle: AppHandle) -> UpdateBuilder { - let updater_config = &handle.config().tauri.updater; let package_info = handle.package_info().clone(); // prepare our endpoints - let endpoints = updater_config + let endpoints = handle + .state::() + .config .endpoints - .as_ref() - .expect("Something wrong with endpoints") .iter() .map(|e| e.to_string()) .collect::>(); diff --git a/plugins/updater/src/updater/move_file.rs b/plugins/updater/src/updater/move_file.rs new file mode 100644 index 00000000..a6ec158e --- /dev/null +++ b/plugins/updater/src/updater/move_file.rs @@ -0,0 +1,118 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use ignore::WalkBuilder; +use std::{fs, path}; + +use crate::Result; + +/// Moves a file from the given path to the specified destination. +/// +/// `source` and `dest` must be on the same filesystem. +/// If `replace_using_temp` is specified, the destination file will be +/// replaced using the given temporary path. +/// +/// * Errors: +/// * Io - copying / renaming +#[derive(Debug)] +pub struct Move<'a> { + source: &'a path::Path, + temp: Option<&'a path::Path>, +} +impl<'a> Move<'a> { + /// Specify source file + pub fn from_source(source: &'a path::Path) -> Move<'a> { + Self { source, temp: None } + } + + /// If specified and the destination file already exists, the "destination" + /// file will be moved to the given temporary location before the "source" + /// file is moved to the "destination" file. + /// + /// In the event of an `io` error while renaming "source" to "destination", + /// the temporary file will be moved back to "destination". + /// + /// The `temp` dir must be explicitly provided since `rename` operations require + /// files to live on the same filesystem. + #[allow(dead_code)] + pub fn replace_using_temp(&mut self, temp: &'a path::Path) -> &mut Self { + self.temp = Some(temp); + self + } + + /// Move source file to specified destination (replace whole directory) + pub fn to_dest(&self, dest: &path::Path) -> Result<()> { + match self.temp { + None => { + fs::rename(self.source, dest)?; + } + Some(temp) => { + if dest.exists() { + fs::rename(dest, temp)?; + if let Err(e) = fs::rename(self.source, dest) { + fs::rename(temp, dest)?; + return Err(e.into()); + } + } else { + fs::rename(self.source, dest)?; + } + } + }; + Ok(()) + } + + /// Walk in the source and copy all files and create directories if needed by + /// replacing existing elements. (equivalent to a cp -R) + #[allow(dead_code)] + pub fn walk_to_dest(&self, dest: &path::Path) -> Result<()> { + match self.temp { + None => { + // got no temp -- no need to backup + walkdir_and_copy(self.source, dest)?; + } + Some(temp) => { + if dest.exists() { + // we got temp and our dest exist, lets make a backup + // of current files + walkdir_and_copy(dest, temp)?; + + if let Err(e) = walkdir_and_copy(self.source, dest) { + // if we got something wrong we reset the dest with our backup + fs::rename(temp, dest)?; + return Err(e); + } + } else { + // got temp but dest didnt exist + walkdir_and_copy(self.source, dest)?; + } + } + }; + Ok(()) + } +} +// Walk into the source and create directories, and copy files +// Overwriting existing items but keeping untouched the files in the dest +// not provided in the source. +fn walkdir_and_copy(source: &path::Path, dest: &path::Path) -> Result<()> { + let walkdir = WalkBuilder::new(source).hidden(false).build(); + + for entry in walkdir { + // Check if it's a file + + let element = entry?; + let metadata = element.metadata()?; + let destination = dest.join(element.path().strip_prefix(source)?); + + // we make sure it's a directory and destination doesnt exist + if metadata.is_dir() && !&destination.exists() { + fs::create_dir_all(&destination)?; + } + + // we make sure it's a file + if metadata.is_file() { + fs::copy(element.path(), destination)?; + } + } + Ok(()) +} diff --git a/plugins/updater/tests/app-updater/src/main.rs b/plugins/updater/tests/app-updater/src/main.rs index 12678e78..c8e21de7 100644 --- a/plugins/updater/tests/app-updater/src/main.rs +++ b/plugins/updater/tests/app-updater/src/main.rs @@ -7,6 +7,7 @@ use tauri_plugin_updater::UpdaterExt; fn main() { + #[allow(unused_mut)] let mut context = tauri::generate_context!(); if std::env::var("TARGET").unwrap_or_default() == "nsis" { // /D sets the default installation directory ($INSTDIR), @@ -14,14 +15,21 @@ fn main() { // It must be the last parameter used in the command line and must not contain any quotes, even if the path contains spaces. // Only absolute paths are supported. // NOTE: we only need this because this is an integration test and we don't want to install the app in the programs folder - context.config_mut().tauri.updater.windows.installer_args = vec![format!( + // TODO mutate plugin config + /*context + .config_mut() + .tauri + .bundle + .updater + .windows + .installer_args = vec![format!( "/D={}", tauri::utils::platform::current_exe() .unwrap() .parent() .unwrap() .display() - )]; + )];*/ } tauri::Builder::default() .plugin(tauri_plugin_updater::Builder::new().build()) diff --git a/plugins/updater/tests/app-updater/tauri.conf.json b/plugins/updater/tests/app-updater/tauri.conf.json index eac17193..6033a992 100644 --- a/plugins/updater/tests/app-updater/tauri.conf.json +++ b/plugins/updater/tests/app-updater/tauri.conf.json @@ -21,17 +21,14 @@ "wix": { "skipWebviewInstall": true } - } - }, - "allowlist": { - "all": false - }, - "updater": { - "active": true, - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5QzMxNjYwNTM5OEUwNTgKUldSWTRKaFRZQmJER1h4d1ZMYVA3dnluSjdpN2RmMldJR09hUFFlZDY0SlFqckkvRUJhZDJVZXAK", - "endpoints": ["http://localhost:3007"], - "windows": { - "installMode": "quiet" + }, + "updater": { + "active": true, + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5QzMxNjYwNTM5OEUwNTgKUldSWTRKaFRZQmJER1h4d1ZMYVA3dnluSjdpN2RmMldJR09hUFFlZDY0SlFqckkvRUJhZDJVZXAK", + "endpoints": ["http://localhost:3007"], + "windows": { + "installMode": "quiet" + } } } } diff --git a/plugins/websocket/examples/svelte-app/src-tauri/tauri.conf.json b/plugins/websocket/examples/svelte-app/src-tauri/tauri.conf.json index 1f14e4f3..3321f62d 100644 --- a/plugins/websocket/examples/svelte-app/src-tauri/tauri.conf.json +++ b/plugins/websocket/examples/svelte-app/src-tauri/tauri.conf.json @@ -33,9 +33,6 @@ "exceptionDomain": "" } }, - "allowlist": { - "all": false - }, "windows": [ { "title": "Tauri App",