tauri-plugin-fs 2.7.1

Access the file system.
Documentation
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

use std::{
    fs::create_dir_all,
    path::{Path, PathBuf},
};

use tauri_utils::acl::manifest::PermissionFile;

#[path = "src/scope.rs"]
#[allow(dead_code)]
mod scope;

/// FS scope entry.
#[derive(schemars::JsonSchema)]
#[serde(untagged)]
#[allow(unused)]
enum FsScopeEntry {
    /// A path that can be accessed by the webview when using the fs APIs.
    /// FS scope path pattern.
    ///
    /// The pattern can start with a variable that resolves to a system base directory.
    /// The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`,
    /// `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`,
    /// `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`,
    /// `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.
    Value(PathBuf),
    Object {
        /// A path that can be accessed by the webview when using the fs APIs.
        ///
        /// The pattern can start with a variable that resolves to a system base directory.
        /// The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`,
        /// `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`,
        /// `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`,
        /// `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.
        path: PathBuf,
    },
}

// Ensure `FsScopeEntry` and `scope::EntryRaw` is kept in sync
fn _f() {
    match scope::EntryRaw::Value(PathBuf::new()) {
        scope::EntryRaw::Value(path) => FsScopeEntry::Value(path),
        scope::EntryRaw::Object { path } => FsScopeEntry::Object { path },
    };
    match FsScopeEntry::Value(PathBuf::new()) {
        FsScopeEntry::Value(path) => scope::EntryRaw::Value(path),
        FsScopeEntry::Object { path } => scope::EntryRaw::Object { path },
    };
}

const BASE_DIR_VARS: &[&str] = &[
    "AUDIO",
    "CACHE",
    "CONFIG",
    "DATA",
    "LOCALDATA",
    "DESKTOP",
    "DOCUMENT",
    "DOWNLOAD",
    "EXE",
    "FONT",
    "HOME",
    "PICTURE",
    "PUBLIC",
    "RUNTIME",
    "TEMPLATE",
    "VIDEO",
    "RESOURCE",
    "LOG",
    "TEMP",
    "APPCONFIG",
    "APPDATA",
    "APPLOCALDATA",
    "APPCACHE",
    "APPLOG",
];
/// 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
    Command::new("unwatch"),
    Command::new("size"),
    Command::new("start_accessing_security_scoped_resource"),
    Command::new("stop_accessing_security_scoped_resource"),
];

fn main() {
    let autogenerated = Path::new("permissions/autogenerated/");
    let base_dirs = &autogenerated.join("base-directories");

    if !base_dirs.exists() {
        create_dir_all(base_dirs).expect("unable to create autogenerated base directories dir");
    }

    for base_dir in BASE_DIR_VARS {
        let upper = base_dir;
        let lower = base_dir.to_lowercase();
        let toml = format!(
            r###"# Automatically generated - DO NOT EDIT!

"$schema" = "../../schemas/schema.json"

# Scopes Section
# This section contains scopes, which define file level access

[[permission]]
identifier = "scope-{lower}-recursive"
description = "This scope permits recursive access to the complete `${upper}` folder, including sub directories and files."

[[permission.scope.allow]]
path = "${upper}"
[[permission.scope.allow]]
path = "${upper}/**"

[[permission]]
identifier = "scope-{lower}"
description = "This scope permits access to all files and list content of top level directories in the `${upper}` folder."

[[permission.scope.allow]]
path = "${upper}"
[[permission.scope.allow]]
path = "${upper}/*"

[[permission]]
identifier = "scope-{lower}-index"
description = "This scope permits to list all files and folders in the `${upper}`folder."

[[permission.scope.allow]]
path = "${upper}"

# Sets Section
# This section combines the scope elements with enablement of commands

[[set]]
identifier = "allow-{lower}-read-recursive"
description = "This allows full recursive read access to the complete `${upper}` folder, files and subdirectories."
permissions = [
    "read-all",
    "scope-{lower}-recursive"
]

[[set]]
identifier = "allow-{lower}-write-recursive"
description = "This allows full recursive write access to the complete `${upper}` folder, files and subdirectories."
permissions = [
    "write-all",
    "scope-{lower}-recursive"
]

[[set]]
identifier = "allow-{lower}-read"
description = "This allows non-recursive read access to the `${upper}` folder."
permissions = [
    "read-all",
    "scope-{lower}"
]

[[set]]
identifier = "allow-{lower}-write"
description = "This allows non-recursive write access to the `${upper}` folder."
permissions = [
    "write-all",
    "scope-{lower}"
]

[[set]]
identifier = "allow-{lower}-meta-recursive"
description = "This allows full recursive read access to metadata of the `${upper}` folder, including file listing and statistics."
permissions = [
    "read-meta",
    "scope-{lower}-recursive"
]

[[set]]
identifier = "allow-{lower}-meta"
description = "This allows non-recursive read access to metadata of the `${upper}` folder, including file listing and statistics."
permissions = [
    "read-meta",
    "scope-{lower}-index"
]"###
        );

        let permission_path = base_dirs.join(format!("{lower}.toml"));
        if toml != std::fs::read_to_string(&permission_path).unwrap_or_default() {
            std::fs::write(permission_path, toml)
                .unwrap_or_else(|e| panic!("unable to autogenerate ${lower}: {e}"));
        }
    }

    tauri_plugin::Builder::new(
        &COMMANDS
            .iter()
            // FIXME: https://docs.rs/crate/tauri-plugin-fs/2.1.0/builds/1571296
            .filter(|c| c.dependencies.is_empty())
            .map(|c| c.name)
            .collect::<Vec<_>>(),
    )
    .global_api_script_path("./api-iife.js")
    .global_scope_schema(schemars::schema_for!(FsScopeEntry))
    .android_path("android")
    .build();

    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 {
        name: command,
        dependencies,
    } in COMMANDS
    {
        if dependencies.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 = std::iter::once(command)
                .chain(dependencies.iter())
                .map(|c| c.to_string())
                .collect();
        }

        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}"#
        );

        if content != out {
            std::fs::write(permission_path, out)
                .unwrap_or_else(|_| panic!("failed to write {command}.toml"));
        }
    }
}

/// 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")
        );
    }
}