diff --git a/impro-plugin/docs/docs.md b/impro-plugin/docs/docs.md index 579a9c3e..ed6495b3 100644 --- a/impro-plugin/docs/docs.md +++ b/impro-plugin/docs/docs.md @@ -14,6 +14,7 @@ The plugin's handle to the running impro app. Exposed as `this.app` on a | Property | Type | Description | | ------ | ------ | ------ | +| `binaryCache` | [`BinaryCache`](#binarycache) | Host-mediated binary storage — see [BinaryCache](#binarycache). | | `currentUser` | [`ProfileView`](#profileview) \| `null` | The signed-in user's basic profile, populated before `onload()` runs. Null when no session is active. | | `data` | [`PluginData`](#plugindata) | Read-only appview accessors — see [PluginData](#plugindata). | @@ -174,6 +175,73 @@ Unmute an actor. Requires the `"mute"` scope. *** +### BinaryCache + +Host-mediated persistent storage for binary data too large for +[Plugin.loadLocalData](#loadlocaldata)/[Plugin.saveLocalData](#savelocaldata) (backed by +localStorage, a few MB shared across every installed plugin) — e.g. a +downloaded WASM engine or model file that shouldn't need re-fetching every +session. Namespaced per plugin; survives reloads but is cleared on +uninstall. Requires the `"binaryCache"` scope in the manifest's +`permissions.storage`. + +#### Constructors + +##### Constructor + +> **new BinaryCache**(): [`BinaryCache`](#binarycache) + +###### Returns + +[`BinaryCache`](#binarycache) + +#### Methods + +##### delete() + +> **delete**(`key`): `Promise`\<`void`\> + +###### Parameters + +| Parameter | Type | +| ------ | ------ | +| `key` | `string` | + +###### Returns + +`Promise`\<`void`\> + +##### get() + +> **get**(`key`): `Promise`\<`ArrayBuffer` \| `null`\> + +###### Parameters + +| Parameter | Type | +| ------ | ------ | +| `key` | `string` | + +###### Returns + +`Promise`\<`ArrayBuffer` \| `null`\> + +##### put() + +> **put**(`key`, `data`): `Promise`\<`void`\> + +###### Parameters + +| Parameter | Type | +| ------ | ------ | +| `key` | `string` | +| `data` | `ArrayBuffer` \| `ArrayBufferView`\<`ArrayBufferLike`\> | + +###### Returns + +`Promise`\<`void`\> + +*** + ### BlobImageComponent Renders a repo blob (by owning DID + blob CID) as an image. Built via diff --git a/impro-plugin/main.d.ts b/impro-plugin/main.d.ts index 6cc0f6bb..e7d7c3c9 100644 --- a/impro-plugin/main.d.ts +++ b/impro-plugin/main.d.ts @@ -215,6 +215,33 @@ export class PluginData { limit?: number; }): Promise; } +/** + * Host-mediated persistent storage for binary data too large for + * {@link Plugin.loadLocalData}/{@link Plugin.saveLocalData} (backed by + * localStorage, a few MB shared across every installed plugin) — e.g. a + * downloaded WASM engine or model file that shouldn't need re-fetching every + * session. Namespaced per plugin; survives reloads but is cleared on + * uninstall. Requires the `"binaryCache"` scope in the manifest's + * `permissions.storage`. + */ +export class BinaryCache { + /** + * @param {string} key + * @returns {Promise} + */ + get(key: string): Promise; + /** + * @param {string} key + * @param {ArrayBuffer | ArrayBufferView} data + * @returns {Promise} + */ + put(key: string, data: ArrayBuffer | ArrayBufferView): Promise; + /** + * @param {string} key + * @returns {Promise} + */ + delete(key: string): Promise; +} /** * The plugin's handle to the running impro app. Exposed as `this.app` on a * {@link Plugin} instance. Owns event subscriptions, data accessors @@ -229,6 +256,8 @@ export class App { currentUser: ProfileView | null; /** Read-only appview accessors — see {@link PluginData}. */ data: PluginData; + /** Host-mediated binary storage — see {@link BinaryCache}. */ + binaryCache: BinaryCache; /** * Register an event listener. Supported events: * diff --git a/impro-plugin/main.js b/impro-plugin/main.js index 2e643415..ebc5a8d4 100644 --- a/impro-plugin/main.js +++ b/impro-plugin/main.js @@ -359,6 +359,50 @@ export class PluginData { } } +/** + * Host-mediated persistent storage for binary data too large for + * {@link Plugin.loadLocalData}/{@link Plugin.saveLocalData} (backed by + * localStorage, a few MB shared across every installed plugin) — e.g. a + * downloaded WASM engine or model file that shouldn't need re-fetching every + * session. Namespaced per plugin; survives reloads but is cleared on + * uninstall. Requires the `"binaryCache"` scope in the manifest's + * `permissions.storage`. + */ +export class BinaryCache { + /** + * @param {string} key + * @returns {Promise} + */ + async get(key) { + const stored = /** @type {string | null} */ ( + await hostCall("getBinaryCacheEntry", { key: String(key) }) + ); + return stored == null ? null : base64ToArrayBuffer(stored); + } + /** + * @param {string} key + * @param {ArrayBuffer | ArrayBufferView} data + * @returns {Promise} + */ + async put(key, data) { + const bytes = + data instanceof ArrayBuffer + ? new Uint8Array(data) + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + await hostCall("putBinaryCacheEntry", { + key: String(key), + data: bytesToBase64(bytes), + }); + } + /** + * @param {string} key + * @returns {Promise} + */ + async delete(key) { + await hostCall("deleteBinaryCacheEntry", { key: String(key) }); + } +} + /** * The plugin's handle to the running impro app. Exposed as `this.app` on a * {@link Plugin} instance. Owns event subscriptions, data accessors @@ -375,6 +419,8 @@ export class App { this.currentUser = null; /** Read-only appview accessors — see {@link PluginData}. */ this.data = new PluginData(); + /** Host-mediated binary storage — see {@link BinaryCache}. */ + this.binaryCache = new BinaryCache(); } /** * Register an event listener. Supported events: diff --git a/src/js/plugins/pluginBinaryCache.js b/src/js/plugins/pluginBinaryCache.js new file mode 100644 index 00000000..6ef6da0e --- /dev/null +++ b/src/js/plugins/pluginBinaryCache.js @@ -0,0 +1,45 @@ +// Host-mediated persistent storage for binary data a plugin's own worker +// can't reliably cache itself: the plugin sandbox iframe runs with +// sandbox="allow-scripts" and no allow-same-origin, which gives it an +// opaque origin — indexedDB/caches/localStorage inside that context are +// either unavailable or reset on every reload. Backed by the same Cache API +// PluginCache already uses for plugin bundles, in a cache namespaced per +// plugin (so clearing on uninstall is a single caches.delete(), not an +// enumerate-and-filter pass). + +const CACHE_PREFIX = "plugin-binary-cache:"; + +// Cache API keys are Request/URL, not arbitrary strings — this constructs a +// stable, never-dereferenced pseudo-URL per cache key. The .invalid TLD +// (RFC 2606) guarantees it can never resolve to a real host even if +// something upstream ever did try to fetch it. +function keyUrl(key) { + return `https://plugin-binary-cache.invalid/${encodeURIComponent(key)}`; +} + +export class PluginBinaryCache { + _cacheName(pluginId) { + return `${CACHE_PREFIX}${pluginId}`; + } + + async get(pluginId, key) { + const cache = await caches.open(this._cacheName(pluginId)); + const response = await cache.match(keyUrl(key)); + if (!response) return null; + return await response.arrayBuffer(); + } + + async put(pluginId, key, arrayBuffer) { + const cache = await caches.open(this._cacheName(pluginId)); + await cache.put(keyUrl(key), new Response(arrayBuffer)); + } + + async delete(pluginId, key) { + const cache = await caches.open(this._cacheName(pluginId)); + await cache.delete(keyUrl(key)); + } + + async clear(pluginId) { + await caches.delete(this._cacheName(pluginId)); + } +} diff --git a/src/js/plugins/pluginBridge.js b/src/js/plugins/pluginBridge.js index 1a810668..0660ab86 100644 --- a/src/js/plugins/pluginBridge.js +++ b/src/js/plugins/pluginBridge.js @@ -99,12 +99,232 @@ function getSdkSource() { return __sdkSourcePromise; } -export async function wrapWorkerSource(source) { +// A plugin's manifest.executables table is the only thing that can make +// WebAssembly.instantiate/compile succeed inside the worker (see +// wasmGatePrelude below) — everything not listed there is refused. This +// runs before the plugin's own main.js text, so it's trusted, host-authored +// code even though it executes inside the plugin's worker. +function wasmGatePrelude(executables) { + const declared = JSON.stringify( + (executables ?? []).map((entry) => entry.sha256), + ); + return /* js */ ` + (() => { + const declaredHashes = new Set(${declared}); + + // Pure-JS SHA-256 (FIPS 180-4) rather than crypto.subtle.digest(): + // this worker's document has sandbox="allow-scripts" with no + // allow-same-origin, which gives it an opaque origin - and per spec, + // an opaque origin is never a secure context, so crypto.subtle is + // unavailable here in browsers that enforce that strictly (confirmed + // in Chrome: self.isSecureContext is false and self.crypto.subtle is + // undefined inside this exact sandboxed worker). Implementing the + // hash in plain JS sidesteps the secure-context requirement entirely + // without loosening the sandbox itself. + const SHA256_K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, + 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, + 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, + 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, + 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, + 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, + 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, + 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, + 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ]); + + function sha256Rotr(x, n) { + return ((x >>> n) | (x << (32 - n))) >>> 0; + } + + async function sha256Hex(bytesLike) { + const bytes = + bytesLike instanceof ArrayBuffer + ? new Uint8Array(bytesLike) + : new Uint8Array( + bytesLike.buffer, + bytesLike.byteOffset, + bytesLike.byteLength, + ); + + const bitLen = bytes.length * 8; + // 1 byte for 0x80, 8 bytes for the 64-bit big-endian length, + // padded so the total is a multiple of 64. + const paddedLen = Math.ceil((bytes.length + 9) / 64) * 64; + const padded = new Uint8Array(paddedLen); + padded.set(bytes); + padded[bytes.length] = 0x80; + const view = new DataView(padded.buffer); + // bitLen fits in 32 bits for any bytes this gate will ever see + // (well under 2^32 bits = 512MiB), so the high 32 bits of the + // 64-bit length are always zero. + view.setUint32(paddedLen - 4, bitLen >>> 0, false); + + let h0 = 0x6a09e667, + h1 = 0xbb67ae85, + h2 = 0x3c6ef372, + h3 = 0xa54ff53a, + h4 = 0x510e527f, + h5 = 0x9b05688c, + h6 = 0x1f83d9ab, + h7 = 0x5be0cd19; + + const w = new Uint32Array(64); + for (let offset = 0; offset < paddedLen; offset += 64) { + for (let i = 0; i < 16; i++) { + w[i] = view.getUint32(offset + i * 4, false); + } + for (let i = 16; i < 64; i++) { + const s0 = + sha256Rotr(w[i - 15], 7) ^ + sha256Rotr(w[i - 15], 18) ^ + (w[i - 15] >>> 3); + const s1 = + sha256Rotr(w[i - 2], 17) ^ + sha256Rotr(w[i - 2], 19) ^ + (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + + let a = h0, + b = h1, + c = h2, + d = h3, + e = h4, + f = h5, + g = h6, + h = h7; + + for (let i = 0; i < 64; i++) { + const S1 = sha256Rotr(e, 6) ^ sha256Rotr(e, 11) ^ sha256Rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const temp1 = (h + S1 + ch + SHA256_K[i] + w[i]) >>> 0; + const S0 = sha256Rotr(a, 2) ^ sha256Rotr(a, 13) ^ sha256Rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (S0 + maj) >>> 0; + + h = g; + g = f; + f = e; + e = (d + temp1) >>> 0; + d = c; + c = b; + b = a; + a = (temp1 + temp2) >>> 0; + } + + h0 = (h0 + a) >>> 0; + h1 = (h1 + b) >>> 0; + h2 = (h2 + c) >>> 0; + h3 = (h3 + d) >>> 0; + h4 = (h4 + e) >>> 0; + h5 = (h5 + f) >>> 0; + h6 = (h6 + g) >>> 0; + h7 = (h7 + h) >>> 0; + } + + return [h0, h1, h2, h3, h4, h5, h6, h7] + .map((x) => x.toString(16).padStart(8, "0")) + .join(""); + } + + async function assertDeclared(bytesLike) { + if ( + !(bytesLike instanceof ArrayBuffer) && + !ArrayBuffer.isView(bytesLike) + ) { + throw new TypeError("WebAssembly bytes must be a BufferSource"); + } + const hash = await sha256Hex(bytesLike); + if (!declaredHashes.has(hash)) { + // Named distinctly (rather than a plain Error) so a plugin can + // tell "the host's CSP genuinely has no wasm-unsafe-eval" (a + // native CompileError/SecurityError, thrown before this check + // ever runs) apart from "wasm-unsafe-eval works fine, this + // particular byte string just isn't declared" - e.g. for + // capability-probing with inert bytes that were never meant to be + // added to the manifest. + const error = new Error( + 'WebAssembly execution blocked: bytes with sha256 "' + + hash + + '" are not declared in this plugin\\'s manifest ' + + '"executables" list. Add an entry with this hash and a ' + + "sourceUrl before this code can run.", + ); + error.name = "WasmProvenanceError"; + throw error; + } + } + + // No WebAssembly global at all (e.g. a stripped-down test harness, or + // a runtime that lacks it entirely) - nothing to gate. + if (!self.WebAssembly) return; + + // Mutated in place (not replaced with a fresh object) so + // WebAssembly.Instance/Memory/Table/validate/the error constructors — + // none of which need gating — stay exactly as the engine provides + // them, with no risk of an incomplete spread dropping one. + const wasm = self.WebAssembly; + const RealModule = wasm.Module; // captured before it's blocked below + const realCompile = wasm.compile.bind(wasm); + const realInstantiate = wasm.instantiate.bind(wasm); + + wasm.compile = async (bytesLike) => { + await assertDeclared(bytesLike); + return realCompile(bytesLike); + }; + wasm.instantiate = async (bytesLikeOrModule, importObject) => { + // A WebAssembly.Module instance can only exist here via the gated + // compile() above (the synchronous Module constructor is disabled + // below), so it's already been checked. + if (bytesLikeOrModule instanceof RealModule) { + return realInstantiate(bytesLikeOrModule, importObject); + } + await assertDeclared(bytesLikeOrModule); + return realInstantiate(bytesLikeOrModule, importObject); + }; + // The synchronous Module constructor can't be gated (crypto.subtle is + // async, constructors can't await), so "new WebAssembly.Module(...)" + // is blocked outright — legitimate code goes through the gated + // compile()/instantiate() above instead. instanceof checks against + // WebAssembly.Module still need to recognize modules that did come + // from that gated path (e.g. Emscripten glue commonly branches on + // "x instanceof WebAssembly.Module"), so Symbol.hasInstance is + // delegated to the real class rather than replacing Module with a + // constructor nothing will ever be an instance of. + const BlockedModule = function () { + throw new Error( + "new WebAssembly.Module() is disabled in the plugin sandbox; " + + "use WebAssembly.compile()/instantiate() instead.", + ); + }; + BlockedModule.prototype = RealModule.prototype; + Object.defineProperty(BlockedModule, Symbol.hasInstance, { + value: (instance) => instance instanceof RealModule, + }); + wasm.Module = BlockedModule; + // instantiateStreaming/compileStreaming can't produce a valid + // Response inside this sandbox anyway (connect-src: none blocks a + // real fetch, and the SDK's own proxied fetch() doesn't return a real + // Response) — removed outright rather than left as an unguarded path. + wasm.compileStreaming = undefined; + wasm.instantiateStreaming = undefined; + })(); + `; +} + +export async function wrapWorkerSource(source, manifest) { const sdkSource = await getSdkSource(); return /* js */ ` delete self.BroadcastChannel; delete self.SharedWorker; + ${wasmGatePrelude(manifest?.executables)} + ${sdkSource} self.module = {}; @@ -186,7 +406,7 @@ export class PluginInstance { } static async loadFromSource(pluginId, manifest, source, callbacks) { - const wrappedSource = await wrapWorkerSource(source); + const wrappedSource = await wrapWorkerSource(source, manifest); const worker = !window.env.playwright // don't sandbox in e2e tests ? createSandboxedWorker(wrappedSource) : createDirectWorker(wrappedSource); diff --git a/src/js/plugins/pluginModal.js b/src/js/plugins/pluginModal.js index 89aff693..7e00dbd5 100644 --- a/src/js/plugins/pluginModal.js +++ b/src/js/plugins/pluginModal.js @@ -118,6 +118,10 @@ const ACTION_LABELS = { 'Send feed feedback (e.g. "show fewer/more like this") on your behalf', }; +const STORAGE_LABELS = { + binaryCache: "Cache downloaded files on this device (e.g. AI/ML models)", +}; + function permissionsSectionTemplate({ title, items }) { return html`
@@ -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/, + ); + }); +}); -- 2.51.2 From 40832228d669507e50b9060da38111e7d7091ef3 Mon Sep 17 00:00:00 2001 From: Nameless 7778777 <7778777@7778777.online> Date: Wed, 12 Aug 2026 15:39:03 +0200 Subject: [PATCH 2/3] Fix binary cache after main's ArrayBuffer transport switch main merged in cleanly (no textual conflicts) but left a real break: pluginService.js still imported bytesToBase64/base64ToArrayBuffer from pluginRequests.js, and the SDK's BinaryCache.get/put still called them by name - both were removed when pluginFetch's response body switched from a base64 string to a raw ArrayBuffer transferred directly over postMessage. The host-side import would have failed to resolve at module-load time (breaking plugin loading entirely, not just the binary cache), and the SDK-side calls would have hit a ReferenceError the first time a plugin actually used app.binaryCache. Fixes both sides to pass the ArrayBuffer straight through, matching pluginFetch's already-merged convention - no encoding layer needed, since postMessage's structured clone handles ArrayBuffer natively in both directions. Widens the SDK's Cloneable type to include ArrayBuffer (previously only special-cased for the fetch-response return path, not for a hostCall argument like putBinaryCacheEntry's). Also fixes pluginService.test.js's binaryCache round-trip test, which was still asserting against a base64 string and only passed by coincidence (its fake binaryCache is an opaque passthrough, so it round-tripped whatever it was given regardless of encoding). Co-Authored-By: Claude Sonnet 5 --- impro-plugin/docs/docs.md | 8 +++--- impro-plugin/main.d.ts | 8 +++--- impro-plugin/main.js | 26 +++++++++++-------- src/js/plugins/pluginService.js | 15 +++-------- .../unit/specs/plugins/pluginService.test.js | 18 +++++++------ 5 files changed, 38 insertions(+), 37 deletions(-) diff --git a/impro-plugin/docs/docs.md b/impro-plugin/docs/docs.md index 5f4a1886..42073ca6 100644 --- a/impro-plugin/docs/docs.md +++ b/impro-plugin/docs/docs.md @@ -2324,10 +2324,12 @@ A record that links to a queried subject. ### Cloneable -> **Cloneable** = `null` \| `undefined` \| `boolean` \| `number` \| `string` \| [`CloneableArray`](#cloneablearray) \| [`CloneableObject`](#cloneableobject) +> **Cloneable** = `null` \| `undefined` \| `boolean` \| `number` \| `string` \| `ArrayBuffer` \| [`CloneableArray`](#cloneablearray) \| [`CloneableObject`](#cloneableobject) -JSON-shaped data — the only thing that can cross between a plugin and the - host. Functions, class instances, `Date`, `Map` and friends cannot. +JSON-shaped data, plus `ArrayBuffer` (structured-clones natively, both + directions - see [BinaryCache](#binarycache) and [PluginResponse](#pluginresponse)) - the + only things that can cross between a plugin and the host. Functions, + class instances, `Date`, `Map` and friends cannot. #### Type Parameters diff --git a/impro-plugin/main.d.ts b/impro-plugin/main.d.ts index 0f37eb26..dd690b4f 100644 --- a/impro-plugin/main.d.ts +++ b/impro-plugin/main.d.ts @@ -1324,10 +1324,12 @@ export type CloneableObject = { */ export type CloneableArray = Cloneable[]; /** - * JSON-shaped data — the only thing that can cross between a plugin and the - * host. Functions, class instances, `Date`, `Map` and friends cannot. + * JSON-shaped data, plus `ArrayBuffer` (structured-clones natively, both + * directions - see {@link BinaryCache} and {@link PluginResponse}) - the + * only things that can cross between a plugin and the host. Functions, + * class instances, `Date`, `Map` and friends cannot. */ -export type Cloneable = null | undefined | boolean | number | string | CloneableArray | CloneableObject; +export type Cloneable = null | undefined | boolean | number | string | ArrayBuffer | CloneableArray | CloneableObject; /** * {@internal} */ diff --git a/impro-plugin/main.js b/impro-plugin/main.js index 9b553321..67ef487c 100644 --- a/impro-plugin/main.js +++ b/impro-plugin/main.js @@ -374,10 +374,9 @@ export class BinaryCache { * @returns {Promise} */ async get(key) { - const stored = /** @type {string | null} */ ( - await hostCall("getBinaryCacheEntry", { key: String(key) }) + return /** @type {Promise} */ ( + hostCall("getBinaryCacheEntry", { key: String(key) }) ); - return stored == null ? null : base64ToArrayBuffer(stored); } /** * @param {string} key @@ -385,14 +384,17 @@ export class BinaryCache { * @returns {Promise} */ async put(key, data) { - const bytes = + const view = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - await hostCall("putBinaryCacheEntry", { - key: String(key), - data: bytesToBase64(bytes), - }); + // Always copies into a fresh, plain ArrayBuffer (never the wider + // ArrayBufferLike a view's .buffer is typed as) - postMessage clones + // ArrayBuffer natively, so this is just about giving hostCall a + // definite Cloneable-compatible type, not about avoiding aliasing. + const buffer = new ArrayBuffer(view.byteLength); + new Uint8Array(buffer).set(view); + await hostCall("putBinaryCacheEntry", { key: String(key), data: buffer }); } /** * @param {string} key @@ -2037,9 +2039,11 @@ export class VirtualEl { * An object whose values are all {@link Cloneable}. * @typedef {Cloneable[]} CloneableArray * An array of {@link Cloneable} values. - * @typedef {null | undefined | boolean | number | string | CloneableArray | CloneableObject} Cloneable - * JSON-shaped data — the only thing that can cross between a plugin and the - * host. Functions, class instances, `Date`, `Map` and friends cannot. + * @typedef {null | undefined | boolean | number | string | ArrayBuffer | CloneableArray | CloneableObject} Cloneable + * JSON-shaped data, plus `ArrayBuffer` (structured-clones natively, both + * directions - see {@link BinaryCache} and {@link PluginResponse}) - the + * only things that can cross between a plugin and the host. Functions, + * class instances, `Date`, `Map` and friends cannot. * @typedef {{ type: "register", target: "eventListener", event: string, handlerId: number }} RegisterEventListenerMessage * {@internal} * @typedef {{ type: "register", target: "sidebarItem", icon: string | SerializedElement, title: string, handlerId: number }} RegisterSidebarItemMessage diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js index 1b589c63..32a320e9 100644 --- a/src/js/plugins/pluginService.js +++ b/src/js/plugins/pluginService.js @@ -23,11 +23,7 @@ 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, @@ -414,8 +410,7 @@ export class PluginService extends ReactiveStore { 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); }, ); @@ -425,11 +420,7 @@ export class PluginService extends ReactiveStore { this._requireStoragePermission(plugin, "binaryCache"); requireHostMethodArg("putBinaryCacheEntry", "key", key); requireHostMethodArg("putBinaryCacheEntry", "data", data); - await this.binaryCache.put( - plugin.pluginId, - key, - base64ToArrayBuffer(data), - ); + await this.binaryCache.put(plugin.pluginId, key, data); }, ); diff --git a/tests/unit/specs/plugins/pluginService.test.js b/tests/unit/specs/plugins/pluginService.test.js index 1f1c81d5..33700786 100644 --- a/tests/unit/specs/plugins/pluginService.test.js +++ b/tests/unit/specs/plugins/pluginService.test.js @@ -2366,7 +2366,7 @@ describe("binaryCache host methods", () => { () => getHandler(service, "putBinaryCacheEntry")(plugin, { key: "k", - data: "AQ==", + data: new Uint8Array([1]).buffer, }), /"binaryCache" storage permission/, ); @@ -2376,20 +2376,22 @@ describe("binaryCache host methods", () => { ); }); - it("round-trips bytes through put/get when permitted", async () => { + it("round-trips raw bytes through put/get when permitted", async () => { + // data crosses the worker<->host postMessage boundary as a real + // ArrayBuffer (structured clone), not a base64 string - the host + // methods are a plain passthrough to binaryCache, no encoding involved. const { service } = makeServiceWithPermissions({ storage: ["binaryCache"], }); - // base64 for the bytes [1, 2, 3] + const buffer = new Uint8Array([1, 2, 3]).buffer; await getHandler(service, "putBinaryCacheEntry")(plugin, { key: "engine", - data: "AQID", + data: buffer, }); - const base64 = await getHandler(service, "getBinaryCacheEntry")(plugin, { + const stored = 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(stored)], [1, 2, 3]); }); it("returns null for a key that was never stored", async () => { @@ -2410,7 +2412,7 @@ describe("binaryCache host methods", () => { }); await getHandler(service, "putBinaryCacheEntry")(plugin, { key: "engine", - data: "AQID", + data: new Uint8Array([1, 2, 3]).buffer, }); await getHandler(service, "deleteBinaryCacheEntry")(plugin, { key: "engine", -- 2.51.2 From 0be167f44100e0b2b7cc7c19ffcd6af9861cac5a Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Wed, 12 Aug 2026 09:58:27 -0400 Subject: [PATCH 3/3] Remove permissions and tweak binary cache API --- impro-plugin/docs/docs.md | 42 +++- impro-plugin/main.d.ts | 29 ++- impro-plugin/main.js | 53 +++-- src/js/plugins/pluginBinaryCache.js | 31 ++- src/js/plugins/pluginBridge.js | 224 +----------------- src/js/plugins/pluginModal.js | 40 +--- src/js/plugins/pluginPermissions.js | 29 --- src/js/plugins/pluginService.js | 70 ++---- src/js/plugins/sourceProvider.js | 47 ---- .../specs/plugins/pluginBinaryCache.test.js | 41 ++++ tests/unit/specs/plugins/pluginBridge.test.js | 189 --------------- .../specs/plugins/pluginPermissions.test.js | 73 ------ .../unit/specs/plugins/pluginService.test.js | 125 ++++++---- .../unit/specs/plugins/sourceProvider.test.js | 134 ----------- 14 files changed, 261 insertions(+), 866 deletions(-) delete mode 100644 tests/unit/specs/plugins/sourceProvider.test.js diff --git a/impro-plugin/docs/docs.md b/impro-plugin/docs/docs.md index 5f4a1886..40bbd91a 100644 --- a/impro-plugin/docs/docs.md +++ b/impro-plugin/docs/docs.md @@ -178,12 +178,9 @@ Unmute an actor. Requires the `"mute"` scope. ### BinaryCache Host-mediated persistent storage for binary data too large for -[Plugin.loadLocalData](#loadlocaldata)/[Plugin.saveLocalData](#savelocaldata) (backed by -localStorage, a few MB shared across every installed plugin) — e.g. a -downloaded WASM engine or model file that shouldn't need re-fetching every -session. Namespaced per plugin; survives reloads but is cleared on -uninstall. Requires the `"binaryCache"` scope in the manifest's -`permissions.storage`. +[Plugin.loadLocalData](#loadlocaldata)/[Plugin.saveLocalData](#savelocaldata). +Namespaced per plugin; survives reloads but is cleared on +uninstall. #### Constructors @@ -225,6 +222,32 @@ uninstall. Requires the `"binaryCache"` scope in the manifest's `Promise`\<`ArrayBuffer` \| `null`\> +##### has() + +> **has**(`key`): `Promise`\<`boolean`\> + +Whether an entry is stored under `key`, without transferring its bytes. + +###### Parameters + +| Parameter | Type | +| ------ | ------ | +| `key` | `string` | + +###### Returns + +`Promise`\<`boolean`\> + +##### keys() + +> **keys**(): `Promise`\<`string`[]\> + +Every key this plugin currently has stored, in no particular order. + +###### Returns + +`Promise`\<`string`[]\> + ##### put() > **put**(`key`, `data`): `Promise`\<`void`\> @@ -2324,10 +2347,11 @@ A record that links to a queried subject. ### Cloneable -> **Cloneable** = `null` \| `undefined` \| `boolean` \| `number` \| `string` \| [`CloneableArray`](#cloneablearray) \| [`CloneableObject`](#cloneableobject) +> **Cloneable** = `null` \| `undefined` \| `boolean` \| `number` \| `string` \| `ArrayBuffer` \| [`CloneableArray`](#cloneablearray) \| [`CloneableObject`](#cloneableobject) -JSON-shaped data — the only thing that can cross between a plugin and the - host. Functions, class instances, `Date`, `Map` and friends cannot. +JSON-shaped data, plus `ArrayBuffer` for binary payloads — the only thing + that can cross between a plugin and the host. Functions, class instances, + `Date`, `Map` and friends cannot. #### Type Parameters diff --git a/impro-plugin/main.d.ts b/impro-plugin/main.d.ts index 0f37eb26..d2713ca4 100644 --- a/impro-plugin/main.d.ts +++ b/impro-plugin/main.d.ts @@ -217,12 +217,9 @@ export class PluginData { } /** * Host-mediated persistent storage for binary data too large for - * {@link Plugin.loadLocalData}/{@link Plugin.saveLocalData} (backed by - * localStorage, a few MB shared across every installed plugin) — e.g. a - * downloaded WASM engine or model file that shouldn't need re-fetching every - * session. Namespaced per plugin; survives reloads but is cleared on - * uninstall. Requires the `"binaryCache"` scope in the manifest's - * `permissions.storage`. + * {@link Plugin.loadLocalData}/{@link Plugin.saveLocalData}. + * Namespaced per plugin; survives reloads but is cleared on + * uninstall. */ export class BinaryCache { /** @@ -230,6 +227,19 @@ export class BinaryCache { * @returns {Promise} */ get(key: string): Promise; + /** + * Whether an entry is stored under `key`, without transferring its bytes. + * + * @param {string} key + * @returns {Promise} + */ + has(key: string): Promise; + /** + * Every key this plugin currently has stored, in no particular order. + * + * @returns {Promise} + */ + keys(): Promise; /** * @param {string} key * @param {ArrayBuffer | ArrayBufferView} data @@ -1324,10 +1334,11 @@ export type CloneableObject = { */ export type CloneableArray = Cloneable[]; /** - * JSON-shaped data — the only thing that can cross between a plugin and the - * host. Functions, class instances, `Date`, `Map` and friends cannot. + * JSON-shaped data, plus `ArrayBuffer` for binary payloads — the only thing + * that can cross between a plugin and the host. Functions, class instances, + * `Date`, `Map` and friends cannot. */ -export type Cloneable = null | undefined | boolean | number | string | CloneableArray | CloneableObject; +export type Cloneable = null | undefined | boolean | number | string | ArrayBuffer | CloneableArray | CloneableObject; /** * {@internal} */ diff --git a/impro-plugin/main.js b/impro-plugin/main.js index 9b553321..84e43327 100644 --- a/impro-plugin/main.js +++ b/impro-plugin/main.js @@ -361,12 +361,9 @@ export class PluginData { /** * Host-mediated persistent storage for binary data too large for - * {@link Plugin.loadLocalData}/{@link Plugin.saveLocalData} (backed by - * localStorage, a few MB shared across every installed plugin) — e.g. a - * downloaded WASM engine or model file that shouldn't need re-fetching every - * session. Namespaced per plugin; survives reloads but is cleared on - * uninstall. Requires the `"binaryCache"` scope in the manifest's - * `permissions.storage`. + * {@link Plugin.loadLocalData}/{@link Plugin.saveLocalData}. + * Namespaced per plugin; survives reloads but is cleared on + * uninstall. */ export class BinaryCache { /** @@ -374,10 +371,28 @@ export class BinaryCache { * @returns {Promise} */ async get(key) { - const stored = /** @type {string | null} */ ( - await hostCall("getBinaryCacheEntry", { key: String(key) }) + return /** @type {Promise} */ ( + hostCall("getBinaryCacheEntry", { key: String(key) }) ); - return stored == null ? null : base64ToArrayBuffer(stored); + } + /** + * Whether an entry is stored under `key`, without transferring its bytes. + * + * @param {string} key + * @returns {Promise} + */ + async has(key) { + return /** @type {boolean} */ ( + await hostCall("hasBinaryCacheEntry", { key: String(key) }) + ); + } + /** + * Every key this plugin currently has stored, in no particular order. + * + * @returns {Promise} + */ + async keys() { + return /** @type {string[]} */ (await hostCall("listBinaryCacheEntries")); } /** * @param {string} key @@ -385,14 +400,13 @@ export class BinaryCache { * @returns {Promise} */ async put(key, data) { - const bytes = + // A view may be a window onto a larger buffer, so send just its bytes. + const buffer = /** @type {ArrayBuffer} */ ( data instanceof ArrayBuffer - ? new Uint8Array(data) - : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - await hostCall("putBinaryCacheEntry", { - key: String(key), - data: bytesToBase64(bytes), - }); + ? data + : data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) + ); + await hostCall("putBinaryCacheEntry", { key: String(key), data: buffer }); } /** * @param {string} key @@ -2037,9 +2051,10 @@ export class VirtualEl { * An object whose values are all {@link Cloneable}. * @typedef {Cloneable[]} CloneableArray * An array of {@link Cloneable} values. - * @typedef {null | undefined | boolean | number | string | CloneableArray | CloneableObject} Cloneable - * JSON-shaped data — the only thing that can cross between a plugin and the - * host. Functions, class instances, `Date`, `Map` and friends cannot. + * @typedef {null | undefined | boolean | number | string | ArrayBuffer | CloneableArray | CloneableObject} Cloneable + * JSON-shaped data, plus `ArrayBuffer` for binary payloads — the only thing + * that can cross between a plugin and the host. Functions, class instances, + * `Date`, `Map` and friends cannot. * @typedef {{ type: "register", target: "eventListener", event: string, handlerId: number }} RegisterEventListenerMessage * {@internal} * @typedef {{ type: "register", target: "sidebarItem", icon: string | SerializedElement, title: string, handlerId: number }} RegisterSidebarItemMessage diff --git a/src/js/plugins/pluginBinaryCache.js b/src/js/plugins/pluginBinaryCache.js index 6ef6da0e..86e7fabf 100644 --- a/src/js/plugins/pluginBinaryCache.js +++ b/src/js/plugins/pluginBinaryCache.js @@ -1,22 +1,15 @@ -// Host-mediated persistent storage for binary data a plugin's own worker -// can't reliably cache itself: the plugin sandbox iframe runs with -// sandbox="allow-scripts" and no allow-same-origin, which gives it an -// opaque origin — indexedDB/caches/localStorage inside that context are -// either unavailable or reset on every reload. Backed by the same Cache API -// PluginCache already uses for plugin bundles, in a cache namespaced per -// plugin (so clearing on uninstall is a single caches.delete(), not an -// enumerate-and-filter pass). - const CACHE_PREFIX = "plugin-binary-cache:"; -// Cache API keys are Request/URL, not arbitrary strings — this constructs a -// stable, never-dereferenced pseudo-URL per cache key. The .invalid TLD -// (RFC 2606) guarantees it can never resolve to a real host even if -// something upstream ever did try to fetch it. +// Cache API keys are Request/URL — this constructs a +// stable, never-dereferenced pseudo-URL per cache key. function keyUrl(key) { return `https://plugin-binary-cache.invalid/${encodeURIComponent(key)}`; } +function keyFromUrl(url) { + return decodeURIComponent(new URL(url).pathname.slice(1)); +} + export class PluginBinaryCache { _cacheName(pluginId) { return `${CACHE_PREFIX}${pluginId}`; @@ -29,6 +22,18 @@ export class PluginBinaryCache { return await response.arrayBuffer(); } + async has(pluginId, key) { + const cache = await caches.open(this._cacheName(pluginId)); + const matches = await cache.keys(keyUrl(key)); + return matches.length > 0; + } + + async keys(pluginId) { + const cache = await caches.open(this._cacheName(pluginId)); + const requests = await cache.keys(); + return requests.map((request) => keyFromUrl(request.url)); + } + async put(pluginId, key, arrayBuffer) { const cache = await caches.open(this._cacheName(pluginId)); await cache.put(keyUrl(key), new Response(arrayBuffer)); diff --git a/src/js/plugins/pluginBridge.js b/src/js/plugins/pluginBridge.js index 0660ab86..1a810668 100644 --- a/src/js/plugins/pluginBridge.js +++ b/src/js/plugins/pluginBridge.js @@ -99,232 +99,12 @@ function getSdkSource() { return __sdkSourcePromise; } -// A plugin's manifest.executables table is the only thing that can make -// WebAssembly.instantiate/compile succeed inside the worker (see -// wasmGatePrelude below) — everything not listed there is refused. This -// runs before the plugin's own main.js text, so it's trusted, host-authored -// code even though it executes inside the plugin's worker. -function wasmGatePrelude(executables) { - const declared = JSON.stringify( - (executables ?? []).map((entry) => entry.sha256), - ); - return /* js */ ` - (() => { - const declaredHashes = new Set(${declared}); - - // Pure-JS SHA-256 (FIPS 180-4) rather than crypto.subtle.digest(): - // this worker's document has sandbox="allow-scripts" with no - // allow-same-origin, which gives it an opaque origin - and per spec, - // an opaque origin is never a secure context, so crypto.subtle is - // unavailable here in browsers that enforce that strictly (confirmed - // in Chrome: self.isSecureContext is false and self.crypto.subtle is - // undefined inside this exact sandboxed worker). Implementing the - // hash in plain JS sidesteps the secure-context requirement entirely - // without loosening the sandbox itself. - const SHA256_K = new Uint32Array([ - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, - 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, - 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, - 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, - 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, - 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, - 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, - 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, - 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, - 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, - 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, - 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, - 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, - ]); - - function sha256Rotr(x, n) { - return ((x >>> n) | (x << (32 - n))) >>> 0; - } - - async function sha256Hex(bytesLike) { - const bytes = - bytesLike instanceof ArrayBuffer - ? new Uint8Array(bytesLike) - : new Uint8Array( - bytesLike.buffer, - bytesLike.byteOffset, - bytesLike.byteLength, - ); - - const bitLen = bytes.length * 8; - // 1 byte for 0x80, 8 bytes for the 64-bit big-endian length, - // padded so the total is a multiple of 64. - const paddedLen = Math.ceil((bytes.length + 9) / 64) * 64; - const padded = new Uint8Array(paddedLen); - padded.set(bytes); - padded[bytes.length] = 0x80; - const view = new DataView(padded.buffer); - // bitLen fits in 32 bits for any bytes this gate will ever see - // (well under 2^32 bits = 512MiB), so the high 32 bits of the - // 64-bit length are always zero. - view.setUint32(paddedLen - 4, bitLen >>> 0, false); - - let h0 = 0x6a09e667, - h1 = 0xbb67ae85, - h2 = 0x3c6ef372, - h3 = 0xa54ff53a, - h4 = 0x510e527f, - h5 = 0x9b05688c, - h6 = 0x1f83d9ab, - h7 = 0x5be0cd19; - - const w = new Uint32Array(64); - for (let offset = 0; offset < paddedLen; offset += 64) { - for (let i = 0; i < 16; i++) { - w[i] = view.getUint32(offset + i * 4, false); - } - for (let i = 16; i < 64; i++) { - const s0 = - sha256Rotr(w[i - 15], 7) ^ - sha256Rotr(w[i - 15], 18) ^ - (w[i - 15] >>> 3); - const s1 = - sha256Rotr(w[i - 2], 17) ^ - sha256Rotr(w[i - 2], 19) ^ - (w[i - 2] >>> 10); - w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; - } - - let a = h0, - b = h1, - c = h2, - d = h3, - e = h4, - f = h5, - g = h6, - h = h7; - - for (let i = 0; i < 64; i++) { - const S1 = sha256Rotr(e, 6) ^ sha256Rotr(e, 11) ^ sha256Rotr(e, 25); - const ch = (e & f) ^ (~e & g); - const temp1 = (h + S1 + ch + SHA256_K[i] + w[i]) >>> 0; - const S0 = sha256Rotr(a, 2) ^ sha256Rotr(a, 13) ^ sha256Rotr(a, 22); - const maj = (a & b) ^ (a & c) ^ (b & c); - const temp2 = (S0 + maj) >>> 0; - - h = g; - g = f; - f = e; - e = (d + temp1) >>> 0; - d = c; - c = b; - b = a; - a = (temp1 + temp2) >>> 0; - } - - h0 = (h0 + a) >>> 0; - h1 = (h1 + b) >>> 0; - h2 = (h2 + c) >>> 0; - h3 = (h3 + d) >>> 0; - h4 = (h4 + e) >>> 0; - h5 = (h5 + f) >>> 0; - h6 = (h6 + g) >>> 0; - h7 = (h7 + h) >>> 0; - } - - return [h0, h1, h2, h3, h4, h5, h6, h7] - .map((x) => x.toString(16).padStart(8, "0")) - .join(""); - } - - async function assertDeclared(bytesLike) { - if ( - !(bytesLike instanceof ArrayBuffer) && - !ArrayBuffer.isView(bytesLike) - ) { - throw new TypeError("WebAssembly bytes must be a BufferSource"); - } - const hash = await sha256Hex(bytesLike); - if (!declaredHashes.has(hash)) { - // Named distinctly (rather than a plain Error) so a plugin can - // tell "the host's CSP genuinely has no wasm-unsafe-eval" (a - // native CompileError/SecurityError, thrown before this check - // ever runs) apart from "wasm-unsafe-eval works fine, this - // particular byte string just isn't declared" - e.g. for - // capability-probing with inert bytes that were never meant to be - // added to the manifest. - const error = new Error( - 'WebAssembly execution blocked: bytes with sha256 "' + - hash + - '" are not declared in this plugin\\'s manifest ' + - '"executables" list. Add an entry with this hash and a ' + - "sourceUrl before this code can run.", - ); - error.name = "WasmProvenanceError"; - throw error; - } - } - - // No WebAssembly global at all (e.g. a stripped-down test harness, or - // a runtime that lacks it entirely) - nothing to gate. - if (!self.WebAssembly) return; - - // Mutated in place (not replaced with a fresh object) so - // WebAssembly.Instance/Memory/Table/validate/the error constructors — - // none of which need gating — stay exactly as the engine provides - // them, with no risk of an incomplete spread dropping one. - const wasm = self.WebAssembly; - const RealModule = wasm.Module; // captured before it's blocked below - const realCompile = wasm.compile.bind(wasm); - const realInstantiate = wasm.instantiate.bind(wasm); - - wasm.compile = async (bytesLike) => { - await assertDeclared(bytesLike); - return realCompile(bytesLike); - }; - wasm.instantiate = async (bytesLikeOrModule, importObject) => { - // A WebAssembly.Module instance can only exist here via the gated - // compile() above (the synchronous Module constructor is disabled - // below), so it's already been checked. - if (bytesLikeOrModule instanceof RealModule) { - return realInstantiate(bytesLikeOrModule, importObject); - } - await assertDeclared(bytesLikeOrModule); - return realInstantiate(bytesLikeOrModule, importObject); - }; - // The synchronous Module constructor can't be gated (crypto.subtle is - // async, constructors can't await), so "new WebAssembly.Module(...)" - // is blocked outright — legitimate code goes through the gated - // compile()/instantiate() above instead. instanceof checks against - // WebAssembly.Module still need to recognize modules that did come - // from that gated path (e.g. Emscripten glue commonly branches on - // "x instanceof WebAssembly.Module"), so Symbol.hasInstance is - // delegated to the real class rather than replacing Module with a - // constructor nothing will ever be an instance of. - const BlockedModule = function () { - throw new Error( - "new WebAssembly.Module() is disabled in the plugin sandbox; " + - "use WebAssembly.compile()/instantiate() instead.", - ); - }; - BlockedModule.prototype = RealModule.prototype; - Object.defineProperty(BlockedModule, Symbol.hasInstance, { - value: (instance) => instance instanceof RealModule, - }); - wasm.Module = BlockedModule; - // instantiateStreaming/compileStreaming can't produce a valid - // Response inside this sandbox anyway (connect-src: none blocks a - // real fetch, and the SDK's own proxied fetch() doesn't return a real - // Response) — removed outright rather than left as an unguarded path. - wasm.compileStreaming = undefined; - wasm.instantiateStreaming = undefined; - })(); - `; -} - -export async function wrapWorkerSource(source, manifest) { +export async function wrapWorkerSource(source) { const sdkSource = await getSdkSource(); return /* js */ ` delete self.BroadcastChannel; delete self.SharedWorker; - ${wasmGatePrelude(manifest?.executables)} - ${sdkSource} self.module = {}; @@ -406,7 +186,7 @@ export class PluginInstance { } static async loadFromSource(pluginId, manifest, source, callbacks) { - const wrappedSource = await wrapWorkerSource(source, manifest); + const wrappedSource = await wrapWorkerSource(source); const worker = !window.env.playwright // don't sandbox in e2e tests ? createSandboxedWorker(wrappedSource) : createDirectWorker(wrappedSource); diff --git a/src/js/plugins/pluginModal.js b/src/js/plugins/pluginModal.js index 7e00dbd5..89aff693 100644 --- a/src/js/plugins/pluginModal.js +++ b/src/js/plugins/pluginModal.js @@ -118,10 +118,6 @@ const ACTION_LABELS = { 'Send feed feedback (e.g. "show fewer/more like this") on your behalf', }; -const STORAGE_LABELS = { - binaryCache: "Cache downloaded files on this device (e.g. AI/ML models)", -}; - function permissionsSectionTemplate({ title, items }) { return html`
@@ -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/, - ); - }); -});