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/, + ); + }); +});