// Copyright 2019-2023 Tauri Programme within The Commons Conservancy // Copyright 2018-2023 the Deno authors. // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT use serde::{Deserialize, Serialize, Serializer}; use serde_repr::{Deserialize_repr, Serialize_repr}; use tauri::{ ipc::{CommandScope, GlobalScope}, path::BaseDirectory, utils::config::FsScope, Manager, Resource, ResourceId, Runtime, Webview, }; use std::{ borrow::Cow, fs::File, io::{BufRead, BufReader, Read, Write}, ops::{Deref, DerefMut}, path::{Path, PathBuf}, str::FromStr, sync::Mutex, time::{SystemTime, UNIX_EPOCH}, }; use crate::{scope::Entry, Error, SafeFilePath}; #[derive(Debug, thiserror::Error)] pub enum CommandError { #[error(transparent)] Anyhow(#[from] anyhow::Error), #[error(transparent)] Plugin(#[from] Error), #[error(transparent)] Tauri(#[from] tauri::Error), #[error(transparent)] Json(#[from] serde_json::Error), #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] UrlParseError(#[from] url::ParseError), #[cfg(feature = "watch")] #[error(transparent)] Watcher(#[from] notify::Error), } impl From for CommandError { fn from(value: String) -> Self { Self::Anyhow(anyhow::anyhow!(value)) } } impl From<&str> for CommandError { fn from(value: &str) -> Self { Self::Anyhow(anyhow::anyhow!(value.to_string())) } } impl Serialize for CommandError { fn serialize(&self, serializer: S) -> std::result::Result where S: Serializer, { if let Self::Anyhow(err) = self { serializer.serialize_str(format!("{err:#}").as_ref()) } else { serializer.serialize_str(self.to_string().as_ref()) } } } pub type CommandResult = std::result::Result; /// Represents either a plain PathBuf or a PathHandle that manages security-scoped resources. pub enum PathKind { /// A plain path that doesn't manage security-scoped resources. #[allow(dead_code)] // only used on mobile Path(PathBuf), /// A path handle that manages security-scoped resources and will clean them up on drop. Handle(PathHandle), } impl PathKind { /// Get a reference to the underlying path. pub fn as_path(&self) -> &Path { match self { PathKind::Path(p) => p.as_ref(), PathKind::Handle(h) => h.as_ref(), } } /// Get a reference to the underlying PathBuf. pub fn as_path_buf(&self) -> &PathBuf { match self { PathKind::Path(p) => p, PathKind::Handle(h) => h, } } } impl AsRef for PathKind { fn as_ref(&self) -> &Path { self.as_path() } } impl AsRef for PathKind { fn as_ref(&self) -> &PathBuf { self.as_path_buf() } } /// A file handle that automatically stops accessing security-scoped resources on iOS when dropped. pub struct FileHandle { file: File, path: PathKind, #[allow(dead_code)] // Used in Drop implementation path_: SafeFilePath, #[allow(dead_code)] // Used in Drop implementation app_handle: tauri::AppHandle, } impl FileHandle { fn new( file: File, path: PathKind, path_: SafeFilePath, app_handle: tauri::AppHandle, ) -> Self { Self { file, path, path_, app_handle, } } /// Get the resolved path. pub fn path(&self) -> &Path { self.path.as_path() } } impl Deref for FileHandle { type Target = File; fn deref(&self) -> &Self::Target { &self.file } } impl DerefMut for FileHandle { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.file } } impl Drop for FileHandle { fn drop(&mut self) { #[cfg(target_os = "ios")] { // Only clean up if we have a plain PathBuf, not a PathHandle // PathHandle will handle its own cleanup when it's dropped if let PathKind::Path(_) = &self.path { use crate::{FilePath, FsExt}; // Convert SafeFilePath to FilePath let file_path: FilePath = match &self.path_ { SafeFilePath::Url(url) => FilePath::Url(url.clone()), SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), }; // Only clean up if we're tracking this resource // If start_accessing_security_scoped_resource was used, it won't be in our tracking // and we shouldn't interfere if let FilePath::Url(url) = file_path { if url.scheme() == "file" { let security_scoped_resources = self.app_handle.state::(); // Only clean up if it's not tracked manually if !security_scoped_resources.is_tracked_manually(url.as_str()) { log::debug!("Stopping accessing security-scoped resource for URL: {url} on drop"); let _ = self .app_handle .fs() .stop_accessing_security_scoped_resource(FilePath::Url( url.clone(), )); security_scoped_resources.remove(url.as_str()); } else { log::debug!("Not cleaning up security-scoped resource for URL: {url} on drop (manually tracked via start_accessing_security_scoped_resource)"); } } } } } } } /// A path handle that automatically stops accessing security-scoped resources on iOS when dropped. pub struct PathHandle { path: PathBuf, #[allow(dead_code)] // Used in Drop implementation path_: SafeFilePath, #[allow(dead_code)] // Used in Drop implementation app_handle: tauri::AppHandle, } impl PathHandle { fn new(path: PathBuf, path_: SafeFilePath, app_handle: tauri::AppHandle) -> Self { Self { path, path_, app_handle, } } } impl Deref for PathHandle { type Target = PathBuf; fn deref(&self) -> &Self::Target { &self.path } } impl AsRef for PathHandle { fn as_ref(&self) -> &Path { self.path.as_ref() } } impl AsRef for PathHandle { fn as_ref(&self) -> &PathBuf { &self.path } } impl Drop for PathHandle { fn drop(&mut self) { #[cfg(target_os = "ios")] { use crate::{FilePath, FsExt}; // Convert SafeFilePath to FilePath let file_path: FilePath = match &self.path_ { SafeFilePath::Url(url) => FilePath::Url(url.clone()), SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), }; // Only clean up if we're tracking this resource (i.e., resolve_path started it) // If start_accessing_security_scoped_resource was used, it won't be in our tracking // and we shouldn't interfere if let FilePath::Url(url) = file_path { if url.scheme() == "file" { let security_scoped_resources = self.app_handle.state::(); // Only clean up if it's not tracked manually if !security_scoped_resources.is_tracked_manually(url.as_str()) { log::debug!( "Stopping accessing security-scoped resource for URL: {url} on drop" ); let _ = self .app_handle .fs() .stop_accessing_security_scoped_resource(FilePath::Url(url.clone())); security_scoped_resources.remove(url.as_str()); } else { log::debug!("Not cleaning up security-scoped resource for URL: {url} on drop (manually tracked via start_accessing_security_scoped_resource)"); } } } } } } #[derive(Debug, Default, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BaseOptions { base_dir: Option, } #[tauri::command] pub fn create( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, path: SafeFilePath, options: Option, ) -> CommandResult { let path_ = path.clone(); let resolved_path_handle = resolve_path( "create", &webview, &global_scope, &command_scope, path, options.and_then(|o| o.base_dir), )?; let file = File::create(&*resolved_path_handle).map_err(|e| { format!( "failed to create file at path: {} with error: {e}", resolved_path_handle.display() ) })?; let app_handle = webview.app_handle().clone(); let file_handle = FileHandle::new( file, PathKind::Handle(resolved_path_handle), path_, app_handle, ); let rid = webview .resources_table() .add(StdFileResource::new(file_handle)); Ok(rid) } #[derive(Debug, Default, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OpenOptions { #[serde(flatten)] base: BaseOptions, #[serde(flatten)] options: crate::OpenOptions, } #[tauri::command] pub fn open( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, path: SafeFilePath, options: Option, ) -> CommandResult { let file_handle = resolve_file( "open", &webview, &global_scope, &command_scope, path, if let Some(opts) = options { OpenOptions { base: opts.base, options: opts.options, } } else { OpenOptions { base: BaseOptions { base_dir: None }, options: crate::OpenOptions { read: true, write: false, truncate: false, create: false, create_new: false, append: false, mode: None, custom_flags: None, }, } }, )?; let rid = webview .resources_table() .add(StdFileResource::new(file_handle)); Ok(rid) } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CopyFileOptions { from_path_base_dir: Option, to_path_base_dir: Option, } #[tauri::command] pub async fn copy_file( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, from_path: SafeFilePath, to_path: SafeFilePath, options: Option, ) -> CommandResult<()> { let resolved_from_path = resolve_path( "copy-file", &webview, &global_scope, &command_scope, from_path, options.as_ref().and_then(|o| o.from_path_base_dir), )?; let resolved_to_path = resolve_path( "copy-file", &webview, &global_scope, &command_scope, to_path, options.as_ref().and_then(|o| o.to_path_base_dir), )?; std::fs::copy(&resolved_from_path, &resolved_to_path).map_err(|e| { format!( "failed to copy file from path: {}, to path: {} with error: {e}", resolved_from_path.display(), resolved_to_path.display() ) })?; Ok(()) } #[derive(Debug, Clone, Deserialize)] pub struct MkdirOptions { #[serde(flatten)] base: BaseOptions, #[allow(unused)] mode: Option, recursive: Option, } #[tauri::command] pub fn mkdir( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, path: SafeFilePath, options: Option, ) -> CommandResult<()> { let resolved_path = resolve_path( "mkdir", &webview, &global_scope, &command_scope, path, options.as_ref().and_then(|o| o.base.base_dir), )?; let mut builder = std::fs::DirBuilder::new(); builder.recursive(options.as_ref().and_then(|o| o.recursive).unwrap_or(false)); #[cfg(unix)] { use std::os::unix::fs::DirBuilderExt; let mode = options.as_ref().and_then(|o| o.mode).unwrap_or(0o777) & 0o777; builder.mode(mode); } builder .create(&resolved_path) .map_err(|e| { format!( "failed to create directory at path: {} with error: {e}", resolved_path.display() ) }) .map_err(Into::into) } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct DirEntry { pub name: String, pub is_directory: bool, pub is_file: bool, pub is_symlink: bool, } #[tauri::command] pub async fn read_dir( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, path: SafeFilePath, options: Option, ) -> CommandResult> { let resolved_path = resolve_path( "read-dir", &webview, &global_scope, &command_scope, path, options.as_ref().and_then(|o| o.base_dir), )?; let entries = std::fs::read_dir(&resolved_path).map_err(|e| { format!( "failed to read directory at path: {} with error: {e}", resolved_path.display() ) })?; let entries = entries .filter_map(|entry| { let entry = entry.ok()?; let name = entry.file_name().into_string().ok()?; let metadata = entry.file_type(); macro_rules! method_or_false { ($method:ident) => { if let Ok(metadata) = &metadata { metadata.$method() } else { false } }; } Some(DirEntry { name, is_file: method_or_false!(is_file), is_directory: method_or_false!(is_dir), is_symlink: method_or_false!(is_symlink), }) }) .collect(); Ok(entries) } #[tauri::command] pub async fn read( webview: Webview, rid: ResourceId, len: usize, ) -> CommandResult { let mut data = vec![0; len]; let file: std::sync::Arc> = webview.resources_table().get(rid)?; let nread = StdFileResource::with_lock(&file, |file| file.read(&mut data)) .map_err(|e| format!("faied to read bytes from file with error: {e}"))?; // This is an optimization to include the number of read bytes (as bigendian bytes) // at the end of returned vector so we can use `tauri::ipc::Response` // and avoid serialization overhead of separate values. #[cfg(target_pointer_width = "16")] let nread = { let nread = nread.to_be_bytes(); let mut out = [0; 8]; out[6..].copy_from_slice(&nread); out }; #[cfg(target_pointer_width = "32")] let nread = { let nread = nread.to_be_bytes(); let mut out = [0; 8]; out[4..].copy_from_slice(&nread); out }; #[cfg(target_pointer_width = "64")] let nread = nread.to_be_bytes(); data.extend(nread); Ok(tauri::ipc::Response::new(data)) } async fn read_file_inner( permission: &str, webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, path: SafeFilePath, options: Option, ) -> CommandResult { let mut file_handle = resolve_file( permission, &webview, &global_scope, &command_scope, path, OpenOptions { base: BaseOptions { base_dir: options.as_ref().and_then(|o| o.base_dir), }, options: crate::OpenOptions { read: true, ..Default::default() }, }, )?; let mut contents = Vec::new(); file_handle.read_to_end(&mut contents).map_err(|e| { format!( "failed to read file as text at path: {} with error: {e}", file_handle.path().display() ) })?; Ok(tauri::ipc::Response::new(contents)) } #[tauri::command] pub async fn read_file( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, path: SafeFilePath, options: Option, ) -> CommandResult { read_file_inner( "read-file", webview, global_scope, command_scope, path, options, ) .await } #[derive(Debug, Default, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReadTextFileOptions { #[serde(flatten)] base: BaseOptions, encoding: Option, } // TODO, remove in v3, rely on `read_file` command instead #[tauri::command] pub async fn read_text_file( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, path: SafeFilePath, options: Option, ) -> CommandResult { read_file_inner( "read-text-file", webview, global_scope, command_scope, path, options, ) .await } #[tauri::command] pub fn read_text_file_lines( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, path: SafeFilePath, options: Option, ) -> CommandResult { let resolved_path = resolve_path( "read-text-file-lines", &webview, &global_scope, &command_scope, path, options.as_ref().and_then(|o| o.base.base_dir), )?; let file = File::open(&resolved_path).map_err(|e| { format!( "failed to open file at path: {} with error: {e}", resolved_path.display() ) })?; let encoding = options.as_ref().and_then(|o| o.encoding.as_deref()); let (lf_bytes, cr_bytes) = lf_cr_bytes_for_encoding_label(encoding); let lines = BufReader::new(file); let rid = webview .resources_table() .add(StdLinesResource::new(lines, lf_bytes, cr_bytes)); Ok(rid) } /// Returns the byte sequences for LF (`\n`) and CR (`\r`) in the encoding label. /// /// The provided encoding label must be a normalized, lowercase string, /// such as one obtained via `(new TextDecoder(encoding)).encoding`. /// /// fn lf_cr_bytes_for_encoding_label(label: Option<&str>) -> (Vec, Vec) { // Defaults to utf-8 // https://developer.mozilla.org/ja/docs/Web/API/TextDecoder/TextDecoder#label let label = label.unwrap_or("utf-8"); // Currently, according to the Web Standard, // the ASCII-incompatible encodings are UTF-16LE/BE and ISO-2022-JP. // However, ISO-2022-JP can still detect line breaks in the same way as ASCII. // // https://encoding.spec.whatwg.org/#security-background if label == "utf-16le" { return (vec![0x0A, 0x00], vec![0x0D, 0x00]); } if label == "utf-16be" { return (vec![0x00, 0x0A], vec![0x00, 0x0D]); } // ASCII-compatible (vec![b'\n'], vec![b'\r']) } #[tauri::command] pub async fn read_text_file_lines_next( webview: Webview, rid: ResourceId, ) -> CommandResult { let mut resource_table = webview.resources_table(); let lines = resource_table.get::(rid)?; let ret = StdLinesResource::with_lock(&lines, |lines| -> CommandResult> { // This is an optimization to include whether we finished iteration or not (1 or 0) // at the end of returned vector so we can use `tauri::ipc::Response` // and avoid serialization overhead of separate values. match lines.next() { Some(Ok(mut bytes)) => { bytes.push(false as u8); Ok(bytes) } Some(Err(_)) => Ok(vec![false as u8]), None => { resource_table.close(rid)?; Ok(vec![true as u8]) } } }); ret.map(tauri::ipc::Response::new) } #[derive(Debug, Clone, Deserialize)] pub struct RemoveOptions { #[serde(flatten)] base: BaseOptions, recursive: Option, } #[tauri::command] pub fn remove( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, path: SafeFilePath, options: Option, ) -> CommandResult<()> { let resolved_path = resolve_path( "remove", &webview, &global_scope, &command_scope, path, options.as_ref().and_then(|o| o.base.base_dir), )?; let metadata = std::fs::symlink_metadata(&resolved_path).map_err(|e| { format!( "failed to get metadata of path: {} with error: {e}", resolved_path.display() ) })?; let file_type = metadata.file_type(); // taken from deno source code: https://github.com/denoland/deno/blob/429759fe8b4207240709c240a8344d12a1e39566/runtime/ops/fs.rs#L728 let res = if file_type.is_file() { std::fs::remove_file(&resolved_path) } else if options.as_ref().and_then(|o| o.recursive).unwrap_or(false) { std::fs::remove_dir_all(&resolved_path) } else if file_type.is_symlink() { #[cfg(unix)] { std::fs::remove_file(&resolved_path) } #[cfg(not(unix))] { use std::os::windows::fs::MetadataExt; const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x00000010; if metadata.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0 { std::fs::remove_dir(&resolved_path) } else { std::fs::remove_file(&resolved_path) } } } else if file_type.is_dir() { std::fs::remove_dir(&resolved_path) } else { // pipes, sockets, etc... std::fs::remove_file(&resolved_path) }; res.map_err(|e| { format!( "failed to remove path: {} with error: {e}", resolved_path.display() ) }) .map_err(Into::into) } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RenameOptions { new_path_base_dir: Option, old_path_base_dir: Option, } #[tauri::command] pub fn rename( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, old_path: SafeFilePath, new_path: SafeFilePath, options: Option, ) -> CommandResult<()> { let resolved_old_path = resolve_path( "rename", &webview, &global_scope, &command_scope, old_path, options.as_ref().and_then(|o| o.old_path_base_dir), )?; let resolved_new_path = resolve_path( "rename", &webview, &global_scope, &command_scope, new_path, options.as_ref().and_then(|o| o.new_path_base_dir), )?; std::fs::rename(&resolved_old_path, &resolved_new_path) .map_err(|e| { format!( "failed to rename old path: {} to new path: {} with error: {e}", resolved_old_path.display(), resolved_new_path.display() ) }) .map_err(Into::into) } #[derive(Serialize_repr, Deserialize_repr, Clone, Copy, Debug)] #[repr(u16)] pub enum SeekMode { Start = 0, Current = 1, End = 2, } #[tauri::command] pub async fn seek( webview: Webview, rid: ResourceId, offset: i64, whence: SeekMode, ) -> CommandResult { use std::io::{Seek, SeekFrom}; let file: std::sync::Arc> = webview.resources_table().get(rid)?; StdFileResource::with_lock(&file, |file| { file.seek(match whence { SeekMode::Start => SeekFrom::Start(offset as u64), SeekMode::Current => SeekFrom::Current(offset), SeekMode::End => SeekFrom::End(offset), }) }) .map_err(|e| format!("failed to seek file with error: {e}")) .map_err(Into::into) } #[cfg(target_os = "android")] fn get_metadata std::io::Result>( permission: &str, metadata_fn: F, webview: &Webview, global_scope: &GlobalScope, command_scope: &CommandScope, path: SafeFilePath, options: Option, ) -> CommandResult { match path { SafeFilePath::Url(url) => { let file_handle = resolve_file( permission, webview, global_scope, command_scope, SafeFilePath::Url(url), OpenOptions { base: BaseOptions { base_dir: None }, options: crate::OpenOptions { read: true, ..Default::default() }, }, )?; file_handle.metadata().map_err(|e| { format!( "failed to get metadata of path: {} with error: {e}", file_handle.path().display() ) .into() }) } SafeFilePath::Path(p) => get_fs_metadata( permission, metadata_fn, webview, global_scope, command_scope, SafeFilePath::Path(p), options, ), } } #[cfg(not(target_os = "android"))] fn get_metadata std::io::Result>( permission: &str, metadata_fn: F, webview: &Webview, global_scope: &GlobalScope, command_scope: &CommandScope, path: SafeFilePath, options: Option, ) -> CommandResult { get_fs_metadata( permission, metadata_fn, webview, global_scope, command_scope, path, options, ) } fn get_fs_metadata std::io::Result>( permission: &str, metadata_fn: F, webview: &Webview, global_scope: &GlobalScope, command_scope: &CommandScope, path: SafeFilePath, options: Option, ) -> CommandResult { let resolved_path = resolve_path( permission, webview, global_scope, command_scope, path, options.as_ref().and_then(|o| o.base_dir), )?; let metadata = metadata_fn(&resolved_path).map_err(|e| { format!( "failed to get metadata of path: {} with error: {e}", resolved_path.display() ) })?; Ok(metadata) } #[tauri::command] pub fn stat( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, path: SafeFilePath, options: Option, ) -> CommandResult { let metadata = get_metadata( "stat", |p| std::fs::metadata(p), &webview, &global_scope, &command_scope, path, options, )?; Ok(get_stat(metadata)) } #[tauri::command] pub fn lstat( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, path: SafeFilePath, options: Option, ) -> CommandResult { let metadata = get_metadata( "lstat", |p| std::fs::symlink_metadata(p), &webview, &global_scope, &command_scope, path, options, )?; Ok(get_stat(metadata)) } #[tauri::command] pub fn fstat(webview: Webview, rid: ResourceId) -> CommandResult { let file: std::sync::Arc> = webview.resources_table().get(rid)?; let metadata = StdFileResource::with_lock(&file, |file| file.metadata()) .map_err(|e| format!("failed to get metadata of file with error: {e}"))?; Ok(get_stat(metadata)) } #[tauri::command] pub async fn truncate( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, path: SafeFilePath, len: Option, options: Option, ) -> CommandResult<()> { let resolved_path = resolve_path( "truncate", &webview, &global_scope, &command_scope, path, options.as_ref().and_then(|o| o.base_dir), )?; let f = std::fs::OpenOptions::new() .write(true) .open(&resolved_path) .map_err(|e| { format!( "failed to open file at path: {} with error: {e}", resolved_path.display() ) })?; f.set_len(len.unwrap_or(0)) .map_err(|e| { format!( "failed to truncate file at path: {} with error: {e}", resolved_path.display() ) }) .map_err(Into::into) } #[tauri::command] pub async fn ftruncate( webview: Webview, rid: ResourceId, len: Option, ) -> CommandResult<()> { let file: std::sync::Arc> = webview.resources_table().get(rid)?; StdFileResource::with_lock(&file, |file| file.set_len(len.unwrap_or(0))) .map_err(|e| format!("failed to truncate file with error: {e}")) .map_err(Into::into) } #[tauri::command] pub async fn write( webview: Webview, rid: ResourceId, data: Vec, ) -> CommandResult { let file: std::sync::Arc> = webview.resources_table().get(rid)?; StdFileResource::with_lock(&file, |file| file.write(&data)) .map_err(|e| format!("failed to write bytes to file with error: {e}")) .map_err(Into::into) } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WriteFileOptions { #[serde(flatten)] base: BaseOptions, #[serde(default)] append: bool, #[serde(default = "default_create_value")] create: bool, #[serde(default)] create_new: bool, #[allow(unused)] mode: Option, } fn default_create_value() -> bool { true } async fn write_file_inner( permission: &str, webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, request: tauri::ipc::Request<'_>, ) -> CommandResult<()> { let path = request .headers() .get("path") .ok_or_else(|| anyhow::anyhow!("missing file path").into()) .and_then(|p| { percent_encoding::percent_decode(p.as_ref()) .decode_utf8() .map_err(|_| anyhow::anyhow!("path is not a valid UTF-8").into()) }) .and_then(|p| SafeFilePath::from_str(&p).map_err(CommandError::from))?; let options: Option = request .headers() .get("options") .and_then(|p| p.to_str().ok()) .and_then(|opts| serde_json::from_str(opts).ok()); let mut file_handle = resolve_file( permission, &webview, &global_scope, &command_scope, path, if let Some(opts) = options { OpenOptions { base: opts.base, options: crate::OpenOptions { read: false, write: true, create: opts.create, truncate: !opts.append, append: opts.append, create_new: opts.create_new, mode: opts.mode, custom_flags: None, }, } } else { OpenOptions { base: BaseOptions { base_dir: None }, options: crate::OpenOptions { read: false, write: true, truncate: true, create: true, create_new: false, append: false, mode: None, custom_flags: None, }, } }, )?; let data = match request.body() { tauri::ipc::InvokeBody::Raw(data) => Cow::Borrowed(data), tauri::ipc::InvokeBody::Json(serde_json::Value::Array(data)) => Cow::Owned( data.iter() .flat_map(|v| v.as_number().and_then(|v| v.as_u64().map(|v| v as u8))) .collect(), ), _ => return Err(anyhow::anyhow!("unexpected invoke body").into()), }; file_handle .write_all(&data) .map_err(|e| { format!( "failed to write bytes to file at path: {} with error: {e}", file_handle.path().display() ) }) .map_err(Into::into) } #[tauri::command] pub async fn write_file( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, request: tauri::ipc::Request<'_>, ) -> CommandResult<()> { write_file_inner("write-file", webview, global_scope, command_scope, request).await } // TODO, remove in v3, rely on `write_file` command instead #[tauri::command] pub async fn write_text_file( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, request: tauri::ipc::Request<'_>, ) -> CommandResult<()> { write_file_inner( "write-text-file", webview, global_scope, command_scope, request, ) .await } #[tauri::command] pub fn exists( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, path: SafeFilePath, options: Option, ) -> CommandResult { let resolved_path = resolve_path( "exists", &webview, &global_scope, &command_scope, path, options.as_ref().and_then(|o| o.base_dir), )?; Ok(resolved_path.exists()) } #[tauri::command] pub async fn size( webview: Webview, global_scope: GlobalScope, command_scope: CommandScope, path: SafeFilePath, options: Option, ) -> CommandResult { let resolved_path = resolve_path( "size", &webview, &global_scope, &command_scope, path, options.as_ref().and_then(|o| o.base_dir), )?; let metadata = resolved_path.metadata()?; if metadata.is_file() { Ok(metadata.len()) } else { let size = get_dir_size(&resolved_path).map_err(|e| { format!( "failed to get size at path: {} with error: {e}", resolved_path.display() ) })?; Ok(size) } } #[tauri::command] pub fn start_accessing_security_scoped_resource( webview: Webview, path: SafeFilePath, ) -> CommandResult<()> { #[cfg(target_os = "ios")] { use crate::FilePath; // Convert SafeFilePath to FilePath let file_path: FilePath = match &path { SafeFilePath::Url(url) => FilePath::Url(url.clone()), SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), }; // Only handle file URLs if let FilePath::Url(url) = &file_path { if url.scheme() == "file" { use objc2_foundation::{NSString, NSURL}; let url_nsstring = NSString::from_str(url.as_str()); let ns_url = unsafe { NSURL::URLWithString(&url_nsstring) }; if let Some(ns_url) = ns_url { // Check if already active let security_scoped_resources = webview.state::(); if security_scoped_resources.is_tracked_manually(url.as_str()) { log::debug!( "Security-scoped resource already active for URL: {}", url.as_str() ); return Ok(()); } // Start accessing the security-scoped resource unsafe { let success = ns_url.startAccessingSecurityScopedResource(); if success { log::debug!( "Started accessing security-scoped resource for URL: {}", url.as_str() ); security_scoped_resources.track_manually(url.as_str().to_string()); } else { log::warn!( "Failed to start accessing security-scoped resource for URL: {}", url.as_str() ); return Err(CommandError::from(format!( "Failed to start accessing security-scoped resource for URL: {}", url.as_str() ))); } } } else { return Err(CommandError::from(format!( "Failed to create NSURL from URL: {}", url.as_str() ))); } } } Ok(()) } #[cfg(not(target_os = "ios"))] { // No-op on non-iOS platforms let _ = webview; let _ = path; Ok(()) } } #[tauri::command] pub fn stop_accessing_security_scoped_resource( webview: Webview, path: SafeFilePath, ) -> CommandResult<()> { #[cfg(target_os = "ios")] { use crate::{FilePath, FsExt}; // Convert SafeFilePath to FilePath let file_path: FilePath = match &path { SafeFilePath::Url(url) => FilePath::Url(url.clone()), SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()), }; // Only handle file URLs if let FilePath::Url(url) = file_path { if url.scheme() == "file" { let security_scoped_resources = webview.state::(); // Check if it's tracked if !security_scoped_resources.is_tracked_manually(url.as_str()) { log::debug!( "Security-scoped resource not tracked as active for URL: {}", url.as_str() ); return Ok(()); } // Stop accessing the security-scoped resource webview .fs() .stop_accessing_security_scoped_resource(FilePath::Url(url.clone()))?; // Remove from tracking security_scoped_resources.remove(url.as_str()); log::debug!( "Stopped accessing security-scoped resource for URL: {}", url.as_str() ); } } Ok(()) } #[cfg(not(target_os = "ios"))] { // No-op on non-iOS platforms let _ = webview; let _ = path; Ok(()) } } fn get_dir_size(path: &PathBuf) -> CommandResult { let mut size = 0; for entry in std::fs::read_dir(path)? { let entry = entry?; let metadata = entry.metadata()?; if metadata.is_file() { size += metadata.len(); } else if metadata.is_dir() { size += get_dir_size(&entry.path())?; } } Ok(size) } #[cfg(desktop)] pub fn resolve_file( permission: &str, webview: &Webview, global_scope: &GlobalScope, command_scope: &CommandScope, path: SafeFilePath, open_options: OpenOptions, ) -> CommandResult> { resolve_file_in_fs( permission, webview, global_scope, command_scope, path, open_options, ) } fn resolve_file_in_fs( permission: &str, webview: &Webview, global_scope: &GlobalScope, command_scope: &CommandScope, path: SafeFilePath, open_options: OpenOptions, ) -> CommandResult> { let path_ = path.clone(); let resolved_path_handle = resolve_path( permission, webview, global_scope, command_scope, path, open_options.base.base_dir, )?; let file = std::fs::OpenOptions::from(open_options.options) .open(&*resolved_path_handle) .map_err(|e| { format!( "failed to open file at path: {} with error: {e}", resolved_path_handle.display() ) })?; let app_handle = webview.app_handle().clone(); Ok(FileHandle::new( file, PathKind::Handle(resolved_path_handle), path_, app_handle, )) } #[cfg(mobile)] pub fn resolve_file( permission: &str, webview: &Webview, global_scope: &GlobalScope, command_scope: &CommandScope, path: SafeFilePath, open_options: OpenOptions, ) -> CommandResult> { use crate::FsExt; let path_ = path.clone(); match path { SafeFilePath::Url(url) => { let resolved_path = url.as_str().into(); let file = webview .fs() .open(SafeFilePath::Url(url.clone()), open_options.options)?; let app_handle = webview.app_handle().clone(); Ok(FileHandle::new( file, PathKind::Path(resolved_path), path_, app_handle, )) } SafeFilePath::Path(path) => resolve_file_in_fs( permission, webview, global_scope, command_scope, SafeFilePath::Path(path), open_options, ), } } pub fn resolve_path( permission: &str, webview: &Webview, global_scope: &GlobalScope, command_scope: &CommandScope, path: SafeFilePath, base_dir: Option, ) -> CommandResult> { let path_ = path.clone(); // On iOS, start accessing security-scoped resource if the path is a file URL // Only if it hasn't been started already via start_accessing_security_scoped_resource #[cfg(target_os = "ios")] { if let SafeFilePath::Url(url) = &path { if url.scheme() == "file" { use objc2_foundation::{NSString, NSURL}; let security_scoped_resources = webview.state::(); // Check if already active (started via start_accessing_security_scoped_resource) if !security_scoped_resources.is_tracked_manually(url.as_str()) { let url_nsstring = NSString::from_str(url.as_str()); let ns_url = unsafe { NSURL::URLWithString(&url_nsstring) }; if let Some(ns_url) = ns_url { // Start accessing the security-scoped resource // This is required for files outside the app's sandbox (e.g., from file picker) unsafe { let success = ns_url.startAccessingSecurityScopedResource(); if success { log::debug!("Started accessing security-scoped resource for URL: {} (via resolve_path)", url.as_str()); // Track it so we know to clean it up security_scoped_resources.track_manually(url.as_str().to_string()); } else { log::warn!("Failed to start accessing security-scoped resource for URL: {}", url.as_str()); } } } else { log::debug!("Failed to create NSURL from URL: {}, ignoring security-scoped resource access request", url.as_str()); } } else { log::debug!("Security-scoped resource already active for URL: {} (started via start_accessing_security_scoped_resource), skipping", url.as_str()); } } } } let path = path.into_path()?; let resolved_path = if let Some(base_dir) = base_dir { webview.path().resolve(&path, base_dir)? } else { path }; let fs_scope = webview.state::(); let scope = tauri::scope::fs::Scope::new( webview, &FsScope::Scope { allow: global_scope .allows() .iter() .filter_map(|e| e.path.clone()) .chain(command_scope.allows().iter().filter_map(|e| e.path.clone())) .collect(), deny: global_scope .denies() .iter() .filter_map(|e| e.path.clone()) .chain(command_scope.denies().iter().filter_map(|e| e.path.clone())) .collect(), require_literal_leading_dot: fs_scope.require_literal_leading_dot, }, )?; let require_literal_leading_dot = fs_scope.require_literal_leading_dot.unwrap_or(cfg!(unix)); if is_forbidden(&fs_scope.scope, &resolved_path, require_literal_leading_dot) || is_forbidden(&scope, &resolved_path, require_literal_leading_dot) { return Err(CommandError::Plugin(Error::PathForbidden(resolved_path))); } if fs_scope.scope.is_allowed(&resolved_path) || scope.is_allowed(&resolved_path) { let app_handle = webview.app_handle().clone(); Ok(PathHandle::new(resolved_path, path_, app_handle)) } else { #[cfg(not(debug_assertions))] return Err(CommandError::Plugin(Error::PathForbidden(resolved_path))); #[cfg(debug_assertions)] Err( anyhow::anyhow!( "forbidden path: {}, maybe it is not allowed on the scope for `allow-{permission}` permission in your capability file", resolved_path.display() ) ) .map_err(Into::into) } } fn is_forbidden>( scope: &tauri::fs::Scope, path: P, require_literal_leading_dot: bool, ) -> bool { let path = path.as_ref(); let path = if path.is_symlink() { match std::fs::read_link(path) { Ok(p) => p, Err(_) => return false, } } else { path.to_path_buf() }; let path = if !path.exists() { crate::Result::Ok(path) } else { std::fs::canonicalize(path).map_err(Into::into) }; if let Ok(path) = path { let path: PathBuf = path.components().collect(); scope.forbidden_patterns().iter().any(|p| { p.matches_path_with( &path, glob::MatchOptions { // this is needed so `/dir/*` doesn't match files within subdirectories such as `/dir/subdir/file.txt` // see: require_literal_separator: true, require_literal_leading_dot, ..Default::default() }, ) }) } else { false } } struct StdFileResource(Mutex>); impl StdFileResource { fn new(file_handle: FileHandle) -> Self { Self(Mutex::new(file_handle)) } fn with_lock Ret>(&self, mut f: F) -> Ret { let mut file_handle = self.0.lock().unwrap(); f(&mut file_handle) } } impl Resource for StdFileResource {} /// Same as [std::io::Lines] but with bytes struct LinesBytes { bytes: T, lf_bytes: Vec, cr_bytes: Vec, } impl LinesBytes { fn new(bytes: T, lf_bytes: Vec, cr_bytes: Vec) -> Self { LinesBytes { bytes, lf_bytes, cr_bytes, } } } impl Iterator for LinesBytes { type Item = std::io::Result>; fn next(&mut self) -> Option>> { let mut buf = Vec::new(); // Search for '\n' match read_until_bytes(&mut self.bytes, &self.lf_bytes, &mut buf) { Ok(0) => None, Ok(_n) => { // Remove '\n' or '\r\n' if buf.ends_with(&self.lf_bytes) { buf.truncate(buf.len() - self.lf_bytes.len()); if buf.ends_with(&self.cr_bytes) { buf.truncate(buf.len() - self.cr_bytes.len()); } } Some(Ok(buf)) } Err(e) => Some(Err(e)), } } } fn read_until_bytes( r: &mut impl BufRead, bytes: &[u8], buf: &mut Vec, ) -> std::io::Result { let last_byte = *bytes .last() .ok_or_else(|| std::io::Error::other("invalid empty bytes"))?; if bytes.len() == 1 { return r.read_until(last_byte, buf); } let mut total_n = 0; loop { let n = r.read_until(last_byte, buf)?; total_n += n; if n == 0 || buf.ends_with(bytes) { return Ok(total_n); } } } struct StdLinesResource(Mutex>>); impl StdLinesResource { fn new(lines: BufReader, lf_bytes: Vec, cr_bytes: Vec) -> Self { Self(Mutex::new(LinesBytes::new(lines, lf_bytes, cr_bytes))) } fn with_lock>) -> R>(&self, mut f: F) -> R { let mut lines = self.0.lock().unwrap(); f(&mut lines) } } impl Resource for StdLinesResource {} // taken from deno source code: https://github.com/denoland/deno/blob/ffffa2f7c44bd26aec5ae1957e0534487d099f48/runtime/ops/fs.rs#L913 #[inline] fn to_msec(maybe_time: std::result::Result) -> Option { match maybe_time { Ok(time) => { let msec = time .duration_since(UNIX_EPOCH) .map(|t| t.as_millis() as u64) .unwrap_or_else(|err| err.duration().as_millis() as u64); Some(msec) } Err(_) => None, } } // taken from deno source code: https://github.com/denoland/deno/blob/ffffa2f7c44bd26aec5ae1957e0534487d099f48/runtime/ops/fs.rs#L926 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct FileInfo { is_file: bool, is_directory: bool, is_symlink: bool, size: u64, // In milliseconds, like JavaScript. Available on both Unix or Windows. mtime: Option, atime: Option, birthtime: Option, readonly: bool, // Following are only valid under Windows. file_attribues: Option, // Following are only valid under Unix. dev: Option, ino: Option, mode: Option, nlink: Option, uid: Option, gid: Option, rdev: Option, blksize: Option, blocks: Option, } // taken from deno source code: https://github.com/denoland/deno/blob/ffffa2f7c44bd26aec5ae1957e0534487d099f48/runtime/ops/fs.rs#L950 #[inline(always)] fn get_stat(metadata: std::fs::Metadata) -> FileInfo { // Unix stat member (number types only). 0 if not on unix. macro_rules! usm { ($member:ident) => {{ #[cfg(unix)] { Some(metadata.$member()) } #[cfg(not(unix))] { None } }}; } #[cfg(unix)] use std::os::unix::fs::MetadataExt; #[cfg(windows)] use std::os::windows::fs::MetadataExt; FileInfo { is_file: metadata.is_file(), is_directory: metadata.is_dir(), is_symlink: metadata.file_type().is_symlink(), size: metadata.len(), // In milliseconds, like JavaScript. Available on both Unix or Windows. mtime: to_msec(metadata.modified()), atime: to_msec(metadata.accessed()), birthtime: to_msec(metadata.created()), readonly: metadata.permissions().readonly(), // Following are only valid under Windows. #[cfg(windows)] file_attribues: Some(metadata.file_attributes()), #[cfg(not(windows))] file_attribues: None, // Following are only valid under Unix. dev: usm!(dev), ino: usm!(ino), mode: usm!(mode), nlink: usm!(nlink), uid: usm!(uid), gid: usm!(gid), rdev: usm!(rdev), blksize: usm!(blksize), blocks: usm!(blocks), } } #[cfg(test)] mod test { use std::io::{BufRead, BufReader}; use super::LinesBytes; #[test] fn safe_file_path_parse() { use super::SafeFilePath; assert!(matches!( serde_json::from_str::("\"C:/Users\""), Ok(SafeFilePath::Path(_)) )); assert!(matches!( serde_json::from_str::("\"file:///C:/Users\""), Ok(SafeFilePath::Url(_)) )); } #[test] fn test_lines_bytes() { // UTF-8 { let base = String::from("line 1\nline2\nline 3\r\nline 4"); let bytes = base.as_bytes(); let string1 = base.lines().collect::(); let string2 = BufReader::new(bytes) .lines() .map_while(Result::ok) .collect::(); let string3 = LinesBytes::new(BufReader::new(bytes), vec![b'\n'], vec![b'\r']) .flatten() .flat_map(String::from_utf8) .collect::(); assert_eq!(string1, string2); assert_eq!(string1, string3); assert_eq!(string2, string3); } // UTF-16 LE { fn utf16(text: &str) -> Vec { text.encode_utf16().flat_map(|u| u.to_le_bytes()).collect() } let base = String::from("line 1\nline2\nline 3\r\nline 4\n"); let bytes = utf16(&base); let mut lines = LinesBytes::new(BufReader::new(&bytes[..]), utf16("\n"), utf16("\r")); assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 1"))); assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line2"))); assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 3"))); assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 4"))); assert!(lines.next().is_none()); } // UTF-16 BE { fn utf16(text: &str) -> Vec { text.encode_utf16().flat_map(|u| u.to_be_bytes()).collect() } // ਗ (U+0A17) encodes to 0x0A 0x17, // which contains 0x0A but is not a line feed (U+000A = 0x00 0x0A). let base = String::from("line 1\nline2ਗ\nline 3\r\nline 4"); let bytes = utf16(&base); let mut lines = LinesBytes::new(BufReader::new(&bytes[..]), utf16("\n"), utf16("\r")); assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 1"))); assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line2ਗ"))); assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 3"))); assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 4"))); assert!(lines.next().is_none()); } } }