fix(fs): allow open, seek and fstat in the write permission sets (#3630)

* fix(fs): allow open, seek and fstat in the write permission sets

The stream path of writeFile calls `open`, which only the nested command
list of `allow-write-file` granted (plugins/fs/permissions/write-all.toml,
write-files.toml), so `fs:allow-appdata-write-recursive` and the other
write sets rejected streamed writes; seek/fstat were missing for
FileHandles created under these sets.

Adds open, seek and fstat to write-all and write-files. This widens the
write sets slightly (open is already implied by allow-write-file); the
generated reference does not list set members, so nothing to regenerate.

* use dependencies instead
This commit is contained in:
Lucas Fernandes Nogueira authored and GitHub committed 2026-10-10 16:06:43 -03:00
1 parent db25585357
commit 6a263bec1c
5 files changed
+147 -65

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
fs: patch
fs-js: patch
---
The `write-all` and `write-files` permissions (and so every `allow-*-write[-recursive]` set) are now sets of the `allow-*` command permissions, so they also allow the commands a command depends on. `writeFile` with a `ReadableStream` uses `open` and failed with "not allowed" under these sets.
+114 -38
View File
@@ -77,35 +77,62 @@ const BASE_DIR_VARS: &[&str] = &[
"APPCACHE", "APPCACHE",
"APPLOG", "APPLOG",
]; ];
const COMMANDS: &[(&str, &[&str])] = &[ /// A command exposed by the plugin.
("mkdir", &[]), struct Command {
("create", &[]), name: &'static str,
("copy_file", &[]), /// Other commands the JS API calls when using this command,
("remove", &[]), /// e.g. `writeFile` with a stream uses `open` and `write`.
("rename", &[]), ///
("truncate", &[]), /// The `allow-*` permission of this command also allows its dependencies,
("ftruncate", &[]), /// and hand-written permissions that allow this command must allow them too.
("write", &[]), dependencies: &'static [&'static str],
("write_file", &["open", "write"]), }
("write_text_file", &[]),
("read_dir", &[]), impl Command {
("read_file", &[]), const fn new(name: &'static str) -> Self {
("read", &[]), Self {
("open", &[]), name,
("read_text_file", &[]), dependencies: &[],
("read_text_file_lines", &["read_text_file_lines_next"]), }
("read_text_file_lines_next", &[]), }
("seek", &[]),
("stat", &[]), const fn dependencies(self, dependencies: &'static [&'static str]) -> Self {
("lstat", &[]), Self {
("fstat", &[]), name: self.name,
("exists", &[]), dependencies,
("watch", &[]), }
}
}
const COMMANDS: &[Command] = &[
Command::new("mkdir"),
Command::new("create"),
Command::new("copy_file"),
Command::new("remove"),
Command::new("rename"),
Command::new("truncate"),
Command::new("ftruncate"),
Command::new("write"),
Command::new("write_file").dependencies(&["open", "write"]),
Command::new("write_text_file"),
Command::new("read_dir"),
Command::new("read_file"),
Command::new("read"),
Command::new("open"),
Command::new("read_text_file"),
Command::new("read_text_file_lines").dependencies(&["read_text_file_lines_next"]),
Command::new("read_text_file_lines_next"),
Command::new("seek"),
Command::new("stat"),
Command::new("lstat"),
Command::new("fstat"),
Command::new("exists"),
Command::new("watch"),
// TODO: Remove this in v3 // TODO: Remove this in v3
("unwatch", &[]), Command::new("unwatch"),
("size", &[]), Command::new("size"),
("start_accessing_security_scoped_resource", &[]), Command::new("start_accessing_security_scoped_resource"),
("stop_accessing_security_scoped_resource", &[]), Command::new("stop_accessing_security_scoped_resource"),
]; ];
fn main() { fn main() {
@@ -215,8 +242,8 @@ permissions = [
&COMMANDS &COMMANDS
.iter() .iter()
// FIXME: https://docs.rs/crate/tauri-plugin-fs/2.1.0/builds/1571296 // FIXME: https://docs.rs/crate/tauri-plugin-fs/2.1.0/builds/1571296
.filter(|c| c.1.is_empty()) .filter(|c| c.dependencies.is_empty())
.map(|c| c.0) .map(|c| c.name)
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )
.global_api_script_path("./api-iife.js") .global_api_script_path("./api-iife.js")
@@ -224,10 +251,16 @@ permissions = [
.android_path("android") .android_path("android")
.build(); .build();
// workaround to include nested permissions as `tauri_plugin` doesn't support it validate_command_dependencies(Path::new("permissions"));
// workaround to include command dependencies as `tauri_plugin` doesn't support it
let permissions_dir = autogenerated.join("commands"); let permissions_dir = autogenerated.join("commands");
for (command, nested_commands) in COMMANDS { for Command {
if nested_commands.is_empty() { name: command,
dependencies,
} in COMMANDS
{
if dependencies.is_empty() {
continue; continue;
} }
@@ -244,11 +277,10 @@ permissions = [
.iter_mut() .iter_mut()
.filter(|p| p.identifier.starts_with("allow")) .filter(|p| p.identifier.starts_with("allow"))
{ {
for c in nested_commands.iter().map(|s| s.to_string()) { p.commands.allow = std::iter::once(command)
if !p.commands.allow.contains(&c) { .chain(dependencies.iter())
p.commands.allow.push(c); .map(|c| c.to_string())
} .collect();
}
} }
let out = toml::to_string_pretty(&permission_file) let out = toml::to_string_pretty(&permission_file)
@@ -267,3 +299,47 @@ permissions = [
} }
} }
} }
/// Ensures every hand-written permission that allows a command also allows its dependencies.
fn validate_command_dependencies(permissions_dir: &Path) {
let mut errors = Vec::new();
for entry in std::fs::read_dir(permissions_dir).expect("failed to read permissions dir") {
let path = entry.expect("failed to read permissions dir entry").path();
if path.extension().and_then(|ext| ext.to_str()) != Some("toml") {
continue;
}
println!("cargo:rerun-if-changed={}", path.display());
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
let permission_file = toml::from_str::<PermissionFile>(&content)
.unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display()));
for permission in &permission_file.permission {
let allowed = &permission.commands.allow;
for command in COMMANDS
.iter()
.filter(|c| allowed.iter().any(|a| a == c.name))
{
for dependency in command.dependencies {
if !allowed.iter().any(|a| a == dependency) {
errors.push(format!(
"{}: `{}` allows `{}` but not its dependency `{dependency}`",
path.display(),
permission.identifier,
command.name,
));
}
}
}
}
}
if !errors.is_empty() {
panic!(
"permissions are missing command dependencies:\n{}",
errors.join("\n")
);
}
}
+4 -4
View File
@@ -2035,16 +2035,16 @@
"markdownDescription": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n" "markdownDescription": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n"
}, },
{ {
"description": "This enables all write related commands without any pre-configured accessible paths.", "description": "This enables all write related commands without any pre-configured accessible paths.\n#### This permission set includes:\n\n- `allow-mkdir`\n- `allow-create`\n- `allow-copy-file`\n- `allow-remove`\n- `allow-rename`\n- `allow-truncate`\n- `allow-ftruncate`\n- `allow-write`\n- `allow-write-file`\n- `allow-write-text-file`",
"type": "string", "type": "string",
"const": "write-all", "const": "write-all",
"markdownDescription": "This enables all write related commands without any pre-configured accessible paths." "markdownDescription": "This enables all write related commands without any pre-configured accessible paths.\n#### This permission set includes:\n\n- `allow-mkdir`\n- `allow-create`\n- `allow-copy-file`\n- `allow-remove`\n- `allow-rename`\n- `allow-truncate`\n- `allow-ftruncate`\n- `allow-write`\n- `allow-write-file`\n- `allow-write-text-file`"
}, },
{ {
"description": "This enables all file write related commands without any pre-configured accessible paths.", "description": "This enables all file write related commands without any pre-configured accessible paths.\n#### This permission set includes:\n\n- `allow-create`\n- `allow-copy-file`\n- `allow-remove`\n- `allow-rename`\n- `allow-truncate`\n- `allow-ftruncate`\n- `allow-write`\n- `allow-write-file`\n- `allow-write-text-file`",
"type": "string", "type": "string",
"const": "write-files", "const": "write-files",
"markdownDescription": "This enables all file write related commands without any pre-configured accessible paths." "markdownDescription": "This enables all file write related commands without any pre-configured accessible paths.\n#### This permission set includes:\n\n- `allow-create`\n- `allow-copy-file`\n- `allow-remove`\n- `allow-rename`\n- `allow-truncate`\n- `allow-ftruncate`\n- `allow-write`\n- `allow-write-file`\n- `allow-write-text-file`"
} }
] ]
} }
+12 -12
View File
@@ -1,17 +1,17 @@
"$schema" = "schemas/schema.json" "$schema" = "schemas/schema.json"
[[permission]] [[set]]
identifier = "write-all" identifier = "write-all"
description = "This enables all write related commands without any pre-configured accessible paths." description = "This enables all write related commands without any pre-configured accessible paths."
commands.allow = [ permissions = [
"mkdir", "allow-mkdir",
"create", "allow-create",
"copy_file", "allow-copy-file",
"remove", "allow-remove",
"rename", "allow-rename",
"truncate", "allow-truncate",
"ftruncate", "allow-ftruncate",
"write", "allow-write",
"write_file", "allow-write-file",
"write_text_file", "allow-write-text-file",
] ]
+11 -11
View File
@@ -1,16 +1,16 @@
"$schema" = "schemas/schema.json" "$schema" = "schemas/schema.json"
[[permission]] [[set]]
identifier = "write-files" identifier = "write-files"
description = "This enables all file write related commands without any pre-configured accessible paths." description = "This enables all file write related commands without any pre-configured accessible paths."
commands.allow = [ permissions = [
"create", "allow-create",
"copy_file", "allow-copy-file",
"remove", "allow-remove",
"rename", "allow-rename",
"truncate", "allow-truncate",
"ftruncate", "allow-ftruncate",
"write", "allow-write",
"write_file", "allow-write-file",
"write_text_file", "allow-write-text-file",
] ]