diff --git a/Cargo.toml b/Cargo.toml index 49748e1..b2430dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ rustc-args = ["--cfg", "docsrs"] rustdoc-args = ["--cfg", "docsrs"] [package.metadata.platforms.support] -windows = { level = "full", notes = "" } +windows = { level = "full", notes = "Apps installed via MSI or NSIS in `perMachine` and `both` mode require admin permissions for write acces in `$RESOURCES` folder" } linux = { level = "full", notes = "No write access to `$RESOURCES` folder" } macos = { level = "full", notes = "No write access to `$RESOURCES` folder" } android = { level = "partial", notes = "Access is restricted to Application folder by default" } @@ -24,6 +24,8 @@ ios = { level = "partial", notes = "Access is restricted to Application folder b tauri-plugin = { workspace = true, features = ["build"] } schemars = { workspace = true } serde = { workspace = true } +toml = "0.8" +tauri-utils = { workspace = true, features = ["build"] } [dependencies] serde = { workspace = true } diff --git a/Source/commands.rs b/Source/commands.rs index 3b5cc44..85c866c 100644 --- a/Source/commands.rs +++ b/Source/commands.rs @@ -15,14 +15,14 @@ use tauri::{ use std::{ borrow::Cow, fs::File, - io::{BufReader, Lines, Read, Write}, - path::PathBuf, + io::{BufRead, BufReader, Read, Write}, + path::{Path, PathBuf}, str::FromStr, sync::Mutex, time::{SystemTime, UNIX_EPOCH}, }; -use crate::{scope::Entry, Error, FsExt, SafeFilePath}; +use crate::{scope::Entry, Error, SafeFilePath}; #[derive(Debug, thiserror::Error)] pub enum CommandError { @@ -372,6 +372,7 @@ pub async fn read_file( Ok(tauri::ipc::Response::new(contents)) } +// TODO, remove in v3, rely on `read_file` command instead #[tauri::command] pub async fn read_text_file( webview: Webview, @@ -379,33 +380,8 @@ pub async fn read_text_file( command_scope: CommandScope, path: SafeFilePath, options: Option, -) -> CommandResult { - let (mut file, path) = resolve_file( - &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 = String::new(); - - file.read_to_string(&mut contents).map_err(|e| { - format!( - "failed to read file as text at path: {} with error: {e}", - path.display() - ) - })?; - - Ok(contents) +) -> CommandResult { + read_file(webview, global_scope, command_scope, path, options).await } #[tauri::command] @@ -416,8 +392,6 @@ pub fn read_text_file_lines( path: SafeFilePath, options: Option, ) -> CommandResult { - use std::io::BufRead; - let resolved_path = resolve_path( &webview, &global_scope, @@ -433,7 +407,7 @@ pub fn read_text_file_lines( ) })?; - let lines = BufReader::new(file).lines(); + let lines = BufReader::new(file); let rid = webview.resources_table().add(StdLinesResource::new(lines)); Ok(rid) @@ -443,18 +417,28 @@ pub fn read_text_file_lines( pub async fn read_text_file_lines_next( webview: Webview, rid: ResourceId, -) -> CommandResult<(Option, bool)> { +) -> CommandResult { let mut resource_table = webview.resources_table(); let lines = resource_table.get::(rid)?; - let ret = StdLinesResource::with_lock(&lines, |lines| { - lines.next().map(|a| (a.ok(), false)).unwrap_or_else(|| { - let _ = resource_table.close(rid); - (None, true) - }) + let ret = StdLinesResource::with_lock(&lines, |lines| -> CommandResult> { + // This is an optimization to include wether 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]) + } + } }); - Ok(ret) + ret.map(tauri::ipc::Response::new) } #[derive(Debug, Clone, Deserialize)] @@ -805,10 +789,11 @@ fn default_create_value() -> bool { true } -fn write_file_inner( +#[tauri::command] +pub async fn write_file( webview: Webview, - global_scope: &GlobalScope, - command_scope: &CommandScope, + global_scope: GlobalScope, + command_scope: CommandScope, request: tauri::ipc::Request<'_>, ) -> CommandResult<()> { let data = match request.body() { @@ -839,8 +824,8 @@ fn write_file_inner( let (mut file, path) = resolve_file( &webview, - global_scope, - command_scope, + &global_scope, + &command_scope, path, if let Some(opts) = options { OpenOptions { @@ -883,17 +868,7 @@ fn write_file_inner( .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(webview, &global_scope, &command_scope, request) -} - -// TODO, in v3, remove this command and rely on `write_file` command only +// TODO, remove in v3, rely on `write_file` command instead #[tauri::command] pub async fn write_text_file( webview: Webview, @@ -901,7 +876,7 @@ pub async fn write_text_file( command_scope: CommandScope, request: tauri::ipc::Request<'_>, ) -> CommandResult<()> { - write_file_inner(webview, &global_scope, &command_scope, request) + write_file(webview, global_scope, command_scope, request).await } #[tauri::command] @@ -967,6 +942,8 @@ pub fn resolve_file( path: SafeFilePath, open_options: OpenOptions, ) -> CommandResult<(File, PathBuf)> { + use crate::FsExt; + match path { SafeFilePath::Url(url) => { let path = url.as_str().into(); @@ -999,40 +976,81 @@ pub fn resolve_path( path }; + let fs_scope = webview.state::(); + let scope = tauri::scope::fs::Scope::new( webview, &FsScope::Scope { - allow: webview - .fs_scope() - .allowed - .lock() - .unwrap() - .clone() - .into_iter() - .chain(global_scope.allows().iter().filter_map(|e| e.path.clone())) + allow: global_scope + .allows() + .iter() + .filter_map(|e| e.path.clone()) .chain(command_scope.allows().iter().filter_map(|e| e.path.clone())) .collect(), - deny: webview - .fs_scope() - .denied - .lock() - .unwrap() - .clone() - .into_iter() - .chain(global_scope.denies().iter().filter_map(|e| e.path.clone())) + 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: webview.fs_scope().require_literal_leading_dot, + require_literal_leading_dot: fs_scope.require_literal_leading_dot, }, )?; - if scope.is_allowed(&path) { + let require_literal_leading_dot = fs_scope.require_literal_leading_dot.unwrap_or(cfg!(unix)); + + if is_forbidden(&fs_scope.scope, &path, require_literal_leading_dot) + || is_forbidden(&scope, &path, require_literal_leading_dot) + { + return Err(CommandError::Plugin(Error::PathForbidden(path))); + } + + if fs_scope.scope.is_allowed(&path) || scope.is_allowed(&path) { Ok(path) } else { Err(CommandError::Plugin(Error::PathForbidden(path))) } } +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 { @@ -1048,14 +1066,38 @@ impl StdFileResource { impl Resource for StdFileResource {} -struct StdLinesResource(Mutex>>); +/// Same as [std::io::Lines] but with bytes +struct LinesBytes(T); + +impl Iterator for LinesBytes { + type Item = std::io::Result>; + + fn next(&mut self) -> Option>> { + let mut buf = Vec::new(); + match self.0.read_until(b'\n', &mut buf) { + Ok(0) => None, + Ok(_n) => { + if buf.last() == Some(&b'\n') { + buf.pop(); + if buf.last() == Some(&b'\r') { + buf.pop(); + } + } + Some(Ok(buf)) + } + Err(e) => Some(Err(e)), + } + } +} + +struct StdLinesResource(Mutex>>); impl StdLinesResource { - fn new(lines: Lines>) -> Self { - Self(Mutex::new(lines)) + fn new(lines: BufReader) -> Self { + Self(Mutex::new(LinesBytes(lines))) } - fn with_lock>) -> R>(&self, mut f: F) -> R { + fn with_lock>) -> R>(&self, mut f: F) -> R { let mut lines = self.0.lock().unwrap(); f(&mut lines) } @@ -1154,7 +1196,12 @@ fn get_stat(metadata: std::fs::Metadata) -> FileInfo { } } +#[cfg(test)] mod test { + use std::io::{BufRead, BufReader}; + + use super::LinesBytes; + #[test] fn safe_file_path_parse() { use super::SafeFilePath; @@ -1168,4 +1215,24 @@ mod test { Ok(SafeFilePath::Url(_)) )); } + + #[test] + fn test_lines_bytes() { + let base = String::from("line 1\nline2\nline 3\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(BufReader::new(bytes)) + .flatten() + .flat_map(String::from_utf8) + .collect::(); + + assert_eq!(string1, string2); + assert_eq!(string1, string3); + assert_eq!(string2, string3); + } } diff --git a/Source/lib.rs b/Source/lib.rs index b48b24d..3240ccb 100644 --- a/Source/lib.rs +++ b/Source/lib.rs @@ -15,7 +15,7 @@ use serde::Deserialize; use tauri::{ ipc::ScopeObject, plugin::{Builder as PluginBuilder, TauriPlugin}, - utils::acl::Value, + utils::{acl::Value, config::FsScope}, AppHandle, DragDropEvent, Manager, RunEvent, Runtime, WindowEvent, }; @@ -39,7 +39,6 @@ pub use desktop::Fs; pub use mobile::Fs; pub use error::Error; -pub use scope::{Event as ScopeEvent, Scope}; pub use file_path::FilePath; pub use file_path::SafeFilePath; @@ -365,21 +364,26 @@ impl ScopeObject for scope::Entry { } } +pub(crate) struct Scope { + pub(crate) scope: tauri::fs::Scope, + pub(crate) require_literal_leading_dot: Option, +} + pub trait FsExt { - fn fs_scope(&self) -> &Scope; - fn try_fs_scope(&self) -> Option<&Scope>; + fn fs_scope(&self) -> tauri::fs::Scope; + fn try_fs_scope(&self) -> Option; /// Cross platform file system APIs that also support manipulating Android files. fn fs(&self) -> &Fs; } impl> FsExt for T { - fn fs_scope(&self) -> &Scope { - self.state::().inner() + fn fs_scope(&self) -> tauri::fs::Scope { + self.state::().scope.clone() } - fn try_fs_scope(&self) -> Option<&Scope> { - self.try_state::().map(|s| s.inner()) + fn try_fs_scope(&self) -> Option { + self.try_state::().map(|s| s.scope.clone()) } fn fs(&self) -> &Fs { @@ -419,11 +423,13 @@ pub fn init() -> TauriPlugin> { watcher::unwatch ]) .setup(|app, api| { - let mut scope = Scope::default(); - scope.require_literal_leading_dot = api - .config() - .as_ref() - .and_then(|c| c.require_literal_leading_dot); + let scope = Scope { + require_literal_leading_dot: api + .config() + .as_ref() + .and_then(|c| c.require_literal_leading_dot), + scope: tauri::fs::Scope::new(app, &FsScope::default())?, + }; #[cfg(target_os = "android")] { @@ -446,9 +452,9 @@ pub fn init() -> TauriPlugin> { let scope = app.fs_scope(); for path in paths { if path.is_file() { - scope.allow_file(path); + let _ = scope.allow_file(path); } else { - scope.allow_directory(path, true); + let _ = scope.allow_directory(path, true); } } } diff --git a/api-iife.js b/api-iife.js index e44a180..d1032cd 100644 --- a/api-iife.js +++ b/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_FS__=function(t){"use strict";function e(t,e,n,i){if("a"===n&&!i)throw new TypeError("Private accessor was defined without a getter");if("function"==typeof e?t!==e||!i:!e.has(t))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===n?i:"a"===n?i.call(t):i?i.value:e.get(t)}function n(t,e,n,i,o){if("function"==typeof e?t!==e||!o:!e.has(t))throw new TypeError("Cannot write private member to an object whose class did not declare it");return e.set(t,n),n}var i,o,r,a;"function"==typeof SuppressedError&&SuppressedError;const s="__TAURI_TO_IPC_KEY__";class c{constructor(){this.__TAURI_CHANNEL_MARKER__=!0,i.set(this,(()=>{})),o.set(this,0),r.set(this,{}),this.id=function(t,e=!1){return window.__TAURI_INTERNALS__.transformCallback(t,e)}((({message:t,id:a})=>{if(a===e(this,o,"f")){n(this,o,a+1),e(this,i,"f").call(this,t);const s=Object.keys(e(this,r,"f"));if(s.length>0){let t=a+1;for(const n of s.sort()){if(parseInt(n)!==t)break;{const o=e(this,r,"f")[n];delete e(this,r,"f")[n],e(this,i,"f").call(this,o),t+=1}}n(this,o,t)}}else e(this,r,"f")[a.toString()]=t}))}set onmessage(t){n(this,i,t)}get onmessage(){return e(this,i,"f")}[(i=new WeakMap,o=new WeakMap,r=new WeakMap,s)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[s]()}}async function f(t,e={},n){return window.__TAURI_INTERNALS__.invoke(t,e,n)}class l{get rid(){return e(this,a,"f")}constructor(t){a.set(this,void 0),n(this,a,t)}async close(){return f("plugin:resources|close",{rid:this.rid})}}var u,p;function w(t){return{isFile:t.isFile,isDirectory:t.isDirectory,isSymlink:t.isSymlink,size:t.size,mtime:null!==t.mtime?new Date(t.mtime):null,atime:null!==t.atime?new Date(t.atime):null,birthtime:null!==t.birthtime?new Date(t.birthtime):null,readonly:t.readonly,fileAttributes:t.fileAttributes,dev:t.dev,ino:t.ino,mode:t.mode,nlink:t.nlink,uid:t.uid,gid:t.gid,rdev:t.rdev,blksize:t.blksize,blocks:t.blocks}}a=new WeakMap,t.BaseDirectory=void 0,(u=t.BaseDirectory||(t.BaseDirectory={}))[u.Audio=1]="Audio",u[u.Cache=2]="Cache",u[u.Config=3]="Config",u[u.Data=4]="Data",u[u.LocalData=5]="LocalData",u[u.Document=6]="Document",u[u.Download=7]="Download",u[u.Picture=8]="Picture",u[u.Public=9]="Public",u[u.Video=10]="Video",u[u.Resource=11]="Resource",u[u.Temp=12]="Temp",u[u.AppConfig=13]="AppConfig",u[u.AppData=14]="AppData",u[u.AppLocalData=15]="AppLocalData",u[u.AppCache=16]="AppCache",u[u.AppLog=17]="AppLog",u[u.Desktop=18]="Desktop",u[u.Executable=19]="Executable",u[u.Font=20]="Font",u[u.Home=21]="Home",u[u.Runtime=22]="Runtime",u[u.Template=23]="Template",t.SeekMode=void 0,(p=t.SeekMode||(t.SeekMode={}))[p.Start=0]="Start",p[p.Current=1]="Current",p[p.End=2]="End";class h extends l{async read(t){if(0===t.byteLength)return 0;const e=await f("plugin:fs|read",{rid:this.rid,len:t.byteLength}),n=function(t){const e=new Uint8ClampedArray(t),n=e.byteLength;let i=0;for(let t=0;tt instanceof URL?t.toString():t)),options:i,onEvent:r});return()=>{d(a)}},t.watchImmediate=async function(t,e,n){const i={recursive:!1,...n,delayMs:null},o=Array.isArray(t)?t:[t];for(const t of o)if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");const r=new c;r.onmessage=e;const a=await f("plugin:fs|watch",{paths:o.map((t=>t instanceof URL?t.toString():t)),options:i,onEvent:r});return()=>{d(a)}},t.writeFile=async function(t,e,n){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");await f("plugin:fs|write_file",e,{headers:{path:encodeURIComponent(t instanceof URL?t.toString():t),options:JSON.stringify(n)}})},t.writeTextFile=async function(t,e,n){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");const i=new TextEncoder;await f("plugin:fs|write_text_file",i.encode(e),{headers:{path:encodeURIComponent(t instanceof URL?t.toString():t),options:JSON.stringify(n)}})},t}({});Object.defineProperty(window.__TAURI__,"fs",{value:__TAURI_PLUGIN_FS__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_FS__=function(t){"use strict";function e(t,e,n,i){if("a"===n&&!i)throw new TypeError("Private accessor was defined without a getter");if("function"==typeof e?t!==e||!i:!e.has(t))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===n?i:"a"===n?i.call(t):i?i.value:e.get(t)}function n(t,e,n,i,o){if("function"==typeof e?t!==e||!o:!e.has(t))throw new TypeError("Cannot write private member to an object whose class did not declare it");return e.set(t,n),n}var i,o,r,a;"function"==typeof SuppressedError&&SuppressedError;const s="__TAURI_TO_IPC_KEY__";class c{constructor(){this.__TAURI_CHANNEL_MARKER__=!0,i.set(this,(()=>{})),o.set(this,0),r.set(this,{}),this.id=function(t,e=!1){return window.__TAURI_INTERNALS__.transformCallback(t,e)}((({message:t,id:a})=>{if(a===e(this,o,"f")){n(this,o,a+1),e(this,i,"f").call(this,t);const s=Object.keys(e(this,r,"f"));if(s.length>0){let t=a+1;for(const n of s.sort()){if(parseInt(n)!==t)break;{const o=e(this,r,"f")[n];delete e(this,r,"f")[n],e(this,i,"f").call(this,o),t+=1}}n(this,o,t)}}else e(this,r,"f")[a.toString()]=t}))}set onmessage(t){n(this,i,t)}get onmessage(){return e(this,i,"f")}[(i=new WeakMap,o=new WeakMap,r=new WeakMap,s)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[s]()}}async function f(t,e={},n){return window.__TAURI_INTERNALS__.invoke(t,e,n)}class l{get rid(){return e(this,a,"f")}constructor(t){a.set(this,void 0),n(this,a,t)}async close(){return f("plugin:resources|close",{rid:this.rid})}}var u,p;function w(t){return{isFile:t.isFile,isDirectory:t.isDirectory,isSymlink:t.isSymlink,size:t.size,mtime:null!==t.mtime?new Date(t.mtime):null,atime:null!==t.atime?new Date(t.atime):null,birthtime:null!==t.birthtime?new Date(t.birthtime):null,readonly:t.readonly,fileAttributes:t.fileAttributes,dev:t.dev,ino:t.ino,mode:t.mode,nlink:t.nlink,uid:t.uid,gid:t.gid,rdev:t.rdev,blksize:t.blksize,blocks:t.blocks}}a=new WeakMap,t.BaseDirectory=void 0,(u=t.BaseDirectory||(t.BaseDirectory={}))[u.Audio=1]="Audio",u[u.Cache=2]="Cache",u[u.Config=3]="Config",u[u.Data=4]="Data",u[u.LocalData=5]="LocalData",u[u.Document=6]="Document",u[u.Download=7]="Download",u[u.Picture=8]="Picture",u[u.Public=9]="Public",u[u.Video=10]="Video",u[u.Resource=11]="Resource",u[u.Temp=12]="Temp",u[u.AppConfig=13]="AppConfig",u[u.AppData=14]="AppData",u[u.AppLocalData=15]="AppLocalData",u[u.AppCache=16]="AppCache",u[u.AppLog=17]="AppLog",u[u.Desktop=18]="Desktop",u[u.Executable=19]="Executable",u[u.Font=20]="Font",u[u.Home=21]="Home",u[u.Runtime=22]="Runtime",u[u.Template=23]="Template",t.SeekMode=void 0,(p=t.SeekMode||(t.SeekMode={}))[p.Start=0]="Start",p[p.Current=1]="Current",p[p.End=2]="End";class d extends l{async read(t){if(0===t.byteLength)return 0;const e=await f("plugin:fs|read",{rid:this.rid,len:t.byteLength}),n=function(t){const e=new Uint8ClampedArray(t),n=e.byteLength;let i=0;for(let t=0;tt instanceof URL?t.toString():t)),options:i,onEvent:r});return()=>{y(a)}},t.watchImmediate=async function(t,e,n){const i={recursive:!1,...n,delayMs:null},o=Array.isArray(t)?t:[t];for(const t of o)if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");const r=new c;r.onmessage=e;const a=await f("plugin:fs|watch",{paths:o.map((t=>t instanceof URL?t.toString():t)),options:i,onEvent:r});return()=>{y(a)}},t.writeFile=async function(t,e,n){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");if(e instanceof ReadableStream){const i=await h(t,n);for await(const t of e)await i.write(t);await i.close()}else await f("plugin:fs|write_file",e,{headers:{path:encodeURIComponent(t instanceof URL?t.toString():t),options:JSON.stringify(n)}})},t.writeTextFile=async function(t,e,n){if(t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");const i=new TextEncoder;await f("plugin:fs|write_text_file",i.encode(e),{headers:{path:encodeURIComponent(t instanceof URL?t.toString():t),options:JSON.stringify(n)}})},t}({});Object.defineProperty(window.__TAURI__,"fs",{value:__TAURI_PLUGIN_FS__})} diff --git a/build.rs b/build.rs index f627c64..328da54 100644 --- a/build.rs +++ b/build.rs @@ -7,6 +7,8 @@ use std::{ path::{Path, PathBuf}, }; +use tauri_utils::acl::manifest::PermissionFile; + #[path = "src/scope.rs"] #[allow(dead_code)] mod scope; @@ -75,31 +77,31 @@ const BASE_DIR_VARS:&[&str] = &[ "APPCACHE", "APPLOG", ]; -const COMMANDS:&[&str] = &[ - "mkdir", - "create", - "copy_file", - "remove", - "rename", - "truncate", - "ftruncate", - "write", - "write_file", - "write_text_file", - "read_dir", - "read_file", - "read", - "open", - "read_text_file", - "read_text_file_lines", - "read_text_file_lines_next", - "seek", - "stat", - "lstat", - "fstat", - "exists", - "watch", - "unwatch", +const COMMANDS: &[(&str, &[&str])] = &[ + ("mkdir", &[]), + ("create", &[]), + ("copy_file", &[]), + ("remove", &[]), + ("rename", &[]), + ("truncate", &[]), + ("ftruncate", &[]), + ("write", &[]), + ("write_file", &["open", "write"]), + ("write_text_file", &[]), + ("read_dir", &[]), + ("read_file", &[]), + ("read", &[]), + ("open", &[]), + ("read_text_file", &[]), + ("read_text_file_lines", &["read_text_file_lines_next"]), + ("read_text_file_lines_next", &[]), + ("seek", &[]), + ("stat", &[]), + ("lstat", &[]), + ("fstat", &[]), + ("exists", &[]), + ("watch", &[]), + ("unwatch", &[]), ]; fn main() { @@ -205,9 +207,47 @@ permissions = [ } } - tauri_plugin::Builder::new(COMMANDS) - .global_api_script_path("./api-iife.js") - .global_scope_schema(schemars::schema_for!(FsScopeEntry)) - .android_path("android") - .build(); + tauri_plugin::Builder::new(&COMMANDS.iter().map(|c| c.0).collect::>()) + .global_api_script_path("./api-iife.js") + .global_scope_schema(schemars::schema_for!(FsScopeEntry)) + .android_path("android") + .build(); + + // workaround to include nested permissions as `tauri_plugin` doesn't support it + let permissions_dir = autogenerated.join("commands"); + for (command, nested_commands) in COMMANDS { + if nested_commands.is_empty() { + continue; + } + + let permission_path = permissions_dir.join(format!("{command}.toml")); + + let content = std::fs::read_to_string(&permission_path) + .unwrap_or_else(|_| panic!("failed to read {command}.toml")); + + let mut permission_file = toml::from_str::(&content) + .unwrap_or_else(|_| panic!("failed to deserialize {command}.toml")); + + for p in permission_file + .permission + .iter_mut() + .filter(|p| p.identifier.starts_with("allow")) + { + p.commands + .allow + .extend(nested_commands.iter().map(|s| s.to_string())); + } + + let out = toml::to_string_pretty(&permission_file) + .unwrap_or_else(|_| panic!("failed to serialize {command}.toml")); + let out = format!( + r#"# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +{out}"# + ); + std::fs::write(permission_path, out) + .unwrap_or_else(|_| panic!("failed to write {command}.toml")); + } } diff --git a/dist-js/index.cjs b/dist-js/index.cjs index 6b19980..d775ebb 100644 --- a/dist-js/index.cjs +++ b/dist-js/index.cjs @@ -110,6 +110,7 @@ function fromBytes(buffer) { const size = bytes.byteLength; let x = 0; for (let i = 0; i < size; i++) { + // eslint-disable-next-line security/detect-object-injection const byte = bytes[i]; x *= 0x100; x += byte; @@ -261,11 +262,11 @@ class FileHandle extends core.Resource { }); } /** - * Writes `p.byteLength` bytes from `p` to the underlying data stream. It - * resolves to the number of bytes written from `p` (`0` <= `n` <= - * `p.byteLength`) or reject with the error encountered that caused the + * Writes `data.byteLength` bytes from `data` to the underlying data stream. It + * resolves to the number of bytes written from `data` (`0` <= `n` <= + * `data.byteLength`) or reject with the error encountered that caused the * write to stop early. `write()` must reject with a non-null error if - * would resolve to `n` < `p.byteLength`. `write()` must not modify the + * would resolve to `n` < `data.byteLength`. `write()` must not modify the * slice data, even temporarily. * * @example @@ -444,13 +445,15 @@ async function readFile(path, options) { * @since 2.0.0 */ async function readTextFile(path, options) { - if (path instanceof URL && path.protocol !== "file:") { - throw new TypeError("Must be a file URL."); - } - return await core.invoke("plugin:fs|read_text_file", { - path: path instanceof URL ? path.toString() : path, - options, - }); + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.'); + } + const arr = await core.invoke('plugin:fs|read_text_file', { + path: path instanceof URL ? path.toString() : path, + options + }); + const bytes = arr instanceof ArrayBuffer ? arr : Uint8Array.from(arr); + return new TextDecoder().decode(bytes); } /** * Returns an async {@linkcode AsyncIterableIterator} over the lines of a file as UTF-8 string. @@ -468,35 +471,43 @@ async function readTextFile(path, options) { * @since 2.0.0 */ async function readTextFileLines(path, options) { - if (path instanceof URL && path.protocol !== "file:") { - throw new TypeError("Must be a file URL."); - } - const pathStr = path instanceof URL ? path.toString() : path; - return await Promise.resolve({ - path: pathStr, - rid: null, - async next() { - if (this.rid === null) { - this.rid = await core.invoke("plugin:fs|read_text_file_lines", { - path: pathStr, - options, - }); - } - const [line, done] = await core.invoke( - "plugin:fs|read_text_file_lines_next", - { rid: this.rid }, - ); - // an iteration is over, reset rid for next iteration - if (done) this.rid = null; - return { - value: done ? "" : line, - done, - }; - }, - [Symbol.asyncIterator]() { - return this; - }, - }); + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.'); + } + const pathStr = path instanceof URL ? path.toString() : path; + return await Promise.resolve({ + path: pathStr, + rid: null, + async next() { + if (this.rid === null) { + this.rid = await core.invoke('plugin:fs|read_text_file_lines', { + path: pathStr, + options + }); + } + const arr = await core.invoke('plugin:fs|read_text_file_lines_next', { rid: this.rid }); + const bytes = arr instanceof ArrayBuffer ? new Uint8Array(arr) : Uint8Array.from(arr); + // Rust side will never return an empty array for this command and + // ensure there is at least one elements there. + // + // This is an optimization to include whether we finished iteration or not (1 or 0) + // at the end of returned array to avoid serialization overhead of separate values. + const done = bytes[bytes.byteLength - 1] === 1; + if (done) { + // a full iteration is over, reset rid for next iteration + this.rid = null; + return { value: null, done }; + } + const line = new TextDecoder().decode(bytes.slice(0, bytes.byteLength)); + return { + value: line, + done + }; + }, + [Symbol.asyncIterator]() { + return this; + } + }); } /** * Removes the named file or directory. @@ -632,17 +643,24 @@ async function truncate(path, len, options) { * @since 2.0.0 */ async function writeFile(path, data, options) { - if (path instanceof URL && path.protocol !== "file:") { - throw new TypeError("Must be a file URL."); - } - await core.invoke("plugin:fs|write_file", data, { - headers: { - path: encodeURIComponent( - path instanceof URL ? path.toString() : path, - ), - options: JSON.stringify(options), - }, - }); + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.'); + } + if (data instanceof ReadableStream) { + const file = await open(path, options); + for await (const chunk of data) { + await file.write(chunk); + } + await file.close(); + } + else { + await core.invoke('plugin:fs|write_file', data, { + headers: { + path: encodeURIComponent(path instanceof URL ? path.toString() : path), + options: JSON.stringify(options) + } + }); + } } /** * Writes UTF-8 string `data` to the given `path`, by default creating a new file if needed, else overwriting. diff --git a/dist-js/index.d.ts b/dist-js/index.d.ts index 3af71b5..4ef1af1 100644 --- a/dist-js/index.d.ts +++ b/dist-js/index.d.ts @@ -210,132 +210,132 @@ interface FileInfo { * @since 2.0.0 */ declare class FileHandle extends Resource { - /** - * Reads up to `p.byteLength` bytes into `p`. It resolves to the number of - * bytes read (`0` < `n` <= `p.byteLength`) and rejects if any error - * encountered. Even if `read()` resolves to `n` < `p.byteLength`, it may - * use all of `p` as scratch space during the call. If some data is - * available but not `p.byteLength` bytes, `read()` conventionally resolves - * to what is available instead of waiting for more. - * - * When `read()` encounters end-of-file condition, it resolves to EOF - * (`null`). - * - * When `read()` encounters an error, it rejects with an error. - * - * Callers should always process the `n` > `0` bytes returned before - * considering the EOF (`null`). Doing so correctly handles I/O errors that - * happen after reading some bytes and also both of the allowed EOF - * behaviors. - * - * @example - * ```typescript - * import { open, BaseDirectory } from "@tauri-apps/plugin-fs" - * // if "$APPCONFIG/foo/bar.txt" contains the text "hello world": - * const file = await open("foo/bar.txt", { baseDir: BaseDirectory.AppConfig }); - * const buf = new Uint8Array(100); - * const numberOfBytesRead = await file.read(buf); // 11 bytes - * const text = new TextDecoder().decode(buf); // "hello world" - * await file.close(); - * ``` - * - * @since 2.0.0 - */ - read(buffer: Uint8Array): Promise; - /** - * Seek sets the offset for the next `read()` or `write()` to offset, - * interpreted according to `whence`: `Start` means relative to the - * start of the file, `Current` means relative to the current offset, - * and `End` means relative to the end. Seek resolves to the new offset - * relative to the start of the file. - * - * Seeking to an offset before the start of the file is an error. Seeking to - * any positive offset is legal, but the behavior of subsequent I/O - * operations on the underlying object is implementation-dependent. - * It returns the number of cursor position. - * - * @example - * ```typescript - * import { open, SeekMode, BaseDirectory } from '@tauri-apps/plugin-fs'; - * - * // Given hello.txt pointing to file with "Hello world", which is 11 bytes long: - * const file = await open('hello.txt', { read: true, write: true, truncate: true, create: true, baseDir: BaseDirectory.AppLocalData }); - * await file.write(new TextEncoder().encode("Hello world")); - * - * // Seek 6 bytes from the start of the file - * console.log(await file.seek(6, SeekMode.Start)); // "6" - * // Seek 2 more bytes from the current position - * console.log(await file.seek(2, SeekMode.Current)); // "8" - * // Seek backwards 2 bytes from the end of the file - * console.log(await file.seek(-2, SeekMode.End)); // "9" (e.g. 11-2) - * - * await file.close(); - * ``` - * - * @since 2.0.0 - */ - seek(offset: number, whence: SeekMode): Promise; - /** - * Returns a {@linkcode FileInfo } for this file. - * - * @example - * ```typescript - * import { open, BaseDirectory } from '@tauri-apps/plugin-fs'; - * const file = await open("file.txt", { read: true, baseDir: BaseDirectory.AppLocalData }); - * const fileInfo = await file.stat(); - * console.log(fileInfo.isFile); // true - * await file.close(); - * ``` - * - * @since 2.0.0 - */ - stat(): Promise; - /** - * Truncates or extends this file, to reach the specified `len`. - * If `len` is not specified then the entire file contents are truncated. - * - * @example - * ```typescript - * import { open, BaseDirectory } from '@tauri-apps/plugin-fs'; - * - * // truncate the entire file - * const file = await open("my_file.txt", { read: true, write: true, create: true, baseDir: BaseDirectory.AppLocalData }); - * await file.truncate(); - * - * // truncate part of the file - * const file = await open("my_file.txt", { read: true, write: true, create: true, baseDir: BaseDirectory.AppLocalData }); - * await file.write(new TextEncoder().encode("Hello World")); - * await file.truncate(7); - * const data = new Uint8Array(32); - * await file.read(data); - * console.log(new TextDecoder().decode(data)); // Hello W - * await file.close(); - * ``` - * - * @since 2.0.0 - */ - truncate(len?: number): Promise; - /** - * Writes `p.byteLength` bytes from `p` to the underlying data stream. It - * resolves to the number of bytes written from `p` (`0` <= `n` <= - * `p.byteLength`) or reject with the error encountered that caused the - * write to stop early. `write()` must reject with a non-null error if - * would resolve to `n` < `p.byteLength`. `write()` must not modify the - * slice data, even temporarily. - * - * @example - * ```typescript - * import { open, write, BaseDirectory } from '@tauri-apps/plugin-fs'; - * const encoder = new TextEncoder(); - * const data = encoder.encode("Hello world"); - * const file = await open("bar.txt", { write: true, baseDir: BaseDirectory.AppLocalData }); - * const bytesWritten = await file.write(data); // 11 - * await file.close(); - * ``` - * - * @since 2.0.0 - */ - write(data: Uint8Array): Promise; + /** + * Reads up to `p.byteLength` bytes into `p`. It resolves to the number of + * bytes read (`0` < `n` <= `p.byteLength`) and rejects if any error + * encountered. Even if `read()` resolves to `n` < `p.byteLength`, it may + * use all of `p` as scratch space during the call. If some data is + * available but not `p.byteLength` bytes, `read()` conventionally resolves + * to what is available instead of waiting for more. + * + * When `read()` encounters end-of-file condition, it resolves to EOF + * (`null`). + * + * When `read()` encounters an error, it rejects with an error. + * + * Callers should always process the `n` > `0` bytes returned before + * considering the EOF (`null`). Doing so correctly handles I/O errors that + * happen after reading some bytes and also both of the allowed EOF + * behaviors. + * + * @example + * ```typescript + * import { open, BaseDirectory } from "@tauri-apps/plugin-fs" + * // if "$APPCONFIG/foo/bar.txt" contains the text "hello world": + * const file = await open("foo/bar.txt", { baseDir: BaseDirectory.AppConfig }); + * const buf = new Uint8Array(100); + * const numberOfBytesRead = await file.read(buf); // 11 bytes + * const text = new TextDecoder().decode(buf); // "hello world" + * await file.close(); + * ``` + * + * @since 2.0.0 + */ + read(buffer: Uint8Array): Promise; + /** + * Seek sets the offset for the next `read()` or `write()` to offset, + * interpreted according to `whence`: `Start` means relative to the + * start of the file, `Current` means relative to the current offset, + * and `End` means relative to the end. Seek resolves to the new offset + * relative to the start of the file. + * + * Seeking to an offset before the start of the file is an error. Seeking to + * any positive offset is legal, but the behavior of subsequent I/O + * operations on the underlying object is implementation-dependent. + * It returns the number of cursor position. + * + * @example + * ```typescript + * import { open, SeekMode, BaseDirectory } from '@tauri-apps/plugin-fs'; + * + * // Given hello.txt pointing to file with "Hello world", which is 11 bytes long: + * const file = await open('hello.txt', { read: true, write: true, truncate: true, create: true, baseDir: BaseDirectory.AppLocalData }); + * await file.write(new TextEncoder().encode("Hello world")); + * + * // Seek 6 bytes from the start of the file + * console.log(await file.seek(6, SeekMode.Start)); // "6" + * // Seek 2 more bytes from the current position + * console.log(await file.seek(2, SeekMode.Current)); // "8" + * // Seek backwards 2 bytes from the end of the file + * console.log(await file.seek(-2, SeekMode.End)); // "9" (e.g. 11-2) + * + * await file.close(); + * ``` + * + * @since 2.0.0 + */ + seek(offset: number, whence: SeekMode): Promise; + /** + * Returns a {@linkcode FileInfo } for this file. + * + * @example + * ```typescript + * import { open, BaseDirectory } from '@tauri-apps/plugin-fs'; + * const file = await open("file.txt", { read: true, baseDir: BaseDirectory.AppLocalData }); + * const fileInfo = await file.stat(); + * console.log(fileInfo.isFile); // true + * await file.close(); + * ``` + * + * @since 2.0.0 + */ + stat(): Promise; + /** + * Truncates or extends this file, to reach the specified `len`. + * If `len` is not specified then the entire file contents are truncated. + * + * @example + * ```typescript + * import { open, BaseDirectory } from '@tauri-apps/plugin-fs'; + * + * // truncate the entire file + * const file = await open("my_file.txt", { read: true, write: true, create: true, baseDir: BaseDirectory.AppLocalData }); + * await file.truncate(); + * + * // truncate part of the file + * const file = await open("my_file.txt", { read: true, write: true, create: true, baseDir: BaseDirectory.AppLocalData }); + * await file.write(new TextEncoder().encode("Hello World")); + * await file.truncate(7); + * const data = new Uint8Array(32); + * await file.read(data); + * console.log(new TextDecoder().decode(data)); // Hello W + * await file.close(); + * ``` + * + * @since 2.0.0 + */ + truncate(len?: number): Promise; + /** + * Writes `data.byteLength` bytes from `data` to the underlying data stream. It + * resolves to the number of bytes written from `data` (`0` <= `n` <= + * `data.byteLength`) or reject with the error encountered that caused the + * write to stop early. `write()` must reject with a non-null error if + * would resolve to `n` < `data.byteLength`. `write()` must not modify the + * slice data, even temporarily. + * + * @example + * ```typescript + * import { open, write, BaseDirectory } from '@tauri-apps/plugin-fs'; + * const encoder = new TextEncoder(); + * const data = encoder.encode("Hello world"); + * const file = await open("bar.txt", { write: true, baseDir: BaseDirectory.AppLocalData }); + * const bytesWritten = await file.write(data); // 11 + * await file.close(); + * ``` + * + * @since 2.0.0 + */ + write(data: Uint8Array): Promise; } /** * @since 2.0.0 @@ -746,11 +746,7 @@ interface WriteFileOptions { * * @since 2.0.0 */ -declare function writeFile( - path: string | URL, - data: Uint8Array, - options?: WriteFileOptions, -): Promise; +declare function writeFile(path: string | URL, data: Uint8Array | ReadableStream, options?: WriteFileOptions): Promise; /** * Writes UTF-8 string `data` to the given `path`, by default creating a new file if needed, else overwriting. @example diff --git a/dist-js/index.js b/dist-js/index.js index 9aaa11a..07f3a81 100644 --- a/dist-js/index.js +++ b/dist-js/index.js @@ -109,6 +109,7 @@ function fromBytes(buffer) { const size = bytes.byteLength; let x = 0; for (let i = 0; i < size; i++) { + // eslint-disable-next-line security/detect-object-injection const byte = bytes[i]; x *= 0x100; x += byte; @@ -260,11 +261,11 @@ class FileHandle extends Resource { }); } /** - * Writes `p.byteLength` bytes from `p` to the underlying data stream. It - * resolves to the number of bytes written from `p` (`0` <= `n` <= - * `p.byteLength`) or reject with the error encountered that caused the + * Writes `data.byteLength` bytes from `data` to the underlying data stream. It + * resolves to the number of bytes written from `data` (`0` <= `n` <= + * `data.byteLength`) or reject with the error encountered that caused the * write to stop early. `write()` must reject with a non-null error if - * would resolve to `n` < `p.byteLength`. `write()` must not modify the + * would resolve to `n` < `data.byteLength`. `write()` must not modify the * slice data, even temporarily. * * @example @@ -443,13 +444,15 @@ async function readFile(path, options) { * @since 2.0.0 */ async function readTextFile(path, options) { - if (path instanceof URL && path.protocol !== "file:") { - throw new TypeError("Must be a file URL."); - } - return await invoke("plugin:fs|read_text_file", { - path: path instanceof URL ? path.toString() : path, - options, - }); + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.'); + } + const arr = await invoke('plugin:fs|read_text_file', { + path: path instanceof URL ? path.toString() : path, + options + }); + const bytes = arr instanceof ArrayBuffer ? arr : Uint8Array.from(arr); + return new TextDecoder().decode(bytes); } /** * Returns an async {@linkcode AsyncIterableIterator} over the lines of a file as UTF-8 string. @@ -467,35 +470,43 @@ async function readTextFile(path, options) { * @since 2.0.0 */ async function readTextFileLines(path, options) { - if (path instanceof URL && path.protocol !== "file:") { - throw new TypeError("Must be a file URL."); - } - const pathStr = path instanceof URL ? path.toString() : path; - return await Promise.resolve({ - path: pathStr, - rid: null, - async next() { - if (this.rid === null) { - this.rid = await invoke("plugin:fs|read_text_file_lines", { - path: pathStr, - options, - }); - } - const [line, done] = await invoke( - "plugin:fs|read_text_file_lines_next", - { rid: this.rid }, - ); - // an iteration is over, reset rid for next iteration - if (done) this.rid = null; - return { - value: done ? "" : line, - done, - }; - }, - [Symbol.asyncIterator]() { - return this; - }, - }); + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.'); + } + const pathStr = path instanceof URL ? path.toString() : path; + return await Promise.resolve({ + path: pathStr, + rid: null, + async next() { + if (this.rid === null) { + this.rid = await invoke('plugin:fs|read_text_file_lines', { + path: pathStr, + options + }); + } + const arr = await invoke('plugin:fs|read_text_file_lines_next', { rid: this.rid }); + const bytes = arr instanceof ArrayBuffer ? new Uint8Array(arr) : Uint8Array.from(arr); + // Rust side will never return an empty array for this command and + // ensure there is at least one elements there. + // + // This is an optimization to include whether we finished iteration or not (1 or 0) + // at the end of returned array to avoid serialization overhead of separate values. + const done = bytes[bytes.byteLength - 1] === 1; + if (done) { + // a full iteration is over, reset rid for next iteration + this.rid = null; + return { value: null, done }; + } + const line = new TextDecoder().decode(bytes.slice(0, bytes.byteLength)); + return { + value: line, + done + }; + }, + [Symbol.asyncIterator]() { + return this; + } + }); } /** * Removes the named file or directory. @@ -631,17 +642,24 @@ async function truncate(path, len, options) { * @since 2.0.0 */ async function writeFile(path, data, options) { - if (path instanceof URL && path.protocol !== "file:") { - throw new TypeError("Must be a file URL."); - } - await invoke("plugin:fs|write_file", data, { - headers: { - path: encodeURIComponent( - path instanceof URL ? path.toString() : path, - ), - options: JSON.stringify(options), - }, - }); + if (path instanceof URL && path.protocol !== 'file:') { + throw new TypeError('Must be a file URL.'); + } + if (data instanceof ReadableStream) { + const file = await open(path, options); + for await (const chunk of data) { + await file.write(chunk); + } + await file.close(); + } + else { + await invoke('plugin:fs|write_file', data, { + headers: { + path: encodeURIComponent(path instanceof URL ? path.toString() : path), + options: JSON.stringify(options) + } + }); + } } /** * Writes UTF-8 string `data` to the given `path`, by default creating a new file if needed, else overwriting. diff --git a/guest-js/index.ts b/guest-js/index.ts index 91676da..2f6a92b 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -262,15 +262,16 @@ type FixedSizeArray = ReadonlyArray & { // https://gist.github.com/zapthedingbat/38ebfbedd98396624e5b5f2ff462611d /** Converts a big-endian eight byte array to number */ function fromBytes(buffer: FixedSizeArray): number { - const bytes = new Uint8ClampedArray(buffer); - const size = bytes.byteLength; - let x = 0; - for (let i = 0; i < size; i++) { - const byte = bytes[i]; - x *= 0x100; - x += byte; - } - return x; + const bytes = new Uint8ClampedArray(buffer) + const size = bytes.byteLength + let x = 0 + for (let i = 0; i < size; i++) { + // eslint-disable-next-line security/detect-object-injection + const byte = bytes[i] + x *= 0x100 + x += byte + } + return x } /** @@ -426,32 +427,32 @@ class FileHandle extends Resource { }); } - /** - * Writes `p.byteLength` bytes from `p` to the underlying data stream. It - * resolves to the number of bytes written from `p` (`0` <= `n` <= - * `p.byteLength`) or reject with the error encountered that caused the - * write to stop early. `write()` must reject with a non-null error if - * would resolve to `n` < `p.byteLength`. `write()` must not modify the - * slice data, even temporarily. - * - * @example - * ```typescript - * import { open, write, BaseDirectory } from '@tauri-apps/plugin-fs'; - * const encoder = new TextEncoder(); - * const data = encoder.encode("Hello world"); - * const file = await open("bar.txt", { write: true, baseDir: BaseDirectory.AppLocalData }); - * const bytesWritten = await file.write(data); // 11 - * await file.close(); - * ``` - * - * @since 2.0.0 - */ - async write(data: Uint8Array): Promise { - return await invoke("plugin:fs|write", { - rid: this.rid, - data, - }); - } + /** + * Writes `data.byteLength` bytes from `data` to the underlying data stream. It + * resolves to the number of bytes written from `data` (`0` <= `n` <= + * `data.byteLength`) or reject with the error encountered that caused the + * write to stop early. `write()` must reject with a non-null error if + * would resolve to `n` < `data.byteLength`. `write()` must not modify the + * slice data, even temporarily. + * + * @example + * ```typescript + * import { open, write, BaseDirectory } from '@tauri-apps/plugin-fs'; + * const encoder = new TextEncoder(); + * const data = encoder.encode("Hello world"); + * const file = await open("bar.txt", { write: true, baseDir: BaseDirectory.AppLocalData }); + * const bytesWritten = await file.write(data); // 11 + * await file.close(); + * ``` + * + * @since 2.0.0 + */ + async write(data: Uint8Array): Promise { + return await invoke('plugin:fs|write', { + rid: this.rid, + data + }) + } } /** @@ -771,10 +772,14 @@ async function readTextFile( throw new TypeError("Must be a file URL."); } - return await invoke("plugin:fs|read_text_file", { - path: path instanceof URL ? path.toString() : path, - options, - }); + const arr = await invoke('plugin:fs|read_text_file', { + path: path instanceof URL ? path.toString() : path, + options + }) + + const bytes = arr instanceof ArrayBuffer ? arr : Uint8Array.from(arr) + + return new TextDecoder().decode(bytes) } /** @@ -802,37 +807,51 @@ async function readTextFileLines( const pathStr = path instanceof URL ? path.toString() : path; - return await Promise.resolve({ - path: pathStr, - rid: null as number | null, - async next(): Promise> { - if (this.rid === null) { - this.rid = await invoke( - "plugin:fs|read_text_file_lines", - { - path: pathStr, - options, - }, - ); - } - - const [line, done] = await invoke<[string | null, boolean]>( - "plugin:fs|read_text_file_lines_next", - { rid: this.rid }, - ); - - // an iteration is over, reset rid for next iteration - if (done) this.rid = null; - - return { - value: done ? "" : line!, - done, - }; - }, - [Symbol.asyncIterator](): AsyncIterableIterator { - return this; - }, - }); + return await Promise.resolve({ + path: pathStr, + rid: null as number | null, + + async next(): Promise> { + if (this.rid === null) { + this.rid = await invoke('plugin:fs|read_text_file_lines', { + path: pathStr, + options + }) + } + + const arr = await invoke( + 'plugin:fs|read_text_file_lines_next', + { rid: this.rid } + ) + + const bytes = + arr instanceof ArrayBuffer ? new Uint8Array(arr) : Uint8Array.from(arr) + + // Rust side will never return an empty array for this command and + // ensure there is at least one elements there. + // + // This is an optimization to include whether we finished iteration or not (1 or 0) + // at the end of returned array to avoid serialization overhead of separate values. + const done = bytes[bytes.byteLength - 1] === 1 + + if (done) { + // a full iteration is over, reset rid for next iteration + this.rid = null + return { value: null, done } + } + + const line = new TextDecoder().decode(bytes.slice(0, bytes.byteLength)) + + return { + value: line, + done + } + }, + + [Symbol.asyncIterator](): AsyncIterableIterator { + return this + } + }) } /** @@ -1048,22 +1067,28 @@ interface WriteFileOptions { * @since 2.0.0 */ async function writeFile( - path: string | URL, - data: Uint8Array, - options?: WriteFileOptions, + path: string | URL, + data: Uint8Array | ReadableStream, + options?: WriteFileOptions ): Promise { if (path instanceof URL && path.protocol !== "file:") { throw new TypeError("Must be a file URL."); } - await invoke("plugin:fs|write_file", data, { - headers: { - path: encodeURIComponent( - path instanceof URL ? path.toString() : path, - ), - options: JSON.stringify(options), - }, - }); + if (data instanceof ReadableStream) { + const file = await open(path, options) + for await (const chunk of data) { + await file.write(chunk) + } + await file.close() + } else { + await invoke('plugin:fs|write_file', data, { + headers: { + path: encodeURIComponent(path instanceof URL ? path.toString() : path), + options: JSON.stringify(options) + } + }) + } } /** diff --git a/permissions/autogenerated/commands/read_text_file_lines.toml b/permissions/autogenerated/commands/read_text_file_lines.toml index 1ba629c..84b4ebb 100644 --- a/permissions/autogenerated/commands/read_text_file_lines.toml +++ b/permissions/autogenerated/commands/read_text_file_lines.toml @@ -5,9 +5,18 @@ [[permission]] identifier = "allow-read-text-file-lines" description = "Enables the read_text_file_lines command without any pre-configured scope." -commands.allow = ["read_text_file_lines"] + +[permission.commands] +allow = [ + "read_text_file_lines", + "read_text_file_lines_next", +] +deny = [] [[permission]] identifier = "deny-read-text-file-lines" description = "Denies the read_text_file_lines command without any pre-configured scope." -commands.deny = ["read_text_file_lines"] + +[permission.commands] +allow = [] +deny = ["read_text_file_lines"] diff --git a/permissions/autogenerated/commands/write_file.toml b/permissions/autogenerated/commands/write_file.toml index cb0450f..ea7d513 100644 --- a/permissions/autogenerated/commands/write_file.toml +++ b/permissions/autogenerated/commands/write_file.toml @@ -5,9 +5,19 @@ [[permission]] identifier = "allow-write-file" description = "Enables the write_file command without any pre-configured scope." -commands.allow = ["write_file"] + +[permission.commands] +allow = [ + "write_file", + "open", + "write", +] +deny = [] [[permission]] identifier = "deny-write-file" description = "Denies the write_file command without any pre-configured scope." -commands.deny = ["write_file"] + +[permission.commands] +allow = [] +deny = ["write_file"] diff --git a/src/scope.rs b/src/scope.rs new file mode 100644 index 0000000..7914706 --- /dev/null +++ b/src/scope.rs @@ -0,0 +1,19 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::path::PathBuf; + +use serde::Deserialize; + +#[derive(Debug)] +pub struct Entry { + pub path: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +pub(crate) enum EntryRaw { + Value(PathBuf), + Object { path: PathBuf }, +}