/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Install / uninstall / enable-disable lifecycle for the extension * host, extracted from `host.ts`. The class methods delegate here so * the lifecycle logic — including rollback on failed updates — can be * read and tested as a unit. * * All functions take the host's primitives as a `deps` object and an * optional `notify` callback for emitting changes back to the host. */ import { sha256Hex, unpackBundleWithSignature, verifyBundle, type ActionIntent, type ActionParams, type ActivationDispatcher, type AuditLog, type Bundle, type ExtensionLoader, type ExtensionRuntime, type InstalledExtensionRecord, type LoadedExtensionStatus, type SignatureInfo, type ValidationError, type ValidationResult, } from '@ifc-lite/extensions'; import type { IdbExtensionStorage } from './idb-storage.js'; export interface ExtensionInstallSummary { id: string; version: string; bundleHash: string; capabilities: string[]; bundle: Bundle; /** * True iff the bundle envelope contained a signature block and that * signature verified against the bundle's canonical content hash. * UI should surface signed/unsigned status alongside the capability * review so users can refuse unsigned bundles for sensitive grants. */ signed: boolean; /** Verified signer info — only present when `signed` is true. */ signature?: SignatureInfo; } export interface InstallerDeps { storage: IdbExtensionStorage; runtime: ExtensionRuntime; loader: ExtensionLoader; dispatcher: ActivationDispatcher; audit: AuditLog; emitAction: (intent: K, params: ActionParams[K]) => void; emit: () => void; } export class ExtensionInstallError extends Error { readonly validationErrors: readonly ValidationError[]; constructor(message: string, errors: readonly ValidationError[]) { super(message); this.name = 'ExtensionInstallError'; this.validationErrors = errors; } } /** * Inspect a `.iflx` byte string without installing it. * * If the bundle envelope carries a signature block we verify it here * — failing the preview rather than the later install, so the user * never gets as far as the capability-review screen for a tampered * bundle. Unsigned bundles preview successfully but are flagged via * `summary.signed = false` so the UI can warn (or block, depending * on policy) before granting capabilities. */ export async function previewBundleBytes( bytes: Uint8Array, ): Promise> { const unpacked = unpackBundleWithSignature(bytes); if (!unpacked.ok) return unpacked; const { bundle, signature: sigBlock } = unpacked.value; let signature: SignatureInfo | undefined; if (sigBlock) { try { signature = await verifyBundle(bundle, sigBlock); } catch (err) { const message = err instanceof Error ? err.message : String(err); return { ok: false, errors: [{ path: 'signature', code: 'invalid_format', message: `Signature verification failed: ${message}`, }], }; } } const hash = await sha256Hex(bytes); return { ok: true, value: { id: bundle.manifest.id, version: bundle.manifest.version, bundleHash: hash, capabilities: bundle.manifest.capabilities, bundle, signed: signature !== undefined, signature, }, }; } /** * Install a previewed bundle. On failure, restore the previous install * (if any) so a bad update doesn't wipe a working extension. */ export async function installFromBytes( deps: InstallerDeps, bytes: Uint8Array, grantedCapabilities: string[], ): Promise { const preview = await previewBundleBytes(bytes); if (!preview.ok) { throw new ExtensionInstallError('Bundle did not unpack', preview.errors); } const { bundle, bundleHash, id, version, signed, signature } = preview.value; // Callers can only grant capabilities the manifest declares. Drops // accidental grant escalation if the review screen pre-filled state // from an earlier version of the bundle. const declaredCaps = new Set(bundle.manifest.capabilities); const unexpected = grantedCapabilities.filter((cap) => !declaredCaps.has(cap)); if (unexpected.length > 0) { throw new ExtensionInstallError( `Unexpected capability grants not declared by manifest: ${unexpected.join(', ')}`, unexpected.map((cap) => ({ path: 'grantedCapabilities', code: 'invalid_capability' as const, message: `Capability "${cap}" was not requested by the bundle manifest.`, })), ); } // Snapshot the previous install so we can restore it if the new // bundle fails to load. Without this, a bad update wipes the // user's previously-working install entirely. const previous = await deps.storage.getExtension(id); let previousBundleBytes: Uint8Array | undefined; if (previous && previous.version !== version) { previousBundleBytes = await deps.storage.getBundle(id, previous.version); await teardownExtension(deps, id); } const record: InstalledExtensionRecord = { id, version, bundleHash, grantedCapabilities, enabled: true, installedAt: new Date().toISOString(), source: 'local', }; await deps.storage.putBundle(id, version, bytes); await deps.storage.putExtension(record); const status = await deps.loader.load(id); if (!status || !status.ok) { // Roll back. Delete the new bundle + record we just wrote. await deps.storage.deleteBundle(id, version); await deps.storage.deleteExtension(id); // Restore the previous install if we had one — re-write its // record and bundle bytes, then re-load. Best effort: log if // restore itself fails, don't mask the original error. if (previous && previousBundleBytes) { try { await deps.storage.putBundle(id, previous.version, previousBundleBytes); await deps.storage.putExtension(previous); await deps.loader.load(id); } catch (restoreErr) { console.error( `[ext-host] Failed to restore previous install of ${id}:`, restoreErr, ); } } throw new ExtensionInstallError( 'Loader rejected the new bundle', status?.errors ?? [], ); } deps.audit.append({ kind: previous ? 'update' : 'install', extensionId: id, version, previousVersion: previous?.version, grantedCapabilities, signed, signerFingerprint: signature?.fingerprint, }); deps.emitAction('extension.install', { id }); // Re-fire onStartup so the freshly-loaded extension activates if it // declared the event. Other startup-subscribed extensions are // unaffected (the dispatcher dedupes activations per session). await deps.dispatcher.fire('onStartup'); deps.emit(); return status; } /** Uninstall an extension and remove its bundle. */ export async function uninstall(deps: InstallerDeps, id: string): Promise { const record = await deps.storage.getExtension(id); if (!record) return; await teardownExtension(deps, id); // Delete bundle bytes too — the storage's cascade already handles // this on deleteExtension, but call it explicitly so the contract // is clear at this layer and a future storage impl can't drift. await deps.storage.deleteBundle(id, record.version); await deps.storage.deleteExtension(id); deps.audit.append({ kind: 'uninstall', extensionId: id, version: record.version, }); deps.emitAction('extension.uninstall', { id }); deps.emit(); } /** Enable/disable without uninstalling. */ export async function setEnabled( deps: InstallerDeps, id: string, enabled: boolean, ): Promise { const record = await deps.storage.getExtension(id); if (!record) return; if (record.enabled === enabled) return; if (enabled) { // Persist enabled=true only after the loader confirms it can // bring the extension up. Without this, a failed load leaves the // persisted state lying about runtime reality. const tentative = { ...record, enabled: true }; await deps.storage.putExtension(tentative); const status = await deps.loader.load(id); if (!status?.ok) { await deps.storage.putExtension(record); throw new ExtensionInstallError( `Failed to enable extension ${id}`, status?.errors ?? [], ); } } else { await teardownExtension(deps, id); await deps.storage.putExtension({ ...record, enabled: false }); } deps.audit.append({ kind: enabled ? 'enable' : 'disable', extensionId: id, version: record.version, }); deps.emitAction(enabled ? 'extension.enable' : 'extension.disable', { id }); deps.emit(); } /** * Tear an extension down: run its `entry.deactivate` hook (via the * loaded bundle), unload it, and clear the dispatcher's * "already activated" flag so a later enable / event re-fire genuinely * re-activates it. Used by update / uninstall / disable. */ async function teardownExtension(deps: InstallerDeps, id: string): Promise { const bundle = deps.loader.getBundle(id); if (bundle) { await deps.runtime.deactivateWithBundle(id, bundle); } else { await deps.runtime.deactivate(id); } await deps.loader.unload(id); deps.dispatcher.resetActivation(id); }