mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(plugins): serve plugin UI assets over the dbx-plugin scheme
The workbench sandbox inlines only the entry script, so code-split chunks had no runtime address and dynamic imports 404'd. Serve each installed plugin's ui.root through dbx-plugin://localhost/<id>/<path> (WebView2 maps the scheme to http://dbx-plugin.localhost), inject the platform-correct form as the sandbox document <base>, and allow it in the resource CSP. Reads reuse the registry's ui-asset validation (traversal confinement, 32 MiB cap, sha256 etag) and answer CORS with ACAO:* for the opaque-origin iframe; responses are no-store so plugin updates land immediately. Existing plugins are unaffected. Builds that emit relative asset URLs (vite base "./" / webpack publicPath "./") additionally regain lazy loading and font loading; documented for plugin authors in both languages.
This commit is contained in:
@@ -424,13 +424,18 @@ function localUiAssetPath(source: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
async function inlineLocalUiAssets(html: string, pluginId: string): Promise<string> {
|
||||
async function inlineLocalUiAssets(html: string, pluginId: string): Promise<{ html: string; entryDirectory: string }> {
|
||||
const document = new DOMParser().parseFromString(html, "text/html");
|
||||
const resources = [...document.querySelectorAll("script[src], link[rel='stylesheet'][href]")];
|
||||
// Dynamic-import chunks and CSS url() references live next to the entry
|
||||
// script; its directory is the <base> the sandbox document needs to resolve
|
||||
// them through the dbx-plugin scheme.
|
||||
let entryDirectory = "";
|
||||
for (const resource of resources) {
|
||||
const source = resource.getAttribute(resource.tagName === "SCRIPT" ? "src" : "href");
|
||||
const path = source ? localUiAssetPath(source) : undefined;
|
||||
if (!path) continue;
|
||||
if (!entryDirectory) entryDirectory = path.split("/").slice(0, -1).join("/");
|
||||
const asset = await api.readPluginUiAsset(pluginId, path);
|
||||
const content = new TextDecoder().decode(Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0)));
|
||||
if (resource.tagName === "SCRIPT") {
|
||||
@@ -446,7 +451,19 @@ async function inlineLocalUiAssets(html: string, pluginId: string): Promise<stri
|
||||
resource.replaceWith(style);
|
||||
}
|
||||
}
|
||||
return document.documentElement.outerHTML;
|
||||
return { html: document.documentElement.outerHTML, entryDirectory };
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL prefix for lazy-loaded plugin UI assets. wry serves custom schemes
|
||||
* natively on WKWebView/webkit2gtk but maps them onto http(s) subdomains on
|
||||
* WebView2, so the host page's own protocol picks the form the webview will
|
||||
* actually request. The web host has no plugin asset protocol.
|
||||
*/
|
||||
function pluginUiBaseUrl(pluginId: string, entryDirectory: string): string | undefined {
|
||||
if (!isTauriRuntime()) return undefined;
|
||||
const origin = location.protocol === "http:" || location.protocol === "https:" ? `${location.protocol}//dbx-plugin.localhost/${pluginId}/` : `dbx-plugin://localhost/${pluginId}/`;
|
||||
return entryDirectory ? `${origin}${entryDirectory}/` : origin;
|
||||
}
|
||||
|
||||
async function loadWorkbench() {
|
||||
@@ -461,9 +478,11 @@ async function loadWorkbench() {
|
||||
const asset = await api.readPluginUiEntry(props.plugin.manifest.id);
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
const bytes = Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0));
|
||||
const html = await inlineLocalUiAssets(new TextDecoder().decode(bytes), props.plugin.manifest.id);
|
||||
const { html, entryDirectory } = await inlineLocalUiAssets(new TextDecoder().decode(bytes), props.plugin.manifest.id);
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
source.value = pluginSandboxDocument(html, props.plugin.manifest.permissions, currentBridgeTheme());
|
||||
source.value = pluginSandboxDocument(html, props.plugin.manifest.permissions, currentBridgeTheme(), {
|
||||
baseUrl: pluginUiBaseUrl(props.plugin.manifest.id, entryDirectory),
|
||||
});
|
||||
await nextTick();
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
createBridge();
|
||||
|
||||
@@ -731,6 +731,34 @@ describe("PluginHostBridge", () => {
|
||||
expect(closed).toContain("host.stream.chunk");
|
||||
});
|
||||
|
||||
it("injects a <base> and widens resource CSP sources for the plugin asset origin", () => {
|
||||
const document = pluginSandboxDocument("<html><head></head><body></body></html>", [], undefined, { baseUrl: "dbx-plugin://localhost/io.github.t8y2.s3/assets/" });
|
||||
expect(document).toContain('<base href="dbx-plugin://localhost/io.github.t8y2.s3/assets/">');
|
||||
expect(document).toContain("script-src 'unsafe-inline' blob: dbx-plugin:;");
|
||||
expect(document).toContain("font-src data: blob: dbx-plugin:;");
|
||||
// <base> leads the head injection so inlined CSS url() resolves against it.
|
||||
expect(document.indexOf("<base ")).toBeLessThan(document.indexOf("<style>"));
|
||||
});
|
||||
|
||||
it("allows the WebView2-mapped asset origin exactly", () => {
|
||||
const document = pluginSandboxDocument("<html><head></head><body></body></html>", [], undefined, { baseUrl: "http://dbx-plugin.localhost/io.github.t8y2.s3/assets/" });
|
||||
expect(document).toContain("script-src 'unsafe-inline' blob: http://dbx-plugin.localhost;");
|
||||
expect(document).toContain('<base href="http://dbx-plugin.localhost/io.github.t8y2.s3/assets/">');
|
||||
});
|
||||
|
||||
it("rejects malformed asset base URLs without touching the CSP", () => {
|
||||
const document = pluginSandboxDocument("<html><head></head><body></body></html>", [], undefined, { baseUrl: "javascript:alert(1)" });
|
||||
expect(document).not.toContain("<base ");
|
||||
expect(document).toContain("script-src 'unsafe-inline' blob:;");
|
||||
});
|
||||
|
||||
it("keeps the sandbox CSP untouched without an asset base URL", () => {
|
||||
const document = pluginSandboxDocument("<html><head></head><body></body></html>");
|
||||
expect(document).toContain("script-src 'unsafe-inline' blob:;");
|
||||
expect(document).toContain("font-src data: blob:;");
|
||||
expect(document).not.toContain("<base ");
|
||||
});
|
||||
|
||||
it("pre-seeds the current theme as the sandbox first paint", () => {
|
||||
const themed = pluginSandboxDocument("<html><head></head><body></body></html>", ["host.events"], { appearance: "dark", tokens: { "--color-background": "#131416", "--color-foreground": "rgb(215 215 219)" } });
|
||||
expect(themed).toContain("color-scheme: dark");
|
||||
|
||||
@@ -477,10 +477,26 @@ export function pluginNetworkOrigins(permissions: readonly string[] | undefined)
|
||||
return [...origins];
|
||||
}
|
||||
|
||||
export function pluginSandboxDocument(html: string, permissions?: readonly string[], theme?: PluginBridgeTheme): string {
|
||||
export interface PluginSandboxOptions {
|
||||
/**
|
||||
* Base URL under which the workbench document may fetch further plugin UI
|
||||
* assets (code-split chunks, fonts) — `dbx-plugin://localhost/<id>/assets/`
|
||||
* on native custom schemes, `http(s)://dbx-plugin.localhost/<id>/assets/`
|
||||
* where WebView2 maps the scheme to an http subdomain. Injected as the
|
||||
* document `<base>` and allowed in the resource CSP directives. Omit on
|
||||
* hosts without the plugin asset protocol (the web host).
|
||||
*/
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export function pluginSandboxDocument(html: string, permissions?: readonly string[], theme?: PluginBridgeTheme, options?: PluginSandboxOptions): string {
|
||||
const networkOrigins = pluginNetworkOrigins(permissions);
|
||||
const connectSrc = networkOrigins.length > 0 ? `connect-src ${networkOrigins.join(" ")};` : "connect-src 'none';";
|
||||
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' blob:; style-src 'unsafe-inline' blob:; img-src data: blob:; font-src data: blob:; ${connectSrc} media-src data: blob:;">`;
|
||||
const assetSource = pluginAssetCspSource(options?.baseUrl);
|
||||
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' blob:${assetSource}; style-src 'unsafe-inline' blob:; img-src data: blob:${assetSource}; font-src data: blob:${assetSource}; ${connectSrc} media-src data: blob:${assetSource};">`;
|
||||
// <base> must precede every relative URL the document resolves (inlined CSS
|
||||
// url(), dynamic import specifiers), so it leads the injection.
|
||||
const base = options?.baseUrl && assetSource ? `<base href="${escapeHtmlAttribute(options.baseUrl)}">` : "";
|
||||
const sdk = `<script>${pluginSdkSource(theme)}</script>`;
|
||||
const uiKit = `<style>${pluginUiKitCss()}</style>`;
|
||||
// Placed after the uiKit so the boot `color-scheme` wins the cascade: the
|
||||
@@ -488,11 +504,28 @@ export function pluginSandboxDocument(html: string, permissions?: readonly strin
|
||||
// has loaded, and the uiKit's token fallbacks would otherwise paint the
|
||||
// first frame white on dark hosts.
|
||||
const bootTheme = pluginBootThemeCss(theme);
|
||||
const injection = `${csp}${uiKit}${bootTheme ? `<style>${bootTheme}</style>` : ""}${sdk}`;
|
||||
const injection = `${csp}${base}${uiKit}${bootTheme ? `<style>${bootTheme}</style>` : ""}${sdk}`;
|
||||
if (/<head(?:\s[^>]*)?>/i.test(html)) return html.replace(/<head(?:\s[^>]*)?>/i, (head) => `${head}${injection}`);
|
||||
return `<!doctype html><html><head>${injection}</head><body>${html}</body></html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSP source expression for the plugin asset base URL: the exact origin for
|
||||
* the http(s)-mapped form (WebView2), the whole scheme for the native custom
|
||||
* scheme form. Only dbx-plugin shapes contribute a source — anything else is
|
||||
* ignored (and suppresses the <base> too).
|
||||
*/
|
||||
function pluginAssetCspSource(baseUrl: string | undefined): string {
|
||||
if (!baseUrl) return "";
|
||||
const mapped = baseUrl.match(/^(https?:\/\/dbx-plugin\.localhost)(?:\/|$)/i);
|
||||
if (mapped) return ` ${mapped[1].toLowerCase()}`;
|
||||
return /^dbx-plugin:\/\//i.test(baseUrl) ? " dbx-plugin:" : "";
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-paint theme seed for the sandbox document. The bridge init message only
|
||||
* arrives after the iframe load event, so without this style the first frame
|
||||
|
||||
@@ -431,6 +431,10 @@ env 消息的 `theme` 结构为 `{ appearance, tokens, editor? }`。`appearance`
|
||||
|
||||
UI 必须构建为包内静态资源,不要把 Vite 开发服务器或 CDN 的脚本地址写入发行包。宿主把入口及资源转换成沙箱可加载内容;CSP 报错时检查构建产物是否存在、路径是否落在 `ui.root` 内,并使用匹配版本的 CLI 复现。用插件自己的对话框组件处理删除/重命名,不要依赖沙箱中的原生 `alert`/`confirm`/`prompt`。
|
||||
|
||||
#### 懒加载与代码分割
|
||||
|
||||
宿主会把入口脚本内联进沙箱文档,`ui/` 下的其余资源在运行时通过 `dbx-plugin` 协议保持可寻址:`dbx-plugin://localhost/<插件id>/<ui.root内相对路径>`(WebView2 上映射为 `http://dbx-plugin.localhost/<插件id>/…`)。宿主会把平台对应的形式注入为沙箱文档的 `<base>`,并放行资源类 CSP,因此打包后的插件可以正常动态 `import()` 代码分割的 chunk——前提是构建产物使用**相对**资源地址:Vite 设 `base: "./"`、webpack 设 `publicPath: "./"`,让 chunk 和 CSS 资源地址基于注入的 base 解析而不是宿主 origin。绝对路径 `/assets/…` 携带不了插件 id,会 404。该协议只读文件、限定在每个插件的 `ui.root` 内,且插件更新后不会沿用旧缓存。
|
||||
|
||||
## 原生 Sidecar 和 RPC
|
||||
|
||||
只有浏览器沙箱无法完成的能力才需要原生后端,例如 S3/SSH 协议、系统凭据、长连接或高性能计算。Sidecar 不是操作系统沙箱,会以当前用户权限运行。
|
||||
|
||||
@@ -431,6 +431,10 @@ Treat `terminal.fontSize`-style concerns as plugin-owned: the host never dictate
|
||||
|
||||
Build the UI as packaged static assets, not references to a Vite server or CDN scripts. The host converts the entry and assets into sandbox-loadable content. For CSP errors, verify build outputs and confinement under `ui.root`, and reproduce with the matching CLI version. Use plugin-owned dialogs for delete/rename rather than relying on native `alert`/`confirm`/`prompt` inside the sandbox.
|
||||
|
||||
#### Lazy loading and code splitting
|
||||
|
||||
The host inlines the entry script into the sandbox document; further `ui/` assets stay addressable at runtime through the `dbx-plugin` scheme — `dbx-plugin://localhost/<plugin-id>/<path-under-ui.root>` (WebView2 maps it to `http://dbx-plugin.localhost/<plugin-id>/…`). The host injects the platform-correct form as the sandbox document's `<base>` and allows it in the resource CSP, so dynamic `import()` of code-split chunks works in packaged plugins — provided the build emits **relative** asset URLs: set `base: "./"` (Vite) or `publicPath: "./"` (webpack) so chunk and CSS asset URLs resolve against the injected base instead of the host origin. Absolute `/assets/…` references cannot carry the plugin id and will 404. The scheme serves files only, confined to each plugin's `ui.root`, and responses are never cached across plugin updates.
|
||||
|
||||
## Native Sidecars and RPC
|
||||
|
||||
Use a native backend only when the browser sandbox cannot provide the capability, such as S3/SSH protocols, system credentials, long-lived connections, or CPU-heavy work. A Sidecar is not an OS sandbox and runs with the current user's privileges.
|
||||
|
||||
@@ -6,6 +6,7 @@ mod macos_app_delegate;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_escape_guard;
|
||||
mod models;
|
||||
mod plugin_ui_protocol;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod startup_recovery;
|
||||
#[cfg(all(not(target_os = "windows"), not(test)))]
|
||||
@@ -1461,7 +1462,10 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init());
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
// Plugin workbench sandbox documents lazy-load their code-split chunks
|
||||
// through this scheme; see plugin_ui_protocol.rs.
|
||||
.register_asynchronous_uri_scheme_protocol(plugin_ui_protocol::PLUGIN_UI_SCHEME, plugin_ui_protocol::handle);
|
||||
|
||||
let builder = if should_enable_single_instance(cfg!(debug_assertions)) {
|
||||
builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Serves installed plugin UI assets over the `dbx-plugin` scheme so the
|
||||
//! sandboxed workbench document can lazy-load code-split chunks at runtime.
|
||||
//!
|
||||
//! URLs carry the plugin id as the first path segment with a fixed host
|
||||
//! (`dbx-plugin://localhost/<plugin-id>/<asset-path>`). The fixed host keeps
|
||||
//! the WebView2-mapped form (`http://dbx-plugin.localhost/...`) a single
|
||||
//! constant origin, which the sandbox CSP can allow without wildcard hosts.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dbx_core::plugins::PluginRegistry;
|
||||
use percent_encoding::percent_decode_str;
|
||||
use tauri::http::{HeaderValue, Method, Response, StatusCode, Uri};
|
||||
use tauri::{Manager, Runtime, UriSchemeContext, UriSchemeResponder};
|
||||
|
||||
use crate::commands::connection::AppState;
|
||||
|
||||
pub const PLUGIN_UI_SCHEME: &str = "dbx-plugin";
|
||||
|
||||
/// Registered on the app builder; see `lib.rs`.
|
||||
pub fn handle<R: Runtime>(
|
||||
ctx: UriSchemeContext<'_, R>,
|
||||
request: tauri::http::Request<Vec<u8>>,
|
||||
responder: UriSchemeResponder,
|
||||
) {
|
||||
let registry = ctx.app_handle().try_state::<Arc<AppState>>().map(|state| state.plugins.clone());
|
||||
let method = request.method().clone();
|
||||
let uri = request.uri().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let response = match registry {
|
||||
Some(registry) => serve_plugin_ui_asset(®istry, &method, &uri),
|
||||
None => error_response(StatusCode::SERVICE_UNAVAILABLE, "Plugin registry is unavailable"),
|
||||
};
|
||||
responder.respond(response);
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn serve_plugin_ui_asset(registry: &PluginRegistry, method: &Method, uri: &Uri) -> Response<Vec<u8>> {
|
||||
if method != Method::GET && method != Method::HEAD {
|
||||
return error_response(StatusCode::METHOD_NOT_ALLOWED, "Plugin UI assets only support GET and HEAD");
|
||||
}
|
||||
let Some((plugin_id, asset_path)) = parse_plugin_ui_uri(uri) else {
|
||||
return error_response(StatusCode::NOT_FOUND, "Plugin UI asset not found");
|
||||
};
|
||||
match registry.read_ui_asset(&plugin_id, &asset_path) {
|
||||
Ok(asset) => asset_response(&asset.content_type, &asset.etag, &asset.bytes, method == Method::HEAD),
|
||||
Err(error) => {
|
||||
// The registry error names filesystem paths; keep it in the log, not the response.
|
||||
log::warn!("[{PLUGIN_UI_SCHEME}] failed to serve {uri}: {error}");
|
||||
error_response(StatusCode::NOT_FOUND, "Plugin UI asset not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits `dbx-plugin://localhost/<plugin-id>/<asset-path>` into its parts.
|
||||
/// The asset path keeps its percent-decoding to the registry's traversal
|
||||
/// validation, which runs on the decoded components.
|
||||
pub(crate) fn parse_plugin_ui_uri(uri: &Uri) -> Option<(String, String)> {
|
||||
if uri.scheme_str() != Some(PLUGIN_UI_SCHEME) {
|
||||
return None;
|
||||
}
|
||||
let path = uri.path().strip_prefix('/')?;
|
||||
let mut segments = path.split('/');
|
||||
let plugin_id = segments.next()?;
|
||||
if !valid_plugin_id(plugin_id) {
|
||||
return None;
|
||||
}
|
||||
let encoded_rest = segments.collect::<Vec<_>>().join("/");
|
||||
if encoded_rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let asset_path = percent_decode_str(&encoded_rest).decode_utf8().ok()?.into_owned();
|
||||
if asset_path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((plugin_id.to_string(), asset_path))
|
||||
}
|
||||
|
||||
fn valid_plugin_id(plugin_id: &str) -> bool {
|
||||
!plugin_id.is_empty()
|
||||
&& plugin_id.len() <= 200
|
||||
&& plugin_id.chars().all(|character| character.is_ascii_alphanumeric() || character == '.' || character == '-')
|
||||
&& !plugin_id.starts_with('.')
|
||||
&& !plugin_id.ends_with('.')
|
||||
}
|
||||
|
||||
fn asset_response(content_type: &str, etag: &str, bytes: &[u8], head: bool) -> Response<Vec<u8>> {
|
||||
let mut response = Response::new(if head { Vec::new() } else { bytes.to_vec() });
|
||||
let headers = response.headers_mut();
|
||||
// The workbench document is an opaque-origin srcdoc iframe, so module
|
||||
// script fetches are CORS-mode with `Origin: null` — only `*` passes.
|
||||
headers.insert("access-control-allow-origin", HeaderValue::from_static("*"));
|
||||
headers.insert(
|
||||
"content-type",
|
||||
HeaderValue::from_str(content_type).unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
|
||||
);
|
||||
if let Ok(value) = HeaderValue::from_str(etag) {
|
||||
headers.insert("etag", value);
|
||||
}
|
||||
// Plugins update in place; never let a stale chunk outlive its install.
|
||||
headers.insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
response
|
||||
}
|
||||
|
||||
fn error_response(status: StatusCode, message: &str) -> Response<Vec<u8>> {
|
||||
let mut response = Response::new(message.as_bytes().to_vec());
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert("access-control-allow-origin", HeaderValue::from_static("*"));
|
||||
response.headers_mut().insert("content-type", HeaderValue::from_static("text/plain; charset=utf-8"));
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_registry() -> PluginRegistry {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let plugin_dir = root.path().join("sample");
|
||||
std::fs::create_dir_all(plugin_dir.join("ui/assets")).unwrap();
|
||||
std::fs::write(plugin_dir.join("ui/index.html"), "<h1>Hello</h1>").unwrap();
|
||||
std::fs::write(plugin_dir.join("ui/assets/chunk.js"), "console.log('chunk')").unwrap();
|
||||
std::fs::write(
|
||||
plugin_dir.join("manifest.json"),
|
||||
serde_json::json!({
|
||||
"manifest_version": 1,
|
||||
"id": "sample",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"publisher": "dbx",
|
||||
"engines": { "dbx": ">=0.1.0", "host_api": "^1.0" },
|
||||
"entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } }
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
// Leak the tempdir so the registry (and the reads below) outlive it.
|
||||
std::mem::forget(root);
|
||||
PluginRegistry::new_with_app_version(plugin_dir.parent().unwrap().to_path_buf(), "0.5.67")
|
||||
}
|
||||
|
||||
fn uri(value: &str) -> Uri {
|
||||
value.parse().unwrap()
|
||||
}
|
||||
|
||||
fn get(path: &str) -> Response<Vec<u8>> {
|
||||
serve_plugin_ui_asset(&test_registry(), &Method::GET, &uri(path))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_assets_with_cors_headers() {
|
||||
let response = get("dbx-plugin://localhost/sample/assets/chunk.js");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()["access-control-allow-origin"], "*");
|
||||
assert_eq!(response.headers()["content-type"], "text/javascript; charset=utf-8");
|
||||
assert_eq!(response.headers()["cache-control"], "no-store");
|
||||
assert_eq!(response.body().as_slice(), b"console.log('chunk')");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_returns_headers_without_body() {
|
||||
let response = serve_plugin_ui_asset(
|
||||
&test_registry(),
|
||||
&Method::HEAD,
|
||||
&uri("dbx-plugin://localhost/sample/assets/chunk.js"),
|
||||
);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(response.body().is_empty());
|
||||
assert_eq!(response.headers()["content-type"], "text/javascript; charset=utf-8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_get_methods() {
|
||||
let response =
|
||||
serve_plugin_ui_asset(&test_registry(), &Method::POST, &uri("dbx-plugin://localhost/sample/ui/index.html"));
|
||||
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_scheme() {
|
||||
assert!(parse_plugin_ui_uri(&uri("http://localhost/sample/ui/index.html")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_paths_that_escape_the_plugin() {
|
||||
for path in
|
||||
["dbx-plugin://localhost/sample/../secret.txt", "dbx-plugin://localhost/sample/ui/../../manifest.json"]
|
||||
{
|
||||
let response = get(path);
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND, "path should be rejected: {path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_plugins_and_empty_paths() {
|
||||
assert_eq!(get("dbx-plugin://localhost/other/ui/index.html").status(), StatusCode::NOT_FOUND);
|
||||
assert!(parse_plugin_ui_uri(&uri("dbx-plugin://localhost/sample")).is_none());
|
||||
assert!(parse_plugin_ui_uri(&uri("dbx-plugin://localhost/")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_percent_encoded_paths() {
|
||||
let response = get("dbx-plugin://localhost/sample/%69ndex.html");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.body().as_slice(), b"<h1>Hello</h1>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_plugin_ids() {
|
||||
assert!(parse_plugin_ui_uri(&uri("dbx-plugin://localhost/..hidden/ui/index.html")).is_none());
|
||||
assert!(parse_plugin_ui_uri(&uri("dbx-plugin://localhost/a%20b/ui/index.html")).is_none());
|
||||
assert!(parse_plugin_ui_uri(&uri("dbx-plugin://localhost/ok.id-1/ui/index.html")).is_some());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user