asset:// 协议与文件作用域
主线位置:启动主线上
prepare_pending_webview挂协议那一步的一个分支。开了protocol-assetfeature,每个 webview 除了tauri、ipc之外还挂一个assetscheme;页面用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);
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。
修复之前,多段范围的响应有三处不对:结尾的分隔行写成了 \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-assetfeature 和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,跨源的页面只看到请求失败。