@@ -129,7 +133,21 @@ function permissionsSectionTemplate({ title, items }) {
`;
}
-function permissionsListTemplate({ permissions }) {
+function executablesSectionTemplate(executables) {
+ if (!executables?.length) return null;
+ return permissionsSectionTemplate({
+ title: "Execute compiled code from:",
+ items: executables.map(
+ (entry) =>
+ html`${entry.name} —
+
${entry.sourceUrl}`,
+ ),
+ });
+}
+
+function permissionsListTemplate({ permissions, executables }) {
const sections = [];
const fetchPatterns = permissions.fetch ?? [];
if (fetchPatterns.length > 0) {
@@ -149,18 +167,30 @@ function permissionsListTemplate({ permissions }) {
}),
);
}
+ const storageScopes = permissions.storage ?? [];
+ if (storageScopes.length > 0) {
+ sections.push(
+ permissionsSectionTemplate({
+ title: "Store data on your device:",
+ items: storageScopes.map((scope) => STORAGE_LABELS[scope] ?? scope),
+ }),
+ );
+ }
+ const executablesSection = executablesSectionTemplate(executables);
+ if (executablesSection) sections.push(executablesSection);
return html`
${sections}
`;
}
export async function showPluginInstallPermissionsModal({
pluginName,
permissions,
+ executables,
}) {
const name = pluginName ?? "This plugin";
return confirmModal(
html`
${name} wants permission to:
- ${permissionsListTemplate({ permissions })}
+ ${permissionsListTemplate({ permissions, executables })}
`,
{
title: "Grant permissions?",
@@ -173,6 +203,7 @@ export async function showPluginUpdatePermissionsModal({
pluginName,
pluginVersion,
permissionsDiff,
+ executablesDiff,
}) {
const name = pluginName ?? "This plugin";
const heading = pluginVersion
@@ -181,7 +212,10 @@ export async function showPluginUpdatePermissionsModal({
return confirmModal(
html`
${heading}
- ${permissionsListTemplate({ permissions: permissionsDiff })}
+ ${permissionsListTemplate({
+ permissions: permissionsDiff,
+ executables: executablesDiff,
+ })}
`,
{
title: "Grant new permissions?",
diff --git a/src/js/plugins/pluginPermissions.js b/src/js/plugins/pluginPermissions.js
index 7c9b8e94..14f3c6b2 100644
--- a/src/js/plugins/pluginPermissions.js
+++ b/src/js/plugins/pluginPermissions.js
@@ -1,6 +1,7 @@
import { unique } from "/js/utils.js";
const ACTION_SCOPES = ["mute", "block", "feedFeedback"];
+const STORAGE_SCOPES = ["binaryCache"];
export function getPermissionsFromManifest(manifest) {
return parsePermissions(manifest.permissions ?? {});
@@ -26,6 +27,15 @@ export function parsePermissions(permissions) {
);
if (actionScopes.length > 0) parsed.actions = actionScopes;
}
+ if (permissions.storage) {
+ const storageArray = Array.isArray(permissions.storage)
+ ? permissions.storage
+ : [permissions.storage];
+ const storageScopes = unique(
+ storageArray.filter((entry) => STORAGE_SCOPES.includes(entry)),
+ );
+ if (storageScopes.length > 0) parsed.storage = storageScopes;
+ }
return parsed;
}
@@ -35,6 +45,11 @@ export function isActionAllowed(action, permissions) {
return (permissions.actions ?? []).includes(action);
}
+// scope is one of STORAGE_SCOPES ("binaryCache" today).
+export function isStorageAllowed(scope, permissions) {
+ return (permissions.storage ?? []).includes(scope);
+}
+
export function diffPermissions(current, next) {
const diff = {};
let hasAny = false;
@@ -55,6 +70,20 @@ export function isEmptyPermissions(obj) {
);
}
+// Like diffPermissions, but for manifest.executables: entries are objects
+// ({name, sourceUrl, sha256}), not primitive strings, so a plain Set can't
+// be used to compare them by value the way diffPermissions compares
+// permission-scope strings. sha256 is the identity a change is measured
+// against — a new or modified binary always gets a new hash, so "changed"
+// and "added" are the same case here and both require re-approval.
+export function diffExecutables(current, next) {
+ const currentHashes = new Set((current ?? []).map((entry) => entry.sha256));
+ const added = (next ?? []).filter(
+ (entry) => !currentHashes.has(entry.sha256),
+ );
+ return added.length > 0 ? added : null;
+}
+
export function isFetchAllowed(url, permissions) {
let parsedUrl = null;
try {
diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js
index 1e8b5a1e..1b589c63 100644
--- a/src/js/plugins/pluginService.js
+++ b/src/js/plugins/pluginService.js
@@ -13,6 +13,7 @@ import {
LocalPluginRegistry,
} from "/js/plugins/pluginRegistry.js";
import { PluginCache } from "/js/plugins/pluginCache.js";
+import { PluginBinaryCache } from "/js/plugins/pluginBinaryCache.js";
import {
PluginLocalDataStore,
PluginMemoryDataStore,
@@ -22,14 +23,20 @@ import { PluginRichTextDispatcher } from "/js/plugins/pluginRichTextDispatcher.j
import { PluginSlotDispatcher } from "/js/plugins/pluginSlotDispatcher.js";
import { SourceProvider } from "/js/plugins/sourceProvider.js";
import { PluginStylesLoader } from "/js/plugins/pluginStylesLoader.js";
-import { pluginFetch } from "/js/plugins/pluginRequests.js";
+import {
+ pluginFetch,
+ bytesToBase64,
+ base64ToArrayBuffer,
+} from "/js/plugins/pluginRequests.js";
import { Slingshot } from "/js/slingshot.js";
import {
getPermissionsFromManifest,
parsePermissions,
diffPermissions,
+ diffExecutables,
isEmptyPermissions,
isActionAllowed,
+ isStorageAllowed,
} from "/js/plugins/pluginPermissions.js";
import { compareVersions, groupBy, isDev, sortBy } from "/js/utils.js";
import { Signal, SignalMap, SignalSet, ReactiveStore } from "/js/signals.js";
@@ -188,6 +195,7 @@ export class PluginService extends ReactiveStore {
? new LocalPluginRegistry()
: null;
this.pluginCache = new PluginCache();
+ this.binaryCache = new PluginBinaryCache();
this.sourceProvider = new SourceProvider(this.pluginCache);
this.pluginStylesLoader = new PluginStylesLoader();
this.pluginBridge = new PluginBridge(
@@ -401,6 +409,39 @@ export class PluginService extends ReactiveStore {
this.localDataStore.set(plugin.pluginId, data);
});
+ this.pluginBridge.addHostMethod(
+ "getBinaryCacheEntry",
+ async (plugin, { key }) => {
+ this._requireStoragePermission(plugin, "binaryCache");
+ requireHostMethodArg("getBinaryCacheEntry", "key", key);
+ const buffer = await this.binaryCache.get(plugin.pluginId, key);
+ return buffer == null ? null : bytesToBase64(new Uint8Array(buffer));
+ },
+ );
+
+ this.pluginBridge.addHostMethod(
+ "putBinaryCacheEntry",
+ async (plugin, { key, data }) => {
+ this._requireStoragePermission(plugin, "binaryCache");
+ requireHostMethodArg("putBinaryCacheEntry", "key", key);
+ requireHostMethodArg("putBinaryCacheEntry", "data", data);
+ await this.binaryCache.put(
+ plugin.pluginId,
+ key,
+ base64ToArrayBuffer(data),
+ );
+ },
+ );
+
+ this.pluginBridge.addHostMethod(
+ "deleteBinaryCacheEntry",
+ async (plugin, { key }) => {
+ this._requireStoragePermission(plugin, "binaryCache");
+ requireHostMethodArg("deleteBinaryCacheEntry", "key", key);
+ await this.binaryCache.delete(plugin.pluginId, key);
+ },
+ );
+
this.pluginBridge.addHostMethod(
"refreshSettingTab",
(plugin, { reset = false } = {}) => {
@@ -638,6 +679,15 @@ export class PluginService extends ReactiveStore {
}
}
+ _requireStoragePermission(plugin, scope) {
+ const permissions = this._getPermissionsForPlugin(plugin.pluginId);
+ if (!isStorageAllowed(scope, permissions)) {
+ throw new Error(
+ `"${plugin.pluginId}" does not have "${scope}" storage permission`,
+ );
+ }
+ }
+
async loadEnabledPlugins() {
try {
await this._loadEnabledPlugins();
@@ -723,7 +773,8 @@ export class PluginService extends ReactiveStore {
return;
}
const permissions = getPermissionsFromManifest(manifest);
- if (!isEmptyPermissions(permissions)) {
+ const executables = manifest.executables ?? [];
+ if (!isEmptyPermissions(permissions) || executables.length > 0) {
showToast(
`"${manifest.name}" can't be previewed because it requires user permissions.`,
{ style: "error", timeout: 5000 },
@@ -740,6 +791,7 @@ export class PluginService extends ReactiveStore {
repo: listing.repo,
enabled: true,
permissions,
+ executables,
});
}
@@ -844,11 +896,13 @@ export class PluginService extends ReactiveStore {
throw new Error("Failed to fetch manifest");
}
const permissions = getPermissionsFromManifest(manifest);
- if (!isEmptyPermissions(permissions)) {
+ const executables = manifest.executables ?? [];
+ if (!isEmptyPermissions(permissions) || executables.length > 0) {
if (
!(await showPluginInstallPermissionsModal({
pluginName: manifest.name,
permissions,
+ executables,
}))
) {
throw new PermissionsDeclinedError();
@@ -864,6 +918,7 @@ export class PluginService extends ReactiveStore {
repo,
enabled: true,
permissions,
+ executables,
});
try {
await this.pluginBridge.loadPlugin(pluginId, version, repo);
@@ -887,11 +942,13 @@ export class PluginService extends ReactiveStore {
throw new Error("Failed to fetch manifest");
}
const permissions = getPermissionsFromManifest(manifest);
- if (!isEmptyPermissions(permissions)) {
+ const executables = manifest.executables ?? [];
+ if (!isEmptyPermissions(permissions) || executables.length > 0) {
if (
!(await showPluginInstallPermissionsModal({
pluginName: manifest.name,
permissions,
+ executables,
}))
) {
throw new PermissionsDeclinedError();
@@ -917,6 +974,7 @@ export class PluginService extends ReactiveStore {
repo,
enabled: true,
permissions,
+ executables,
});
try {
await this.pluginBridge.loadPlugin(id, version, repo);
@@ -933,6 +991,7 @@ export class PluginService extends ReactiveStore {
await this.prefManager.removeInstalledPlugin(pluginId);
await this.prefManager.clearSettingsForPlugin(pluginId);
this.localDataStore.clear(pluginId);
+ await this.binaryCache.clear(pluginId);
await this._reconcileCache(this.prefManager.$installedPlugins.get());
}
@@ -967,11 +1026,15 @@ export class PluginService extends ReactiveStore {
const currentPermissions = installedPlugin.permissions ?? {};
const permissions = getPermissionsFromManifest(liveManifest);
const permissionsDiff = diffPermissions(currentPermissions, permissions);
- if (permissionsDiff) {
+ const currentExecutables = installedPlugin.executables ?? [];
+ const executables = liveManifest.executables ?? [];
+ const executablesDiff = diffExecutables(currentExecutables, executables);
+ if (permissionsDiff || executablesDiff) {
const accepted = await showPluginUpdatePermissionsModal({
pluginName: liveManifest.name,
pluginVersion: liveManifest.version,
- permissionsDiff,
+ permissionsDiff: permissionsDiff ?? {},
+ executablesDiff,
});
if (!accepted) throw new PermissionsDeclinedError();
}
@@ -983,6 +1046,7 @@ export class PluginService extends ReactiveStore {
author,
description,
permissions,
+ executables,
}));
await this.pluginBridge.reloadPlugin(
pluginId,
diff --git a/src/js/plugins/sourceProvider.js b/src/js/plugins/sourceProvider.js
index 30ac8645..d0b10b74 100644
--- a/src/js/plugins/sourceProvider.js
+++ b/src/js/plugins/sourceProvider.js
@@ -32,7 +32,46 @@ function parseFontEntry(entry, index) {
return { ...entry, family, file };
}
-function parsePluginManifest(pluginId, manifest) {
+// Case-insensitive on input (some checksum tools emit uppercase hex) - the
+// parsed entry is always normalized to lowercase below, matching the
+// lowercase hex the worker's own crypto.subtle-based digest produces (see
+// pluginBridge.js#wasmGatePrelude), so comparisons never need to
+// case-fold at the enforcement point.
+const SHA256_HEX = /^[0-9a-fA-F]{64}$/;
+
+// A plugin can only get compiled code (WASM) to run if its manifest points
+// at where that code came from and pins the exact bytes — see the
+// WebAssembly-gating prelude in pluginBridge.js#wrapWorkerSource, which
+// refuses to instantiate anything whose hash isn't listed here. sourceUrl is
+// intentionally unvalidated for "is this really open source" — it can point
+// at a proprietary vendor's page — its only job is to give a human
+// somewhere to look before approving the corresponding install/update
+// permissions prompt.
+function parseExecutableEntry(entry, index) {
+ if (!entry || typeof entry !== "object") {
+ throw new Error(`executables[${index}] must be an object`);
+ }
+ const { name, sourceUrl, sha256 } = entry;
+ if (typeof name !== "string" || name.length === 0) {
+ throw new Error(`executables[${index}] missing required field "name"`);
+ }
+ if (typeof sourceUrl !== "string" || sourceUrl.length === 0) {
+ throw new Error(`executables[${index}] missing required field "sourceUrl"`);
+ }
+ try {
+ new URL(sourceUrl);
+ } catch {
+ throw new Error(`executables[${index}] sourceUrl is not a valid URL`);
+ }
+ if (typeof sha256 !== "string" || !SHA256_HEX.test(sha256)) {
+ throw new Error(
+ `executables[${index}] sha256 must be a 64-character hex digest`,
+ );
+ }
+ return { ...entry, name, sourceUrl, sha256: sha256.toLowerCase() };
+}
+
+export function parsePluginManifest(pluginId, manifest) {
for (const field of REQUIRED_MANIFEST_FIELDS) {
if (typeof manifest[field] !== "string") {
throw new Error(`missing required field "${field}"`);
@@ -49,6 +88,14 @@ function parsePluginManifest(pluginId, manifest) {
}
manifest.fonts = manifest.fonts.map((entry, i) => parseFontEntry(entry, i));
}
+ if (manifest.executables !== undefined) {
+ if (!Array.isArray(manifest.executables)) {
+ throw new Error(`executables must be an array`);
+ }
+ manifest.executables = manifest.executables.map((entry, i) =>
+ parseExecutableEntry(entry, i),
+ );
+ }
return manifest;
}
diff --git a/src/plugin-sandbox.html b/src/plugin-sandbox.html
index 47e079eb..239731c3 100644
--- a/src/plugin-sandbox.html
+++ b/src/plugin-sandbox.html
@@ -5,7 +5,7 @@
Plugin sandbox
diff --git a/tests/unit/specs/plugins/pluginBinaryCache.test.js b/tests/unit/specs/plugins/pluginBinaryCache.test.js
new file mode 100644
index 00000000..01462686
--- /dev/null
+++ b/tests/unit/specs/plugins/pluginBinaryCache.test.js
@@ -0,0 +1,120 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { PluginBinaryCache } from "/js/plugins/pluginBinaryCache.js";
+
+// A minimal Cache API stand-in, string-keyed throughout (unlike
+// pluginCache.test.js's FakeCache, which models .delete(request) against a
+// Request-like object — PluginBinaryCache always calls match/put/delete
+// with the same plain URL string it builds itself, which the real Cache
+// API accepts for all three).
+class FakeCache {
+ constructor() {
+ this._store = new Map(); // url -> ArrayBuffer
+ }
+ async match(url) {
+ const buffer = this._store.get(url);
+ return buffer ? { arrayBuffer: async () => buffer } : undefined;
+ }
+ async put(url, response) {
+ this._store.set(url, await response.arrayBuffer());
+ }
+ async delete(url) {
+ return this._store.delete(url);
+ }
+}
+
+class FakeCaches {
+ constructor() {
+ this._buckets = new Map();
+ }
+ async open(name) {
+ if (!this._buckets.has(name)) this._buckets.set(name, new FakeCache());
+ return this._buckets.get(name);
+ }
+ async delete(name) {
+ return this._buckets.delete(name);
+ }
+ has(name) {
+ return this._buckets.has(name);
+ }
+}
+
+function stubCaches() {
+ const fakeCaches = new FakeCaches();
+ const original = globalThis.caches;
+ globalThis.caches = fakeCaches;
+ return {
+ caches: fakeCaches,
+ restore() {
+ globalThis.caches = original;
+ },
+ };
+}
+
+describe("PluginBinaryCache", () => {
+ it("returns null for a key that was never stored", async () => {
+ const { restore } = stubCaches();
+ try {
+ const cache = new PluginBinaryCache();
+ assert.deepEqual(await cache.get("plugin-a", "engine"), null);
+ } finally {
+ restore();
+ }
+ });
+
+ it("round-trips arbitrary bytes exactly", async () => {
+ const { restore } = stubCaches();
+ try {
+ const cache = new PluginBinaryCache();
+ const bytes = new Uint8Array([0, 1, 2, 255, 254, 128]).buffer;
+ await cache.put("plugin-a", "engine", bytes);
+ const got = new Uint8Array(await cache.get("plugin-a", "engine"));
+ assert.deepEqual([...got], [0, 1, 2, 255, 254, 128]);
+ } finally {
+ restore();
+ }
+ });
+
+ it("isolates entries between plugin ids", async () => {
+ const { restore } = stubCaches();
+ try {
+ const cache = new PluginBinaryCache();
+ await cache.put("plugin-a", "k", new Uint8Array([1]).buffer);
+ await cache.put("plugin-b", "k", new Uint8Array([2]).buffer);
+ const a = new Uint8Array(await cache.get("plugin-a", "k"));
+ const b = new Uint8Array(await cache.get("plugin-b", "k"));
+ assert.deepEqual([...a], [1]);
+ assert.deepEqual([...b], [2]);
+ } finally {
+ restore();
+ }
+ });
+
+ it("delete removes a single entry", async () => {
+ const { restore } = stubCaches();
+ try {
+ const cache = new PluginBinaryCache();
+ await cache.put("plugin-a", "k1", new Uint8Array([1]).buffer);
+ await cache.put("plugin-a", "k2", new Uint8Array([2]).buffer);
+ await cache.delete("plugin-a", "k1");
+ assert.deepEqual(await cache.get("plugin-a", "k1"), null);
+ assert.notDeepEqual(await cache.get("plugin-a", "k2"), null);
+ } finally {
+ restore();
+ }
+ });
+
+ it("clear drops every entry for a plugin in one call", async () => {
+ const { caches, restore } = stubCaches();
+ try {
+ const cache = new PluginBinaryCache();
+ await cache.put("plugin-a", "k1", new Uint8Array([1]).buffer);
+ await cache.put("plugin-a", "k2", new Uint8Array([2]).buffer);
+ await cache.clear("plugin-a");
+ assert.deepEqual(caches.has("plugin-binary-cache:plugin-a"), false);
+ assert.deepEqual(await cache.get("plugin-a", "k1"), null);
+ } finally {
+ restore();
+ }
+ });
+});
diff --git a/tests/unit/specs/plugins/pluginBridge.test.js b/tests/unit/specs/plugins/pluginBridge.test.js
index 696c6025..526ff6ec 100644
--- a/tests/unit/specs/plugins/pluginBridge.test.js
+++ b/tests/unit/specs/plugins/pluginBridge.test.js
@@ -1099,6 +1099,195 @@ describe("internals:wrapWorkerSource ordering", () => {
});
});
+// The prelude mutates self.WebAssembly in place, so each test needs its own
+// fresh namespace object wrapping the engine's real WebAssembly bindings —
+// reusing the actual global WebAssembly object here would permanently patch
+// it for the rest of this test process (all these specs share one process).
+describe("internals:wasmGatePrelude", () => {
+ useStubbedSdkFetch();
+
+ const EMPTY_MODULE_BYTES = new Uint8Array([
+ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
+ ]).buffer;
+
+ function freshWebAssemblyNamespace() {
+ return {
+ compile: WebAssembly.compile.bind(WebAssembly),
+ instantiate: WebAssembly.instantiate.bind(WebAssembly),
+ compileStreaming: WebAssembly.compileStreaming?.bind(WebAssembly),
+ instantiateStreaming: WebAssembly.instantiateStreaming?.bind(WebAssembly),
+ Module: WebAssembly.Module,
+ Instance: WebAssembly.Instance,
+ Memory: WebAssembly.Memory,
+ Table: WebAssembly.Table,
+ validate: WebAssembly.validate,
+ CompileError: WebAssembly.CompileError,
+ LinkError: WebAssembly.LinkError,
+ RuntimeError: WebAssembly.RuntimeError,
+ };
+ }
+
+ async function sha256Hex(buffer) {
+ const digest = await crypto.subtle.digest("SHA-256", buffer);
+ return Array.from(new Uint8Array(digest))
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+ }
+
+ async function runInFakeWorker(manifest) {
+ const wrapped = await wrapWorkerSource("", manifest);
+ const workerSelf = { WebAssembly: freshWebAssemblyNamespace(), crypto };
+ new Function("self", wrapped)(workerSelf);
+ return workerSelf;
+ }
+
+ it("blocks compile/instantiate when no executables are declared", async () => {
+ const workerSelf = await runInFakeWorker({ id: "demo", version: "1.0.0" });
+ await assert.rejects(
+ () => workerSelf.WebAssembly.compile(EMPTY_MODULE_BYTES),
+ /WebAssembly execution blocked/,
+ );
+ await assert.rejects(
+ () => workerSelf.WebAssembly.instantiate(EMPTY_MODULE_BYTES, {}),
+ /WebAssembly execution blocked/,
+ );
+ });
+
+ it("allows compile/instantiate when the exact hash is declared", async () => {
+ const hash = await sha256Hex(EMPTY_MODULE_BYTES);
+ const workerSelf = await runInFakeWorker({
+ id: "demo",
+ version: "1.0.0",
+ executables: [
+ {
+ name: "engine",
+ sourceUrl: "https://example.com/engine",
+ sha256: hash,
+ },
+ ],
+ });
+ const mod = await workerSelf.WebAssembly.compile(EMPTY_MODULE_BYTES);
+ assert(mod instanceof workerSelf.WebAssembly.Module);
+ const result = await workerSelf.WebAssembly.instantiate(
+ EMPTY_MODULE_BYTES,
+ {},
+ );
+ assert(result.instance instanceof workerSelf.WebAssembly.Instance);
+ assert(result.module instanceof workerSelf.WebAssembly.Module);
+ });
+
+ it("rejects bytes that don't match any declared hash", async () => {
+ const workerSelf = await runInFakeWorker({
+ id: "demo",
+ version: "1.0.0",
+ executables: [
+ {
+ name: "engine",
+ sourceUrl: "https://example.com/engine",
+ sha256: "a".repeat(64),
+ },
+ ],
+ });
+ await assert.rejects(
+ () => workerSelf.WebAssembly.compile(EMPTY_MODULE_BYTES),
+ /WebAssembly execution blocked/,
+ );
+ });
+
+ it("names the rejection error distinctly so callers can tell a provenance block apart from a real CSP block", async () => {
+ const workerSelf = await runInFakeWorker({ id: "demo", version: "1.0.0" });
+ try {
+ await workerSelf.WebAssembly.compile(EMPTY_MODULE_BYTES);
+ assert.fail("expected compile() to reject");
+ } catch (error) {
+ assert.equal(error.name, "WasmProvenanceError");
+ }
+ });
+
+ it("reuses an already-compiled Module without re-checking its hash", async () => {
+ const hash = await sha256Hex(EMPTY_MODULE_BYTES);
+ const workerSelf = await runInFakeWorker({
+ id: "demo",
+ version: "1.0.0",
+ executables: [
+ {
+ name: "engine",
+ sourceUrl: "https://example.com/engine",
+ sha256: hash,
+ },
+ ],
+ });
+ const mod = await workerSelf.WebAssembly.compile(EMPTY_MODULE_BYTES);
+ const result = await workerSelf.WebAssembly.instantiate(mod, {});
+ assert(result instanceof workerSelf.WebAssembly.Instance);
+ });
+
+ it("blocks the synchronous Module constructor even when the hash is declared", async () => {
+ const hash = await sha256Hex(EMPTY_MODULE_BYTES);
+ const workerSelf = await runInFakeWorker({
+ id: "demo",
+ version: "1.0.0",
+ executables: [
+ {
+ name: "engine",
+ sourceUrl: "https://example.com/engine",
+ sha256: hash,
+ },
+ ],
+ });
+ assert.throws(
+ () => new workerSelf.WebAssembly.Module(EMPTY_MODULE_BYTES),
+ /disabled in the plugin sandbox/,
+ );
+ });
+
+ it("still resolves instanceof WebAssembly.Module for legitimately-compiled modules", async () => {
+ const hash = await sha256Hex(EMPTY_MODULE_BYTES);
+ const workerSelf = await runInFakeWorker({
+ id: "demo",
+ version: "1.0.0",
+ executables: [
+ {
+ name: "engine",
+ sourceUrl: "https://example.com/engine",
+ sha256: hash,
+ },
+ ],
+ });
+ const mod = await workerSelf.WebAssembly.compile(EMPTY_MODULE_BYTES);
+ assert(mod instanceof workerSelf.WebAssembly.Module);
+ });
+
+ it("disables compileStreaming and instantiateStreaming", async () => {
+ const workerSelf = await runInFakeWorker({ id: "demo", version: "1.0.0" });
+ assert.deepEqual(workerSelf.WebAssembly.compileStreaming, undefined);
+ assert.deepEqual(workerSelf.WebAssembly.instantiateStreaming, undefined);
+ });
+
+ it("leaves Memory/Table/validate untouched", async () => {
+ const workerSelf = await runInFakeWorker({ id: "demo", version: "1.0.0" });
+ assert.deepEqual(workerSelf.WebAssembly.Memory, WebAssembly.Memory);
+ assert.deepEqual(workerSelf.WebAssembly.Table, WebAssembly.Table);
+ assert.deepEqual(workerSelf.WebAssembly.validate, WebAssembly.validate);
+ });
+
+ it("does not throw when self.WebAssembly is absent", async () => {
+ const wrapped = await wrapWorkerSource("", {
+ id: "demo",
+ version: "1.0.0",
+ });
+ const workerSelf = {};
+ new Function("self", wrapped)(workerSelf);
+ assert.deepEqual(workerSelf.WebAssembly, undefined);
+ });
+
+ it("never mutates the real global WebAssembly namespace", async () => {
+ const realCompile = WebAssembly.compile;
+ await runInFakeWorker({ id: "demo", version: "1.0.0" });
+ assert.deepEqual(WebAssembly.compile, realCompile);
+ });
+});
+
describe("internals:SandboxedWorker", () => {
it("appends a sandboxed iframe to document.body and posts init on load", () => {
const before = document.body.querySelectorAll("iframe").length;
diff --git a/tests/unit/specs/plugins/pluginPermissions.test.js b/tests/unit/specs/plugins/pluginPermissions.test.js
index d7223142..430d0d8a 100644
--- a/tests/unit/specs/plugins/pluginPermissions.test.js
+++ b/tests/unit/specs/plugins/pluginPermissions.test.js
@@ -3,9 +3,11 @@ import assert from "node:assert/strict";
import {
parsePermissions,
diffPermissions,
+ diffExecutables,
isEmptyPermissions,
isFetchAllowed,
isActionAllowed,
+ isStorageAllowed,
} from "/js/plugins/pluginPermissions.js";
describe("parsePermissions", () => {
@@ -61,6 +63,24 @@ describe("parsePermissions", () => {
assert.deepEqual(parsePermissions({ actions: [] }), {});
assert.deepEqual(parsePermissions({ actions: ["feedback"] }), {});
});
+
+ it("parses the binaryCache storage scope and drops unknown ones", () => {
+ assert.deepEqual(
+ parsePermissions({ storage: ["binaryCache", "everything"] }),
+ { storage: ["binaryCache"] },
+ );
+ });
+
+ it("wraps a string storage value into an array", () => {
+ assert.deepEqual(parsePermissions({ storage: "binaryCache" }), {
+ storage: ["binaryCache"],
+ });
+ });
+
+ it("omits the storage key when no valid scopes remain", () => {
+ assert.deepEqual(parsePermissions({ storage: [] }), {});
+ assert.deepEqual(parsePermissions({ storage: ["localStorage"] }), {});
+ });
});
describe("isActionAllowed", () => {
@@ -76,6 +96,13 @@ describe("isActionAllowed", () => {
});
});
+describe("isStorageAllowed", () => {
+ it("allows only granted storage scopes", () => {
+ assert(isStorageAllowed("binaryCache", { storage: ["binaryCache"] }));
+ assert(!isStorageAllowed("binaryCache", {}));
+ });
+});
+
describe("diffPermissions", () => {
it("returns null when there are no new permissions", () => {
assert.deepEqual(
@@ -192,3 +219,49 @@ describe("isFetchAllowed", () => {
assert(!isFetchAllowed("https://example.com/", {}));
});
});
+
+describe("diffExecutables", () => {
+ const engineV1 = {
+ name: "engine",
+ sourceUrl: "https://example.com/engine",
+ sha256: "a".repeat(64),
+ };
+ const engineV2 = {
+ name: "engine",
+ sourceUrl: "https://example.com/engine",
+ sha256: "b".repeat(64),
+ };
+
+ it("returns null when there are no new executables", () => {
+ assert.deepEqual(diffExecutables([engineV1], [engineV1]), null);
+ });
+
+ it("returns null when current and next are both empty/missing", () => {
+ assert.deepEqual(diffExecutables(undefined, undefined), null);
+ assert.deepEqual(diffExecutables([], []), null);
+ });
+
+ it("treats a hash change as a new entry requiring approval", () => {
+ // Same name/sourceUrl, different bytes - identity is the hash, not the
+ // name, since a plugin swapping in different bytes under an unchanged
+ // name is exactly the case this needs to catch.
+ assert.deepEqual(diffExecutables([engineV1], [engineV2]), [engineV2]);
+ });
+
+ it("returns only the newly-added entries", () => {
+ const model = {
+ name: "model",
+ sourceUrl: "https://example.com/model",
+ sha256: "c".repeat(64),
+ };
+ assert.deepEqual(diffExecutables([engineV1], [engineV1, model]), [model]);
+ });
+
+ it("treats a missing current list as 'everything new'", () => {
+ assert.deepEqual(diffExecutables(undefined, [engineV1]), [engineV1]);
+ });
+
+ it("returns null when next has no entries", () => {
+ assert.deepEqual(diffExecutables([engineV1], []), null);
+ });
+});
diff --git a/tests/unit/specs/plugins/pluginService.test.js b/tests/unit/specs/plugins/pluginService.test.js
index 9b023497..1f1c81d5 100644
--- a/tests/unit/specs/plugins/pluginService.test.js
+++ b/tests/unit/specs/plugins/pluginService.test.js
@@ -143,6 +143,12 @@ function makeService({
reconcileCalls.push(urls);
},
};
+ const binaryCacheClearCalls = [];
+ service.binaryCache = {
+ clear: async (pluginId) => {
+ binaryCacheClearCalls.push(pluginId);
+ },
+ };
return {
service,
state,
@@ -151,6 +157,7 @@ function makeService({
reloadCalls,
unloadCalls,
reconcileCalls,
+ binaryCacheClearCalls,
};
}
@@ -179,6 +186,7 @@ describe("installPlugin", () => {
repo: "ow/alpha",
enabled: true,
permissions: {},
+ executables: [],
},
]);
assert.deepEqual(loadCalls, [
@@ -312,6 +320,7 @@ describe("updatePlugin", () => {
repo: "ow/alpha",
enabled: true,
permissions: {},
+ executables: [],
});
assert.deepEqual(reloadCalls, [
{ id: "alpha", version: "1.1.0", repo: "ow/alpha" },
@@ -986,6 +995,7 @@ describe("installUnregisteredPlugin", () => {
repo: "ow/alpha",
enabled: true,
permissions: {},
+ executables: [],
},
]);
assert.deepEqual(loadCalls, [
@@ -2311,6 +2321,123 @@ describe("loadLocalData/saveLocalData host methods", () => {
});
});
+describe("binaryCache host methods", () => {
+ function makeServiceWithPermissions(permissions) {
+ const { state, provider } = makeProvider();
+ state.installedPlugins = [
+ { id: "translate", version: "1.0.0", enabled: true, permissions },
+ ];
+ const service = makeServiceWithRealBridge({ provider });
+ // The real store is Cache-API backed; these tests are about permission
+ // gating and argument plumbing, which pluginBinaryCache.test.js already
+ // covers directly against the real class - swap in an inert fake here.
+ const calls = [];
+ service.binaryCache = {
+ _data: new Map(),
+ async get(pluginId, key) {
+ calls.push(["get", pluginId, key]);
+ return this._data.get(`${pluginId}:${key}`) ?? null;
+ },
+ async put(pluginId, key, buffer) {
+ calls.push(["put", pluginId, key]);
+ this._data.set(`${pluginId}:${key}`, buffer);
+ },
+ async delete(pluginId, key) {
+ calls.push(["delete", pluginId, key]);
+ this._data.delete(`${pluginId}:${key}`);
+ },
+ };
+ return { service, calls };
+ }
+
+ function getHandler(service, name) {
+ return service.pluginBridge._hostCallHandlers.get(name);
+ }
+
+ const plugin = { pluginId: "translate" };
+
+ it("rejects every operation without the binaryCache storage scope", async () => {
+ const { service } = makeServiceWithPermissions({});
+ await assert.rejects(
+ () => getHandler(service, "getBinaryCacheEntry")(plugin, { key: "k" }),
+ /"binaryCache" storage permission/,
+ );
+ await assert.rejects(
+ () =>
+ getHandler(service, "putBinaryCacheEntry")(plugin, {
+ key: "k",
+ data: "AQ==",
+ }),
+ /"binaryCache" storage permission/,
+ );
+ await assert.rejects(
+ () => getHandler(service, "deleteBinaryCacheEntry")(plugin, { key: "k" }),
+ /"binaryCache" storage permission/,
+ );
+ });
+
+ it("round-trips bytes through put/get when permitted", async () => {
+ const { service } = makeServiceWithPermissions({
+ storage: ["binaryCache"],
+ });
+ // base64 for the bytes [1, 2, 3]
+ await getHandler(service, "putBinaryCacheEntry")(plugin, {
+ key: "engine",
+ data: "AQID",
+ });
+ const base64 = await getHandler(service, "getBinaryCacheEntry")(plugin, {
+ key: "engine",
+ });
+ const stored = new Uint8Array(Buffer.from(base64, "base64"));
+ assert.deepEqual([...stored], [1, 2, 3]);
+ });
+
+ it("returns null for a key that was never stored", async () => {
+ const { service } = makeServiceWithPermissions({
+ storage: ["binaryCache"],
+ });
+ assert.deepEqual(
+ await getHandler(service, "getBinaryCacheEntry")(plugin, {
+ key: "missing",
+ }),
+ null,
+ );
+ });
+
+ it("delete removes the entry and calls are scoped to this plugin's id", async () => {
+ const { service, calls } = makeServiceWithPermissions({
+ storage: ["binaryCache"],
+ });
+ await getHandler(service, "putBinaryCacheEntry")(plugin, {
+ key: "engine",
+ data: "AQID",
+ });
+ await getHandler(service, "deleteBinaryCacheEntry")(plugin, {
+ key: "engine",
+ });
+ assert.deepEqual(
+ await getHandler(service, "getBinaryCacheEntry")(plugin, {
+ key: "engine",
+ }),
+ null,
+ );
+ assert.deepEqual(
+ calls.every(([, pluginId]) => pluginId === "translate"),
+ true,
+ );
+ });
+
+ it("requires a key argument", async () => {
+ const { service } = makeServiceWithPermissions({
+ storage: ["binaryCache"],
+ });
+ await assert.rejects(
+ () => getHandler(service, "getBinaryCacheEntry")(plugin, {}),
+ /key/,
+ );
+ });
+});
+
describe("getPostComposerInit", () => {
function addListener(service, pluginId, handler) {
let listeners = service.registries.eventListeners.get("post-composer-open");
diff --git a/tests/unit/specs/plugins/sourceProvider.test.js b/tests/unit/specs/plugins/sourceProvider.test.js
new file mode 100644
index 00000000..e5487914
--- /dev/null
+++ b/tests/unit/specs/plugins/sourceProvider.test.js
@@ -0,0 +1,134 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { parsePluginManifest } from "/js/plugins/sourceProvider.js";
+
+const VALID_HASH = "a".repeat(64);
+
+function baseManifest(overrides = {}) {
+ return { id: "demo", name: "Demo", version: "1.0.0", ...overrides };
+}
+
+describe("parsePluginManifest: executables", () => {
+ it("is undefined when the manifest declares no executables", () => {
+ const manifest = parsePluginManifest("demo", baseManifest());
+ assert.deepEqual(manifest.executables, undefined);
+ });
+
+ it("accepts a well-formed entry and lowercases the hash", () => {
+ const manifest = parsePluginManifest(
+ "demo",
+ baseManifest({
+ executables: [
+ {
+ name: "engine.wasm",
+ sourceUrl: "https://example.com/engine",
+ sha256: VALID_HASH.toUpperCase(),
+ },
+ ],
+ }),
+ );
+ assert.deepEqual(manifest.executables, [
+ {
+ name: "engine.wasm",
+ sourceUrl: "https://example.com/engine",
+ sha256: VALID_HASH,
+ },
+ ]);
+ });
+
+ it("rejects a non-array executables field", () => {
+ assert.throws(
+ () => parsePluginManifest("demo", baseManifest({ executables: {} })),
+ /must be an array/,
+ );
+ });
+
+ it("rejects an entry missing name", () => {
+ assert.throws(
+ () =>
+ parsePluginManifest(
+ "demo",
+ baseManifest({
+ executables: [
+ { sourceUrl: "https://example.com/x", sha256: VALID_HASH },
+ ],
+ }),
+ ),
+ /missing required field "name"/,
+ );
+ });
+
+ it("rejects an entry missing sourceUrl", () => {
+ assert.throws(
+ () =>
+ parsePluginManifest(
+ "demo",
+ baseManifest({
+ executables: [{ name: "x", sha256: VALID_HASH }],
+ }),
+ ),
+ /missing required field "sourceUrl"/,
+ );
+ });
+
+ it("rejects an entry with a malformed sourceUrl", () => {
+ assert.throws(
+ () =>
+ parsePluginManifest(
+ "demo",
+ baseManifest({
+ executables: [
+ { name: "x", sourceUrl: "not-a-url", sha256: VALID_HASH },
+ ],
+ }),
+ ),
+ /sourceUrl is not a valid URL/,
+ );
+ });
+
+ it("accepts a sourceUrl pointing at a proprietary vendor page (a transparency pointer, not an openness check)", () => {
+ const manifest = parsePluginManifest(
+ "demo",
+ baseManifest({
+ executables: [
+ {
+ name: "vendor-engine",
+ sourceUrl: "https://vendor.example/product/engine",
+ sha256: VALID_HASH,
+ },
+ ],
+ }),
+ );
+ assert.deepEqual(
+ manifest.executables[0].sourceUrl,
+ "https://vendor.example/product/engine",
+ );
+ });
+
+ it("rejects a sha256 that is not 64 hex characters", () => {
+ assert.throws(
+ () =>
+ parsePluginManifest(
+ "demo",
+ baseManifest({
+ executables: [
+ {
+ name: "x",
+ sourceUrl: "https://example.com/x",
+ sha256: "deadbeef",
+ },
+ ],
+ }),
+ ),
+ /sha256 must be a 64-character hex digest/,
+ );
+ });
+
+ it("rejects a non-object entry", () => {
+ assert.throws(
+ () =>
+ parsePluginManifest("demo", baseManifest({ executables: ["nope"] })),
+ /executables\[0\] must be an object/,
+ );
+ });
+});