diff --git a/.changes/fix-fs-write-sets-open.md b/.changes/fix-fs-write-sets-open.md new file mode 100644 index 000000000..193aace47 --- /dev/null +++ b/.changes/fix-fs-write-sets-open.md @@ -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. diff --git a/plugins/fs/build.rs b/plugins/fs/build.rs index 34b9475df..c77870f2d 100644 --- a/plugins/fs/build.rs +++ b/plugins/fs/build.rs @@ -77,35 +77,62 @@ const BASE_DIR_VARS: &[&str] = &[ "APPCACHE", "APPLOG", ]; -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", &[]), +/// A command exposed by the plugin. +struct Command { + name: &'static str, + /// Other commands the JS API calls when using this command, + /// e.g. `writeFile` with a stream uses `open` and `write`. + /// + /// The `allow-*` permission of this command also allows its dependencies, + /// and hand-written permissions that allow this command must allow them too. + dependencies: &'static [&'static str], +} + +impl Command { + const fn new(name: &'static str) -> Self { + Self { + name, + dependencies: &[], + } + } + + const fn dependencies(self, dependencies: &'static [&'static str]) -> Self { + Self { + name: self.name, + dependencies, + } + } +} + +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 - ("unwatch", &[]), - ("size", &[]), - ("start_accessing_security_scoped_resource", &[]), - ("stop_accessing_security_scoped_resource", &[]), + Command::new("unwatch"), + Command::new("size"), + Command::new("start_accessing_security_scoped_resource"), + Command::new("stop_accessing_security_scoped_resource"), ]; fn main() { @@ -215,8 +242,8 @@ permissions = [ &COMMANDS .iter() // FIXME: https://docs.rs/crate/tauri-plugin-fs/2.1.0/builds/1571296 - .filter(|c| c.1.is_empty()) - .map(|c| c.0) + .filter(|c| c.dependencies.is_empty()) + .map(|c| c.name) .collect::>(), ) .global_api_script_path("./api-iife.js") @@ -224,10 +251,16 @@ permissions = [ .android_path("android") .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"); - for (command, nested_commands) in COMMANDS { - if nested_commands.is_empty() { + for Command { + name: command, + dependencies, + } in COMMANDS + { + if dependencies.is_empty() { continue; } @@ -244,11 +277,10 @@ permissions = [ .iter_mut() .filter(|p| p.identifier.starts_with("allow")) { - for c in nested_commands.iter().map(|s| s.to_string()) { - if !p.commands.allow.contains(&c) { - p.commands.allow.push(c); - } - } + p.commands.allow = std::iter::once(command) + .chain(dependencies.iter()) + .map(|c| c.to_string()) + .collect(); } 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::(&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") + ); + } +} diff --git a/plugins/fs/permissions/schemas/schema.json b/plugins/fs/permissions/schemas/schema.json index c274b7c99..e14c6d043 100644 --- a/plugins/fs/permissions/schemas/schema.json +++ b/plugins/fs/permissions/schemas/schema.json @@ -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" }, { - "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", "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", "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`" } ] } diff --git a/plugins/fs/permissions/write-all.toml b/plugins/fs/permissions/write-all.toml index c18027825..bd354b1d1 100644 --- a/plugins/fs/permissions/write-all.toml +++ b/plugins/fs/permissions/write-all.toml @@ -1,17 +1,17 @@ "$schema" = "schemas/schema.json" -[[permission]] +[[set]] identifier = "write-all" description = "This enables all write related commands without any pre-configured accessible paths." -commands.allow = [ - "mkdir", - "create", - "copy_file", - "remove", - "rename", - "truncate", - "ftruncate", - "write", - "write_file", - "write_text_file", +permissions = [ + "allow-mkdir", + "allow-create", + "allow-copy-file", + "allow-remove", + "allow-rename", + "allow-truncate", + "allow-ftruncate", + "allow-write", + "allow-write-file", + "allow-write-text-file", ] diff --git a/plugins/fs/permissions/write-files.toml b/plugins/fs/permissions/write-files.toml index 2d6aeffb1..434d84e0a 100644 --- a/plugins/fs/permissions/write-files.toml +++ b/plugins/fs/permissions/write-files.toml @@ -1,16 +1,16 @@ "$schema" = "schemas/schema.json" -[[permission]] +[[set]] identifier = "write-files" description = "This enables all file write related commands without any pre-configured accessible paths." -commands.allow = [ - "create", - "copy_file", - "remove", - "rename", - "truncate", - "ftruncate", - "write", - "write_file", - "write_text_file", +permissions = [ + "allow-create", + "allow-copy-file", + "allow-remove", + "allow-rename", + "allow-truncate", + "allow-ftruncate", + "allow-write", + "allow-write-file", + "allow-write-text-file", ]