@@ -133,21 +129,7 @@ function permissionsSectionTemplate({ title, items }) {
`;
}
-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 }) {
+function permissionsListTemplate({ permissions }) {
const sections = [];
const fetchPatterns = permissions.fetch ?? [];
if (fetchPatterns.length > 0) {
@@ -167,30 +149,18 @@ function permissionsListTemplate({ permissions, executables }) {
}),
);
}
- 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, executables })}
+ ${permissionsListTemplate({ permissions })}
`,
{
title: "Grant permissions?",
@@ -203,7 +173,6 @@ export async function showPluginUpdatePermissionsModal({
pluginName,
pluginVersion,
permissionsDiff,
- executablesDiff,
}) {
const name = pluginName ?? "This plugin";
const heading = pluginVersion
@@ -212,10 +181,7 @@ export async function showPluginUpdatePermissionsModal({
return confirmModal(
html`
${heading}
- ${permissionsListTemplate({
- permissions: permissionsDiff,
- executables: executablesDiff,
- })}
+ ${permissionsListTemplate({ permissions: permissionsDiff })}
`,
{
title: "Grant new permissions?",
diff --git a/src/js/plugins/pluginPermissions.js b/src/js/plugins/pluginPermissions.js
index 14f3c6b2..7c9b8e94 100644
--- a/src/js/plugins/pluginPermissions.js
+++ b/src/js/plugins/pluginPermissions.js
@@ -1,7 +1,6 @@
import { unique } from "/js/utils.js";
const ACTION_SCOPES = ["mute", "block", "feedFeedback"];
-const STORAGE_SCOPES = ["binaryCache"];
export function getPermissionsFromManifest(manifest) {
return parsePermissions(manifest.permissions ?? {});
@@ -27,15 +26,6 @@ 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;
}
@@ -45,11 +35,6 @@ 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;
@@ -70,20 +55,6 @@ 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 1b589c63..8dc4416b 100644
--- a/src/js/plugins/pluginService.js
+++ b/src/js/plugins/pluginService.js
@@ -23,20 +23,14 @@ 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,
- bytesToBase64,
- base64ToArrayBuffer,
-} from "/js/plugins/pluginRequests.js";
+import { pluginFetch } 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";
@@ -412,31 +406,41 @@ export class PluginService extends ReactiveStore {
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));
+ return await this.binaryCache.get(plugin.pluginId, key);
+ },
+ );
+
+ this.pluginBridge.addHostMethod(
+ "hasBinaryCacheEntry",
+ async (plugin, { key }) => {
+ requireHostMethodArg("hasBinaryCacheEntry", "key", key);
+ return await this.binaryCache.has(plugin.pluginId, key);
+ },
+ );
+
+ this.pluginBridge.addHostMethod(
+ "listBinaryCacheEntries",
+ async (plugin) => {
+ return await this.binaryCache.keys(plugin.pluginId);
},
);
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),
- );
+ if (!(data instanceof ArrayBuffer)) {
+ throw new Error("putBinaryCacheEntry data must be an ArrayBuffer");
+ }
+ await this.binaryCache.put(plugin.pluginId, key, data);
},
);
this.pluginBridge.addHostMethod(
"deleteBinaryCacheEntry",
async (plugin, { key }) => {
- this._requireStoragePermission(plugin, "binaryCache");
requireHostMethodArg("deleteBinaryCacheEntry", "key", key);
await this.binaryCache.delete(plugin.pluginId, key);
},
@@ -679,15 +683,6 @@ 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();
@@ -773,8 +768,7 @@ export class PluginService extends ReactiveStore {
return;
}
const permissions = getPermissionsFromManifest(manifest);
- const executables = manifest.executables ?? [];
- if (!isEmptyPermissions(permissions) || executables.length > 0) {
+ if (!isEmptyPermissions(permissions)) {
showToast(
`"${manifest.name}" can't be previewed because it requires user permissions.`,
{ style: "error", timeout: 5000 },
@@ -791,7 +785,6 @@ export class PluginService extends ReactiveStore {
repo: listing.repo,
enabled: true,
permissions,
- executables,
});
}
@@ -896,13 +889,11 @@ export class PluginService extends ReactiveStore {
throw new Error("Failed to fetch manifest");
}
const permissions = getPermissionsFromManifest(manifest);
- const executables = manifest.executables ?? [];
- if (!isEmptyPermissions(permissions) || executables.length > 0) {
+ if (!isEmptyPermissions(permissions)) {
if (
!(await showPluginInstallPermissionsModal({
pluginName: manifest.name,
permissions,
- executables,
}))
) {
throw new PermissionsDeclinedError();
@@ -918,7 +909,6 @@ export class PluginService extends ReactiveStore {
repo,
enabled: true,
permissions,
- executables,
});
try {
await this.pluginBridge.loadPlugin(pluginId, version, repo);
@@ -942,13 +932,11 @@ export class PluginService extends ReactiveStore {
throw new Error("Failed to fetch manifest");
}
const permissions = getPermissionsFromManifest(manifest);
- const executables = manifest.executables ?? [];
- if (!isEmptyPermissions(permissions) || executables.length > 0) {
+ if (!isEmptyPermissions(permissions)) {
if (
!(await showPluginInstallPermissionsModal({
pluginName: manifest.name,
permissions,
- executables,
}))
) {
throw new PermissionsDeclinedError();
@@ -974,7 +962,6 @@ export class PluginService extends ReactiveStore {
repo,
enabled: true,
permissions,
- executables,
});
try {
await this.pluginBridge.loadPlugin(id, version, repo);
@@ -1026,15 +1013,11 @@ export class PluginService extends ReactiveStore {
const currentPermissions = installedPlugin.permissions ?? {};
const permissions = getPermissionsFromManifest(liveManifest);
const permissionsDiff = diffPermissions(currentPermissions, permissions);
- const currentExecutables = installedPlugin.executables ?? [];
- const executables = liveManifest.executables ?? [];
- const executablesDiff = diffExecutables(currentExecutables, executables);
- if (permissionsDiff || executablesDiff) {
+ if (permissionsDiff) {
const accepted = await showPluginUpdatePermissionsModal({
pluginName: liveManifest.name,
pluginVersion: liveManifest.version,
- permissionsDiff: permissionsDiff ?? {},
- executablesDiff,
+ permissionsDiff,
});
if (!accepted) throw new PermissionsDeclinedError();
}
@@ -1046,7 +1029,6 @@ 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 d0b10b74..f053ae11 100644
--- a/src/js/plugins/sourceProvider.js
+++ b/src/js/plugins/sourceProvider.js
@@ -32,45 +32,6 @@ function parseFontEntry(entry, index) {
return { ...entry, family, file };
}
-// 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") {
@@ -88,14 +49,6 @@ export 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/tests/unit/specs/plugins/pluginBinaryCache.test.js b/tests/unit/specs/plugins/pluginBinaryCache.test.js
index 01462686..0a9bdf4e 100644
--- a/tests/unit/specs/plugins/pluginBinaryCache.test.js
+++ b/tests/unit/specs/plugins/pluginBinaryCache.test.js
@@ -21,6 +21,12 @@ class FakeCache {
async delete(url) {
return this._store.delete(url);
}
+ async keys(url) {
+ const urls = [...this._store.keys()].filter(
+ (stored) => url === undefined || stored === url,
+ );
+ return urls.map((stored) => ({ url: stored }));
+ }
}
class FakeCaches {
@@ -104,6 +110,41 @@ describe("PluginBinaryCache", () => {
}
});
+ it("has reports presence without reading the body", async () => {
+ const { restore } = stubCaches();
+ try {
+ const cache = new PluginBinaryCache();
+ await cache.put("plugin-a", "k1", new Uint8Array([1]).buffer);
+ assert.deepEqual(await cache.has("plugin-a", "k1"), true);
+ assert.deepEqual(await cache.has("plugin-a", "k2"), false);
+ assert.deepEqual(await cache.has("plugin-b", "k1"), false);
+ } finally {
+ restore();
+ }
+ });
+
+ it("keys lists a plugin's stored keys, decoded and isolated", async () => {
+ const { restore } = stubCaches();
+ try {
+ const cache = new PluginBinaryCache();
+ await cache.put(
+ "plugin-a",
+ "models/en de.bin",
+ new Uint8Array([1]).buffer,
+ );
+ await cache.put("plugin-a", "k2", new Uint8Array([2]).buffer);
+ await cache.put("plugin-b", "other", new Uint8Array([3]).buffer);
+ assert.deepEqual((await cache.keys("plugin-a")).sort(), [
+ "k2",
+ "models/en de.bin",
+ ]);
+ assert.deepEqual(await cache.keys("plugin-b"), ["other"]);
+ assert.deepEqual(await cache.keys("plugin-c"), []);
+ } finally {
+ restore();
+ }
+ });
+
it("clear drops every entry for a plugin in one call", async () => {
const { caches, restore } = stubCaches();
try {
diff --git a/tests/unit/specs/plugins/pluginBridge.test.js b/tests/unit/specs/plugins/pluginBridge.test.js
index 526ff6ec..696c6025 100644
--- a/tests/unit/specs/plugins/pluginBridge.test.js
+++ b/tests/unit/specs/plugins/pluginBridge.test.js
@@ -1099,195 +1099,6 @@ 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 430d0d8a..d7223142 100644
--- a/tests/unit/specs/plugins/pluginPermissions.test.js
+++ b/tests/unit/specs/plugins/pluginPermissions.test.js
@@ -3,11 +3,9 @@ import assert from "node:assert/strict";
import {
parsePermissions,
diffPermissions,
- diffExecutables,
isEmptyPermissions,
isFetchAllowed,
isActionAllowed,
- isStorageAllowed,
} from "/js/plugins/pluginPermissions.js";
describe("parsePermissions", () => {
@@ -63,24 +61,6 @@ 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", () => {
@@ -96,13 +76,6 @@ 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(
@@ -219,49 +192,3 @@ 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 1f1c81d5..951725d9 100644
--- a/tests/unit/specs/plugins/pluginService.test.js
+++ b/tests/unit/specs/plugins/pluginService.test.js
@@ -186,7 +186,6 @@ describe("installPlugin", () => {
repo: "ow/alpha",
enabled: true,
permissions: {},
- executables: [],
},
]);
assert.deepEqual(loadCalls, [
@@ -320,7 +319,6 @@ describe("updatePlugin", () => {
repo: "ow/alpha",
enabled: true,
permissions: {},
- executables: [],
});
assert.deepEqual(reloadCalls, [
{ id: "alpha", version: "1.1.0", repo: "ow/alpha" },
@@ -995,7 +993,6 @@ describe("installUnregisteredPlugin", () => {
repo: "ow/alpha",
enabled: true,
permissions: {},
- executables: [],
},
]);
assert.deepEqual(loadCalls, [
@@ -2322,15 +2319,19 @@ describe("loadLocalData/saveLocalData host methods", () => {
});
describe("binaryCache host methods", () => {
- function makeServiceWithPermissions(permissions) {
+ function bytes(values) {
+ return new Uint8Array(values).buffer;
+ }
+
+ function makeServiceWithBinaryCache() {
const { state, provider } = makeProvider();
state.installedPlugins = [
- { id: "translate", version: "1.0.0", enabled: true, permissions },
+ { 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.
+ // The real store is Cache-API backed; these tests are about 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(),
@@ -2346,6 +2347,16 @@ describe("binaryCache host methods", () => {
calls.push(["delete", pluginId, key]);
this._data.delete(`${pluginId}:${key}`);
},
+ async has(pluginId, key) {
+ calls.push(["has", pluginId, key]);
+ return this._data.has(`${pluginId}:${key}`);
+ },
+ async keys(pluginId) {
+ calls.push(["keys", pluginId]);
+ return [...this._data.keys()]
+ .filter((stored) => stored.startsWith(`${pluginId}:`))
+ .map((stored) => stored.slice(pluginId.length + 1));
+ },
};
return { service, calls };
}
@@ -2356,46 +2367,64 @@ describe("binaryCache host methods", () => {
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/,
+ it("has reports whether a key is stored", async () => {
+ const { service } = makeServiceWithBinaryCache();
+ await getHandler(service, "putBinaryCacheEntry")(plugin, {
+ key: "engine",
+ data: bytes([1, 2, 3]),
+ });
+ assert.deepEqual(
+ await getHandler(service, "hasBinaryCacheEntry")(plugin, {
+ key: "engine",
+ }),
+ true,
);
- await assert.rejects(
- () =>
- getHandler(service, "putBinaryCacheEntry")(plugin, {
- key: "k",
- data: "AQ==",
- }),
- /"binaryCache" storage permission/,
+ assert.deepEqual(
+ await getHandler(service, "hasBinaryCacheEntry")(plugin, {
+ key: "missing",
+ }),
+ false,
);
- await assert.rejects(
- () => getHandler(service, "deleteBinaryCacheEntry")(plugin, { key: "k" }),
- /"binaryCache" storage permission/,
+ });
+
+ it("lists this plugin's keys", async () => {
+ const { service, calls } = makeServiceWithBinaryCache();
+ assert.deepEqual(
+ await getHandler(service, "listBinaryCacheEntries")(plugin),
+ [],
+ );
+ await getHandler(service, "putBinaryCacheEntry")(plugin, {
+ key: "engine",
+ data: bytes([1, 2, 3]),
+ });
+ await getHandler(service, "putBinaryCacheEntry")(plugin, {
+ key: "model",
+ data: bytes([1, 2, 3]),
+ });
+ assert.deepEqual(
+ (await getHandler(service, "listBinaryCacheEntries")(plugin)).sort(),
+ ["engine", "model"],
+ );
+ assert.deepEqual(
+ calls.every(([, pluginId]) => pluginId === "translate"),
+ true,
);
});
it("round-trips bytes through put/get when permitted", async () => {
- const { service } = makeServiceWithPermissions({
- storage: ["binaryCache"],
- });
- // base64 for the bytes [1, 2, 3]
+ const { service } = makeServiceWithBinaryCache();
await getHandler(service, "putBinaryCacheEntry")(plugin, {
key: "engine",
- data: "AQID",
+ data: bytes([1, 2, 3]),
});
- const base64 = await getHandler(service, "getBinaryCacheEntry")(plugin, {
+ const buffer = await getHandler(service, "getBinaryCacheEntry")(plugin, {
key: "engine",
});
- const stored = new Uint8Array(Buffer.from(base64, "base64"));
- assert.deepEqual([...stored], [1, 2, 3]);
+ assert.deepEqual([...new Uint8Array(buffer)], [1, 2, 3]);
});
it("returns null for a key that was never stored", async () => {
- const { service } = makeServiceWithPermissions({
- storage: ["binaryCache"],
- });
+ const { service } = makeServiceWithBinaryCache();
assert.deepEqual(
await getHandler(service, "getBinaryCacheEntry")(plugin, {
key: "missing",
@@ -2405,12 +2434,10 @@ describe("binaryCache host methods", () => {
});
it("delete removes the entry and calls are scoped to this plugin's id", async () => {
- const { service, calls } = makeServiceWithPermissions({
- storage: ["binaryCache"],
- });
+ const { service, calls } = makeServiceWithBinaryCache();
await getHandler(service, "putBinaryCacheEntry")(plugin, {
key: "engine",
- data: "AQID",
+ data: bytes([1, 2, 3]),
});
await getHandler(service, "deleteBinaryCacheEntry")(plugin, {
key: "engine",
@@ -2427,14 +2454,30 @@ describe("binaryCache host methods", () => {
);
});
+ it("rejects put data that isn't an ArrayBuffer", async () => {
+ const { service } = makeServiceWithBinaryCache();
+ for (const data of ["AQID", new Uint8Array([1, 2, 3]), { byteLength: 3 }]) {
+ await assert.rejects(
+ () =>
+ getHandler(service, "putBinaryCacheEntry")(plugin, {
+ key: "engine",
+ data,
+ }),
+ /must be an ArrayBuffer/,
+ );
+ }
+ });
+
it("requires a key argument", async () => {
- const { service } = makeServiceWithPermissions({
- storage: ["binaryCache"],
- });
+ const { service } = makeServiceWithBinaryCache();
await assert.rejects(
() => getHandler(service, "getBinaryCacheEntry")(plugin, {}),
/key/,
);
+ await assert.rejects(
+ () => getHandler(service, "hasBinaryCacheEntry")(plugin, {}),
+ /key/,
+ );
});
});
diff --git a/tests/unit/specs/plugins/sourceProvider.test.js b/tests/unit/specs/plugins/sourceProvider.test.js
deleted file mode 100644
index e5487914..00000000
--- a/tests/unit/specs/plugins/sourceProvider.test.js
+++ /dev/null
@@ -1,134 +0,0 @@
-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/,
- );
- });
-});