Rust in Everything
基于 tauri 2.12.5

编译期解析

主线位置:启动主线第一步 generate_context! 里的一小段。宏读到 tauri-build 写好的清单表和能力文件后调用 Resolved::resolve,结果作为 Context 的一部分编进二进制;调用主线上 on_message 查的就是这张表。本页讲这张表怎么算出来、长什么样,以及运行时 add_capability 怎么往里追加。

flowchart TB
  caps["能力 BTreeMap"] --> active["按平台过滤"]
  active --> entry["逐个 PermissionEntry"]
  manifests["清单表"] --> expand["get_permissions 展开集合"]
  entry --> expand
  expand --> merge["合并作用域"]
  merge --> check{"有命令吗"}
  check -->|没有| gscope["global_scope 按插件"]
  check -->|有| sid["分配 scope_id"]
  sid --> rc["resolve_command 每个来源一条"]
  rc --> allowed["allowed_commands"]
  rc --> denied["denied_commands"]

输入从哪来、宏怎么读文件,在 tauri-build 和 generate_context! 已经讲过:清单表 BTreeMap<String, Manifest> 来自 OUT_DIR/acl-manifests.json,能力表来自 capabilities.json 再由 get_capabilities 合并配置。本页从 Resolved::resolve 开始。实验沿用上一页的 sec-acl,平台 M2 Max、macOS 27、rustc 1.99.0。

结果的形状

Resolved 有五个字段:

pub struct Resolved {
  /// If we should check the ACL for the app commands
  pub has_app_acl: bool,
  /// The commands that are allowed. Map each command with its context to a [`ResolvedCommand`].
  pub allowed_commands: BTreeMap<String, Vec<ResolvedCommand>>,
  /// The commands that are denied. Map each command with its context to a [`ResolvedCommand`].
  pub denied_commands: BTreeMap<String, Vec<ResolvedCommand>>,
  /// The store of scopes referenced by a [`ResolvedCommand`].
  pub command_scope: BTreeMap<ScopeKey, ResolvedScope>,
  /// The global scope.
  pub global_scope: BTreeMap<String, ResolvedScope>,
}

两张命令表的键是 IPC 里的命令名(greet、plugin:files|read),值是一列 ResolvedCommand:一个执行上下文(Local 或 Remote { url })、一列窗口 glob、一列 webview glob、一个可选的作用域编号;debug 构建里还有 referenced_by,记下是哪份能力的哪条权限带来的。作用域不直接挂在命令上,而是存进 command_scope,用 u64 编号引用:同一份作用域可能被一条权限里的多个命令共享,存一次就够了。has_app_acl 不是解析出来的,是看清单表里有没有 __app-acl__ 这个键,它的作用见运行时检查。

ResolvedCommandReference 的字段带 #[cfg(debug_assertions)],结构体本身不带。源码注释说这是为了 #13865,避免「different debug_assertions settings」下的编译错误:生成这段代码的过程宏(以及它依赖的 tauri-utils)和使用它的 crate 不一定用同一套编译设置,所以宏生成的是 ResolvedCommandReference::new(capability, permission),release 下这个构造函数把参数丢掉。

一步一步

外层循环只看当前平台生效的能力(capability.is_active(&target)),对每个能力调 with_resolved_permissions,它对能力里的每个 PermissionEntry 做三件事:

  1. get_permissions 按前缀找清单(没有前缀就是 __app-acl__),把 default 或权限集递归展开成一列具体的 Permission,找不到报 UnknownManifest / UnknownPermission,再按权限自己的 platforms 过滤。
  2. 对展开出的每条权限,把能力里追加的作用域和权限自带的作用域并到一起。所以能力给一个权限集追加作用域时,集合里每条权限都会带上这份作用域。
  3. 交给回调。

回调里分两路:

          if commands.allow.is_empty() && commands.deny.is_empty() {
            // global scope
            global_scope.entry(key).or_default().push(scope);
          } else {
            let scope_id = if scope.allow.is_some() || scope.deny.is_some() {
              current_scope_id += 1;
              command_scope.insert(
                current_scope_id,
                ResolvedScope {
                  allow: scope.allow.unwrap_or_default(),
                  deny: scope.deny.unwrap_or_default(),
                },
              );
              Some(current_scope_id)
            } else {
              None
            };

            for allowed_command in &commands.allow {
              resolve_command(
                &mut allowed_commands,
                if key == APP_ACL_KEY {
                  allowed_command.to_string()
                } else if let Some(core_plugin_name) = key.strip_prefix("core:") {
                  format!("plugin:{core_plugin_name}|{allowed_command}")
                } else {
                  format!("plugin:{key}|{allowed_command}")
                },
                // ...

命令名在这里从「清单 + 命令」变成 IPC 里用的名字:应用清单的命令原样,core:window 的 close 变成 plugin:window|close(去掉 core:,所以 JS 侧调的还是 plugin:window|...),其余插件是 plugin:<清单名>|<命令>。拒绝列表走同样的路,进 denied_commands。

resolve_command 按能力的来源展开:local 为真加一条 Local,remote.urls 每个模式加一条 Remote。每条都带上这份能力的 windows、webviews(排序后编译成 glob::Pattern)和刚才的作用域编号。远程模式解析失败直接 panic!("invalid URL pattern for remote URL ...");编译期这一步在 generate_context! 的展开过程中执行。

所有能力处理完,global_scope 里同一个键下的多份作用域合并成一份 ResolvedScope。

这里没有「拒绝优先」的逻辑。同一个命令可以同时出现在 allowed_commands 和 denied_commands 里,两份都原样保留,裁决留给运行时的 resolve_access。目录里说的「拒绝优先」发生在运行时,而且规则和直觉不一样,见运行时检查。

一个小应用的 Resolved

sec-acl 的测试读出 OUT_DIR 里的两份 JSON,自己调一次 Resolved::resolve(和 generate_context! 里那一次输入相同),逐条打印:

    let r = Resolved::resolve(&acl, caps, tauri::utils::platform::Target::current()).unwrap();
    println!("has_app_acl: {}", r.has_app_acl);
    for (name, map) in [("allowed_commands", &r.allowed_commands), ("denied_commands", &r.denied_commands)] {
      println!("{name}:");
      for (cmd, list) in map {
        for c in list {
          #[cfg(debug_assertions)]
          let by = format!(" by {}/{}", c.referenced_by.capability, c.referenced_by.permission);
          #[cfg(not(debug_assertions))]
          let by = String::new();
          println!(
            "  {cmd:<18} {:?} windows={:?} webviews={:?} scope_id={:?}{by}",
            c.context,
            c.windows.iter().map(|p| p.as_str()).collect::<Vec<_>>(),
            c.webviews.iter().map(|p| p.as_str()).collect::<Vec<_>>(),
            c.scope_id
          );
        }
      }
    }
    println!("command_scope: {:?}", r.command_scope);
    println!("global_scope: {:?}", r.global_scope);
❗IMPORTANT
动脑筋:main 能力里和 plugin:files|read 有关的有三项:files:allow-read-tmp、带追加作用域的 files:allow-read、files:global-deny-secret。allowed_commands["plugin:files|read"] 这个列表有几个元素?

能力 main 一共引用了十项,audit 一项。allowed_commands 的键不会比引用的权限多,作用域编号从 1 开始连续分配,只有带作用域、又带命令的权限才拿编号。main 能力没有引用 core:default,所以表里没有任何 plugin:window|... 这类命令;真实应用一般都会引用它,表会长出几百行。

运行时追加:add_capability

Resolved 编进二进制以后,由 runtime_authority! 宏变成 RuntimeAuthority(宏的两个版本见 generate_context!)。开了 dynamic-acl feature(默认开)时,它留着完整的清单表,于是运行时可以再解析能力。入口是 Manager 上的 add_capability,参数是任何实现了 RuntimeCapability 的东西:一个 JSON 字符串(impl<T: AsRef<str>>,解析失败 expect("invalid capability")),或者 CapabilityBuilder。

add_capability_inner 对新能力做一次完整的解析,再把五张表合并进已有的:

    let resolved = Resolved::resolve_with_base_scope_id(
      &self.acl,
      capabilities,
      tauri_utils::platform::Target::current(),
      self.scope_manager.last_scope_id(),
    )
    .unwrap();

resolve_with_base_scope_id 和 resolve 是同一个函数,只是作用域编号从已有的最大编号之后开始分,新旧编号不会撞。全局作用域按插件名追加,并把全局作用域的缓存清空;命令表按命令名追加到已有的 Vec 后面。

📝NOTE
冷知识:编号曾经从 1 重新数起

resolve_with_base_scope_id 是 #15291(首次发布于 tauri 2.12.0)加的。在此之前 add_capability 直接调 resolve,新能力的作用域编号又从 1 开始,和编译期那张表里的编号重叠,合并时把新作用域并进了别的命令的作用域。CHANGELOG 原文:「Fix capabilities added at runtime through add_capability (feature dynamic-acl) being merged into the command scopes of the build time ACL, which polluted unrelated plugin scopes and made their deserialization fail.」resolve 上方留了一条注释,说 v3 要把 base_scope_id 直接做成 resolve 的参数,「so that callers merging into an already resolved ACL cannot forget to offset the scope ids」。

sec-acl 在 main 窗口里模拟一个来自 http://127.0.0.1:18970/page.html 的请求,追加能力前后各发一次:

    call(&main, "greet", remote, serde_json::json!({ "name": "rie" }));
    app
      .add_capability(
        tauri::ipc::CapabilityBuilder::new("remote-greet")
          .remote("http://127.0.0.1:18970/*".into())
          .local(false)
          .window("main")
          .permission("allow-greet"),
      )
      .unwrap();
    call(&main, "greet", remote, serde_json::json!({ "name": "rie" }));
    call(&main, "greet", "http://127.0.0.1:18971/page.html", serde_json::json!({ "name": "rie" }));
[main @ http://127.0.0.1:18970/page.html] greet -> Err:
    greet not allowed on window "main", webview "main", URL: http://127.0.0.1:18970/page.html
    
    allowed on: [windows: "main", URL: local]
    
    referenced by: capability: main, permission: allow-greet
[main @ http://127.0.0.1:18970/page.html] greet -> Ok("hello, rie")
[main @ http://127.0.0.1:18971/page.html] greet -> Err:
    greet not allowed on window "main", webview "main", URL: http://127.0.0.1:18971/page.html
    
    allowed on: [windows: "main", URL: local], [windows: "main", URL: http://127.0.0.1:18970/*]
    
    referenced by: capability: main, permission: allow-greet || capability: remote-greet, permission: allow-greet

第三次的拒绝信息里,greet 的允许列表已经有两条,第二条就是刚追加的。--release 构建里第二次同样成功(dynamic-acl 不依赖 debug_assertions),拒绝信息变成一句 Command greet not allowed by ACL。在真实 webview 里用同样的能力放行一个本地 HTTP 服务上的页面,见运行时检查。

add_capability 的签名返回 Result,但解析失败走的是上面那个 .unwrap()。权限名写错时:

      app.add_capability(
        tauri::ipc::CapabilityBuilder::new("typo").window("main").permission("allow-gret"),
      )
thread 'tests::add_capability_typo' (2609264) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.12.5/src/ipc/authority.rs:180:6:
called `Result::unwrap()` on an `Err` value: UnknownPermission { key: "app manifest", permission: "allow-gret" }

调用方拿不到 Err,进程 panic,而且是在持有 runtime_authority 的 Mutex 时 panic 的,锁会中毒。这和编译期的行为差得很远:同样的拼写错误在构建时会得到「did you mean "allow-greet"?」(见 tauri-build)。在 setup 里调用它的应用,等于把一个构建期错误推迟成了启动时崩溃。

动脑筋答案

两个。dump_resolved 测试的输出(debug 构建):

manifests: ["__app-acl__", "core", "core:app", "core:event", "core:image", "core:menu", "core:path", "core:resources", "core:tray", "core:webview", "core:window", "files", "inline", "notes"]
has_app_acl: true
allowed_commands:
  greet              Local windows=["main"] webviews=[] scope_id=None by main/allow-greet
  open_doc           Local windows=["main"] webviews=[] scope_id=Some(1) by main/allow-open-docs
  plugin:files|read  Local windows=["main"] webviews=[] scope_id=Some(2) by main/allow-read-tmp
  plugin:files|read  Local windows=["main"] webviews=[] scope_id=Some(3) by main/allow-read
  plugin:inline|read Local windows=["main"] webviews=[] scope_id=None by main/allow-read
  plugin:notes|read  Local windows=["main"] webviews=[] scope_id=None by main/allow-read
  wipe               Local windows=["main"] webviews=[] scope_id=None by main/allow-wipe
denied_commands:
  wipe               Local windows=["audit"] webviews=[] scope_id=None by audit/deny-wipe
command_scope: {1: ResolvedScope { allow: [Map({"path": String("/docs/*")})], deny: [Map({"path": String("/docs/private/*")})] }, 2: ResolvedScope { allow: [Map({"path": String("/tmp/*")})], deny: [] }, 3: ResolvedScope { allow: [Map({"path": String("/home/*")})], deny: [] }}
global_scope: {"__app-acl__": ResolvedScope { allow: [Map({"path": String("/app/*")})], deny: [] }, "files": ResolvedScope { allow: [], deny: [Map({"path": String("*secret*")})] }, "notes": ResolvedScope { allow: [Map({"path": String("/notes/*")})], deny: [] }}

逐项看:

  • files:allow-read-tmp 和 files:allow-read 都允许 read,各生成一条 ResolvedCommand,作用域编号分别是 2(权限自带的 /tmp/*)和 3(能力追加的 /home/*)。两条不合并,运行时 CommandScope 会把所有命中条目的作用域拼在一起,上一页看到的 allow=["/tmp/*", "/home/*"] 就是这么来的。
  • files:global-deny-secret 没有命令,不在任何命令表里,只出现在 global_scope["files"]。
  • 应用的 global-app 也没有命令,进了 global_scope["__app-acl__"]。
  • wipe 同时在两张表里,denied_commands 那条的窗口是 ["audit"]。解析不管冲突。
  • 清单表的键里有 core,它只有一个默认权限集,见 tauri-build。
  • --release 构建的输出相同,只是每行少了 by .../...:referenced_by 在 release 里不存在。

小结

  • Resolved::resolve 对每份生效的能力展开权限(default、权限集递归展开、按平台过滤),把能力追加的作用域并进每条权限。
  • 有命令的权限按来源(local 一条、每个 remote.urls 一条)生成 ResolvedCommand,带窗口和 webview 的 glob、作用域编号;作用域另存在 command_scope。
  • 没有命令的权限只贡献作用域,按清单名进 global_scope;应用的键是 __app-acl__。
  • 允许和拒绝分开存,解析阶段不裁决冲突。
  • dynamic-acl 下 add_capability 用同一个解析函数,作用域编号接着已有的分;解析失败时它 panic 而不是返回 Err。

💬 关联讨论

发起讨论
加载中...