Rust in Everything
基于 tauri 2.12.5

asset:// 协议与文件作用域

主线位置:启动主线上 prepare_pending_webview 挂协议那一步的一个分支。开了 protocol-asset feature,每个 webview 除了 tauri、ipc 之外还挂一个 asset scheme;页面用 convertFileSrc 生成的 URL 直接读本地文件,不经过 invoke,也不经过 ACL,能读什么只由一张 glob 作用域表决定。

flowchart TB
  js["convertFileSrc path"] --> url["asset://localhost/编码后的整条路径"]
  url --> handler["asset::get 主线程收到"]
  handler --> spawn["async_runtime::spawn"]
  spawn --> safe{"SafePathBuf 含 .."}
  safe -->|是| f403a["403 空正文"]
  safe -->|否| scope{"scope.is_allowed"}
  scope -->|否| f403b["403 空正文"]
  scope -->|是| open{"File::open"}
  open -->|NotFound| f404["404"]
  open -->|成功| mime["读前 8 KiB 定 MIME"]
  mime --> range{"有 Range 头"}
  range -->|是| r206["206 最多 1000 KiB"]
  range -->|否| r200["200 整个文件"]

本页实验 crate 是 exp-tauri/wv-asset:tauri 开 protocol-asset,配置里 assetProtocol.enable: true,作用域允许实验目录下的 files/**、拒绝其中的 secret.txt。files/ 里放 hello.txt(12 字节)、big.bin(3 000 000 字节)、.hidden.txt、secret.txt、sub/x.txt、img.png;目录外放 outside.txt 和 outside2.txt,后者在 setup 里用 asset_protocol_scope().allow_file 临时放行。页面对每种情况 fetch 一次,记录状态码、能读到的响应头和正文开头。另一个 crate wv-asset-off 用同一个页面,但不开 feature、不配 assetProtocol。平台 M2 Max、macOS 27、rustc 1.99.0,debug 构建,--features tauri/custom-protocol。

开关:feature 和配置要一起开

asset 协议的代码整个在 #[cfg(feature = "protocol-asset")] 里。prepare_pending_webview 末尾:

    #[cfg(feature = "protocol-asset")]
    if !registered_scheme_protocols.contains(&"asset".into()) {
      let asset_scope = app_manager
        .state
        .get::<crate::Scopes>()
        .asset_protocol
        .clone();
      let protocol = crate::protocol::asset::get(asset_scope, window_origin.clone());
      pending.register_uri_scheme_protocol("asset", move |webview_id, request, responder| {
        protocol(webview_id, request, UriSchemeResponder(responder))
      });
    }

用户自己用 register_uri_scheme_protocol 注册了 asset 的话,内置的这个就让位。配置项 AssetProtocolConfig 只有 scope 和 enable 两个字段;运行时根本不读 enable,它的作用在编译期:tauri-build 要求「enable 为真」和「tauri 依赖开了 protocol-asset」两者一致,不一致就报「The tauri dependency features on the Cargo.toml file does not match the allowlist defined under tauri.conf.json」(tauri-build)。

两者都不开时,WebKit 不认识 asset: 这个 scheme,wv-asset-off 的页面上每个请求都是同一个结果:

 "GET hello": "error TypeError: Load failed",
 "HEAD hello": "error TypeError: Load failed",
 "range 0-4": "error TypeError: Load failed",
...
 "outside scope": "error TypeError: Load failed",
...
 "img": "error"

没有状态码,没有 403,<img> 只触发 onerror。从页面上看,这和「路径不在作用域里」(下文的 403)是两种不同的失败。

URL 长什么样

core.js 里的 convertFileSrc:

  Object.defineProperty(window.__TAURI_INTERNALS__, 'convertFileSrc', {
    value: function (filePath, protocol = 'asset') {
      const path = encodeURIComponent(filePath)
      return osName === 'windows' || osName === 'android'
        ? `${protocolScheme}://${protocol}.localhost/${path}`
        : `${protocol}://localhost/${path}`
    }
  })

整条绝对路径过一遍 encodeURIComponent,连 / 都编成 %2F,放在 asset://localhost/ 后面。Rust 侧的 Webview::convert_file_src 用 protocol::origin 拼前缀、用一个和 encodeURIComponent 对齐的 ENCODE_URI_COMPONENT 字符集编码,两边结果一样:

 "convertFileSrc": "asset://localhost/%2FUsers%2Fhal%2Fbuild%2Fui-ecosystem%2Fexp-tauri%2Fwv-asset%2Ffiles%2Fhello.txt",
...
Rust convert_file_src: asset://localhost/%2FUsers%2Fhal%2Fbuild%2Fui-ecosystem%2Fexp-tauri%2Fwv-asset%2Ffiles%2Fhello.txt

把 / 也编码掉之后,整条路径在 URL 里只是一个路径段,WebKit 不会按 ..、. 去规范化它:下文实验里含 .. 的路径原样到达了 Rust 侧。Windows 上 osName 是 windows,URL 变成 http://asset.localhost/...(useHttpsScheme 时是 https),原因和 http://tauri.localhost 一样,见 tauri:// 协议与 CSP;这一点没有实测。

处理函数

protocol::asset::get 和 tauri 协议一样,在主线程收到请求后 spawn 到 async_runtime 上;get_response 出错时回 500 和错误文本。get_response 的前半段是三道检查:

  // skip leading `/`
  let path = request.uri().path().as_bytes();
  let path =
    percent_encoding::percent_decode(path.strip_prefix(b"/").unwrap_or(path)).decode_utf8_lossy();

  let mut resp = Response::builder().header("Access-Control-Allow-Origin", window_origin);

  let safe_path = match SafePathBuf::new(path.as_ref().into()) {
    Ok(path) => path,
    Err(e) => {
      log::error!("asset protocol path \"{path}\" is not valid: {e}");
      return resp.status(403).body(Vec::new().into()).map_err(Into::into);
    }
  };

  if !scope.is_allowed(&safe_path) {
    log::error!("asset protocol not configured to allow the path: {path}");
    return resp.status(403).body(Vec::new().into()).map_err(Into::into);
  }

  // Separate block for easier error handling
  let mut file = match File::open(&safe_path).await {
    Ok(file) => file,
    Err(e) => {
      // ...
      return if e.kind() == std::io::ErrorKind::NotFound {
        log::error!("File does not exist at path: {path}");
        return resp.status(404).body(Vec::new().into()).map_err(Into::into);
      } else if e.kind() == std::io::ErrorKind::PermissionDenied {
        log::error!("Missing OS permission to access path \"{path}\": {e}");
        return resp.status(403).body(Vec::new().into()).map_err(Into::into);
      } else {
        Err(e.into())
      };
    }
  };

主机名不看,只取 URI 的路径部分、去掉开头的 /、百分号解码,得到的就是文件系统路径。SafePathBuf::new 只做一件事:路径里有 .. 这个组件就拒绝(「cannot traverse directory, rewrite the path without the use of ../」)。三种拒绝的原因只写进 log::error!,页面拿到的都是一样的空 403。

作用域

Scopes 是 Builder::build 里 manage 进去的状态,其中的 asset_protocol 是一个 scope::fs::Scope,由配置构造(Scope::new):

  • allow / deny 里的每一项先经 PathResolver::parse 展开 $APPDATA、$HOME、$TEMP 这类变量,展开失败的条目被 if let Ok(..) 静默跳过;
  • 每一项以原样和规范化(canonicalize,路径不存在就规范化它最近的存在的父目录再接回去)两种形式各存一个 glob::Pattern;
  • 匹配选项 require_literal_separator: true(* 不跨目录,注释引用了安全公告 GHSA-6mv3-wm7j-h4w5),require_literal_leading_dot 在 Unix 上默认为真(*、** 不匹配以 . 开头的文件名)。

is_allowed 先把请求的路径解开一层符号链接、规范化,再查拒绝表,命中任何一条就是 false;然后查允许表:

  pub fn is_allowed<P: AsRef<Path>>(&self, path: P) -> bool {
    let path = try_resolve_symlink_and_canonicalize(path);

    if let Ok(path) = path {
      let path: PathBuf = path.components().collect();
      let forbidden = self
        .inner
        .forbidden_patterns
        .lock()
        .unwrap()
        .iter()
        .any(|p| p.matches_path_with(&path, self.inner.match_options));

      if forbidden {
        false
      } else {
        self
          .inner
          .allowed_patterns
          .lock()
          .unwrap()
          .iter()
          .any(|p| p.matches_path_with(&path, self.inner.match_options))
      }
    } else {
      false
    }
  }

作用域在运行时可以改:Manager::asset_protocol_scope() 拿到同一个 Scope,allow_file、allow_directory(path, recursive)、forbid_file、forbid_directory 往两张表里加模式(加进去的路径先经 glob::Pattern::escape,所以文件名里的 *、[ 不会被当成通配符)。实验在 setup 里放行了 outside2.txt,打印出来的表:

allowed patterns: [
    "~/exp-tauri/wv-asset/files/**",
    "~/exp-tauri/wv-asset/outside2.txt",
]
forbidden patterns: ["~/exp-tauri/wv-asset/files/secret.txt"]

(实验路径上没有符号链接,原样和规范化后的模式相同,HashSet 里只剩一份。)这些改动不持久化,重启即失。还有一处会自动改它:用户把文件拖进窗口时,on_window_event 和 on_webview_event 处理 DragDrop::Drop 的分支会对每个拖进来的路径调 Scopes::allow_file 或 allow_directory,窗口那边目录是递归放行,webview 那边不递归。

页面拿到了什么

wv-asset 的 REPORT:

 "GET hello": "200 content-type=text/plain content-length=12 body=12B \"hello asset\\\\n\"",
 "HEAD hello": "200 content-type=text/plain content-length=12 body=0B \"\"",
 "range 0-4": "206 content-type=text/plain content-length=5 content-range=bytes 0-4/12 body=5B \"hello\"",
 "range big 0-": "206 content-type=application/octet-stream content-length=1024000 content-range=bytes 0-1023999/3000000 body=1024000B ...",
 "GET big": "200 content-type=application/octet-stream content-length=3000000 body=3000000B ...",
 "range multi": "206 content-type=multipart/byteranges; boundary=526cbcf725a13a3ca6bf433a5a2b60bf871d8e3438e0701fa6518e8120 content-length=312 body=312B \"\\\\r\\\\n--526cbcf725a1\"",
 "range unsatisfiable": "error TypeError: Load failed",
 "range unsatisfiable no-cors": "resolved type=opaque status=0",
 "outside scope": "403 content-length=0 body=0B \"\"",
 "denied secret": "403 content-length=0 body=0B \"\"",
 "dotfile": "403 content-length=0 body=0B \"\"",
 "sub dir": "200 content-type=text/plain content-length=2 body=2B \"x\\\\n\"",
 "missing": "404 content-length=0 body=0B \"\"",
 "dotdot": "403 content-length=0 body=0B \"\"",
 "runtime allow_file": "200 content-type=text/plain content-length=9 body=9B \"outside2\\\\n\"",
 "img": "loaded 32x32"

(big.bin 两行的正文开头是 16 个控制字符,从略。)

  • 403 有四种来源,页面分不出来:作用域外(outside.txt)、被 deny 的(secret.txt)、以 . 开头而 ** 不匹配的(.hidden.txt)、含 .. 的(files/sub/../hello.txt,encodeURIComponent 让 .. 原样到达 Rust,SafePathBuf 拒绝)。files/** 能匹配子目录里的 sub/x.txt;文件不存在时是 404,前提是它先通过了作用域检查。
  • HEAD 只回 content-length,不读文件。
  • MIME 类型和 tauri 协议一样用 MimeType::parse,只是嗅探的是文件前 8 KiB;big.bin 的内容嗅不出来、扩展名 bin 在表里,得到 application/octet-stream。小于 8 KiB 的文件不会再读第二遍。
  • 页面是 tauri://localhost,资源是 asset://localhost,跨源。响应带 Access-Control-Allow-Origin: tauri://localhost(这个头本身 JS 读不到,但请求通过了 CORS 检查),Range 响应额外带 Access-Control-Expose-Headers: content-range,所以页面能读到 content-range。

Range 请求

后半段只有收到 Range 头才走:

      /// The Maximum bytes we send in one range
      const MAX_LEN: u64 = 1000 * 1024;

      // single-part range header
      if ranges.len() == 1 {
        let &(start, mut end) = ranges.first().unwrap();

        // check if a range is not satisfiable
        //
        // this should be already taken care of by the range parsing library
        // but checking here again for extra assurance
        if start >= len || end >= len || end < start {
          return not_satisfiable();
        }

        // adjust end byte for MAX_LEN
        end = start + (end - start).min(len - start).min(MAX_LEN - 1);
❗IMPORTANT
动脑筋:页面对 3 000 000 字节的 big.bin 发 fetch(url, { headers: { Range: 'bytes=0-' } }),拿到的状态码、Content-Range 和正文长度各是什么?平台 M2 Max、macOS 27,tauri 2.12.5,debug 构建。

范围由 http-range crate 解析;一段范围时最多给 MAX_LEN(1000 × 1024 = 1 024 000 字节),不管请求要多少。想要整个文件,要么不带 Range,要么按 Content-Range 接着请求下一段;只发一次 Range: bytes=0- 的代码只会拿到第一块。多段范围拼成 multipart/byteranges,每段同样截到 MAX_LEN。

范围不可满足时,not_satisfiable 是另起炉灶建的响应:

      let not_satisfiable = || {
        Response::builder()
          .status(StatusCode::RANGE_NOT_SATISFIABLE)
          .header(CONTENT_RANGE, format!("bytes */{len}"))
          .body(vec![].into())
          .map_err(Into::into)
      };

它用的是新的 Response::builder(),不是带着 Access-Control-Allow-Origin 的那个 resp。实验里 bytes=100-200(文件只有 12 字节)的 fetch 失败成 TypeError: Load failed,同样的请求改成 mode: 'no-cors' 就能拿到一个 opaque 响应:失败发生在 CORS 检查上,页面拿不到 416 和 Content-Range: bytes */12。

📝NOTE
冷知识:多段 Range 响应到 2.12.0 才写对

修复之前,多段范围的响应有三处不对:结尾的分隔行写成了 \r\n--{boundary}\r\n(少了表示结束的 --);Content-Type 用 header() 追加,于是响应里同时有文件自己的 MIME 类型和 multipart/byteranges 两个 Content-Type;状态码没设,是 200。PR #15838(「fix(core): correct asset protocol multi-range responses」,首次发布于 tauri 2.12.0)把结尾改成 --{boundary}--,用 headers_mut().insert 替换 Content-Type(上面代码前那条注释「Builder::header appends, we want to replace the file mime type set earlier」就是这次加的),补上 206,并加了 multi_range_request 测试。CHANGELOG:「Fix malformed asset:// multi-range responses: the multipart body now ends with the closing boundary, Content-Type is only multipart/byteranges, and the status is 206 Partial Content.」实验里那条 range multi 的 206 和单一的 multipart/byteranges 就是修复后的样子。

和 ACL 的关系

asset 协议不是命令,不经过 Webview::on_message,也就没有能力、权限那一套:只要 webview 能执行 JS、知道路径、路径在作用域里,就能读。它的「权限」全部在 assetProtocol.scope 和运行时对 Scope 的修改里。所以作用域应当尽量窄,"**" 这样的配置等于把整块磁盘交给页面里能执行的任何脚本。配了 CSP 时页面要用 asset: 还得在相应指令里放行它;CSP 和远程页面的问题见 CSP、原型冻结与隔离模式。

动脑筋答案

 "range big 0-": "206 content-type=application/octet-stream content-length=1024000 content-range=bytes 0-1023999/3000000 body=1024000B ...",
  • 状态码 206。bytes=0- 被 http-range 解析成「从 0 到文件末尾」,即 (0, 2999999)。
  • end = 0 + (2999999 - 0).min(3000000 - 0).min(1024000 - 1),结果是 1 023 999,所以 Content-Range: bytes 0-1023999/3000000。
  • 正文 1 024 000 字节,Content-Length 也是这个数。不带 Range 的 GET big 则一次拿到全部 3 000 000 字节:MAX_LEN 只限制范围请求。

小结

  • protocol-asset feature 和 assetProtocol.enable 必须同时开;都不开时 asset:// 请求在 WebKit 里直接失败,不是 403。
  • convertFileSrc 把整条路径 encodeURIComponent 后接在 asset://localhost/ 后面(Windows 上是 http://asset.localhost/),Rust 侧 convert_file_src 生成同样的 URL。
  • 处理函数在 tokio 上依次检查:含 .. → 403;不在作用域(拒绝表优先、Unix 上通配符不匹配点文件)→ 403;打不开 → 404 或 403;四种 403 都是空正文,原因只在日志里。
  • 作用域由配置的 glob 初始化,运行时可增减,拖放文件会自动放行;asset 协议不经过 ACL。
  • Range 单段每次最多 1 024 000 字节,多段拼 multipart/byteranges(2.12.0 修正了格式);416 响应缺 Access-Control-Allow-Origin,跨源的页面只看到请求失败。

💬 关联讨论

发起讨论
加载中...