Merge branch 'v2' into fix/fs/scope-escape

pull/2070/head
Amr Bashir 8 months ago committed by GitHub
commit cc182fb6a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,6 @@
---
"fs": "patch"
"fs-js": "patch"
---
Improve performance of `readTextFile` and `readTextFileLines` APIs

@ -0,0 +1,7 @@
---
"fs": "patch"
"fs-js": "patch"
---
Add support for using `ReadableStream<Unit8Array>` with `writeFile` API.

2
Cargo.lock generated

@ -6565,7 +6565,9 @@ dependencies = [
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.3",
"toml 0.8.19",
"url",
"uuid",
]

@ -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 }

File diff suppressed because one or more lines are too long

@ -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)
tauri_plugin::Builder::new(&COMMANDS.iter().map(|c| c.0).collect::<Vec<_>>())
.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::<PermissionFile>(&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"));
}
}

@ -266,6 +266,7 @@ function fromBytes(buffer: FixedSizeArray<number, 8>): number {
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
@ -427,11 +428,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
@ -769,10 +770,14 @@ async function readTextFile(
throw new TypeError('Must be a file URL.')
}
return await invoke<string>('plugin:fs|read_text_file', {
const arr = await invoke<ArrayBuffer | number[]>('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)
}
/**
@ -803,6 +808,7 @@ async function readTextFileLines(
return await Promise.resolve({
path: pathStr,
rid: null as number | null,
async next(): Promise<IteratorResult<string>> {
if (this.rid === null) {
this.rid = await invoke<number>('plugin:fs|read_text_file_lines', {
@ -811,19 +817,35 @@ async function readTextFileLines(
})
}
const [line, done] = await invoke<[string | null, boolean]>(
const arr = await invoke<ArrayBuffer | number[]>(
'plugin:fs|read_text_file_lines_next',
{ rid: this.rid }
)
// an iteration is over, reset rid for next iteration
if (done) this.rid = null
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: done ? '' : line!,
value: line,
done
}
},
[Symbol.asyncIterator](): AsyncIterableIterator<string> {
return this
}
@ -1044,19 +1066,27 @@ interface WriteFileOptions {
*/
async function writeFile(
path: string | URL,
data: Uint8Array,
data: Uint8Array | ReadableStream<Uint8Array>,
options?: WriteFileOptions
): Promise<void> {
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)
}
})
}
}
/**

@ -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"]

@ -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"]

@ -15,7 +15,7 @@ use tauri::{
use std::{
borrow::Cow,
fs::File,
io::{BufReader, Lines, Read, Write},
io::{BufRead, BufReader, Read, Write},
path::{Path, PathBuf},
str::FromStr,
sync::Mutex,
@ -372,6 +372,7 @@ pub async fn read_file<R: Runtime>(
Ok(tauri::ipc::Response::new(contents))
}
// TODO, remove in v3, rely on `read_file` command instead
#[tauri::command]
pub async fn read_text_file<R: Runtime>(
webview: Webview<R>,
@ -379,33 +380,8 @@ pub async fn read_text_file<R: Runtime>(
command_scope: CommandScope<Entry>,
path: SafeFilePath,
options: Option<BaseOptions>,
) -> CommandResult<String> {
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<tauri::ipc::Response> {
read_file(webview, global_scope, command_scope, path, options).await
}
#[tauri::command]
@ -416,8 +392,6 @@ pub fn read_text_file_lines<R: Runtime>(
path: SafeFilePath,
options: Option<BaseOptions>,
) -> CommandResult<ResourceId> {
use std::io::BufRead;
let resolved_path = resolve_path(
&webview,
&global_scope,
@ -433,7 +407,7 @@ pub fn read_text_file_lines<R: Runtime>(
)
})?;
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<R: Runtime>(
pub async fn read_text_file_lines_next<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> CommandResult<(Option<String>, bool)> {
) -> CommandResult<tauri::ipc::Response> {
let mut resource_table = webview.resources_table();
let lines = resource_table.get::<StdLinesResource>(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<Vec<u8>> {
// 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<R: Runtime>(
#[tauri::command]
pub async fn write_file<R: Runtime>(
webview: Webview<R>,
global_scope: &GlobalScope<Entry>,
command_scope: &CommandScope<Entry>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
request: tauri::ipc::Request<'_>,
) -> CommandResult<()> {
let data = match request.body() {
@ -839,8 +824,8 @@ fn write_file_inner<R: Runtime>(
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<R: Runtime>(
.map_err(Into::into)
}
#[tauri::command]
pub async fn write_file<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
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<R: Runtime>(
webview: Webview<R>,
@ -901,7 +876,7 @@ pub async fn write_text_file<R: Runtime>(
command_scope: CommandScope<Entry>,
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]
@ -1091,14 +1066,38 @@ impl StdFileResource {
impl Resource for StdFileResource {}
struct StdLinesResource(Mutex<Lines<BufReader<File>>>);
/// Same as [std::io::Lines] but with bytes
struct LinesBytes<T: BufRead>(T);
impl<B: BufRead> Iterator for LinesBytes<B> {
type Item = std::io::Result<Vec<u8>>;
fn next(&mut self) -> Option<std::io::Result<Vec<u8>>> {
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<LinesBytes<BufReader<File>>>);
impl StdLinesResource {
fn new(lines: Lines<BufReader<File>>) -> Self {
Self(Mutex::new(lines))
fn new(lines: BufReader<File>) -> Self {
Self(Mutex::new(LinesBytes(lines)))
}
fn with_lock<R, F: FnMut(&mut Lines<BufReader<File>>) -> R>(&self, mut f: F) -> R {
fn with_lock<R, F: FnMut(&mut LinesBytes<BufReader<File>>) -> R>(&self, mut f: F) -> R {
let mut lines = self.0.lock().unwrap();
f(&mut lines)
}
@ -1197,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;
@ -1211,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::<String>();
let string2 = BufReader::new(bytes)
.lines()
.map_while(Result::ok)
.collect::<String>();
let string3 = LinesBytes(BufReader::new(bytes))
.flatten()
.flat_map(String::from_utf8)
.collect::<String>();
assert_eq!(string1, string2);
assert_eq!(string1, string3);
assert_eq!(string2, string3);
}
}

@ -15,7 +15,7 @@ rustdoc-args = ["--cfg", "docsrs"]
targets = ["x86_64-unknown-linux-gnu", "x86_64-linux-android"]
[package.metadata.platforms.support]
windows = { level = "full", notes = "" }
windows = { level = "full", notes = "Only works for installed apps. Shows powershell name & icon in development." }
linux = { level = "full", notes = "" }
macos = { level = "full", notes = "" }
android = { level = "full", notes = "" }

Loading…
Cancel
Save