From 92ba15fcf3c800e08447178266cafdd8cb5de4a6 Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Mon, 13 Jul 2026 13:09:13 +0200 Subject: [PATCH 1/9] feat: add manifest delta updates, channel discovery, and rolledBack event Co-Authored-By: Claude Fable 5 --- README.md | 43 ++-- e2e/app.spec.ts | 14 ++ example/scripts/mock-server.mjs | 159 +++++++++++- src/engine/api-client.ts | 94 ++++++- src/engine/definitions.ts | 82 ++++++- src/engine/download.ts | 24 +- src/engine/engine.ts | 362 +++++++++++++++++++++++++-- src/engine/errors.ts | 9 + src/engine/manifest.ts | 103 ++++++++ src/main/live-update.ts | 18 ++ src/renderer/index.ts | 13 + src/shared/ipc.ts | 2 + tests/api-client.test.ts | 77 +++++- tests/engine.test.ts | 421 ++++++++++++++++++++++++++++++-- tests/helpers.ts | 6 +- tests/manifest.test.ts | 81 ++++++ 16 files changed, 1435 insertions(+), 73 deletions(-) create mode 100644 src/engine/manifest.ts create mode 100644 tests/manifest.test.ts diff --git a/README.md b/README.md index 1d09a76..2ed9279 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ This SDK speaks the same protocol and the same vocabulary as the [`@capawesome/c - 🛟 **Kill-safe rollback**: A pending-boot marker and boot counter are persisted to disk _before_ a new bundle loads. If the app crashes, hangs or is killed during boot — even by a power loss — the next start automatically reverts to the last bundle that worked and optionally blocks the broken one. - 🔒 **Signature verification**: RSA signature verification of every downloaded bundle (`publicKey`), plus checksum re-verification of the installed bundle at activation time — tampering after download is detected too. - 🌐 **Stable origin serving**: A privileged custom scheme serves the active bundle under a constant origin, so `localStorage`, IndexedDB and service workers survive bundle switches. A simple path-based mode is available as an alternative. -- 🚦 **Channels**: Deliver different bundles to different user groups (production, beta, staged rollouts). +- 🚦 **Channels**: Deliver different bundles to different user groups (production, beta, staged rollouts), and discover them at runtime with `fetchChannels()`. +- 🧩 **Delta updates**: The `manifest` artifact type downloads only the files that changed and reuses the rest from the current bundle — smaller, faster updates. - 📂 **Multiple bundles**: Download, manage and switch between bundles programmatically. - 🔁 **Background updates**: Optional automatic sync at app start, on focus and on resume. - 🔐 **Secure by default**: HTTPS-only downloads (localhost exempt for development), zip-slip protection, atomic bundle installation. @@ -204,19 +205,20 @@ const { currentBundleId } = await engine.initialize(); // BEFORE loading web con The API mirrors [`@capawesome/capacitor-live-update`](https://capawesome.io/plugins/live-update/). Differences that exist are deliberate and listed here: -| Aspect | Capacitor plugin | This SDK | -| -------------------------------- | --------------------------------------- | ------------------------------------------------------------------------- | -| `readyTimeout` default | `0` (disabled) | `0` (disabled) — same default, same recommendation to set `10000` | -| Rollback target | Default bundle | **Last successful bundle**, then default — desktop has no store reinstall | -| Kill-safe boot rollback | — | Pending-boot marker on disk, checked at every process start | -| Activation-time verification | — | Installed bundles re-verified against install-time checksums | -| Rollback blocking | On `ready()` | At rollback time (survives a kill before `ready()`) | -| Configuration | Capacitor config file | `createLiveUpdate()` options | -| `versionCode` / `versionName` | Native app version | `app.getVersion()` unless configured | -| Device ID | Random UUID (Android) / vendor ID (iOS) | Random UUID, persisted per app ID | -| Serving | Capacitor WebView | `serve()` custom scheme or `getCurrentBundlePath()` | -| `fetchChannels()`, `setConfig()` | Available | Not yet available | -| `manifest` artifact type | Available (delta updates) | Not yet available (`zip` only) | +| Aspect | Capacitor plugin | This SDK | +| ----------------------------- | --------------------------------------- | ------------------------------------------------------------------------- | +| `readyTimeout` default | `0` (disabled) | `0` (disabled) — same default, same recommendation to set `10000` | +| Rollback target | Default bundle | **Last successful bundle**, then default — desktop has no store reinstall | +| Kill-safe boot rollback | — | Pending-boot marker on disk, checked at every process start | +| Activation-time verification | — | Installed bundles re-verified against install-time checksums | +| Rollback blocking | On `ready()` | At rollback time (survives a kill before `ready()`) | +| Configuration | Capacitor config file | `createLiveUpdate()` options | +| `versionCode` / `versionName` | Native app version | `app.getVersion()` unless configured | +| Device ID | Random UUID (Android) / vendor ID (iOS) | Random UUID, persisted per app ID | +| Serving | Capacitor WebView | `serve()` custom scheme or `getCurrentBundlePath()` | +| `setConfig()` | Available | Not available | +| `fetchChannels()` | Available | **Available** | +| `manifest` artifact type | Available (delta updates) | **Available** (delta updates) | ## API @@ -247,7 +249,7 @@ Creates the SDK. Call once, early in your main process (before `app.whenReady()` The returned `LiveUpdate` object implements the shared vocabulary — the same methods you know from the Capacitor plugin: -`clearBlockedBundles()`, `deleteBundle(options)`, `downloadBundle(options)`, `fetchLatestBundle(options?)`, `getBlockedBundles()`, `getChannel()`, `getCurrentBundle()`, `getCustomId()`, `getDeviceId()`, `getDownloadedBundles()`, `getNextBundle()`, `getVersionCode()`, `getVersionName()`, `isSyncing()`, `ready()`, `reload()`, `reset()`, `setChannel(options)`, `setCustomId(options)`, `setNextBundle(options)`, `sync(options?)`, `addListener(eventName, listener)`, `removeAllListeners()` +`clearBlockedBundles()`, `deleteBundle(options)`, `downloadBundle(options)`, `fetchChannels(options?)`, `fetchLatestBundle(options?)`, `getBlockedBundles()`, `getChannel()`, `getCurrentBundle()`, `getCustomId()`, `getDeviceId()`, `getDownloadedBundles()`, `getNextBundle()`, `getVersionCode()`, `getVersionName()`, `isSyncing()`, `ready()`, `reload()`, `reset()`, `setChannel(options)`, `setCustomId(options)`, `setNextBundle(options)`, `sync(options?)`, `addListener(eventName, listener)`, `removeAllListeners()` plus the Electron-specific serving integration: @@ -260,11 +262,12 @@ All options and results use the exact same shapes as the Capacitor plugin (`Sync #### Events -| Event | Payload | Emitted when | -| ------------------------ | ----------------------------------------------------- | ----------------------------------- | -| `downloadBundleProgress` | `{ bundleId, downloadedBytes, progress, totalBytes }` | A bundle download makes progress | -| `nextBundleSet` | `{ bundleId }` | A bundle is set as the next bundle | -| `reloaded` | – | The app was reloaded via `reload()` | +| Event | Payload | Emitted when | +| ------------------------ | ----------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `downloadBundleProgress` | `{ bundleId, downloadedBytes, progress, totalBytes }` | A bundle download makes progress | +| `nextBundleSet` | `{ bundleId }` | A bundle is set as the next bundle | +| `reloaded` | – | The app was reloaded via `reload()` | +| `rolledBack` | `{ currentBundleId, previousBundleId }` | The app was rolled back to a previous bundle after a boot did not signal readiness in time | Events are available in the main process (`liveUpdate.addListener(...)`) and forwarded to attached renderers (`LiveUpdate.addListener(...)`). diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index c5f5137..d33082d 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -102,6 +102,20 @@ test('rejects a tampered bundle (signature verification)', async () => { await app.close(); }); +test('syncs a manifest (delta) bundle over the built-in bundle', async () => { + await mockServer.setLatest('4.0.0-manifest'); + const { app, page } = await launchExample(); + await page.getByTestId('sync').click(); + await expect(page.getByTestId('next-bundle')).toHaveText('4.0.0-manifest'); + await page.getByTestId('reload').click(); + await expect(page.getByTestId('current-bundle')).toHaveText('4.0.0-manifest'); + await expect(page.getByTestId('marker')).toHaveText('2.0.0'); + await expect(page.getByTestId('ready-state')).toContainText( + 'rollback: false', + ); + await app.close(); +}); + test('simple mode: syncs and reloads via getCurrentBundlePath()', async () => { await mockServer.setLatest('2.0.0'); const { app, page } = await launchExample({ servingMode: 'simple' }); diff --git a/example/scripts/mock-server.mjs b/example/scripts/mock-server.mjs index 564f7bf..a6165fe 100644 --- a/example/scripts/mock-server.mjs +++ b/example/scripts/mock-server.mjs @@ -3,27 +3,106 @@ * * Speaks the Live Update protocol: * - GET /v1/apps/{appId}/bundles/latest -> latest bundle JSON or 404 + * - GET /v1/apps/{appId}/channels -> list of channels (or 401 when + * CHANNELS_DISABLED is set) * - GET /download/{file} -> zip bytes with X-Checksum * and X-Signature headers + * - GET /manifest/{bundleId}?href= -> the manifest JSON (delta) + * or a single file with its + * X-Checksum / X-Signature headers * - POST /__control -> {"latest": "" | null} * switches the offered bundle * * The offered bundle can also be set via the LATEST env variable. */ -import { readFile } from 'node:fs/promises'; +import { createHash, createSign } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { readFile, readdir } from 'node:fs/promises'; import { createServer } from 'node:http'; -import { dirname, join } from 'node:path'; +import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; const exampleDirectory = dirname(dirname(fileURLToPath(import.meta.url))); const bundlesDirectory = join(exampleDirectory, 'dist', 'bundles'); +const keysDirectory = join(exampleDirectory, 'dist', 'keys'); const port = Number(process.env.MOCK_SERVER_PORT ?? 4100); +const MANIFEST_FILE_NAME = 'capawesome-live-update-manifest.json'; + const bundles = JSON.parse( await readFile(join(bundlesDirectory, 'index.json'), 'utf8'), ); let latestBundleId = process.env.LATEST ?? null; +const privateKeyPath = join(keysDirectory, 'private.pem'); +const privateKeyPem = existsSync(privateKeyPath) + ? await readFile(privateKeyPath, 'utf8') + : null; + +// Manifest (delta) bundles are served directly from a source directory +// of web assets; the manifest itself is generated on the fly. +const manifestBundles = { + '4.0.0-manifest': join(exampleDirectory, 'dist', 'bundle-2.0.0'), +}; + +const channels = [ + { id: 'a1b2c3d4-0000-0000-0000-000000000001', name: 'production' }, + { id: 'a1b2c3d4-0000-0000-0000-000000000002', name: 'beta' }, + { id: 'a1b2c3d4-0000-0000-0000-000000000003', name: 'canary' }, +]; + +function checksum(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +function sign(bytes) { + if (!privateKeyPem) { + return null; + } + const signer = createSign('RSA-SHA256'); + signer.update(bytes); + return signer.sign(privateKeyPem).toString('base64'); +} + +async function listFiles(directory) { + const files = []; + const walk = async current => { + for (const entry of await readdir(current, { withFileTypes: true })) { + const entryPath = join(current, entry.name); + if (entry.isDirectory()) { + await walk(entryPath); + } else if (entry.isFile()) { + files.push({ + absolutePath: entryPath, + href: relative(directory, entryPath).split('\\').join('/'), + }); + } + } + }; + await walk(directory); + return files; +} + +async function buildManifest(directory) { + const files = await listFiles(directory); + return Promise.all( + files.map(async file => { + const bytes = await readFile(file.absolutePath); + return { + checksum: checksum(bytes), + href: file.href, + sizeInBytes: bytes.length, + }; + }), + ); +} + +function sendJson(response, status, payload) { + response.statusCode = status; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(payload)); +} + const server = createServer(async (request, response) => { const url = new URL(request.url ?? '/', `http://localhost:${port}`); console.log(`[mock-server] ${request.method} ${url.pathname}${url.search}`); @@ -36,25 +115,83 @@ const server = createServer(async (request, response) => { response.end(JSON.stringify({ latest: latestBundleId })); return; } + if ( + request.method === 'GET' && + /^\/v1\/apps\/[^/]+\/channels$/.test(url.pathname) + ) { + if (process.env.CHANNELS_DISABLED) { + response.statusCode = 401; + response.end( + JSON.stringify({ + message: + 'Unauthorized. Channel Discovery may not be enabled for this app.', + }), + ); + return; + } + const limit = Number(url.searchParams.get('limit') ?? 50); + const offset = Number(url.searchParams.get('offset') ?? 0); + const query = url.searchParams.get('query'); + const filtered = channels.filter(channel => + query ? channel.name.includes(query) : true, + ); + sendJson(response, 200, filtered.slice(offset, offset + limit)); + return; + } if ( request.method === 'GET' && /^\/v1\/apps\/[^/]+\/bundles\/latest$/.test(url.pathname) ) { + if (latestBundleId && manifestBundles[latestBundleId]) { + sendJson(response, 200, { + artifactType: 'manifest', + bundleId: latestBundleId, + url: `http://localhost:${port}/manifest/${latestBundleId}`, + }); + return; + } const bundle = latestBundleId === null ? undefined : bundles[latestBundleId]; if (!bundle) { - response.statusCode = 404; - response.end(JSON.stringify({ message: 'No bundle available.' })); + sendJson(response, 404, { message: 'No bundle available.' }); return; } - response.setHeader('Content-Type', 'application/json'); - response.end( - JSON.stringify({ - artifactType: 'zip', - bundleId: latestBundleId, - url: `http://localhost:${port}/download/${bundle.file}`, - }), + sendJson(response, 200, { + artifactType: 'zip', + bundleId: latestBundleId, + url: `http://localhost:${port}/download/${bundle.file}`, + }); + return; + } + if (request.method === 'GET' && url.pathname.startsWith('/manifest/')) { + const bundleId = decodeURIComponent( + url.pathname.slice('/manifest/'.length), ); + const sourceDirectory = manifestBundles[bundleId]; + if (!sourceDirectory || !existsSync(sourceDirectory)) { + response.statusCode = 404; + response.end('Not found'); + return; + } + const href = url.searchParams.get('href'); + if (href === MANIFEST_FILE_NAME) { + sendJson(response, 200, await buildManifest(sourceDirectory)); + return; + } + const files = await listFiles(sourceDirectory); + const file = files.find(entry => entry.href === href); + if (!file) { + response.statusCode = 404; + response.end('Not found'); + return; + } + const bytes = await readFile(file.absolutePath); + response.setHeader('X-Checksum', checksum(bytes)); + const signature = sign(bytes); + if (signature) { + response.setHeader('X-Signature', signature); + } + response.end(bytes); return; } if (request.method === 'GET' && url.pathname.startsWith('/download/')) { diff --git a/src/engine/api-client.ts b/src/engine/api-client.ts index 94a69eb..d9c2f4d 100644 --- a/src/engine/api-client.ts +++ b/src/engine/api-client.ts @@ -25,8 +25,24 @@ export interface FetchLatestBundleRequest { deviceId: string; osVersion: string; platform: string; + pluginVersion: string; runtime: string | null; - sdkVersion: string; +} + +export interface FetchChannelsRequest { + appId: string; + deviceId: string; + limit: number; + offset: number; + query: string | null; +} + +/** + * A single channel returned by the Capawesome Cloud channels endpoint. + */ +export interface GetChannelsResponseItem { + id: string; + name: string; } export interface CloudApiClientOptions { @@ -89,7 +105,7 @@ export class CloudApiClient { this.appendQueryParameter(url, 'deviceId', request.deviceId); this.appendQueryParameter(url, 'osVersion', request.osVersion); this.appendQueryParameter(url, 'platform', request.platform); - this.appendQueryParameter(url, 'pluginVersion', request.sdkVersion); + this.appendQueryParameter(url, 'pluginVersion', request.pluginVersion); this.appendQueryParameter(url, 'runtime', request.runtime); let response: Response; try { @@ -121,6 +137,63 @@ export class CloudApiClient { return this.parseLatestBundleResponse(json); } + /** + * Fetch the available channels for the app. + * + * Throws `ChannelDiscoveryNotEnabled` on HTTP 401 (public channels + * not enabled), mirroring the `@capawesome/capacitor-live-update` + * plugin behavior. + */ + public async getChannels( + request: FetchChannelsRequest, + ): Promise { + const url = new URL( + `${this.getBaseUrl()}/v1/apps/${encodeURIComponent(request.appId)}/channels`, + ); + this.appendQueryParameter(url, 'limit', String(request.limit)); + this.appendQueryParameter(url, 'offset', String(request.offset)); + this.appendQueryParameter(url, 'query', request.query); + let response: Response; + try { + response = await fetch(url, { + headers: { + 'X-Capawesome-Device-Id': request.deviceId, + }, + signal: AbortSignal.timeout(this.options.httpTimeout), + }); + } catch (error) { + if (isTimeoutError(error)) { + throw new LiveUpdateError(ErrorCode.HttpTimeout, 'Request timed out.'); + } + throw new LiveUpdateError( + ErrorCode.Unknown, + 'An unknown error has occurred.', + ); + } + if (response.status === 401) { + throw new LiveUpdateError( + ErrorCode.ChannelDiscoveryNotEnabled, + 'Unauthorized. Channel Discovery may not be enabled for this app.', + ); + } + if (!response.ok) { + throw new LiveUpdateError( + ErrorCode.Unknown, + 'An unknown error has occurred.', + ); + } + let json: unknown; + try { + json = await response.json(); + } catch { + throw new LiveUpdateError( + ErrorCode.Unknown, + 'An unknown error has occurred.', + ); + } + return this.parseChannelsResponse(json); + } + private appendQueryParameter( url: URL, name: string, @@ -131,6 +204,23 @@ export class CloudApiClient { } } + private parseChannelsResponse(json: unknown): GetChannelsResponseItem[] { + if (!Array.isArray(json)) { + return []; + } + const channels: GetChannelsResponseItem[] = []; + for (const entry of json) { + if (typeof entry !== 'object' || entry === null) { + continue; + } + const record = entry as Record; + if (typeof record.id === 'string' && typeof record.name === 'string') { + channels.push({ id: record.id, name: record.name }); + } + } + return channels; + } + private parseLatestBundleResponse( json: unknown, ): GetLatestBundleResponse | null { diff --git a/src/engine/definitions.ts b/src/engine/definitions.ts index 174f984..12f26da 100644 --- a/src/engine/definitions.ts +++ b/src/engine/definitions.ts @@ -5,6 +5,26 @@ */ export type ArtifactType = 'manifest' | 'zip'; +/** + * A channel that bundles can be delivered on. + * + * @since 0.1.0 + */ +export interface Channel { + /** + * The unique identifier of the channel. + * + * @since 0.1.0 + */ + id: string; + /** + * The name of the channel. + * + * @since 0.1.0 + */ + name: string; +} + /** * @since 0.1.0 */ @@ -25,8 +45,8 @@ export interface DownloadBundleOptions { /** * The artifact type of the bundle. * - * **Attention**: The `manifest` artifact type is not yet supported - * by this SDK. + * Use `manifest` for delta updates: only files that changed compared + * to the current bundle are downloaded, the rest are copied locally. * * @since 0.1.0 * @default 'zip' @@ -76,6 +96,44 @@ export interface DownloadBundleOptions { url: string; } +/** + * @since 0.1.0 + */ +export interface FetchChannelsOptions { + /** + * The maximum number of channels to return. + * + * @since 0.1.0 + * @default 50 + */ + limit?: number; + /** + * The number of channels to skip. + * + * @since 0.1.0 + * @default 0 + */ + offset?: number; + /** + * The query to filter channels by name. + * + * @since 0.1.0 + */ + query?: string; +} + +/** + * @since 0.1.0 + */ +export interface FetchChannelsResult { + /** + * The list of channels. + * + * @since 0.1.0 + */ + channels: Channel[]; +} + /** * @since 0.1.0 */ @@ -539,6 +597,16 @@ export interface LiveUpdateApi { * @since 0.1.0 */ downloadBundle(options: DownloadBundleOptions): Promise; + /** + * Fetch the available channels using the [Capawesome Cloud](https://capawesome.io/cloud/). + * + * **Attention**: This method only works for apps with public channels + * enabled (Channel Discovery). Private channels can still be selected + * with `setChannel(...)`. + * + * @since 0.1.0 + */ + fetchChannels(options?: FetchChannelsOptions): Promise; /** * Fetch the latest bundle using the [Capawesome Cloud](https://capawesome.io/cloud/). * @@ -698,6 +766,16 @@ export interface LiveUpdateApi { eventName: 'reloaded', listener: ReloadedListener, ): ListenerHandle; + /** + * Listen for when the engine reverted to a previous bundle because the + * app did not signal readiness in time. + * + * @since 0.1.0 + */ + addListener( + eventName: 'rolledBack', + listener: RolledBackListener, + ): ListenerHandle; /** * Remove all listeners of this instance. * diff --git a/src/engine/download.ts b/src/engine/download.ts index d04d47e..db1f130 100644 --- a/src/engine/download.ts +++ b/src/engine/download.ts @@ -37,9 +37,25 @@ export interface DownloadFileOptions { destinationPath: string; httpTimeout: number; onProgress?: (downloadedBytes: number, totalBytes: number) => void; + /** + * An optional external signal to abort the download (e.g. to cancel + * sibling downloads when one of a parallel batch fails). + */ + signal?: AbortSignal; url: string; } +/** + * Append an `href` query parameter to a (pre-signed) bundle URL, + * preserving any existing query parameters. Used to request individual + * files of a `manifest` (delta) bundle. + */ +export function withHrefQueryParameter(baseUrl: string, href: string): string { + const url = assertSecureUrl(baseUrl); + url.searchParams.append('href', href); + return url.toString(); +} + export interface DownloadFileResult { /** * Value of the `X-Checksum` response header, if present. @@ -59,11 +75,13 @@ export async function downloadFile( options: DownloadFileOptions, ): Promise { const url = assertSecureUrl(options.url); + const timeoutSignal = AbortSignal.timeout(options.httpTimeout); + const signal = options.signal + ? AbortSignal.any([timeoutSignal, options.signal]) + : timeoutSignal; let response: Response; try { - response = await fetch(url, { - signal: AbortSignal.timeout(options.httpTimeout), - }); + response = await fetch(url, { signal }); } catch (error) { throw toRequestError(error); } diff --git a/src/engine/engine.ts b/src/engine/engine.ts index 807edfe..70d0028 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import { EventEmitter } from 'node:events'; -import { join } from 'node:path'; +import { copyFile, mkdir, readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; import { CloudApiClient } from './api-client'; import { BundleStore, assertValidBundleId } from './bundle-store'; @@ -8,6 +9,8 @@ import type { DeleteBundleOptions, DownloadBundleOptions, DownloadBundleProgressEvent, + FetchChannelsOptions, + FetchChannelsResult, FetchLatestBundleOptions, FetchLatestBundleResult, GetBlockedBundlesResult, @@ -29,8 +32,14 @@ import type { SyncOptions, SyncResult, } from './definitions'; -import { downloadFile } from './download'; +import { downloadFile, withHrefQueryParameter } from './download'; import { ErrorCode, LiveUpdateError, unknownError } from './errors'; +import { + MANIFEST_FILE_NAME, + parseManifest, + resolveManifestFilePath, + type ManifestItem, +} from './manifest'; import { StateFile } from './state-file'; import { calculateContentChecksums, @@ -110,6 +119,17 @@ export interface LiveUpdateEngineConfig { * @since 0.1.0 */ dataDirectory: string; + /** + * The absolute path to the directory containing the default bundle + * (the web assets packaged with the app). + * + * Used by `manifest` (delta) updates to reuse unchanged files of the + * packaged default bundle when it is the current bundle. If not set, + * a delta update on top of the default bundle downloads all files. + * + * @since 0.1.0 + */ + defaultBundlePath?: string; /** * The default channel of the app. * @@ -146,6 +166,17 @@ export interface LiveUpdateEngineConfig { * @since 0.1.0 */ platform: string; + /** + * The plugin version reported to Capawesome Cloud (the `pluginVersion` + * request parameter). + * + * Defaults to `sdkVersion`. The standalone Electron SDK reports the + * engine version; a Capacitor adapter reports the plugin package + * version. + * + * @since 0.1.0 + */ + pluginVersion?: string; /** * The public key to verify the integrity of the bundle. * @@ -262,6 +293,14 @@ const DEFAULT_HTTP_TIMEOUT = 60000; const DEFAULT_READY_TIMEOUT = 0; const DEFAULT_SERVER_DOMAIN = 'api.cloud.capawesome.io'; const MAX_BLOCKED_BUNDLES = 100; +const DEFAULT_FETCH_CHANNELS_LIMIT = 50; +const DEFAULT_FETCH_CHANNELS_OFFSET = 0; +/** + * The maximum number of files downloaded in parallel for a `manifest` + * (delta) bundle. Mirrors OkHttp's default per-host limit used by the + * mobile plugins. + */ +const MANIFEST_DOWNLOAD_CONCURRENCY = 5; const defaultLogger: LiveUpdateLogger = { debug: message => console.debug(`[LiveUpdate] ${message}`), @@ -288,6 +327,7 @@ export class LiveUpdateEngine { private readonly appId: string | null; private readonly autoBlockRolledBackBundles: boolean; private readonly autoDeleteBundles: boolean; + private readonly defaultBundlePath: string | null; private readonly defaultChannel: string | null; private readonly emitter = new EventEmitter(); private readonly httpTimeout: number; @@ -295,6 +335,7 @@ export class LiveUpdateEngine { private readonly logger: LiveUpdateLogger; private readonly osVersion: string; private readonly platform: string; + private readonly pluginVersion: string; private readonly publicKey: string | undefined; private readonly readyTimeout: number; private rollbackPerformed = false; @@ -312,11 +353,13 @@ export class LiveUpdateEngine { this.autoBlockRolledBackBundles = config.autoBlockRolledBackBundles ?? false; this.autoDeleteBundles = config.autoDeleteBundles ?? false; + this.defaultBundlePath = config.defaultBundlePath ?? null; this.defaultChannel = config.defaultChannel ?? null; this.httpTimeout = config.httpTimeout ?? DEFAULT_HTTP_TIMEOUT; this.logger = config.logger ?? defaultLogger; this.osVersion = config.osVersion; this.platform = config.platform; + this.pluginVersion = config.pluginVersion ?? config.sdkVersion; this.publicKey = config.publicKey; this.readyTimeout = config.readyTimeout ?? DEFAULT_READY_TIMEOUT; this.runtime = config.runtime ?? null; @@ -522,17 +565,18 @@ export class LiveUpdateEngine { } if (!(await this.store.has(bundleId))) { if (latest.artifactType === 'manifest') { - throw new LiveUpdateError( - ErrorCode.ArtifactTypeNotSupported, - 'The manifest artifact type is not yet supported by this SDK.', - ); + await this.downloadBundleOfTypeManifest({ + bundleId, + url: latest.url, + }); + } else { + await this.downloadBundleInternal({ + bundleId, + checksum: latest.checksum, + signature: latest.signature, + url: latest.url, + }); } - await this.downloadBundleInternal({ - bundleId, - checksum: latest.checksum, - signature: latest.signature, - url: latest.url, - }); } await this.setNextBundleInternal(bundleId); return { nextBundleId: bundleId }; @@ -565,6 +609,34 @@ export class LiveUpdateEngine { }; } + /** + * Fetch the available channels using the [Capawesome Cloud](https://capawesome.io/cloud/). + * + * Only works for apps with public channels enabled (Channel + * Discovery). Throws `ChannelDiscoveryNotEnabled` otherwise. + * + * @since 0.1.0 + */ + public async fetchChannels( + options?: FetchChannelsOptions, + ): Promise { + this.assertInitialized(); + if (!this.appId) { + throw new LiveUpdateError( + ErrorCode.AppIdMissing, + 'appId must be configured.', + ); + } + const channels = await this.apiClient.getChannels({ + appId: this.appId, + deviceId: await this.getOrCreateDeviceId(), + limit: options?.limit ?? DEFAULT_FETCH_CHANNELS_LIMIT, + offset: options?.offset ?? DEFAULT_FETCH_CHANNELS_OFFSET, + query: options?.query ?? null, + }); + return { channels }; + } + /** * Download a bundle. * @@ -579,18 +651,19 @@ export class LiveUpdateEngine { throw new LiveUpdateError(ErrorCode.UrlMissing, 'url must be provided.'); } assertValidBundleId(options.bundleId); - if (options.artifactType === 'manifest') { - throw new LiveUpdateError( - ErrorCode.ArtifactTypeNotSupported, - 'The manifest artifact type is not yet supported by this SDK.', - ); - } if (await this.store.has(options.bundleId)) { throw new LiveUpdateError( ErrorCode.BundleAlreadyExists, 'bundle already exists.', ); } + if (options.artifactType === 'manifest') { + await this.downloadBundleOfTypeManifest({ + bundleId: options.bundleId, + url: options.url, + }); + return; + } await this.downloadBundleInternal({ bundleId: options.bundleId, checksum: options.checksum, @@ -889,8 +962,8 @@ export class LiveUpdateEngine { deviceId: await this.getOrCreateDeviceId(), osVersion: this.osVersion, platform: this.platform, + pluginVersion: this.pluginVersion, runtime: this.runtime, - sdkVersion: this.sdkVersion, }); } @@ -946,6 +1019,257 @@ export class LiveUpdateEngine { } } + /** + * Download and install a `manifest` (delta) bundle. + * + * Downloads the manifest, diffs it against the current bundle's + * per-file checksums, copies unchanged files locally and downloads + * only the missing/changed files (in parallel, fail-fast). Each + * downloaded file is verified with the same precedence as the zip + * path. The assembled directory is then installed atomically. + */ + private async downloadBundleOfTypeManifest(options: { + bundleId: string; + url: string; + }): Promise { + const stagingDirectory = await this.store.createStagingDirectory(); + try { + const assembleDirectory = join(stagingDirectory, 'bundle'); + await mkdir(assembleDirectory, { recursive: true }); + // Download the manifest of the latest bundle. + const manifestFilePath = join(stagingDirectory, MANIFEST_FILE_NAME); + await downloadFile({ + destinationPath: manifestFilePath, + httpTimeout: this.httpTimeout, + url: withHrefQueryParameter(options.url, MANIFEST_FILE_NAME), + }); + const latestItems = parseManifest( + JSON.parse(await readFile(manifestFilePath, 'utf8')), + ); + // Diff against the current bundle by checksum: copy the files that + // are unchanged, download the rest. + const currentChecksums = await this.getCurrentBundleChecksums(); + const itemsToCopy: ManifestItem[] = []; + const itemsToDownload: ManifestItem[] = []; + if (currentChecksums === null) { + itemsToDownload.push(...latestItems); + } else { + const checksumToPath = new Map(); + for (const [path, checksum] of Object.entries(currentChecksums)) { + if (!checksumToPath.has(checksum)) { + checksumToPath.set(checksum, path); + } + } + for (const item of latestItems) { + if (checksumToPath.has(item.checksum)) { + itemsToCopy.push(item); + } else { + itemsToDownload.push(item); + } + } + const copyFailures = await this.copyManifestFiles( + itemsToCopy, + checksumToPath, + this.getCurrentBundleSourcePath(), + assembleDirectory, + ); + // Files that could not be copied locally are downloaded instead. + itemsToDownload.push(...copyFailures); + } + // Download the missing/changed files in parallel with fail-fast. + await this.downloadManifestFiles( + options.url, + itemsToDownload, + assembleDirectory, + options.bundleId, + ); + // Locate the bundle root and install atomically. + const bundleRoot = await findIndexHtmlDirectory(assembleDirectory); + if (!bundleRoot) { + throw new LiveUpdateError( + ErrorCode.BundleIndexHtmlMissing, + 'The bundle does not contain an index.html file.', + ); + } + const fileChecksums = await calculateContentChecksums(bundleRoot); + await this.store.add(options.bundleId, bundleRoot); + await this.stateFile.update(s => { + s.bundles[options.bundleId] = { + fileChecksums, + signed: this.publicKey !== undefined, + }; + }); + } catch (error) { + throw unknownError(error); + } finally { + await this.store.cleanUpStaging(stagingDirectory); + } + } + + /** + * Return the per-file checksums (path -> SHA-256) of the current + * bundle, or `null` if none can be determined (in which case a delta + * update downloads all files). + */ + private async getCurrentBundleChecksums(): Promise<{ + [path: string]: string; + } | null> { + const currentBundleId = this.stateFile.get().currentBundleId; + if (currentBundleId !== null) { + const metadata = this.stateFile.get().bundles[currentBundleId]; + if (metadata && Object.keys(metadata.fileChecksums).length > 0) { + return metadata.fileChecksums; + } + if (await this.store.has(currentBundleId)) { + return calculateContentChecksums(this.store.getPath(currentBundleId)); + } + return null; + } + // The default bundle has no recorded checksums; compute them lazily. + if (this.defaultBundlePath) { + try { + return await calculateContentChecksums(this.defaultBundlePath); + } catch { + return null; + } + } + return null; + } + + /** + * Return the on-disk directory of the current bundle to copy + * unchanged files from, or `null` if the default bundle is active and + * no `defaultBundlePath` is configured. + */ + private getCurrentBundleSourcePath(): string | null { + const currentBundleId = this.stateFile.get().currentBundleId; + if (currentBundleId !== null) { + return this.store.getPath(currentBundleId); + } + return this.defaultBundlePath; + } + + /** + * Copy the given files from the current bundle into the assembly + * directory. Returns the items that could not be copied (missing on + * disk), so they can be downloaded instead. + */ + private async copyManifestFiles( + items: ManifestItem[], + checksumToPath: Map, + sourceDirectory: string | null, + destinationDirectory: string, + ): Promise { + const failures: ManifestItem[] = []; + for (const item of items) { + const sourceRelativePath = checksumToPath.get(item.checksum); + if (sourceDirectory === null || sourceRelativePath === undefined) { + failures.push(item); + continue; + } + try { + const destinationPath = resolveManifestFilePath( + destinationDirectory, + item.href, + ); + await mkdir(dirname(destinationPath), { recursive: true }); + await copyFile( + join(sourceDirectory, sourceRelativePath), + destinationPath, + ); + } catch { + failures.push(item); + } + } + return failures; + } + + /** + * Download the given files in parallel (bounded concurrency, + * fail-fast) and verify each one. Emits aggregated download progress + * across all files. + */ + private async downloadManifestFiles( + baseUrl: string, + items: ManifestItem[], + destinationDirectory: string, + bundleId: string, + ): Promise { + if (items.length === 0) { + this.emit('downloadBundleProgress', { + bundleId, + downloadedBytes: 0, + progress: 1, + totalBytes: 0, + }); + return; + } + const totalBytes = items.reduce((sum, item) => sum + item.sizeInBytes, 0); + const downloadedPerFile = new Array(items.length).fill(0); + const controller = new AbortController(); + const emitProgress = (): void => { + const downloadedBytes = downloadedPerFile.reduce( + (sum, bytes) => sum + bytes, + 0, + ); + this.emit('downloadBundleProgress', { + bundleId, + downloadedBytes, + progress: + totalBytes > 0 ? Math.min(downloadedBytes / totalBytes, 1) : 1, + totalBytes, + }); + }; + let nextIndex = 0; + const worker = async (): Promise => { + for (;;) { + const index = nextIndex++; + if (index >= items.length) { + return; + } + const item = items[index] as ManifestItem; + const destinationPath = resolveManifestFilePath( + destinationDirectory, + item.href, + ); + await mkdir(dirname(destinationPath), { recursive: true }); + const result = await downloadFile({ + destinationPath, + httpTimeout: this.httpTimeout, + onProgress: downloadedBytes => { + downloadedPerFile[index] = downloadedBytes; + emitProgress(); + }, + signal: controller.signal, + url: withHrefQueryParameter(baseUrl, item.href), + }); + await verifyDownloadedFile({ + checksum: result.checksum, + filePath: destinationPath, + publicKey: this.publicKey, + signature: result.signature, + }); + // Account for the full file size even if no Content-Length was + // sent, so the aggregate progress reaches the total. + downloadedPerFile[index] = item.sizeInBytes; + emitProgress(); + } + }; + const workers = Array.from( + { length: Math.min(MANIFEST_DOWNLOAD_CONCURRENCY, items.length) }, + () => worker(), + ); + try { + await Promise.all(workers); + } catch (error) { + // Fail-fast: cancel the in-flight downloads and let them settle. + controller.abort(); + await Promise.allSettled(workers); + throw error; + } + emitProgress(); + } + private async setNextBundleInternal(bundleId: string | null): Promise { await this.stateFile.update(s => { s.nextBundleId = bundleId; diff --git a/src/engine/errors.ts b/src/engine/errors.ts index 06480c1..0705c59 100644 --- a/src/engine/errors.ts +++ b/src/engine/errors.ts @@ -49,6 +49,15 @@ export enum ErrorCode { * @since 0.1.0 */ BundleNotFound = 'BUNDLE_NOT_FOUND', + /** + * Channel Discovery is not enabled for this app. + * + * Enable public channels in the Capawesome Cloud Console to use + * `fetchChannels()`. + * + * @since 0.1.0 + */ + ChannelDiscoveryNotEnabled = 'CHANNEL_DISCOVERY_NOT_ENABLED', /** * The checksum of the bundle could not be calculated. * diff --git a/src/engine/manifest.ts b/src/engine/manifest.ts new file mode 100644 index 0000000..324b17e --- /dev/null +++ b/src/engine/manifest.ts @@ -0,0 +1,103 @@ +import { join, resolve, sep } from 'node:path'; + +import { ErrorCode, LiveUpdateError } from './errors'; + +/** + * The reserved file name of the manifest of a `manifest` (delta) bundle. + * + * DO NOT CHANGE: this is part of the Capawesome Cloud Live Update + * protocol and must match the mobile plugins. + */ +export const MANIFEST_FILE_NAME = 'capawesome-live-update-manifest.json'; + +/** + * A single entry of a bundle manifest. + */ +export interface ManifestItem { + /** + * The SHA-256 checksum of the file, used for diffing. + */ + checksum: string; + /** + * The path of the file relative to the bundle root. + */ + href: string; + /** + * The size of the file in bytes, used for progress aggregation. + */ + sizeInBytes: number; +} + +/** + * Parse the JSON of a bundle manifest into a list of manifest items. + * + * The manifest is a JSON array of `{ href, checksum, sizeInBytes }` + * objects. Entries without a `href` or `checksum` are ignored. + */ +export function parseManifest(json: unknown): ManifestItem[] { + if (!Array.isArray(json)) { + throw new LiveUpdateError( + ErrorCode.DownloadFailed, + 'Bundle could not be downloaded.', + ); + } + const items: ManifestItem[] = []; + for (const entry of json) { + if (typeof entry !== 'object' || entry === null) { + continue; + } + const record = entry as Record; + if ( + typeof record.href !== 'string' || + typeof record.checksum !== 'string' + ) { + continue; + } + items.push({ + checksum: record.checksum, + href: record.href, + sizeInBytes: + typeof record.sizeInBytes === 'number' && + Number.isFinite(record.sizeInBytes) + ? record.sizeInBytes + : 0, + }); + } + return items; +} + +/** + * Resolve a manifest item `href` to an absolute path inside the target + * directory, rejecting any path that would escape it (path traversal). + */ +export function resolveManifestFilePath( + targetDirectory: string, + href: string, +): string { + if (href.includes('\0')) { + throw pathError(); + } + const normalized = href.replace(/\\/g, '/'); + if (normalized.startsWith('/') || /^[a-zA-Z]:/.test(normalized)) { + throw pathError(); + } + const segments = normalized + .split('/') + .filter(segment => segment.length > 0 && segment !== '.'); + if (segments.length === 0 || segments.some(segment => segment === '..')) { + throw pathError(); + } + const targetRoot = resolve(targetDirectory); + const filePath = join(targetRoot, ...segments); + if (filePath !== targetRoot && !filePath.startsWith(targetRoot + sep)) { + throw pathError(); + } + return filePath; +} + +function pathError(): LiveUpdateError { + return new LiveUpdateError( + ErrorCode.DownloadFailed, + 'Bundle could not be downloaded.', + ); +} diff --git a/src/main/live-update.ts b/src/main/live-update.ts index 088fa62..6e12729 100644 --- a/src/main/live-update.ts +++ b/src/main/live-update.ts @@ -15,6 +15,8 @@ import type { DeleteBundleOptions, DownloadBundleOptions, DownloadBundleProgressListener, + FetchChannelsOptions, + FetchChannelsResult, FetchLatestBundleOptions, FetchLatestBundleResult, GetBlockedBundlesResult, @@ -31,6 +33,7 @@ import type { NextBundleSetListener, ReadyResult, ReloadedListener, + RolledBackListener, SetChannelOptions, SetCustomIdOptions, SetNextBundleOptions, @@ -85,6 +88,7 @@ class LiveUpdateImpl implements LiveUpdate { autoDeleteBundles: config.autoDeleteBundles, dataDirectory: config.dataDirectory ?? join(app.getPath('userData'), 'live-update'), + defaultBundlePath: config.defaultBundlePath, defaultChannel: config.defaultChannel, httpTimeout: config.httpTimeout, logger: this.logger, @@ -112,6 +116,7 @@ class LiveUpdateImpl implements LiveUpdate { : `bundle '${event.currentBundleId}'` }.`, ); + this.emitEvent('rolledBack', event); void this.reloadAttachedWindows().catch(error => this.logger.error( `Failed to reload after rollback: ${this.describeError(error)}`, @@ -208,6 +213,13 @@ class LiveUpdateImpl implements LiveUpdate { return this.engine.sync(options); } + public async fetchChannels( + options?: FetchChannelsOptions, + ): Promise { + await this.initialization; + return this.engine.fetchChannels(options); + } + public async fetchLatestBundle( options?: FetchLatestBundleOptions, ): Promise { @@ -312,6 +324,10 @@ class LiveUpdateImpl implements LiveUpdate { eventName: 'reloaded', listener: ReloadedListener, ): ListenerHandle; + public addListener( + eventName: 'rolledBack', + listener: RolledBackListener, + ): ListenerHandle; public addListener( eventName: IpcEvent, listener: (...args: never[]) => void, @@ -461,6 +477,8 @@ class LiveUpdateImpl implements LiveUpdate { return this.deleteBundle(options as DeleteBundleOptions); case 'downloadBundle': return this.downloadBundle(options as DownloadBundleOptions); + case 'fetchChannels': + return this.fetchChannels(options as FetchChannelsOptions | undefined); case 'fetchLatestBundle': return this.fetchLatestBundle( options as FetchLatestBundleOptions | undefined, diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 7c0d1bc..ad845b6 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -2,6 +2,8 @@ import type { DeleteBundleOptions, DownloadBundleOptions, DownloadBundleProgressListener, + FetchChannelsOptions, + FetchChannelsResult, FetchLatestBundleOptions, FetchLatestBundleResult, GetBlockedBundlesResult, @@ -19,6 +21,7 @@ import type { NextBundleSetListener, ReadyResult, ReloadedListener, + RolledBackListener, SetChannelOptions, SetCustomIdOptions, SetNextBundleOptions, @@ -76,6 +79,12 @@ class LiveUpdateClient implements LiveUpdateApi { return invoke('downloadBundle', options); } + public fetchChannels( + options?: FetchChannelsOptions, + ): Promise { + return invoke('fetchChannels', options); + } + public fetchLatestBundle( options?: FetchLatestBundleOptions, ): Promise { @@ -162,6 +171,10 @@ class LiveUpdateClient implements LiveUpdateApi { eventName: 'reloaded', listener: ReloadedListener, ): ListenerHandle; + public addListener( + eventName: 'rolledBack', + listener: RolledBackListener, + ): ListenerHandle; public addListener( eventName: IpcEvent, listener: (...args: never[]) => void, diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 04c2499..cf5b62b 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -15,6 +15,7 @@ export const IPC_METHODS = [ 'clearBlockedBundles', 'deleteBundle', 'downloadBundle', + 'fetchChannels', 'fetchLatestBundle', 'getBlockedBundles', 'getChannel', @@ -44,6 +45,7 @@ export const IPC_EVENTS = [ 'downloadBundleProgress', 'nextBundleSet', 'reloaded', + 'rolledBack', ] as const; export type IpcEvent = (typeof IPC_EVENTS)[number]; diff --git a/tests/api-client.test.ts b/tests/api-client.test.ts index c16caff..699d13a 100644 --- a/tests/api-client.test.ts +++ b/tests/api-client.test.ts @@ -19,8 +19,8 @@ describe('CloudApiClient', () => { deviceId: 'device-1', osVersion: '25.5.0', platform: '2', + pluginVersion: '0.0.1', runtime: 'electron' as string | null, - sdkVersion: '0.0.1', }; beforeEach(async () => { @@ -175,4 +175,79 @@ describe('CloudApiClient', () => { code: ErrorCode.Unknown, }); }); + + describe('getChannels', () => { + const channelsRequest = { + appId: 'app-123', + deviceId: 'device-1', + limit: 50, + offset: 0, + query: null as string | null, + }; + + it('requests the channels with the exact protocol query parameters', async () => { + server.route('/v1/apps/app-123/channels', { + body: JSON.stringify([{ id: 'c1', name: 'production' }]), + }); + const channels = await client.getChannels({ + ...channelsRequest, + limit: 10, + offset: 5, + query: 'prod', + }); + expect(channels).toEqual([{ id: 'c1', name: 'production' }]); + const recorded = server.requests[0]; + expect(recorded?.url.pathname).toBe('/v1/apps/app-123/channels'); + const params = recorded?.url.searchParams; + expect(params?.get('limit')).toBe('10'); + expect(params?.get('offset')).toBe('5'); + expect(params?.get('query')).toBe('prod'); + expect(recorded?.headers['x-capawesome-device-id']).toBe('device-1'); + }); + + it('omits the query parameter when not provided', async () => { + server.route('/v1/apps/app-123/channels', { body: '[]' }); + await client.getChannels(channelsRequest); + const params = server.requests[0]?.url.searchParams; + expect(params?.has('query')).toBe(false); + expect(params?.get('limit')).toBe('50'); + expect(params?.get('offset')).toBe('0'); + }); + + it('throws CHANNEL_DISCOVERY_NOT_ENABLED on 401', async () => { + server.route('/v1/apps/app-123/channels', { + body: 'unauthorized', + status: 401, + }); + await expect(client.getChannels(channelsRequest)).rejects.toMatchObject({ + code: ErrorCode.ChannelDiscoveryNotEnabled, + message: + 'Unauthorized. Channel Discovery may not be enabled for this app.', + }); + }); + + it('throws on other non-2xx responses', async () => { + server.route('/v1/apps/app-123/channels', { + body: 'boom', + status: 500, + }); + await expect(client.getChannels(channelsRequest)).rejects.toMatchObject({ + code: ErrorCode.Unknown, + }); + }); + + it('ignores malformed channel entries', async () => { + server.route('/v1/apps/app-123/channels', { + body: JSON.stringify([ + { id: 'c1', name: 'production' }, + { id: 'c2' }, + 'garbage', + { name: 'no-id' }, + ]), + }); + expect(await client.getChannels(channelsRequest)).toEqual([ + { id: 'c1', name: 'production' }, + ]); + }); + }); }); diff --git a/tests/engine.test.ts b/tests/engine.test.ts index 2e96116..944b42a 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; -import { readFile, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import type { IncomingMessage } from 'node:http'; +import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { @@ -13,6 +14,7 @@ import { type LiveUpdateEngineConfig, } from '../src/engine/engine'; import { ErrorCode } from '../src/engine/errors'; +import { MANIFEST_FILE_NAME } from '../src/engine/manifest'; import { MockServer, @@ -21,6 +23,7 @@ import { createTemporaryDirectory, generateRsaKeyPair, removeDirectory, + sha256Hex, signBytes, } from './helpers'; @@ -94,6 +97,81 @@ describe('LiveUpdateEngine', () => { }); } + interface ManifestFile { + content: string; + href: string; + } + + function manifestUrl(bundleId: string): string { + return `${server.origin}/manifest/${bundleId}`; + } + + function hrefOf(request: IncomingMessage): string | null { + return new URL(request.url ?? '/', server.origin).searchParams.get('href'); + } + + /** + * Serve a `manifest` (delta) bundle: the manifest JSON under + * `?href=` and each file under `?href=`. + */ + function serveManifestBundle( + bundleId: string, + files: ManifestFile[], + options: { privateKeyPem?: string } = {}, + ): void { + const manifest = files.map(file => ({ + checksum: sha256Hex(file.content), + href: file.href, + sizeInBytes: Buffer.byteLength(file.content), + })); + server.route(`/manifest/${bundleId}`, request => { + const href = hrefOf(request); + if (href === MANIFEST_FILE_NAME) { + return { + body: JSON.stringify(manifest), + headers: { 'Content-Type': 'application/json' }, + }; + } + const file = files.find(entry => entry.href === href); + if (!file) { + return { body: 'Not found', status: 404 }; + } + const bytes = Buffer.from(file.content, 'utf8'); + const headers: Record = options.privateKeyPem + ? { 'X-Signature': signBytes(bytes, options.privateKeyPem) } + : { 'X-Checksum': sha256Hex(bytes) }; + return { body: bytes, headers }; + }); + } + + function serveLatestManifestBundle(bundleId: string): void { + server.route('/v1/apps/app-123/bundles/latest', { + body: JSON.stringify({ + artifactType: 'manifest', + bundleId, + url: manifestUrl(bundleId), + }), + headers: { 'Content-Type': 'application/json' }, + }); + } + + async function writeDefaultBundle(files: ManifestFile[]): Promise { + const directory = await createTemporaryDirectory(); + for (const file of files) { + const filePath = join(directory, file.href); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, file.content, 'utf8'); + } + return directory; + } + + function requestedHrefs(): string[] { + return server.requests + .filter(recorded => recorded.url.pathname.startsWith('/manifest/')) + .map(recorded => recorded.url.searchParams.get('href')) + .filter((href): href is string => href !== null); + } + describe('downloadBundle', () => { it('downloads, verifies and installs a bundle', async () => { const engine = createEngine(); @@ -149,18 +227,6 @@ describe('LiveUpdateEngine', () => { ).rejects.toMatchObject({ code: ErrorCode.BundleAlreadyExists }); }); - it('rejects the manifest artifact type', async () => { - const engine = createEngine(); - await engine.initialize(); - await expect( - engine.downloadBundle({ - artifactType: 'manifest', - bundleId: '1.0.0', - url: 'https://example.com/b', - }), - ).rejects.toMatchObject({ code: ErrorCode.ArtifactTypeNotSupported }); - }); - it('rejects a bundle with a checksum mismatch and leaves no traces', async () => { const engine = createEngine(); await engine.initialize(); @@ -701,6 +767,333 @@ describe('LiveUpdateEngine', () => { }); }); + describe('manifest (delta) bundles', () => { + async function readBundleFile( + bundleId: string, + href: string, + ): Promise { + return readFile( + join(dataDirectory, 'bundles', bundleId, ...href.split('/')), + 'utf8', + ); + } + + it('downloads every file on a first delta over the default bundle without a default path', async () => { + const engine = createEngine(); + await engine.initialize(); + serveManifestBundle('delta', [ + { href: 'index.html', content: 'delta' }, + { href: 'assets/app.js', content: 'console.log("delta");' }, + ]); + await engine.downloadBundle({ + artifactType: 'manifest', + bundleId: 'delta', + url: manifestUrl('delta'), + }); + expect((await engine.getDownloadedBundles()).bundleIds).toEqual([ + 'delta', + ]); + expect(await readBundleFile('delta', 'index.html')).toBe( + 'delta', + ); + expect(requestedHrefs().sort()).toEqual( + [MANIFEST_FILE_NAME, 'assets/app.js', 'index.html'].sort(), + ); + }); + + it('reuses unchanged files of the packaged default bundle (first delta on default)', async () => { + const defaultBundlePath = await writeDefaultBundle([ + { href: 'index.html', content: 'v1' }, + { href: 'assets/app.js', content: 'shared-code' }, + ]); + const engine = createEngine({ defaultBundlePath }); + await engine.initialize(); + serveManifestBundle('v2', [ + { href: 'index.html', content: 'v2' }, + { href: 'assets/app.js', content: 'shared-code' }, + ]); + await engine.downloadBundle({ + artifactType: 'manifest', + bundleId: 'v2', + url: manifestUrl('v2'), + }); + // The changed index.html was downloaded, the unchanged app.js copied. + expect(await readBundleFile('v2', 'index.html')).toBe('v2'); + expect(await readBundleFile('v2', 'assets/app.js')).toBe('shared-code'); + expect(requestedHrefs()).not.toContain('assets/app.js'); + expect(requestedHrefs()).toContain('index.html'); + await removeDirectory(defaultBundlePath); + }); + + it('reuses unchanged files of a previously installed bundle', async () => { + const engine = createEngine(); + await engine.initialize(); + const baseZip = await buildZip([ + { path: 'index.html', content: 'base' }, + { path: 'assets/app.js', content: 'shared-code' }, + ]); + server.route('/download/base.zip', { body: baseZip }); + await engine.downloadBundle({ + bundleId: 'base', + url: `${server.origin}/download/base.zip`, + }); + await engine.setNextBundle({ bundleId: 'base' }); + await engine.applyNextBundle(); + serveManifestBundle('next', [ + { href: 'index.html', content: 'next' }, + { href: 'assets/app.js', content: 'shared-code' }, + ]); + await engine.downloadBundle({ + artifactType: 'manifest', + bundleId: 'next', + url: manifestUrl('next'), + }); + expect(await readBundleFile('next', 'index.html')).toBe( + 'next', + ); + expect(await readBundleFile('next', 'assets/app.js')).toBe('shared-code'); + expect(requestedHrefs()).not.toContain('assets/app.js'); + }); + + it('emits aggregated download progress across files', async () => { + const engine = createEngine(); + await engine.initialize(); + const files = [ + { href: 'index.html', content: 'delta' }, + { href: 'assets/app.js', content: 'console.log("delta");' }, + ]; + serveManifestBundle('delta', files); + const progressEvents: DownloadBundleProgressEvent[] = []; + engine.on('downloadBundleProgress', event => progressEvents.push(event)); + await engine.downloadBundle({ + artifactType: 'manifest', + bundleId: 'delta', + url: manifestUrl('delta'), + }); + expect(progressEvents.length).toBeGreaterThan(0); + const lastEvent = progressEvents[progressEvents.length - 1]; + expect(lastEvent?.bundleId).toBe('delta'); + expect(lastEvent?.progress).toBe(1); + const expectedTotal = files.reduce( + (sum, file) => sum + Buffer.byteLength(file.content), + 0, + ); + expect(lastEvent?.totalBytes).toBe(expectedTotal); + expect(lastEvent?.downloadedBytes).toBe(expectedTotal); + }); + + it('verifies each file signature with the configured public key', async () => { + const { privateKeyPem, publicKeyPem } = generateRsaKeyPair(); + const engine = createEngine({ publicKey: publicKeyPem }); + await engine.initialize(); + serveManifestBundle( + 'signed', + [ + { href: 'index.html', content: 'signed' }, + { href: 'assets/app.js', content: 'console.log("signed");' }, + ], + { privateKeyPem }, + ); + await engine.downloadBundle({ + artifactType: 'manifest', + bundleId: 'signed', + url: manifestUrl('signed'), + }); + expect((await engine.getDownloadedBundles()).bundleIds).toEqual([ + 'signed', + ]); + }); + + it('rejects a delta with an invalid per-file signature', async () => { + const { privateKeyPem, publicKeyPem } = generateRsaKeyPair(); + const engine = createEngine({ publicKey: publicKeyPem }); + await engine.initialize(); + const manifest = [ + { + checksum: sha256Hex('ok'), + href: 'index.html', + sizeInBytes: Buffer.byteLength('ok'), + }, + ]; + server.route('/manifest/tampered', request => { + const href = hrefOf(request); + if (href === MANIFEST_FILE_NAME) { + return { body: JSON.stringify(manifest) }; + } + // Serve tampered content with a signature of different bytes. + return { + body: 'evil', + headers: { + 'X-Signature': signBytes(Buffer.from('other'), privateKeyPem), + }, + }; + }); + await expect( + engine.downloadBundle({ + artifactType: 'manifest', + bundleId: 'tampered', + url: manifestUrl('tampered'), + }), + ).rejects.toMatchObject({ code: ErrorCode.SignatureVerificationFailed }); + expect((await engine.getDownloadedBundles()).bundleIds).toEqual([]); + }); + + it('fails fast when a file cannot be downloaded and leaves no traces', async () => { + const engine = createEngine(); + await engine.initialize(); + const manifest = [ + { + checksum: sha256Hex('a'), + href: 'index.html', + sizeInBytes: 1, + }, + { + checksum: sha256Hex('b'), + href: 'missing.js', + sizeInBytes: 1, + }, + ]; + server.route('/manifest/broken', request => { + const href = hrefOf(request); + if (href === MANIFEST_FILE_NAME) { + return { body: JSON.stringify(manifest) }; + } + if (href === 'index.html') { + return { body: 'a', headers: { 'X-Checksum': sha256Hex('a') } }; + } + return { body: 'Not found', status: 404 }; + }); + await expect( + engine.downloadBundle({ + artifactType: 'manifest', + bundleId: 'broken', + url: manifestUrl('broken'), + }), + ).rejects.toMatchObject({ code: ErrorCode.DownloadFailed }); + expect((await engine.getDownloadedBundles()).bundleIds).toEqual([]); + }); + + it('rejects a delta without an index.html file', async () => { + const engine = createEngine(); + await engine.initialize(); + serveManifestBundle('noindex', [ + { href: 'assets/app.js', content: 'console.log("x");' }, + ]); + await expect( + engine.downloadBundle({ + artifactType: 'manifest', + bundleId: 'noindex', + url: manifestUrl('noindex'), + }), + ).rejects.toMatchObject({ code: ErrorCode.BundleIndexHtmlMissing }); + expect((await engine.getDownloadedBundles()).bundleIds).toEqual([]); + }); + + it('syncs a manifest bundle end to end', async () => { + const engine = createEngine(); + await engine.initialize(); + serveManifestBundle('3.0.0', [ + { href: 'index.html', content: '3.0.0' }, + ]); + serveLatestManifestBundle('3.0.0'); + const result = await engine.sync(); + expect(result).toEqual({ nextBundleId: '3.0.0' }); + expect((await engine.getNextBundle()).bundleId).toBe('3.0.0'); + expect(await readBundleFile('3.0.0', 'index.html')).toBe( + '3.0.0', + ); + }); + }); + + describe('fetchChannels', () => { + it('returns the channels', async () => { + const engine = createEngine(); + await engine.initialize(); + server.route('/v1/apps/app-123/channels', { + body: JSON.stringify([ + { id: 'c1', name: 'production' }, + { id: 'c2', name: 'beta' }, + ]), + }); + const result = await engine.fetchChannels(); + expect(result).toEqual({ + channels: [ + { id: 'c1', name: 'production' }, + { id: 'c2', name: 'beta' }, + ], + }); + }); + + it('sends the default and overridden pagination parameters', async () => { + const engine = createEngine(); + await engine.initialize(); + server.route('/v1/apps/app-123/channels', { body: '[]' }); + await engine.fetchChannels(); + let params = server.requests[0]?.url.searchParams; + expect(params?.get('limit')).toBe('50'); + expect(params?.get('offset')).toBe('0'); + expect(params?.has('query')).toBe(false); + await engine.fetchChannels({ limit: 5, offset: 10, query: 'prod' }); + params = server.requests[1]?.url.searchParams; + expect(params?.get('limit')).toBe('5'); + expect(params?.get('offset')).toBe('10'); + expect(params?.get('query')).toBe('prod'); + }); + + it('throws CHANNEL_DISCOVERY_NOT_ENABLED on 401', async () => { + const engine = createEngine(); + await engine.initialize(); + server.route('/v1/apps/app-123/channels', { + body: 'unauthorized', + status: 401, + }); + await expect(engine.fetchChannels()).rejects.toMatchObject({ + code: ErrorCode.ChannelDiscoveryNotEnabled, + message: + 'Unauthorized. Channel Discovery may not be enabled for this app.', + }); + }); + + it('requires an appId', async () => { + const engine = createEngine({ appId: undefined }); + await engine.initialize(); + await expect(engine.fetchChannels()).rejects.toMatchObject({ + code: ErrorCode.AppIdMissing, + }); + }); + }); + + describe('injectable runtime and pluginVersion', () => { + it('defaults pluginVersion to the SDK version', async () => { + const engine = createEngine(); + await engine.initialize(); + server.route('/v1/apps/app-123/bundles/latest', { + body: 'no', + status: 404, + }); + await engine.sync(); + expect(server.requests[0]?.url.searchParams.get('pluginVersion')).toBe( + '0.0.1', + ); + }); + + it('sends the injected pluginVersion and runtime', async () => { + const engine = createEngine({ + pluginVersion: '8.4.0', + runtime: 'capacitor', + }); + await engine.initialize(); + server.route('/v1/apps/app-123/bundles/latest', { + body: 'no', + status: 404, + }); + await engine.sync(); + const params = server.requests[0]?.url.searchParams; + expect(params?.get('pluginVersion')).toBe('8.4.0'); + expect(params?.get('runtime')).toBe('capacitor'); + }); + }); + describe('guards', () => { it('throws when used before initialize()', async () => { const engine = createEngine(); diff --git a/tests/helpers.ts b/tests/helpers.ts index 8f5f12d..3335d10 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,4 +1,4 @@ -import { createSign, generateKeyPairSync } from 'node:crypto'; +import { createHash, createSign, generateKeyPairSync } from 'node:crypto'; import { createWriteStream } from 'node:fs'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { @@ -261,3 +261,7 @@ export class MockServer { export async function readTextFile(filePath: string): Promise { return readFile(filePath, 'utf8'); } + +export function sha256Hex(content: string | Buffer): string { + return createHash('sha256').update(content).digest('hex'); +} diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts new file mode 100644 index 0000000..8ecbb76 --- /dev/null +++ b/tests/manifest.test.ts @@ -0,0 +1,81 @@ +import { join, sep } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { ErrorCode } from '../src/engine/errors'; +import { + MANIFEST_FILE_NAME, + parseManifest, + resolveManifestFilePath, +} from '../src/engine/manifest'; + +describe('manifest', () => { + describe('parseManifest', () => { + it('parses a valid manifest', () => { + const items = parseManifest([ + { href: 'index.html', checksum: 'aaa', sizeInBytes: 10 }, + { href: 'assets/app.js', checksum: 'bbb', sizeInBytes: 20 }, + ]); + expect(items).toEqual([ + { href: 'index.html', checksum: 'aaa', sizeInBytes: 10 }, + { href: 'assets/app.js', checksum: 'bbb', sizeInBytes: 20 }, + ]); + }); + + it('defaults a missing or invalid sizeInBytes to 0', () => { + const items = parseManifest([{ href: 'a', checksum: 'c' }]); + expect(items[0]?.sizeInBytes).toBe(0); + }); + + it('ignores entries without a href or checksum', () => { + const items = parseManifest([ + { href: 'a', checksum: 'c' }, + { href: 'b' }, + { checksum: 'd' }, + 'garbage', + null, + ]); + expect(items).toEqual([{ href: 'a', checksum: 'c', sizeInBytes: 0 }]); + }); + + it('throws when the manifest is not an array', () => { + expect(() => parseManifest({})).toThrowError( + expect.objectContaining({ code: ErrorCode.DownloadFailed }), + ); + }); + }); + + describe('resolveManifestFilePath', () => { + it('resolves a nested path inside the target directory', () => { + const root = join('/tmp', 'bundle'); + expect(resolveManifestFilePath(root, 'assets/app.js')).toBe( + join(root, 'assets', 'app.js'), + ); + }); + + it('rejects path traversal', () => { + const root = join('/tmp', 'bundle'); + for (const href of [ + '../escape.js', + 'assets/../../escape.js', + `/etc/passwd`, + 'C:/windows', + 'a\0b', + '', + ]) { + expect(() => resolveManifestFilePath(root, href)).toThrowError( + expect.objectContaining({ code: ErrorCode.DownloadFailed }), + ); + } + }); + + it('normalizes backslashes and keeps the path inside the root', () => { + const root = join('/tmp', 'bundle'); + const resolved = resolveManifestFilePath(root, 'assets\\app.js'); + expect(resolved.startsWith(root + sep)).toBe(true); + }); + }); + + it('exposes the reserved manifest file name', () => { + expect(MANIFEST_FILE_NAME).toBe('capawesome-live-update-manifest.json'); + }); +}); From 78acf3e508f833b6d3dae65b3d25c9b573654185 Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Tue, 14 Jul 2026 11:12:25 +0200 Subject: [PATCH 2/9] fix: address review feedback (portable tests, single href param, manifest checksum verification) Co-Authored-By: Claude Fable 5 --- src/engine/download.ts | 9 ++-- src/engine/engine.ts | 1 + src/engine/verify.ts | 30 +++++++++-- tests/engine.test.ts | 111 +++++++++++++++++++++++++++++++++++++++++ tests/manifest.test.ts | 7 +-- tests/verify.test.ts | 53 ++++++++++++++++++++ 6 files changed, 200 insertions(+), 11 deletions(-) diff --git a/src/engine/download.ts b/src/engine/download.ts index db1f130..0c34a46 100644 --- a/src/engine/download.ts +++ b/src/engine/download.ts @@ -46,13 +46,14 @@ export interface DownloadFileOptions { } /** - * Append an `href` query parameter to a (pre-signed) bundle URL, - * preserving any existing query parameters. Used to request individual - * files of a `manifest` (delta) bundle. + * Set the `href` query parameter on a (pre-signed) bundle URL, + * preserving any other query parameters. Used to request individual + * files of a `manifest` (delta) bundle. `set` (rather than `append`) + * guarantees exactly one `href` even if the base URL already has one. */ export function withHrefQueryParameter(baseUrl: string, href: string): string { const url = assertSecureUrl(baseUrl); - url.searchParams.append('href', href); + url.searchParams.set('href', href); return url.toString(); } diff --git a/src/engine/engine.ts b/src/engine/engine.ts index 70d0028..5e39cae 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -1246,6 +1246,7 @@ export class LiveUpdateEngine { await verifyDownloadedFile({ checksum: result.checksum, filePath: destinationPath, + manifestChecksum: item.checksum, publicKey: this.publicKey, signature: result.signature, }); diff --git a/src/engine/verify.ts b/src/engine/verify.ts index 391b81f..5a156aa 100644 --- a/src/engine/verify.ts +++ b/src/engine/verify.ts @@ -77,6 +77,14 @@ export interface VerifyDownloadedFileOptions { */ checksum?: string; filePath: string; + /** + * SHA-256 checksum in hex format from the trusted bundle manifest + * (a `manifest`/delta bundle item). Used to verify individual files + * of a delta bundle when no `publicKey` is configured: it is used as + * a fallback when no `checksum` header is present and, when both are + * present, a `checksum` header contradicting it fails verification. + */ + manifestChecksum?: string; /** * PEM-encoded RSA public key from the SDK configuration. */ @@ -94,8 +102,12 @@ export interface VerifyDownloadedFileOptions { * Verification precedence (mirrors the Capacitor plugin): * 1. If a `publicKey` is configured, a signature is REQUIRED and the * checksum is ignored. - * 2. Otherwise, if a checksum is available, it is verified. - * 3. Otherwise, the file is accepted without verification. + * 2. Otherwise, if a `checksum` header and a `manifestChecksum` are + * both present and disagree, verification fails: a header + * contradicting the trusted manifest is suspicious. + * 3. Otherwise, if either a `checksum` header or a `manifestChecksum` + * is available, it is verified (the header taking precedence). + * 4. Otherwise, the file is accepted without verification. */ export async function verifyDownloadedFile( options: VerifyDownloadedFileOptions, @@ -111,9 +123,19 @@ export async function verifyDownloadedFile( await verifyFileSignature(options.filePath, options.signature, publicKey); return; } - if (options.checksum) { + const headerChecksum = options.checksum?.toLowerCase(); + const manifestChecksum = options.manifestChecksum?.toLowerCase(); + if ( + headerChecksum !== undefined && + manifestChecksum !== undefined && + headerChecksum !== manifestChecksum + ) { + throw new LiveUpdateError(ErrorCode.ChecksumMismatch, 'Checksum mismatch.'); + } + const expectedChecksum = headerChecksum ?? manifestChecksum; + if (expectedChecksum) { const actualChecksum = await calculateFileChecksum(options.filePath); - if (actualChecksum !== options.checksum.toLowerCase()) { + if (actualChecksum !== expectedChecksum) { throw new LiveUpdateError( ErrorCode.ChecksumMismatch, 'Checksum mismatch.', diff --git a/tests/engine.test.ts b/tests/engine.test.ts index 944b42a..24db158 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -973,6 +973,117 @@ describe('LiveUpdateEngine', () => { expect((await engine.getDownloadedBundles()).bundleIds).toEqual([]); }); + it('falls back to the manifest checksum when the X-Checksum header is absent', async () => { + const engine = createEngine(); + await engine.initialize(); + const content = 'no-header'; + const manifest = [ + { + checksum: sha256Hex(content), + href: 'index.html', + sizeInBytes: Buffer.byteLength(content), + }, + ]; + server.route('/manifest/no-header', request => { + const href = hrefOf(request); + if (href === MANIFEST_FILE_NAME) { + return { body: JSON.stringify(manifest) }; + } + // Serve the file WITHOUT an X-Checksum header. + return { body: content }; + }); + await engine.downloadBundle({ + artifactType: 'manifest', + bundleId: 'no-header', + url: manifestUrl('no-header'), + }); + expect((await engine.getDownloadedBundles()).bundleIds).toEqual([ + 'no-header', + ]); + expect(await readBundleFile('no-header', 'index.html')).toBe(content); + }); + + it('rejects a delta when the manifest checksum does not match the file', async () => { + const engine = createEngine(); + await engine.initialize(); + const content = 'corrupt'; + const manifest = [ + { + // Manifest checksum of different bytes than what is served. + checksum: sha256Hex('other'), + href: 'index.html', + sizeInBytes: Buffer.byteLength(content), + }, + ]; + server.route('/manifest/corrupt', request => { + const href = hrefOf(request); + if (href === MANIFEST_FILE_NAME) { + return { body: JSON.stringify(manifest) }; + } + // Serve the file WITHOUT an X-Checksum header. + return { body: content }; + }); + await expect( + engine.downloadBundle({ + artifactType: 'manifest', + bundleId: 'corrupt', + url: manifestUrl('corrupt'), + }), + ).rejects.toMatchObject({ code: ErrorCode.ChecksumMismatch }); + expect((await engine.getDownloadedBundles()).bundleIds).toEqual([]); + }); + + it('rejects a delta whose X-Checksum header contradicts the manifest', async () => { + const engine = createEngine(); + await engine.initialize(); + const content = 'contradiction'; + const manifest = [ + { + checksum: sha256Hex(content), + href: 'index.html', + sizeInBytes: Buffer.byteLength(content), + }, + ]; + server.route('/manifest/contradiction', request => { + const href = hrefOf(request); + if (href === MANIFEST_FILE_NAME) { + return { body: JSON.stringify(manifest) }; + } + // Correct bytes, but a header checksum that disagrees with the + // trusted manifest checksum. + return { body: content, headers: { 'X-Checksum': 'a'.repeat(64) } }; + }); + await expect( + engine.downloadBundle({ + artifactType: 'manifest', + bundleId: 'contradiction', + url: manifestUrl('contradiction'), + }), + ).rejects.toMatchObject({ code: ErrorCode.ChecksumMismatch }); + expect((await engine.getDownloadedBundles()).bundleIds).toEqual([]); + }); + + it('accepts a delta whose X-Checksum header matches the manifest', async () => { + const engine = createEngine(); + await engine.initialize(); + // serveManifestBundle sends an X-Checksum header equal to the + // manifest checksum for every file. + serveManifestBundle('agree', [ + { href: 'index.html', content: 'agree' }, + ]); + await engine.downloadBundle({ + artifactType: 'manifest', + bundleId: 'agree', + url: manifestUrl('agree'), + }); + expect((await engine.getDownloadedBundles()).bundleIds).toEqual([ + 'agree', + ]); + expect(await readBundleFile('agree', 'index.html')).toBe( + 'agree', + ); + }); + it('rejects a delta without an index.html file', async () => { const engine = createEngine(); await engine.initialize(); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 8ecbb76..c2b745a 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -1,3 +1,4 @@ +import { tmpdir } from 'node:os'; import { join, sep } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -46,14 +47,14 @@ describe('manifest', () => { describe('resolveManifestFilePath', () => { it('resolves a nested path inside the target directory', () => { - const root = join('/tmp', 'bundle'); + const root = join(tmpdir(), 'bundle'); expect(resolveManifestFilePath(root, 'assets/app.js')).toBe( join(root, 'assets', 'app.js'), ); }); it('rejects path traversal', () => { - const root = join('/tmp', 'bundle'); + const root = join(tmpdir(), 'bundle'); for (const href of [ '../escape.js', 'assets/../../escape.js', @@ -69,7 +70,7 @@ describe('manifest', () => { }); it('normalizes backslashes and keeps the path inside the root', () => { - const root = join('/tmp', 'bundle'); + const root = join(tmpdir(), 'bundle'); const resolved = resolveManifestFilePath(root, 'assets\\app.js'); expect(resolved.startsWith(root + sep)).toBe(true); }); diff --git a/tests/verify.test.ts b/tests/verify.test.ts index 4a99c97..345f603 100644 --- a/tests/verify.test.ts +++ b/tests/verify.test.ts @@ -69,6 +69,59 @@ describe('verification', () => { await expect(verifyDownloadedFile({ filePath })).resolves.toBeUndefined(); }); + it('falls back to the manifest checksum when no header checksum is present', async () => { + const manifestChecksum = createHash('sha256') + .update(fileContent) + .digest('hex'); + await expect( + verifyDownloadedFile({ filePath, manifestChecksum }), + ).resolves.toBeUndefined(); + }); + + it('rejects a manifest checksum mismatch when no header checksum is present', async () => { + await expect( + verifyDownloadedFile({ filePath, manifestChecksum: 'a'.repeat(64) }), + ).rejects.toMatchObject({ + code: ErrorCode.ChecksumMismatch, + message: 'Checksum mismatch.', + }); + }); + + it('accepts when the header checksum matches the manifest checksum', async () => { + const checksum = createHash('sha256').update(fileContent).digest('hex'); + await expect( + verifyDownloadedFile({ filePath, checksum, manifestChecksum: checksum }), + ).resolves.toBeUndefined(); + }); + + it('rejects when the header checksum contradicts the manifest checksum', async () => { + const checksum = createHash('sha256').update(fileContent).digest('hex'); + await expect( + verifyDownloadedFile({ + filePath, + checksum, + manifestChecksum: 'a'.repeat(64), + }), + ).rejects.toMatchObject({ + code: ErrorCode.ChecksumMismatch, + message: 'Checksum mismatch.', + }); + }); + + it('ignores the manifest checksum when a public key is configured', async () => { + const { privateKeyPem, publicKeyPem } = generateRsaKeyPair(); + const signature = signBytes(fileContent, privateKeyPem); + // Wrong manifest checksum, valid signature: the signature path wins. + await expect( + verifyDownloadedFile({ + filePath, + publicKey: publicKeyPem, + signature, + manifestChecksum: 'a'.repeat(64), + }), + ).resolves.toBeUndefined(); + }); + it('verifies a valid signature', async () => { const { privateKeyPem, publicKeyPem } = generateRsaKeyPair(); const signature = signBytes(fileContent, privateKeyPem); From 2ba564f92a8b66ce286f9ad36f6f3aa06738a9ed Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Tue, 14 Jul 2026 13:33:46 +0200 Subject: [PATCH 3/9] fix: brand-namespace default data directory and scheme Co-Authored-By: Claude Fable 5 --- README.md | 36 ++++++++++++++++++------------------ e2e/helpers.mjs | 2 +- src/main/definitions.ts | 6 +++--- src/main/live-update.ts | 5 +++-- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 2ed9279..2205185 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ That's it. The renderer code is line-for-line the same vocabulary you would use ### Custom scheme (recommended): `serve()` -`serve()` registers a privileged custom scheme (default: `live-update`) and serves the files of the active bundle under the stable origin `live-update://bundle`. Because the origin never changes: +`serve()` registers a privileged custom scheme (default: `capawesome-live-update`) and serves the files of the active bundle under the stable origin `capawesome-live-update://bundle`. Because the origin never changes: - `localStorage`, IndexedDB, and other origin-scoped storage **survive bundle switches**, - `fetch()` and service workers work as on a regular secure origin, @@ -101,7 +101,7 @@ That's it. The renderer code is line-for-line the same vocabulary you would use ```ts liveUpdate.serve(); // or liveUpdate.serve({ scheme: 'my-app' }) -await window.loadURL(liveUpdate.getServeUrl()); // 'live-update://bundle/' +await window.loadURL(liveUpdate.getServeUrl()); // 'capawesome-live-update://bundle/' ``` ### Simple mode: `getCurrentBundlePath()` @@ -228,22 +228,22 @@ Creates the SDK. Call once, early in your main process (before `app.whenReady()` #### Configuration -| Option | Type | Default | Description | -| ---------------------------- | ------------------------ | ---------------------------------------------- | --------------------------------------------------------------------------------------------- | -| `appId` | `string` | – | Capawesome Cloud app ID. Required for `sync()`/`fetchLatestBundle()`. | -| `autoBlockRolledBackBundles` | `boolean` | `false` | Block bundles that caused a rollback. No effect if `readyTimeout` is `0`. | -| `autoDeleteBundles` | `boolean` | `false` | Delete unused bundles after `ready()`. | -| `autoUpdateStrategy` | `'none' \| 'background'` | `'none'` | `background`: sync automatically at start, on focus and on resume (at most every 15 minutes). | -| `dataDirectory` | `string` | `join(app.getPath('userData'), 'live-update')` | Where bundles and state are stored. | -| `defaultChannel` | `string` | – | Default update channel. | -| `defaultBundlePath` | `string` | – | Directory of the packaged web assets. Required for `serve()`. | -| `httpTimeout` | `number` | `60000` | HTTP timeout in milliseconds. | -| `logger` | `LiveUpdateLogger` | `console` | Custom logger. | -| `publicKey` | `string` | – | PEM-encoded RSA public key for signature verification. | -| `readyTimeout` | `number` | `0` | Rollback protection timeout in milliseconds. `0` disables it. Recommended: `10000`. | -| `serverDomain` | `string` | `'api.cloud.capawesome.io'` | API domain, without scheme or path. Localhost domains use plain HTTP for development. | -| `versionCode` | `string` | `app.getVersion()` | Version code reported to the update server. | -| `versionName` | `string` | `app.getVersion()` | Version name reported to the update server. | +| Option | Type | Default | Description | +| ---------------------------- | ------------------------ | --------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `appId` | `string` | – | Capawesome Cloud app ID. Required for `sync()`/`fetchLatestBundle()`. | +| `autoBlockRolledBackBundles` | `boolean` | `false` | Block bundles that caused a rollback. No effect if `readyTimeout` is `0`. | +| `autoDeleteBundles` | `boolean` | `false` | Delete unused bundles after `ready()`. | +| `autoUpdateStrategy` | `'none' \| 'background'` | `'none'` | `background`: sync automatically at start, on focus and on resume (at most every 15 minutes). | +| `dataDirectory` | `string` | `join(app.getPath('userData'), 'capawesome-live-update')` | Where bundles and state are stored. | +| `defaultChannel` | `string` | – | Default update channel. | +| `defaultBundlePath` | `string` | – | Directory of the packaged web assets. Required for `serve()`. | +| `httpTimeout` | `number` | `60000` | HTTP timeout in milliseconds. | +| `logger` | `LiveUpdateLogger` | `console` | Custom logger. | +| `publicKey` | `string` | – | PEM-encoded RSA public key for signature verification. | +| `readyTimeout` | `number` | `0` | Rollback protection timeout in milliseconds. `0` disables it. Recommended: `10000`. | +| `serverDomain` | `string` | `'api.cloud.capawesome.io'` | API domain, without scheme or path. Localhost domains use plain HTTP for development. | +| `versionCode` | `string` | `app.getVersion()` | Version code reported to the update server. | +| `versionName` | `string` | `app.getVersion()` | Version name reported to the update server. | #### Methods diff --git a/e2e/helpers.mjs b/e2e/helpers.mjs index d77f7cc..01afd8f 100644 --- a/e2e/helpers.mjs +++ b/e2e/helpers.mjs @@ -93,7 +93,7 @@ export async function readState(userDataDirectory) { try { return JSON.parse( await readFile( - join(userDataDirectory, 'live-update', 'state.json'), + join(userDataDirectory, 'capawesome-live-update', 'state.json'), 'utf8', ), ); diff --git a/src/main/definitions.ts b/src/main/definitions.ts index cf78087..64c0d30 100644 --- a/src/main/definitions.ts +++ b/src/main/definitions.ts @@ -75,7 +75,7 @@ export interface LiveUpdateConfig { * The directory where the SDK stores its bundles and state. * * @since 0.1.0 - * @default join(app.getPath('userData'), 'live-update') + * @default join(app.getPath('userData'), 'capawesome-live-update') */ dataDirectory?: string; /** @@ -177,7 +177,7 @@ export interface ServeOptions { * updates. * * @since 0.1.0 - * @default 'live-update' + * @default 'capawesome-live-update' */ scheme?: string; } @@ -217,7 +217,7 @@ export interface LiveUpdate extends LiveUpdateApi { * Only available after `serve()` has been called. * * @since 0.1.0 - * @example 'live-update://bundle/' + * @example 'capawesome-live-update://bundle/' */ getServeUrl(): string; /** diff --git a/src/main/live-update.ts b/src/main/live-update.ts index 6e12729..76c0a34 100644 --- a/src/main/live-update.ts +++ b/src/main/live-update.ts @@ -54,7 +54,7 @@ import { import type { LiveUpdate, LiveUpdateConfig, ServeOptions } from './definitions'; import { resolveServedFile } from './serving'; -const DEFAULT_SCHEME = 'live-update'; +const DEFAULT_SCHEME = 'capawesome-live-update'; const SERVE_HOST = 'bundle'; const AUTO_UPDATE_MIN_INTERVAL = 15 * 60 * 1000; const ELECTRON_PLATFORM = '2'; @@ -87,7 +87,8 @@ class LiveUpdateImpl implements LiveUpdate { autoBlockRolledBackBundles: config.autoBlockRolledBackBundles, autoDeleteBundles: config.autoDeleteBundles, dataDirectory: - config.dataDirectory ?? join(app.getPath('userData'), 'live-update'), + config.dataDirectory ?? + join(app.getPath('userData'), 'capawesome-live-update'), defaultBundlePath: config.defaultBundlePath, defaultChannel: config.defaultChannel, httpTimeout: config.httpTimeout, From 505c43733a9a027ef12f8e73eb200b5604059f79 Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Tue, 14 Jul 2026 13:57:12 +0200 Subject: [PATCH 4/9] fix(e2e): download the Electron binary when missing Co-Authored-By: Claude Fable 5 --- e2e/run.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/e2e/run.mjs b/e2e/run.mjs index 056634e..299c30e 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -6,6 +6,7 @@ * Prerequisites: `npm run build` and `npm run build --workspace example`. */ import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { artifactsDirectory, repositoryRoot } from './helpers.mjs'; @@ -22,6 +23,18 @@ function run(command, args, env = {}) { } } +// The electron npm package no longer downloads its binary via an install +// script, so a fresh `npm ci` leaves node_modules/electron/dist missing. +// Fetch it explicitly before the drill copies the distribution. +const electronPackageDirectory = join( + repositoryRoot, + 'node_modules', + 'electron', +); +if (!existsSync(join(electronPackageDirectory, 'dist'))) { + run(process.execPath, [join(electronPackageDirectory, 'install.js')]); +} + run(process.execPath, [join(repositoryRoot, 'e2e', 'drill.mjs')]); const binaryPath = ( From 3cb5ab35d1843dc0a8affb47d1aea985e24c61c9 Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Tue, 14 Jul 2026 14:17:42 +0200 Subject: [PATCH 5/9] fix(e2e): launch Electron with --no-sandbox in tests Co-Authored-By: Claude Fable 5 --- e2e/app.spec.ts | 7 +++++-- e2e/drill.mjs | 13 +++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index d33082d..6198d34 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -38,7 +38,10 @@ async function launchExample( const userDataDirectory = options.userDataDirectory ?? (await createUserDataDirectory()); const app = await electron.launch({ - args: [exampleDirectory as string], + // `--no-sandbox` lets Electron launch on Linux CI, where the copied + // distribution has no setuid sandbox helper and unprivileged user + // namespaces are restricted. No-op on macOS and Windows. + args: ['--no-sandbox', exampleDirectory as string], env: { ...(process.env as Record), EXAMPLE_PUBLIC_KEY: (await getExamplePublicKey()) as string, @@ -132,7 +135,7 @@ test('the packaged app boots the built-in bundle from the asar archive', async ( const userDataDirectory = await createUserDataDirectory(); const app = await electron.launch({ executablePath: process.env.E2E_PACKAGED_BINARY as string, - args: [], + args: ['--no-sandbox'], env: { ...(process.env as Record), EXAMPLE_SERVER_DOMAIN: mockServer.serverDomain, diff --git a/e2e/drill.mjs b/e2e/drill.mjs index 405c99b..6fc87c6 100644 --- a/e2e/drill.mjs +++ b/e2e/drill.mjs @@ -139,7 +139,13 @@ async function packageApp() { } function launchApp(binaryPath, userDataDirectory, serverDomain, publicKey) { - const child = spawn(binaryPath, [], { + // `--no-sandbox` is required on Linux CI: the Electron distribution is + // copied into place by the drill, so its `chrome-sandbox` helper is not + // owned by root with the setuid bit, and the runner (Ubuntu) also + // restricts unprivileged user namespaces. Without this flag Electron + // aborts on launch and never writes any engine state. Harmless on macOS + // and Windows, which do not use the SUID sandbox. + const child = spawn(binaryPath, ['--no-sandbox'], { env: { ...process.env, EXAMPLE_AUTO_UPDATE: 'background', @@ -148,7 +154,10 @@ function launchApp(binaryPath, userDataDirectory, serverDomain, publicKey) { EXAMPLE_SERVER_DOMAIN: serverDomain, EXAMPLE_USER_DATA: userDataDirectory, }, - stdio: 'ignore', + // Keep stderr attached so a launch failure (e.g. the Chromium + // sandbox aborting on CI) surfaces in the logs instead of leaving + // waitForState to time out with no explanation. + stdio: ['ignore', 'ignore', 'inherit'], }); return child; } From d222bf6a398826be05a8677ef9bd41603f21bfd7 Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Tue, 14 Jul 2026 14:30:34 +0200 Subject: [PATCH 6/9] fix: make rollback reloads win navigation races and harden e2e timing Co-Authored-By: Claude Fable 5 --- e2e/app.spec.ts | 5 ++++- e2e/drill.mjs | 8 +++++++- example/src/main.ts | 17 ++++++++++++----- src/main/live-update.ts | 36 ++++++++++++++++++++++++++---------- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 6198d34..edee595 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -45,7 +45,10 @@ async function launchExample( env: { ...(process.env as Record), EXAMPLE_PUBLIC_KEY: (await getExamplePublicKey()) as string, - EXAMPLE_READY_TIMEOUT: '10000', + // Generous watchdog ceiling: a spurious rollback (and bundle + // block) during a slow CI boot would break these specs. No spec + // relies on the watchdog timer firing. + EXAMPLE_READY_TIMEOUT: '60000', EXAMPLE_SERVER_DOMAIN: mockServer.serverDomain, EXAMPLE_SERVING_MODE: options.servingMode ?? 'serve', EXAMPLE_USER_DATA: userDataDirectory, diff --git a/e2e/drill.mjs b/e2e/drill.mjs index 6fc87c6..e727486 100644 --- a/e2e/drill.mjs +++ b/e2e/drill.mjs @@ -150,7 +150,13 @@ function launchApp(binaryPath, userDataDirectory, serverDomain, publicKey) { ...process.env, EXAMPLE_AUTO_UPDATE: 'background', EXAMPLE_PUBLIC_KEY: publicKey, - EXAMPLE_READY_TIMEOUT: '10000', + // Generous watchdog ceiling: a cold Electron boot on a slow CI + // runner can take longer than 10 s to call ready(). If the + // watchdog fires during a legitimate boot it rolls back AND + // blocks the bundle (autoBlockRolledBackBundles), after which + // the drill's expected states are unreachable. The drill tests + // rollback via kills, never by waiting for this timer. + EXAMPLE_READY_TIMEOUT: '60000', EXAMPLE_SERVER_DOMAIN: serverDomain, EXAMPLE_USER_DATA: userDataDirectory, }, diff --git a/example/src/main.ts b/example/src/main.ts index 099a85b..36709e4 100644 --- a/example/src/main.ts +++ b/example/src/main.ts @@ -28,11 +28,18 @@ app.whenReady().then(async () => { webPreferences: { preload: join(__dirname, 'preload.js') }, }); liveUpdate.attach(window); - if (simpleMode) { - const bundlePath = await liveUpdate.getCurrentBundlePath(); - await window.loadFile(join(bundlePath ?? '', 'index.html')); - } else { - await window.loadURL(liveUpdate.getServeUrl()); + try { + if (simpleMode) { + const bundlePath = await liveUpdate.getCurrentBundlePath(); + await window.loadFile(join(bundlePath ?? '', 'index.html')); + } else { + await window.loadURL(liveUpdate.getServeUrl()); + } + } catch (error) { + // The initial load is aborted (ERR_ABORTED) when an SDK-initiated + // reload (e.g. a rollback) navigates the window while the load is + // still pending. The interrupting navigation supersedes this one. + console.warn('[example] Initial load was superseded:', error); } }); diff --git a/src/main/live-update.ts b/src/main/live-update.ts index 76c0a34..973e375 100644 --- a/src/main/live-update.ts +++ b/src/main/live-update.ts @@ -377,17 +377,33 @@ class LiveUpdateImpl implements LiveUpdate { if (window.isDestroyed()) { continue; } - if (this.scheme !== null) { - await window.webContents.loadURL(this.getServeUrl()); - } else { - const bundlePath = await this.getCurrentBundlePath(); - if (!bundlePath) { - throw new LiveUpdateError( - ErrorCode.Unknown, - 'Cannot reload: no bundle is active and no defaultBundlePath is configured.', - ); + // Cancel any in-flight navigation (e.g. a still-pending initial + // load) so it cannot commit afterwards and abort this reload. + window.webContents.stop(); + try { + if (this.scheme !== null) { + await window.webContents.loadURL(this.getServeUrl()); + } else { + const bundlePath = await this.getCurrentBundlePath(); + if (!bundlePath) { + throw new LiveUpdateError( + ErrorCode.Unknown, + 'Cannot reload: no bundle is active and no defaultBundlePath is configured.', + ); + } + await window.webContents.loadFile(join(bundlePath, 'index.html')); } - await window.webContents.loadFile(join(bundlePath, 'index.html')); + } catch (error) { + // ERR_ABORTED means another navigation superseded this reload + // (e.g. the host app navigated the window concurrently). The + // navigation that won decides what the window shows; failing + // the whole reload for it would be wrong. + if ((error as { code?: string }).code !== 'ERR_ABORTED') { + throw error; + } + this.logger.warn( + 'Reload was superseded by another navigation in the same window.', + ); } } } From 2ea1071ae44603b90d286f6165c64091a1fe825e Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Tue, 14 Jul 2026 15:05:02 +0200 Subject: [PATCH 7/9] fix: verify reused delta files and reject empty integrity headers Co-Authored-By: Claude Fable 5 --- src/engine/engine.ts | 26 +++++++++++++--- src/engine/verify.ts | 13 +++++--- tests/download.test.ts | 16 ++++++++++ tests/engine.test.ts | 70 ++++++++++++++++++++++++++++++++++++++++++ tests/verify.test.ts | 35 +++++++++++++++++++++ 5 files changed, 151 insertions(+), 9 deletions(-) diff --git a/src/engine/engine.ts b/src/engine/engine.ts index 5e39cae..2c0c325 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -43,6 +43,7 @@ import { import { StateFile } from './state-file'; import { calculateContentChecksums, + calculateFileChecksum, parsePublicKey, verifyContentChecksums, verifyDownloadedFile, @@ -1151,8 +1152,16 @@ export class LiveUpdateEngine { /** * Copy the given files from the current bundle into the assembly - * directory. Returns the items that could not be copied (missing on - * disk), so they can be downloaded instead. + * directory. Returns the items that could not be reused (missing on + * disk, copy failure, or content that no longer matches the manifest + * checksum), so they can be downloaded instead. + * + * The copied file is re-hashed and compared against the manifest + * checksum: the diff key comes from the install-time metadata, so a + * file tampered on disk after install would otherwise be copied and + * then re-blessed by the fresh install-time checksum pass, defeating + * the activation-time integrity guarantee. On any mismatch the file + * is downloaded from the server instead. */ private async copyManifestFiles( items: ManifestItem[], @@ -1177,6 +1186,10 @@ export class LiveUpdateEngine { join(sourceDirectory, sourceRelativePath), destinationPath, ); + const checksum = await calculateFileChecksum(destinationPath); + if (checksum !== item.checksum.toLowerCase()) { + failures.push(item); + } } catch { failures.push(item); } @@ -1251,8 +1264,13 @@ export class LiveUpdateEngine { signature: result.signature, }); // Account for the full file size even if no Content-Length was - // sent, so the aggregate progress reaches the total. - downloadedPerFile[index] = item.sizeInBytes; + // sent, so the aggregate progress reaches the total. Never drop + // below the bytes already streamed (e.g. when `sizeInBytes` is + // missing/0) so the aggregate progress can only ever increase. + downloadedPerFile[index] = Math.max( + downloadedPerFile[index] ?? 0, + item.sizeInBytes, + ); emitProgress(); } }; diff --git a/src/engine/verify.ts b/src/engine/verify.ts index 5a156aa..9d87586 100644 --- a/src/engine/verify.ts +++ b/src/engine/verify.ts @@ -101,20 +101,23 @@ export interface VerifyDownloadedFileOptions { * * Verification precedence (mirrors the Capacitor plugin): * 1. If a `publicKey` is configured, a signature is REQUIRED and the - * checksum is ignored. + * checksum is ignored. A present-but-empty signature is NOT treated + * as missing: it flows into the verification and fails there. Only + * an absent (`undefined`) signature is reported as missing. * 2. Otherwise, if a `checksum` header and a `manifestChecksum` are * both present and disagree, verification fails: a header * contradicting the trusted manifest is suspicious. * 3. Otherwise, if either a `checksum` header or a `manifestChecksum` - * is available, it is verified (the header taking precedence). - * 4. Otherwise, the file is accepted without verification. + * is available, it is verified (the header taking precedence). A + * present-but-empty value is a value: it is compared and rejects. + * 4. Otherwise (both absent), the file is accepted without verification. */ export async function verifyDownloadedFile( options: VerifyDownloadedFileOptions, ): Promise { if (options.publicKey) { const publicKey = parsePublicKey(options.publicKey); - if (!options.signature) { + if (options.signature === undefined) { throw new LiveUpdateError( ErrorCode.SignatureMissing, 'Bundle does not contain a signature.', @@ -133,7 +136,7 @@ export async function verifyDownloadedFile( throw new LiveUpdateError(ErrorCode.ChecksumMismatch, 'Checksum mismatch.'); } const expectedChecksum = headerChecksum ?? manifestChecksum; - if (expectedChecksum) { + if (expectedChecksum !== undefined) { const actualChecksum = await calculateFileChecksum(options.filePath); if (actualChecksum !== expectedChecksum) { throw new LiveUpdateError( diff --git a/tests/download.test.ts b/tests/download.test.ts index 1ab637a..39ce16e 100644 --- a/tests/download.test.ts +++ b/tests/download.test.ts @@ -101,6 +101,22 @@ describe('downloadFile', () => { expect(result.signature).toBe('ZmFrZQ=='); }); + it('preserves present-but-empty verification headers as empty strings', async () => { + server.route('/bundle.zip', { + body: 'data', + headers: { 'X-Checksum': '', 'X-Signature': '' }, + }); + const result = await downloadFile({ + destinationPath: join(workingDirectory, 'bundle.zip'), + httpTimeout: 5000, + url: `${server.origin}/bundle.zip`, + }); + // An empty header must not be collapsed to undefined: a present-but- + // empty value flows into verification and rejects there. + expect(result.checksum).toBe(''); + expect(result.signature).toBe(''); + }); + it('fails with DOWNLOAD_FAILED on a non-2xx response', async () => { server.route('/bundle.zip', { body: 'gone', status: 404 }); await expect( diff --git a/tests/engine.test.ts b/tests/engine.test.ts index 24db158..c5872ab 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -855,6 +855,76 @@ describe('LiveUpdateEngine', () => { expect(requestedHrefs()).not.toContain('assets/app.js'); }); + it('downloads a reused file whose on-disk copy no longer matches its checksum', async () => { + const engine = createEngine(); + await engine.initialize(); + const baseZip = await buildZip([ + { path: 'index.html', content: 'base' }, + { path: 'assets/app.js', content: 'shared-code' }, + ]); + server.route('/download/base.zip', { body: baseZip }); + await engine.downloadBundle({ + bundleId: 'base', + url: `${server.origin}/download/base.zip`, + }); + await engine.setNextBundle({ bundleId: 'base' }); + await engine.applyNextBundle(); + // Tamper the installed file on disk AFTER install. The diff still + // uses the install-time checksum, so the delta would try to copy + // it; the copy re-verification must catch the mismatch and download + // the file from the server instead. + await writeFile( + join(dataDirectory, 'bundles', 'base', 'assets', 'app.js'), + 'tampered-on-disk', + ); + serveManifestBundle('next', [ + { href: 'index.html', content: 'next' }, + { href: 'assets/app.js', content: 'shared-code' }, + ]); + await engine.downloadBundle({ + artifactType: 'manifest', + bundleId: 'next', + url: manifestUrl('next'), + }); + // The assembled bundle contains the server version, not the + // tampered on-disk copy. + expect(await readBundleFile('next', 'assets/app.js')).toBe('shared-code'); + expect(requestedHrefs()).toContain('assets/app.js'); + }); + + it('never regresses aggregate progress when sizeInBytes is missing or zero', async () => { + const engine = createEngine(); + await engine.initialize(); + const content = 'zero-size'; + const manifest = [ + { checksum: sha256Hex(content), href: 'index.html', sizeInBytes: 0 }, + ]; + server.route('/manifest/zero', request => { + const href = hrefOf(request); + if (href === MANIFEST_FILE_NAME) { + return { body: JSON.stringify(manifest) }; + } + return { body: content, headers: { 'X-Checksum': sha256Hex(content) } }; + }); + const progressEvents: DownloadBundleProgressEvent[] = []; + engine.on('downloadBundleProgress', event => progressEvents.push(event)); + await engine.downloadBundle({ + artifactType: 'manifest', + bundleId: 'zero', + url: manifestUrl('zero'), + }); + // The aggregate downloaded byte count must be monotonic even though + // the completed file reports a sizeInBytes of 0. + let previous = 0; + for (const event of progressEvents) { + expect(event.downloadedBytes).toBeGreaterThanOrEqual(previous); + previous = event.downloadedBytes; + } + const lastEvent = progressEvents[progressEvents.length - 1]; + expect(lastEvent?.progress).toBe(1); + expect((await engine.getDownloadedBundles()).bundleIds).toEqual(['zero']); + }); + it('emits aggregated download progress across files', async () => { const engine = createEngine(); await engine.initialize(); diff --git a/tests/verify.test.ts b/tests/verify.test.ts index 345f603..f4a6da2 100644 --- a/tests/verify.test.ts +++ b/tests/verify.test.ts @@ -69,6 +69,27 @@ describe('verification', () => { await expect(verifyDownloadedFile({ filePath })).resolves.toBeUndefined(); }); + it('rejects a present-but-empty checksum header', async () => { + await expect( + verifyDownloadedFile({ filePath, checksum: '' }), + ).rejects.toMatchObject({ + code: ErrorCode.ChecksumMismatch, + message: 'Checksum mismatch.', + }); + }); + + it('rejects a present-but-empty checksum header even with a manifest checksum', async () => { + const manifestChecksum = createHash('sha256') + .update(fileContent) + .digest('hex'); + await expect( + verifyDownloadedFile({ filePath, checksum: '', manifestChecksum }), + ).rejects.toMatchObject({ + code: ErrorCode.ChecksumMismatch, + message: 'Checksum mismatch.', + }); + }); + it('falls back to the manifest checksum when no header checksum is present', async () => { const manifestChecksum = createHash('sha256') .update(fileContent) @@ -164,6 +185,20 @@ describe('verification', () => { }); }); + it('rejects a present-but-empty signature (not treated as missing)', async () => { + const { publicKeyPem } = generateRsaKeyPair(); + await expect( + verifyDownloadedFile({ + filePath, + publicKey: publicKeyPem, + signature: '', + }), + ).rejects.toMatchObject({ + code: ErrorCode.SignatureVerificationFailed, + message: 'Signature verification failed.', + }); + }); + it('ignores the checksum when a public key is configured', async () => { const { privateKeyPem, publicKeyPem } = generateRsaKeyPair(); const signature = signBytes(fileContent, privateKeyPem); From b14925d82681d191ca95f3034bf6fdcb47251e5c Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Tue, 14 Jul 2026 15:38:36 +0200 Subject: [PATCH 8/9] fix: retry file operations on Windows lock errors and unpoison the state write queue Co-Authored-By: Claude Fable 5 --- src/engine/bundle-store.ts | 26 +++++++-- src/engine/fs-retry.ts | 55 +++++++++++++++++++ src/engine/state-file.ts | 18 +++++-- tests/state-file.test.ts | 107 +++++++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 src/engine/fs-retry.ts create mode 100644 tests/state-file.test.ts diff --git a/src/engine/bundle-store.ts b/src/engine/bundle-store.ts index 0de9161..bb34d65 100644 --- a/src/engine/bundle-store.ts +++ b/src/engine/bundle-store.ts @@ -1,8 +1,21 @@ import { randomUUID } from 'node:crypto'; -import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises'; +import { mkdir, readdir, rm, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { ErrorCode, LiveUpdateError } from './errors'; +import { renameWithRetry } from './fs-retry'; + +/** + * Options for the recursive `rm` calls: on Windows, deleting a + * directory fails with EPERM/EBUSY while another process (e.g. an + * antivirus scanner) holds a handle on a file inside it. `rm` retries + * these errors natively with a linear backoff. + */ +const RM_RETRY_OPTIONS = { + force: true, + maxRetries: 5, + recursive: true, +} as const; /** * The bundle identifier value that is reserved for the built-in bundle. @@ -61,7 +74,7 @@ export class BundleStore { public async initialize(): Promise { await mkdir(this.bundlesDirectory, { recursive: true }); // Leftover staging data from a previous crashed run is garbage. - await rm(this.stagingDirectory, { recursive: true, force: true }); + await rm(this.stagingDirectory, RM_RETRY_OPTIONS); await mkdir(this.stagingDirectory, { recursive: true }); } @@ -112,17 +125,20 @@ export class BundleStore { 'bundle already exists.', ); } - await rename(sourceDirectory, this.getPath(bundleId)); + // Retried: on Windows the rename fails with EPERM/EACCES while an + // antivirus scanner holds a freshly written file in the staging + // directory. + await renameWithRetry(sourceDirectory, this.getPath(bundleId)); } public async delete(bundleId: string): Promise { if (!(await this.has(bundleId))) { throw new LiveUpdateError(ErrorCode.BundleNotFound, 'bundle not found.'); } - await rm(this.getPath(bundleId), { recursive: true, force: true }); + await rm(this.getPath(bundleId), RM_RETRY_OPTIONS); } public async cleanUpStaging(directory: string): Promise { - await rm(directory, { recursive: true, force: true }); + await rm(directory, RM_RETRY_OPTIONS); } } diff --git a/src/engine/fs-retry.ts b/src/engine/fs-retry.ts new file mode 100644 index 0000000..1638409 --- /dev/null +++ b/src/engine/fs-retry.ts @@ -0,0 +1,55 @@ +import { rename } from 'node:fs/promises'; + +const RETRYABLE_ERROR_CODES = new Set(['EACCES', 'EBUSY', 'EPERM']); + +/** + * Delays between retry attempts, roughly one second in total. + * + * Exported for tests so they can exhaust the retries deterministically. + */ +export const RETRY_DELAYS_MS = [20, 40, 80, 160, 300, 400]; + +/** + * Run a file system operation, retrying briefly when it fails because + * another process holds an open handle on the target. + * + * On Windows, replacing, renaming or deleting a file fails with EPERM, + * EACCES or EBUSY while ANY other handle is open on it. Antivirus + * scanners and external readers do this routinely and release the + * handle within milliseconds, so the operation is retried with + * increasing delays (the same remedy graceful-fs applies) before the + * error is rethrown. On POSIX systems these codes indicate persistent + * permission problems, which surface unchanged after the bounded + * retries. + */ +export async function retryOnFileLock( + operation: () => Promise, +): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await operation(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + attempt >= RETRY_DELAYS_MS.length || + code === undefined || + !RETRYABLE_ERROR_CODES.has(code) + ) { + throw error; + } + await new Promise(resolve => + setTimeout(resolve, RETRY_DELAYS_MS[attempt]), + ); + } + } +} + +/** + * `rename` with bounded retries for transient file locks. + */ +export function renameWithRetry( + oldPath: string, + newPath: string, +): Promise { + return retryOnFileLock(() => rename(oldPath, newPath)); +} diff --git a/src/engine/state-file.ts b/src/engine/state-file.ts index f31fa67..f9c8d90 100644 --- a/src/engine/state-file.ts +++ b/src/engine/state-file.ts @@ -1,6 +1,8 @@ -import { open, mkdir, readFile, rename, rm } from 'node:fs/promises'; +import { open, mkdir, readFile, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; +import { renameWithRetry, retryOnFileLock } from './fs-retry'; + /** * Metadata stored for each downloaded bundle. * @@ -199,7 +201,11 @@ export class StateFile { public async update(mutate: (state: PersistedState) => void): Promise { mutate(this.state); const snapshot = JSON.stringify(this.state, null, 2); - this.writeQueue = this.writeQueue.then(() => this.write(snapshot)); + // A failed write rejects THIS update, but must not poison the + // queue: later updates write the then-latest snapshot regardless. + this.writeQueue = this.writeQueue + .catch(() => undefined) + .then(() => this.write(snapshot)); return this.writeQueue; } @@ -213,7 +219,9 @@ export class StateFile { } finally { await fileHandle.close(); } - await rename(temporaryPath, this.filePath); + // Retried: on Windows the rename fails with EPERM while any other + // process (antivirus, an external reader) holds the destination. + await renameWithRetry(temporaryPath, this.filePath); try { // Flush the rename itself. Not supported on all platforms // (e.g. directories cannot be opened on Windows), so best effort. @@ -229,7 +237,9 @@ export class StateFile { } public async delete(): Promise { - await rm(this.filePath, { force: true }); + // Retried for the same reason as the rename in write(): deleting + // an externally held file fails with EPERM/EBUSY on Windows. + await retryOnFileLock(() => rm(this.filePath, { force: true })); this.state = createDefaultState(); } } diff --git a/tests/state-file.test.ts b/tests/state-file.test.ts new file mode 100644 index 0000000..f8bff6b --- /dev/null +++ b/tests/state-file.test.ts @@ -0,0 +1,107 @@ +import type * as fsPromises from 'node:fs/promises'; +import { readFile, rename } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { RETRY_DELAYS_MS } from '../src/engine/fs-retry'; +import { StateFile } from '../src/engine/state-file'; + +import { createTemporaryDirectory, removeDirectory } from './helpers'; + +// Simulates the Windows file-lock failure mode: `rename` onto a +// destination fails with EPERM/EACCES/EBUSY while another process +// (antivirus, an external reader) holds an open handle on it. +const renameFailures = vi.hoisted(() => ({ code: 'EPERM', remaining: 0 })); + +vi.mock('node:fs/promises', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + rename: vi.fn(async (oldPath: string, newPath: string) => { + if (renameFailures.remaining > 0) { + renameFailures.remaining -= 1; + const error = new Error( + `${renameFailures.code}: operation not permitted, rename '${oldPath}' -> '${newPath}'`, + ) as NodeJS.ErrnoException; + error.code = renameFailures.code; + throw error; + } + return actual.rename(oldPath, newPath); + }), + }; +}); + +const MAX_RENAME_ATTEMPTS = RETRY_DELAYS_MS.length + 1; + +async function readPersistedState( + directory: string, +): Promise> { + return JSON.parse( + await readFile(join(directory, 'state.json'), 'utf8'), + ) as Record; +} + +describe('StateFile', () => { + let directory: string; + let stateFile: StateFile; + + beforeEach(async () => { + renameFailures.code = 'EPERM'; + renameFailures.remaining = 0; + vi.mocked(rename).mockClear(); + directory = await createTemporaryDirectory(); + stateFile = new StateFile(directory); + await stateFile.load(); + }); + + afterEach(async () => { + await removeDirectory(directory); + }); + + it('persists updates atomically', async () => { + await stateFile.update(state => { + state.currentBundleId = '1.0.0'; + }); + const persisted = await readPersistedState(directory); + expect(persisted.currentBundleId).toBe('1.0.0'); + }); + + it('retries the rename while the destination is transiently locked', async () => { + renameFailures.remaining = 2; + await stateFile.update(state => { + state.currentBundleId = '2.0.0'; + }); + expect(rename).toHaveBeenCalledTimes(3); + const persisted = await readPersistedState(directory); + expect(persisted.currentBundleId).toBe('2.0.0'); + }); + + it('does not retry non-lock errors', async () => { + renameFailures.code = 'ENOENT'; + renameFailures.remaining = 1; + await expect( + stateFile.update(state => { + state.currentBundleId = '2.0.0'; + }), + ).rejects.toMatchObject({ code: 'ENOENT' }); + expect(rename).toHaveBeenCalledTimes(1); + }); + + it('rethrows a persistent lock and recovers on the next update', async () => { + renameFailures.remaining = MAX_RENAME_ATTEMPTS; + await expect( + stateFile.update(state => { + state.currentBundleId = '2.0.0'; + }), + ).rejects.toMatchObject({ code: 'EPERM' }); + expect(rename).toHaveBeenCalledTimes(MAX_RENAME_ATTEMPTS); + // The failed write must not poison the queue: the next update + // persists the then-latest state, including the earlier mutation. + await stateFile.update(state => { + state.nextBundleId = '3.0.0'; + }); + const persisted = await readPersistedState(directory); + expect(persisted.currentBundleId).toBe('2.0.0'); + expect(persisted.nextBundleId).toBe('3.0.0'); + }); +}); From c4e1d8caf5595d1cda83a3527ff60a8dd8bb13a7 Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Fri, 17 Jul 2026 12:01:11 +0200 Subject: [PATCH 9/9] refactor: remove manifest (delta) update support Co-Authored-By: Claude Fable 5 --- README.md | 3 +- e2e/app.spec.ts | 14 - example/scripts/mock-server.mjs | 108 +------ src/engine/definitions.ts | 4 +- src/engine/download.ts | 12 - src/engine/engine.ts | 334 ++------------------- src/engine/manifest.ts | 103 ------- src/engine/verify.ts | 27 +- src/main/live-update.ts | 1 - tests/engine.test.ts | 513 +------------------------------- tests/manifest.test.ts | 82 ----- tests/verify.test.ts | 65 ---- 12 files changed, 40 insertions(+), 1226 deletions(-) delete mode 100644 src/engine/manifest.ts delete mode 100644 tests/manifest.test.ts diff --git a/README.md b/README.md index 2205185..0e9ca31 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ This SDK speaks the same protocol and the same vocabulary as the [`@capawesome/c - 🔒 **Signature verification**: RSA signature verification of every downloaded bundle (`publicKey`), plus checksum re-verification of the installed bundle at activation time — tampering after download is detected too. - 🌐 **Stable origin serving**: A privileged custom scheme serves the active bundle under a constant origin, so `localStorage`, IndexedDB and service workers survive bundle switches. A simple path-based mode is available as an alternative. - 🚦 **Channels**: Deliver different bundles to different user groups (production, beta, staged rollouts), and discover them at runtime with `fetchChannels()`. -- 🧩 **Delta updates**: The `manifest` artifact type downloads only the files that changed and reuses the rest from the current bundle — smaller, faster updates. - 📂 **Multiple bundles**: Download, manage and switch between bundles programmatically. - 🔁 **Background updates**: Optional automatic sync at app start, on focus and on resume. - 🔐 **Secure by default**: HTTPS-only downloads (localhost exempt for development), zip-slip protection, atomic bundle installation. @@ -218,7 +217,7 @@ The API mirrors [`@capawesome/capacitor-live-update`](https://capawesome.io/plug | Serving | Capacitor WebView | `serve()` custom scheme or `getCurrentBundlePath()` | | `setConfig()` | Available | Not available | | `fetchChannels()` | Available | **Available** | -| `manifest` artifact type | Available (delta updates) | **Available** (delta updates) | +| `manifest` artifact type | Available (delta updates) | Not yet available (`zip` only) | ## API diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index edee595..35b0075 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -108,20 +108,6 @@ test('rejects a tampered bundle (signature verification)', async () => { await app.close(); }); -test('syncs a manifest (delta) bundle over the built-in bundle', async () => { - await mockServer.setLatest('4.0.0-manifest'); - const { app, page } = await launchExample(); - await page.getByTestId('sync').click(); - await expect(page.getByTestId('next-bundle')).toHaveText('4.0.0-manifest'); - await page.getByTestId('reload').click(); - await expect(page.getByTestId('current-bundle')).toHaveText('4.0.0-manifest'); - await expect(page.getByTestId('marker')).toHaveText('2.0.0'); - await expect(page.getByTestId('ready-state')).toContainText( - 'rollback: false', - ); - await app.close(); -}); - test('simple mode: syncs and reloads via getCurrentBundlePath()', async () => { await mockServer.setLatest('2.0.0'); const { app, page } = await launchExample({ servingMode: 'simple' }); diff --git a/example/scripts/mock-server.mjs b/example/scripts/mock-server.mjs index a6165fe..78420d5 100644 --- a/example/scripts/mock-server.mjs +++ b/example/scripts/mock-server.mjs @@ -7,96 +7,31 @@ * CHANNELS_DISABLED is set) * - GET /download/{file} -> zip bytes with X-Checksum * and X-Signature headers - * - GET /manifest/{bundleId}?href= -> the manifest JSON (delta) - * or a single file with its - * X-Checksum / X-Signature headers * - POST /__control -> {"latest": "" | null} * switches the offered bundle * * The offered bundle can also be set via the LATEST env variable. */ -import { createHash, createSign } from 'node:crypto'; -import { existsSync } from 'node:fs'; -import { readFile, readdir } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { createServer } from 'node:http'; -import { dirname, join, relative } from 'node:path'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const exampleDirectory = dirname(dirname(fileURLToPath(import.meta.url))); const bundlesDirectory = join(exampleDirectory, 'dist', 'bundles'); -const keysDirectory = join(exampleDirectory, 'dist', 'keys'); const port = Number(process.env.MOCK_SERVER_PORT ?? 4100); -const MANIFEST_FILE_NAME = 'capawesome-live-update-manifest.json'; - const bundles = JSON.parse( await readFile(join(bundlesDirectory, 'index.json'), 'utf8'), ); let latestBundleId = process.env.LATEST ?? null; -const privateKeyPath = join(keysDirectory, 'private.pem'); -const privateKeyPem = existsSync(privateKeyPath) - ? await readFile(privateKeyPath, 'utf8') - : null; - -// Manifest (delta) bundles are served directly from a source directory -// of web assets; the manifest itself is generated on the fly. -const manifestBundles = { - '4.0.0-manifest': join(exampleDirectory, 'dist', 'bundle-2.0.0'), -}; - const channels = [ { id: 'a1b2c3d4-0000-0000-0000-000000000001', name: 'production' }, { id: 'a1b2c3d4-0000-0000-0000-000000000002', name: 'beta' }, { id: 'a1b2c3d4-0000-0000-0000-000000000003', name: 'canary' }, ]; -function checksum(bytes) { - return createHash('sha256').update(bytes).digest('hex'); -} - -function sign(bytes) { - if (!privateKeyPem) { - return null; - } - const signer = createSign('RSA-SHA256'); - signer.update(bytes); - return signer.sign(privateKeyPem).toString('base64'); -} - -async function listFiles(directory) { - const files = []; - const walk = async current => { - for (const entry of await readdir(current, { withFileTypes: true })) { - const entryPath = join(current, entry.name); - if (entry.isDirectory()) { - await walk(entryPath); - } else if (entry.isFile()) { - files.push({ - absolutePath: entryPath, - href: relative(directory, entryPath).split('\\').join('/'), - }); - } - } - }; - await walk(directory); - return files; -} - -async function buildManifest(directory) { - const files = await listFiles(directory); - return Promise.all( - files.map(async file => { - const bytes = await readFile(file.absolutePath); - return { - checksum: checksum(bytes), - href: file.href, - sizeInBytes: bytes.length, - }; - }), - ); -} - function sendJson(response, status, payload) { response.statusCode = status; response.setHeader('Content-Type', 'application/json'); @@ -142,14 +77,6 @@ const server = createServer(async (request, response) => { request.method === 'GET' && /^\/v1\/apps\/[^/]+\/bundles\/latest$/.test(url.pathname) ) { - if (latestBundleId && manifestBundles[latestBundleId]) { - sendJson(response, 200, { - artifactType: 'manifest', - bundleId: latestBundleId, - url: `http://localhost:${port}/manifest/${latestBundleId}`, - }); - return; - } const bundle = latestBundleId === null ? undefined : bundles[latestBundleId]; if (!bundle) { @@ -163,37 +90,6 @@ const server = createServer(async (request, response) => { }); return; } - if (request.method === 'GET' && url.pathname.startsWith('/manifest/')) { - const bundleId = decodeURIComponent( - url.pathname.slice('/manifest/'.length), - ); - const sourceDirectory = manifestBundles[bundleId]; - if (!sourceDirectory || !existsSync(sourceDirectory)) { - response.statusCode = 404; - response.end('Not found'); - return; - } - const href = url.searchParams.get('href'); - if (href === MANIFEST_FILE_NAME) { - sendJson(response, 200, await buildManifest(sourceDirectory)); - return; - } - const files = await listFiles(sourceDirectory); - const file = files.find(entry => entry.href === href); - if (!file) { - response.statusCode = 404; - response.end('Not found'); - return; - } - const bytes = await readFile(file.absolutePath); - response.setHeader('X-Checksum', checksum(bytes)); - const signature = sign(bytes); - if (signature) { - response.setHeader('X-Signature', signature); - } - response.end(bytes); - return; - } if (request.method === 'GET' && url.pathname.startsWith('/download/')) { const file = url.pathname.slice('/download/'.length); const entry = Object.values(bundles).find(bundle => bundle.file === file); diff --git a/src/engine/definitions.ts b/src/engine/definitions.ts index 12f26da..ba51287 100644 --- a/src/engine/definitions.ts +++ b/src/engine/definitions.ts @@ -45,8 +45,8 @@ export interface DownloadBundleOptions { /** * The artifact type of the bundle. * - * Use `manifest` for delta updates: only files that changed compared - * to the current bundle are downloaded, the rest are copied locally. + * **Attention**: The `manifest` artifact type is not yet supported + * by this SDK. * * @since 0.1.0 * @default 'zip' diff --git a/src/engine/download.ts b/src/engine/download.ts index 0c34a46..5ef937a 100644 --- a/src/engine/download.ts +++ b/src/engine/download.ts @@ -45,18 +45,6 @@ export interface DownloadFileOptions { url: string; } -/** - * Set the `href` query parameter on a (pre-signed) bundle URL, - * preserving any other query parameters. Used to request individual - * files of a `manifest` (delta) bundle. `set` (rather than `append`) - * guarantees exactly one `href` even if the base URL already has one. - */ -export function withHrefQueryParameter(baseUrl: string, href: string): string { - const url = assertSecureUrl(baseUrl); - url.searchParams.set('href', href); - return url.toString(); -} - export interface DownloadFileResult { /** * Value of the `X-Checksum` response header, if present. diff --git a/src/engine/engine.ts b/src/engine/engine.ts index 2c0c325..7a67fad 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -1,7 +1,6 @@ import { randomUUID } from 'node:crypto'; import { EventEmitter } from 'node:events'; -import { copyFile, mkdir, readFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { CloudApiClient } from './api-client'; import { BundleStore, assertValidBundleId } from './bundle-store'; @@ -32,18 +31,11 @@ import type { SyncOptions, SyncResult, } from './definitions'; -import { downloadFile, withHrefQueryParameter } from './download'; +import { downloadFile } from './download'; import { ErrorCode, LiveUpdateError, unknownError } from './errors'; -import { - MANIFEST_FILE_NAME, - parseManifest, - resolveManifestFilePath, - type ManifestItem, -} from './manifest'; import { StateFile } from './state-file'; import { calculateContentChecksums, - calculateFileChecksum, parsePublicKey, verifyContentChecksums, verifyDownloadedFile, @@ -120,17 +112,6 @@ export interface LiveUpdateEngineConfig { * @since 0.1.0 */ dataDirectory: string; - /** - * The absolute path to the directory containing the default bundle - * (the web assets packaged with the app). - * - * Used by `manifest` (delta) updates to reuse unchanged files of the - * packaged default bundle when it is the current bundle. If not set, - * a delta update on top of the default bundle downloads all files. - * - * @since 0.1.0 - */ - defaultBundlePath?: string; /** * The default channel of the app. * @@ -296,12 +277,6 @@ const DEFAULT_SERVER_DOMAIN = 'api.cloud.capawesome.io'; const MAX_BLOCKED_BUNDLES = 100; const DEFAULT_FETCH_CHANNELS_LIMIT = 50; const DEFAULT_FETCH_CHANNELS_OFFSET = 0; -/** - * The maximum number of files downloaded in parallel for a `manifest` - * (delta) bundle. Mirrors OkHttp's default per-host limit used by the - * mobile plugins. - */ -const MANIFEST_DOWNLOAD_CONCURRENCY = 5; const defaultLogger: LiveUpdateLogger = { debug: message => console.debug(`[LiveUpdate] ${message}`), @@ -328,7 +303,6 @@ export class LiveUpdateEngine { private readonly appId: string | null; private readonly autoBlockRolledBackBundles: boolean; private readonly autoDeleteBundles: boolean; - private readonly defaultBundlePath: string | null; private readonly defaultChannel: string | null; private readonly emitter = new EventEmitter(); private readonly httpTimeout: number; @@ -354,7 +328,6 @@ export class LiveUpdateEngine { this.autoBlockRolledBackBundles = config.autoBlockRolledBackBundles ?? false; this.autoDeleteBundles = config.autoDeleteBundles ?? false; - this.defaultBundlePath = config.defaultBundlePath ?? null; this.defaultChannel = config.defaultChannel ?? null; this.httpTimeout = config.httpTimeout ?? DEFAULT_HTTP_TIMEOUT; this.logger = config.logger ?? defaultLogger; @@ -566,18 +539,17 @@ export class LiveUpdateEngine { } if (!(await this.store.has(bundleId))) { if (latest.artifactType === 'manifest') { - await this.downloadBundleOfTypeManifest({ - bundleId, - url: latest.url, - }); - } else { - await this.downloadBundleInternal({ - bundleId, - checksum: latest.checksum, - signature: latest.signature, - url: latest.url, - }); + throw new LiveUpdateError( + ErrorCode.ArtifactTypeNotSupported, + 'The manifest artifact type is not yet supported by this SDK.', + ); } + await this.downloadBundleInternal({ + bundleId, + checksum: latest.checksum, + signature: latest.signature, + url: latest.url, + }); } await this.setNextBundleInternal(bundleId); return { nextBundleId: bundleId }; @@ -652,19 +624,18 @@ export class LiveUpdateEngine { throw new LiveUpdateError(ErrorCode.UrlMissing, 'url must be provided.'); } assertValidBundleId(options.bundleId); + if (options.artifactType === 'manifest') { + throw new LiveUpdateError( + ErrorCode.ArtifactTypeNotSupported, + 'The manifest artifact type is not yet supported by this SDK.', + ); + } if (await this.store.has(options.bundleId)) { throw new LiveUpdateError( ErrorCode.BundleAlreadyExists, 'bundle already exists.', ); } - if (options.artifactType === 'manifest') { - await this.downloadBundleOfTypeManifest({ - bundleId: options.bundleId, - url: options.url, - }); - return; - } await this.downloadBundleInternal({ bundleId: options.bundleId, checksum: options.checksum, @@ -1020,275 +991,6 @@ export class LiveUpdateEngine { } } - /** - * Download and install a `manifest` (delta) bundle. - * - * Downloads the manifest, diffs it against the current bundle's - * per-file checksums, copies unchanged files locally and downloads - * only the missing/changed files (in parallel, fail-fast). Each - * downloaded file is verified with the same precedence as the zip - * path. The assembled directory is then installed atomically. - */ - private async downloadBundleOfTypeManifest(options: { - bundleId: string; - url: string; - }): Promise { - const stagingDirectory = await this.store.createStagingDirectory(); - try { - const assembleDirectory = join(stagingDirectory, 'bundle'); - await mkdir(assembleDirectory, { recursive: true }); - // Download the manifest of the latest bundle. - const manifestFilePath = join(stagingDirectory, MANIFEST_FILE_NAME); - await downloadFile({ - destinationPath: manifestFilePath, - httpTimeout: this.httpTimeout, - url: withHrefQueryParameter(options.url, MANIFEST_FILE_NAME), - }); - const latestItems = parseManifest( - JSON.parse(await readFile(manifestFilePath, 'utf8')), - ); - // Diff against the current bundle by checksum: copy the files that - // are unchanged, download the rest. - const currentChecksums = await this.getCurrentBundleChecksums(); - const itemsToCopy: ManifestItem[] = []; - const itemsToDownload: ManifestItem[] = []; - if (currentChecksums === null) { - itemsToDownload.push(...latestItems); - } else { - const checksumToPath = new Map(); - for (const [path, checksum] of Object.entries(currentChecksums)) { - if (!checksumToPath.has(checksum)) { - checksumToPath.set(checksum, path); - } - } - for (const item of latestItems) { - if (checksumToPath.has(item.checksum)) { - itemsToCopy.push(item); - } else { - itemsToDownload.push(item); - } - } - const copyFailures = await this.copyManifestFiles( - itemsToCopy, - checksumToPath, - this.getCurrentBundleSourcePath(), - assembleDirectory, - ); - // Files that could not be copied locally are downloaded instead. - itemsToDownload.push(...copyFailures); - } - // Download the missing/changed files in parallel with fail-fast. - await this.downloadManifestFiles( - options.url, - itemsToDownload, - assembleDirectory, - options.bundleId, - ); - // Locate the bundle root and install atomically. - const bundleRoot = await findIndexHtmlDirectory(assembleDirectory); - if (!bundleRoot) { - throw new LiveUpdateError( - ErrorCode.BundleIndexHtmlMissing, - 'The bundle does not contain an index.html file.', - ); - } - const fileChecksums = await calculateContentChecksums(bundleRoot); - await this.store.add(options.bundleId, bundleRoot); - await this.stateFile.update(s => { - s.bundles[options.bundleId] = { - fileChecksums, - signed: this.publicKey !== undefined, - }; - }); - } catch (error) { - throw unknownError(error); - } finally { - await this.store.cleanUpStaging(stagingDirectory); - } - } - - /** - * Return the per-file checksums (path -> SHA-256) of the current - * bundle, or `null` if none can be determined (in which case a delta - * update downloads all files). - */ - private async getCurrentBundleChecksums(): Promise<{ - [path: string]: string; - } | null> { - const currentBundleId = this.stateFile.get().currentBundleId; - if (currentBundleId !== null) { - const metadata = this.stateFile.get().bundles[currentBundleId]; - if (metadata && Object.keys(metadata.fileChecksums).length > 0) { - return metadata.fileChecksums; - } - if (await this.store.has(currentBundleId)) { - return calculateContentChecksums(this.store.getPath(currentBundleId)); - } - return null; - } - // The default bundle has no recorded checksums; compute them lazily. - if (this.defaultBundlePath) { - try { - return await calculateContentChecksums(this.defaultBundlePath); - } catch { - return null; - } - } - return null; - } - - /** - * Return the on-disk directory of the current bundle to copy - * unchanged files from, or `null` if the default bundle is active and - * no `defaultBundlePath` is configured. - */ - private getCurrentBundleSourcePath(): string | null { - const currentBundleId = this.stateFile.get().currentBundleId; - if (currentBundleId !== null) { - return this.store.getPath(currentBundleId); - } - return this.defaultBundlePath; - } - - /** - * Copy the given files from the current bundle into the assembly - * directory. Returns the items that could not be reused (missing on - * disk, copy failure, or content that no longer matches the manifest - * checksum), so they can be downloaded instead. - * - * The copied file is re-hashed and compared against the manifest - * checksum: the diff key comes from the install-time metadata, so a - * file tampered on disk after install would otherwise be copied and - * then re-blessed by the fresh install-time checksum pass, defeating - * the activation-time integrity guarantee. On any mismatch the file - * is downloaded from the server instead. - */ - private async copyManifestFiles( - items: ManifestItem[], - checksumToPath: Map, - sourceDirectory: string | null, - destinationDirectory: string, - ): Promise { - const failures: ManifestItem[] = []; - for (const item of items) { - const sourceRelativePath = checksumToPath.get(item.checksum); - if (sourceDirectory === null || sourceRelativePath === undefined) { - failures.push(item); - continue; - } - try { - const destinationPath = resolveManifestFilePath( - destinationDirectory, - item.href, - ); - await mkdir(dirname(destinationPath), { recursive: true }); - await copyFile( - join(sourceDirectory, sourceRelativePath), - destinationPath, - ); - const checksum = await calculateFileChecksum(destinationPath); - if (checksum !== item.checksum.toLowerCase()) { - failures.push(item); - } - } catch { - failures.push(item); - } - } - return failures; - } - - /** - * Download the given files in parallel (bounded concurrency, - * fail-fast) and verify each one. Emits aggregated download progress - * across all files. - */ - private async downloadManifestFiles( - baseUrl: string, - items: ManifestItem[], - destinationDirectory: string, - bundleId: string, - ): Promise { - if (items.length === 0) { - this.emit('downloadBundleProgress', { - bundleId, - downloadedBytes: 0, - progress: 1, - totalBytes: 0, - }); - return; - } - const totalBytes = items.reduce((sum, item) => sum + item.sizeInBytes, 0); - const downloadedPerFile = new Array(items.length).fill(0); - const controller = new AbortController(); - const emitProgress = (): void => { - const downloadedBytes = downloadedPerFile.reduce( - (sum, bytes) => sum + bytes, - 0, - ); - this.emit('downloadBundleProgress', { - bundleId, - downloadedBytes, - progress: - totalBytes > 0 ? Math.min(downloadedBytes / totalBytes, 1) : 1, - totalBytes, - }); - }; - let nextIndex = 0; - const worker = async (): Promise => { - for (;;) { - const index = nextIndex++; - if (index >= items.length) { - return; - } - const item = items[index] as ManifestItem; - const destinationPath = resolveManifestFilePath( - destinationDirectory, - item.href, - ); - await mkdir(dirname(destinationPath), { recursive: true }); - const result = await downloadFile({ - destinationPath, - httpTimeout: this.httpTimeout, - onProgress: downloadedBytes => { - downloadedPerFile[index] = downloadedBytes; - emitProgress(); - }, - signal: controller.signal, - url: withHrefQueryParameter(baseUrl, item.href), - }); - await verifyDownloadedFile({ - checksum: result.checksum, - filePath: destinationPath, - manifestChecksum: item.checksum, - publicKey: this.publicKey, - signature: result.signature, - }); - // Account for the full file size even if no Content-Length was - // sent, so the aggregate progress reaches the total. Never drop - // below the bytes already streamed (e.g. when `sizeInBytes` is - // missing/0) so the aggregate progress can only ever increase. - downloadedPerFile[index] = Math.max( - downloadedPerFile[index] ?? 0, - item.sizeInBytes, - ); - emitProgress(); - } - }; - const workers = Array.from( - { length: Math.min(MANIFEST_DOWNLOAD_CONCURRENCY, items.length) }, - () => worker(), - ); - try { - await Promise.all(workers); - } catch (error) { - // Fail-fast: cancel the in-flight downloads and let them settle. - controller.abort(); - await Promise.allSettled(workers); - throw error; - } - emitProgress(); - } - private async setNextBundleInternal(bundleId: string | null): Promise { await this.stateFile.update(s => { s.nextBundleId = bundleId; diff --git a/src/engine/manifest.ts b/src/engine/manifest.ts deleted file mode 100644 index 324b17e..0000000 --- a/src/engine/manifest.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { join, resolve, sep } from 'node:path'; - -import { ErrorCode, LiveUpdateError } from './errors'; - -/** - * The reserved file name of the manifest of a `manifest` (delta) bundle. - * - * DO NOT CHANGE: this is part of the Capawesome Cloud Live Update - * protocol and must match the mobile plugins. - */ -export const MANIFEST_FILE_NAME = 'capawesome-live-update-manifest.json'; - -/** - * A single entry of a bundle manifest. - */ -export interface ManifestItem { - /** - * The SHA-256 checksum of the file, used for diffing. - */ - checksum: string; - /** - * The path of the file relative to the bundle root. - */ - href: string; - /** - * The size of the file in bytes, used for progress aggregation. - */ - sizeInBytes: number; -} - -/** - * Parse the JSON of a bundle manifest into a list of manifest items. - * - * The manifest is a JSON array of `{ href, checksum, sizeInBytes }` - * objects. Entries without a `href` or `checksum` are ignored. - */ -export function parseManifest(json: unknown): ManifestItem[] { - if (!Array.isArray(json)) { - throw new LiveUpdateError( - ErrorCode.DownloadFailed, - 'Bundle could not be downloaded.', - ); - } - const items: ManifestItem[] = []; - for (const entry of json) { - if (typeof entry !== 'object' || entry === null) { - continue; - } - const record = entry as Record; - if ( - typeof record.href !== 'string' || - typeof record.checksum !== 'string' - ) { - continue; - } - items.push({ - checksum: record.checksum, - href: record.href, - sizeInBytes: - typeof record.sizeInBytes === 'number' && - Number.isFinite(record.sizeInBytes) - ? record.sizeInBytes - : 0, - }); - } - return items; -} - -/** - * Resolve a manifest item `href` to an absolute path inside the target - * directory, rejecting any path that would escape it (path traversal). - */ -export function resolveManifestFilePath( - targetDirectory: string, - href: string, -): string { - if (href.includes('\0')) { - throw pathError(); - } - const normalized = href.replace(/\\/g, '/'); - if (normalized.startsWith('/') || /^[a-zA-Z]:/.test(normalized)) { - throw pathError(); - } - const segments = normalized - .split('/') - .filter(segment => segment.length > 0 && segment !== '.'); - if (segments.length === 0 || segments.some(segment => segment === '..')) { - throw pathError(); - } - const targetRoot = resolve(targetDirectory); - const filePath = join(targetRoot, ...segments); - if (filePath !== targetRoot && !filePath.startsWith(targetRoot + sep)) { - throw pathError(); - } - return filePath; -} - -function pathError(): LiveUpdateError { - return new LiveUpdateError( - ErrorCode.DownloadFailed, - 'Bundle could not be downloaded.', - ); -} diff --git a/src/engine/verify.ts b/src/engine/verify.ts index 9d87586..d1da00a 100644 --- a/src/engine/verify.ts +++ b/src/engine/verify.ts @@ -77,14 +77,6 @@ export interface VerifyDownloadedFileOptions { */ checksum?: string; filePath: string; - /** - * SHA-256 checksum in hex format from the trusted bundle manifest - * (a `manifest`/delta bundle item). Used to verify individual files - * of a delta bundle when no `publicKey` is configured: it is used as - * a fallback when no `checksum` header is present and, when both are - * present, a `checksum` header contradicting it fails verification. - */ - manifestChecksum?: string; /** * PEM-encoded RSA public key from the SDK configuration. */ @@ -104,13 +96,9 @@ export interface VerifyDownloadedFileOptions { * checksum is ignored. A present-but-empty signature is NOT treated * as missing: it flows into the verification and fails there. Only * an absent (`undefined`) signature is reported as missing. - * 2. Otherwise, if a `checksum` header and a `manifestChecksum` are - * both present and disagree, verification fails: a header - * contradicting the trusted manifest is suspicious. - * 3. Otherwise, if either a `checksum` header or a `manifestChecksum` - * is available, it is verified (the header taking precedence). A + * 2. Otherwise, if a `checksum` is available, it is verified. A * present-but-empty value is a value: it is compared and rejects. - * 4. Otherwise (both absent), the file is accepted without verification. + * 3. Otherwise, the file is accepted without verification. */ export async function verifyDownloadedFile( options: VerifyDownloadedFileOptions, @@ -126,16 +114,7 @@ export async function verifyDownloadedFile( await verifyFileSignature(options.filePath, options.signature, publicKey); return; } - const headerChecksum = options.checksum?.toLowerCase(); - const manifestChecksum = options.manifestChecksum?.toLowerCase(); - if ( - headerChecksum !== undefined && - manifestChecksum !== undefined && - headerChecksum !== manifestChecksum - ) { - throw new LiveUpdateError(ErrorCode.ChecksumMismatch, 'Checksum mismatch.'); - } - const expectedChecksum = headerChecksum ?? manifestChecksum; + const expectedChecksum = options.checksum?.toLowerCase(); if (expectedChecksum !== undefined) { const actualChecksum = await calculateFileChecksum(options.filePath); if (actualChecksum !== expectedChecksum) { diff --git a/src/main/live-update.ts b/src/main/live-update.ts index 973e375..f7e56a8 100644 --- a/src/main/live-update.ts +++ b/src/main/live-update.ts @@ -89,7 +89,6 @@ class LiveUpdateImpl implements LiveUpdate { dataDirectory: config.dataDirectory ?? join(app.getPath('userData'), 'capawesome-live-update'), - defaultBundlePath: config.defaultBundlePath, defaultChannel: config.defaultChannel, httpTimeout: config.httpTimeout, logger: this.logger, diff --git a/tests/engine.test.ts b/tests/engine.test.ts index c5872ab..55921b8 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -1,7 +1,6 @@ import { createHash } from 'node:crypto'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import type { IncomingMessage } from 'node:http'; -import { dirname, join } from 'node:path'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { @@ -14,7 +13,6 @@ import { type LiveUpdateEngineConfig, } from '../src/engine/engine'; import { ErrorCode } from '../src/engine/errors'; -import { MANIFEST_FILE_NAME } from '../src/engine/manifest'; import { MockServer, @@ -23,7 +21,6 @@ import { createTemporaryDirectory, generateRsaKeyPair, removeDirectory, - sha256Hex, signBytes, } from './helpers'; @@ -97,81 +94,6 @@ describe('LiveUpdateEngine', () => { }); } - interface ManifestFile { - content: string; - href: string; - } - - function manifestUrl(bundleId: string): string { - return `${server.origin}/manifest/${bundleId}`; - } - - function hrefOf(request: IncomingMessage): string | null { - return new URL(request.url ?? '/', server.origin).searchParams.get('href'); - } - - /** - * Serve a `manifest` (delta) bundle: the manifest JSON under - * `?href=` and each file under `?href=`. - */ - function serveManifestBundle( - bundleId: string, - files: ManifestFile[], - options: { privateKeyPem?: string } = {}, - ): void { - const manifest = files.map(file => ({ - checksum: sha256Hex(file.content), - href: file.href, - sizeInBytes: Buffer.byteLength(file.content), - })); - server.route(`/manifest/${bundleId}`, request => { - const href = hrefOf(request); - if (href === MANIFEST_FILE_NAME) { - return { - body: JSON.stringify(manifest), - headers: { 'Content-Type': 'application/json' }, - }; - } - const file = files.find(entry => entry.href === href); - if (!file) { - return { body: 'Not found', status: 404 }; - } - const bytes = Buffer.from(file.content, 'utf8'); - const headers: Record = options.privateKeyPem - ? { 'X-Signature': signBytes(bytes, options.privateKeyPem) } - : { 'X-Checksum': sha256Hex(bytes) }; - return { body: bytes, headers }; - }); - } - - function serveLatestManifestBundle(bundleId: string): void { - server.route('/v1/apps/app-123/bundles/latest', { - body: JSON.stringify({ - artifactType: 'manifest', - bundleId, - url: manifestUrl(bundleId), - }), - headers: { 'Content-Type': 'application/json' }, - }); - } - - async function writeDefaultBundle(files: ManifestFile[]): Promise { - const directory = await createTemporaryDirectory(); - for (const file of files) { - const filePath = join(directory, file.href); - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, file.content, 'utf8'); - } - return directory; - } - - function requestedHrefs(): string[] { - return server.requests - .filter(recorded => recorded.url.pathname.startsWith('/manifest/')) - .map(recorded => recorded.url.searchParams.get('href')) - .filter((href): href is string => href !== null); - } - describe('downloadBundle', () => { it('downloads, verifies and installs a bundle', async () => { const engine = createEngine(); @@ -227,6 +149,18 @@ describe('LiveUpdateEngine', () => { ).rejects.toMatchObject({ code: ErrorCode.BundleAlreadyExists }); }); + it('rejects the manifest artifact type', async () => { + const engine = createEngine(); + await engine.initialize(); + await expect( + engine.downloadBundle({ + artifactType: 'manifest', + bundleId: '1.0.0', + url: 'https://example.com/b', + }), + ).rejects.toMatchObject({ code: ErrorCode.ArtifactTypeNotSupported }); + }); + it('rejects a bundle with a checksum mismatch and leaves no traces', async () => { const engine = createEngine(); await engine.initialize(); @@ -767,425 +701,6 @@ describe('LiveUpdateEngine', () => { }); }); - describe('manifest (delta) bundles', () => { - async function readBundleFile( - bundleId: string, - href: string, - ): Promise { - return readFile( - join(dataDirectory, 'bundles', bundleId, ...href.split('/')), - 'utf8', - ); - } - - it('downloads every file on a first delta over the default bundle without a default path', async () => { - const engine = createEngine(); - await engine.initialize(); - serveManifestBundle('delta', [ - { href: 'index.html', content: 'delta' }, - { href: 'assets/app.js', content: 'console.log("delta");' }, - ]); - await engine.downloadBundle({ - artifactType: 'manifest', - bundleId: 'delta', - url: manifestUrl('delta'), - }); - expect((await engine.getDownloadedBundles()).bundleIds).toEqual([ - 'delta', - ]); - expect(await readBundleFile('delta', 'index.html')).toBe( - 'delta', - ); - expect(requestedHrefs().sort()).toEqual( - [MANIFEST_FILE_NAME, 'assets/app.js', 'index.html'].sort(), - ); - }); - - it('reuses unchanged files of the packaged default bundle (first delta on default)', async () => { - const defaultBundlePath = await writeDefaultBundle([ - { href: 'index.html', content: 'v1' }, - { href: 'assets/app.js', content: 'shared-code' }, - ]); - const engine = createEngine({ defaultBundlePath }); - await engine.initialize(); - serveManifestBundle('v2', [ - { href: 'index.html', content: 'v2' }, - { href: 'assets/app.js', content: 'shared-code' }, - ]); - await engine.downloadBundle({ - artifactType: 'manifest', - bundleId: 'v2', - url: manifestUrl('v2'), - }); - // The changed index.html was downloaded, the unchanged app.js copied. - expect(await readBundleFile('v2', 'index.html')).toBe('v2'); - expect(await readBundleFile('v2', 'assets/app.js')).toBe('shared-code'); - expect(requestedHrefs()).not.toContain('assets/app.js'); - expect(requestedHrefs()).toContain('index.html'); - await removeDirectory(defaultBundlePath); - }); - - it('reuses unchanged files of a previously installed bundle', async () => { - const engine = createEngine(); - await engine.initialize(); - const baseZip = await buildZip([ - { path: 'index.html', content: 'base' }, - { path: 'assets/app.js', content: 'shared-code' }, - ]); - server.route('/download/base.zip', { body: baseZip }); - await engine.downloadBundle({ - bundleId: 'base', - url: `${server.origin}/download/base.zip`, - }); - await engine.setNextBundle({ bundleId: 'base' }); - await engine.applyNextBundle(); - serveManifestBundle('next', [ - { href: 'index.html', content: 'next' }, - { href: 'assets/app.js', content: 'shared-code' }, - ]); - await engine.downloadBundle({ - artifactType: 'manifest', - bundleId: 'next', - url: manifestUrl('next'), - }); - expect(await readBundleFile('next', 'index.html')).toBe( - 'next', - ); - expect(await readBundleFile('next', 'assets/app.js')).toBe('shared-code'); - expect(requestedHrefs()).not.toContain('assets/app.js'); - }); - - it('downloads a reused file whose on-disk copy no longer matches its checksum', async () => { - const engine = createEngine(); - await engine.initialize(); - const baseZip = await buildZip([ - { path: 'index.html', content: 'base' }, - { path: 'assets/app.js', content: 'shared-code' }, - ]); - server.route('/download/base.zip', { body: baseZip }); - await engine.downloadBundle({ - bundleId: 'base', - url: `${server.origin}/download/base.zip`, - }); - await engine.setNextBundle({ bundleId: 'base' }); - await engine.applyNextBundle(); - // Tamper the installed file on disk AFTER install. The diff still - // uses the install-time checksum, so the delta would try to copy - // it; the copy re-verification must catch the mismatch and download - // the file from the server instead. - await writeFile( - join(dataDirectory, 'bundles', 'base', 'assets', 'app.js'), - 'tampered-on-disk', - ); - serveManifestBundle('next', [ - { href: 'index.html', content: 'next' }, - { href: 'assets/app.js', content: 'shared-code' }, - ]); - await engine.downloadBundle({ - artifactType: 'manifest', - bundleId: 'next', - url: manifestUrl('next'), - }); - // The assembled bundle contains the server version, not the - // tampered on-disk copy. - expect(await readBundleFile('next', 'assets/app.js')).toBe('shared-code'); - expect(requestedHrefs()).toContain('assets/app.js'); - }); - - it('never regresses aggregate progress when sizeInBytes is missing or zero', async () => { - const engine = createEngine(); - await engine.initialize(); - const content = 'zero-size'; - const manifest = [ - { checksum: sha256Hex(content), href: 'index.html', sizeInBytes: 0 }, - ]; - server.route('/manifest/zero', request => { - const href = hrefOf(request); - if (href === MANIFEST_FILE_NAME) { - return { body: JSON.stringify(manifest) }; - } - return { body: content, headers: { 'X-Checksum': sha256Hex(content) } }; - }); - const progressEvents: DownloadBundleProgressEvent[] = []; - engine.on('downloadBundleProgress', event => progressEvents.push(event)); - await engine.downloadBundle({ - artifactType: 'manifest', - bundleId: 'zero', - url: manifestUrl('zero'), - }); - // The aggregate downloaded byte count must be monotonic even though - // the completed file reports a sizeInBytes of 0. - let previous = 0; - for (const event of progressEvents) { - expect(event.downloadedBytes).toBeGreaterThanOrEqual(previous); - previous = event.downloadedBytes; - } - const lastEvent = progressEvents[progressEvents.length - 1]; - expect(lastEvent?.progress).toBe(1); - expect((await engine.getDownloadedBundles()).bundleIds).toEqual(['zero']); - }); - - it('emits aggregated download progress across files', async () => { - const engine = createEngine(); - await engine.initialize(); - const files = [ - { href: 'index.html', content: 'delta' }, - { href: 'assets/app.js', content: 'console.log("delta");' }, - ]; - serveManifestBundle('delta', files); - const progressEvents: DownloadBundleProgressEvent[] = []; - engine.on('downloadBundleProgress', event => progressEvents.push(event)); - await engine.downloadBundle({ - artifactType: 'manifest', - bundleId: 'delta', - url: manifestUrl('delta'), - }); - expect(progressEvents.length).toBeGreaterThan(0); - const lastEvent = progressEvents[progressEvents.length - 1]; - expect(lastEvent?.bundleId).toBe('delta'); - expect(lastEvent?.progress).toBe(1); - const expectedTotal = files.reduce( - (sum, file) => sum + Buffer.byteLength(file.content), - 0, - ); - expect(lastEvent?.totalBytes).toBe(expectedTotal); - expect(lastEvent?.downloadedBytes).toBe(expectedTotal); - }); - - it('verifies each file signature with the configured public key', async () => { - const { privateKeyPem, publicKeyPem } = generateRsaKeyPair(); - const engine = createEngine({ publicKey: publicKeyPem }); - await engine.initialize(); - serveManifestBundle( - 'signed', - [ - { href: 'index.html', content: 'signed' }, - { href: 'assets/app.js', content: 'console.log("signed");' }, - ], - { privateKeyPem }, - ); - await engine.downloadBundle({ - artifactType: 'manifest', - bundleId: 'signed', - url: manifestUrl('signed'), - }); - expect((await engine.getDownloadedBundles()).bundleIds).toEqual([ - 'signed', - ]); - }); - - it('rejects a delta with an invalid per-file signature', async () => { - const { privateKeyPem, publicKeyPem } = generateRsaKeyPair(); - const engine = createEngine({ publicKey: publicKeyPem }); - await engine.initialize(); - const manifest = [ - { - checksum: sha256Hex('ok'), - href: 'index.html', - sizeInBytes: Buffer.byteLength('ok'), - }, - ]; - server.route('/manifest/tampered', request => { - const href = hrefOf(request); - if (href === MANIFEST_FILE_NAME) { - return { body: JSON.stringify(manifest) }; - } - // Serve tampered content with a signature of different bytes. - return { - body: 'evil', - headers: { - 'X-Signature': signBytes(Buffer.from('other'), privateKeyPem), - }, - }; - }); - await expect( - engine.downloadBundle({ - artifactType: 'manifest', - bundleId: 'tampered', - url: manifestUrl('tampered'), - }), - ).rejects.toMatchObject({ code: ErrorCode.SignatureVerificationFailed }); - expect((await engine.getDownloadedBundles()).bundleIds).toEqual([]); - }); - - it('fails fast when a file cannot be downloaded and leaves no traces', async () => { - const engine = createEngine(); - await engine.initialize(); - const manifest = [ - { - checksum: sha256Hex('a'), - href: 'index.html', - sizeInBytes: 1, - }, - { - checksum: sha256Hex('b'), - href: 'missing.js', - sizeInBytes: 1, - }, - ]; - server.route('/manifest/broken', request => { - const href = hrefOf(request); - if (href === MANIFEST_FILE_NAME) { - return { body: JSON.stringify(manifest) }; - } - if (href === 'index.html') { - return { body: 'a', headers: { 'X-Checksum': sha256Hex('a') } }; - } - return { body: 'Not found', status: 404 }; - }); - await expect( - engine.downloadBundle({ - artifactType: 'manifest', - bundleId: 'broken', - url: manifestUrl('broken'), - }), - ).rejects.toMatchObject({ code: ErrorCode.DownloadFailed }); - expect((await engine.getDownloadedBundles()).bundleIds).toEqual([]); - }); - - it('falls back to the manifest checksum when the X-Checksum header is absent', async () => { - const engine = createEngine(); - await engine.initialize(); - const content = 'no-header'; - const manifest = [ - { - checksum: sha256Hex(content), - href: 'index.html', - sizeInBytes: Buffer.byteLength(content), - }, - ]; - server.route('/manifest/no-header', request => { - const href = hrefOf(request); - if (href === MANIFEST_FILE_NAME) { - return { body: JSON.stringify(manifest) }; - } - // Serve the file WITHOUT an X-Checksum header. - return { body: content }; - }); - await engine.downloadBundle({ - artifactType: 'manifest', - bundleId: 'no-header', - url: manifestUrl('no-header'), - }); - expect((await engine.getDownloadedBundles()).bundleIds).toEqual([ - 'no-header', - ]); - expect(await readBundleFile('no-header', 'index.html')).toBe(content); - }); - - it('rejects a delta when the manifest checksum does not match the file', async () => { - const engine = createEngine(); - await engine.initialize(); - const content = 'corrupt'; - const manifest = [ - { - // Manifest checksum of different bytes than what is served. - checksum: sha256Hex('other'), - href: 'index.html', - sizeInBytes: Buffer.byteLength(content), - }, - ]; - server.route('/manifest/corrupt', request => { - const href = hrefOf(request); - if (href === MANIFEST_FILE_NAME) { - return { body: JSON.stringify(manifest) }; - } - // Serve the file WITHOUT an X-Checksum header. - return { body: content }; - }); - await expect( - engine.downloadBundle({ - artifactType: 'manifest', - bundleId: 'corrupt', - url: manifestUrl('corrupt'), - }), - ).rejects.toMatchObject({ code: ErrorCode.ChecksumMismatch }); - expect((await engine.getDownloadedBundles()).bundleIds).toEqual([]); - }); - - it('rejects a delta whose X-Checksum header contradicts the manifest', async () => { - const engine = createEngine(); - await engine.initialize(); - const content = 'contradiction'; - const manifest = [ - { - checksum: sha256Hex(content), - href: 'index.html', - sizeInBytes: Buffer.byteLength(content), - }, - ]; - server.route('/manifest/contradiction', request => { - const href = hrefOf(request); - if (href === MANIFEST_FILE_NAME) { - return { body: JSON.stringify(manifest) }; - } - // Correct bytes, but a header checksum that disagrees with the - // trusted manifest checksum. - return { body: content, headers: { 'X-Checksum': 'a'.repeat(64) } }; - }); - await expect( - engine.downloadBundle({ - artifactType: 'manifest', - bundleId: 'contradiction', - url: manifestUrl('contradiction'), - }), - ).rejects.toMatchObject({ code: ErrorCode.ChecksumMismatch }); - expect((await engine.getDownloadedBundles()).bundleIds).toEqual([]); - }); - - it('accepts a delta whose X-Checksum header matches the manifest', async () => { - const engine = createEngine(); - await engine.initialize(); - // serveManifestBundle sends an X-Checksum header equal to the - // manifest checksum for every file. - serveManifestBundle('agree', [ - { href: 'index.html', content: 'agree' }, - ]); - await engine.downloadBundle({ - artifactType: 'manifest', - bundleId: 'agree', - url: manifestUrl('agree'), - }); - expect((await engine.getDownloadedBundles()).bundleIds).toEqual([ - 'agree', - ]); - expect(await readBundleFile('agree', 'index.html')).toBe( - 'agree', - ); - }); - - it('rejects a delta without an index.html file', async () => { - const engine = createEngine(); - await engine.initialize(); - serveManifestBundle('noindex', [ - { href: 'assets/app.js', content: 'console.log("x");' }, - ]); - await expect( - engine.downloadBundle({ - artifactType: 'manifest', - bundleId: 'noindex', - url: manifestUrl('noindex'), - }), - ).rejects.toMatchObject({ code: ErrorCode.BundleIndexHtmlMissing }); - expect((await engine.getDownloadedBundles()).bundleIds).toEqual([]); - }); - - it('syncs a manifest bundle end to end', async () => { - const engine = createEngine(); - await engine.initialize(); - serveManifestBundle('3.0.0', [ - { href: 'index.html', content: '3.0.0' }, - ]); - serveLatestManifestBundle('3.0.0'); - const result = await engine.sync(); - expect(result).toEqual({ nextBundleId: '3.0.0' }); - expect((await engine.getNextBundle()).bundleId).toBe('3.0.0'); - expect(await readBundleFile('3.0.0', 'index.html')).toBe( - '3.0.0', - ); - }); - }); - describe('fetchChannels', () => { it('returns the channels', async () => { const engine = createEngine(); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts deleted file mode 100644 index c2b745a..0000000 --- a/tests/manifest.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { tmpdir } from 'node:os'; -import { join, sep } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -import { ErrorCode } from '../src/engine/errors'; -import { - MANIFEST_FILE_NAME, - parseManifest, - resolveManifestFilePath, -} from '../src/engine/manifest'; - -describe('manifest', () => { - describe('parseManifest', () => { - it('parses a valid manifest', () => { - const items = parseManifest([ - { href: 'index.html', checksum: 'aaa', sizeInBytes: 10 }, - { href: 'assets/app.js', checksum: 'bbb', sizeInBytes: 20 }, - ]); - expect(items).toEqual([ - { href: 'index.html', checksum: 'aaa', sizeInBytes: 10 }, - { href: 'assets/app.js', checksum: 'bbb', sizeInBytes: 20 }, - ]); - }); - - it('defaults a missing or invalid sizeInBytes to 0', () => { - const items = parseManifest([{ href: 'a', checksum: 'c' }]); - expect(items[0]?.sizeInBytes).toBe(0); - }); - - it('ignores entries without a href or checksum', () => { - const items = parseManifest([ - { href: 'a', checksum: 'c' }, - { href: 'b' }, - { checksum: 'd' }, - 'garbage', - null, - ]); - expect(items).toEqual([{ href: 'a', checksum: 'c', sizeInBytes: 0 }]); - }); - - it('throws when the manifest is not an array', () => { - expect(() => parseManifest({})).toThrowError( - expect.objectContaining({ code: ErrorCode.DownloadFailed }), - ); - }); - }); - - describe('resolveManifestFilePath', () => { - it('resolves a nested path inside the target directory', () => { - const root = join(tmpdir(), 'bundle'); - expect(resolveManifestFilePath(root, 'assets/app.js')).toBe( - join(root, 'assets', 'app.js'), - ); - }); - - it('rejects path traversal', () => { - const root = join(tmpdir(), 'bundle'); - for (const href of [ - '../escape.js', - 'assets/../../escape.js', - `/etc/passwd`, - 'C:/windows', - 'a\0b', - '', - ]) { - expect(() => resolveManifestFilePath(root, href)).toThrowError( - expect.objectContaining({ code: ErrorCode.DownloadFailed }), - ); - } - }); - - it('normalizes backslashes and keeps the path inside the root', () => { - const root = join(tmpdir(), 'bundle'); - const resolved = resolveManifestFilePath(root, 'assets\\app.js'); - expect(resolved.startsWith(root + sep)).toBe(true); - }); - }); - - it('exposes the reserved manifest file name', () => { - expect(MANIFEST_FILE_NAME).toBe('capawesome-live-update-manifest.json'); - }); -}); diff --git a/tests/verify.test.ts b/tests/verify.test.ts index f4a6da2..32f971c 100644 --- a/tests/verify.test.ts +++ b/tests/verify.test.ts @@ -78,71 +78,6 @@ describe('verification', () => { }); }); - it('rejects a present-but-empty checksum header even with a manifest checksum', async () => { - const manifestChecksum = createHash('sha256') - .update(fileContent) - .digest('hex'); - await expect( - verifyDownloadedFile({ filePath, checksum: '', manifestChecksum }), - ).rejects.toMatchObject({ - code: ErrorCode.ChecksumMismatch, - message: 'Checksum mismatch.', - }); - }); - - it('falls back to the manifest checksum when no header checksum is present', async () => { - const manifestChecksum = createHash('sha256') - .update(fileContent) - .digest('hex'); - await expect( - verifyDownloadedFile({ filePath, manifestChecksum }), - ).resolves.toBeUndefined(); - }); - - it('rejects a manifest checksum mismatch when no header checksum is present', async () => { - await expect( - verifyDownloadedFile({ filePath, manifestChecksum: 'a'.repeat(64) }), - ).rejects.toMatchObject({ - code: ErrorCode.ChecksumMismatch, - message: 'Checksum mismatch.', - }); - }); - - it('accepts when the header checksum matches the manifest checksum', async () => { - const checksum = createHash('sha256').update(fileContent).digest('hex'); - await expect( - verifyDownloadedFile({ filePath, checksum, manifestChecksum: checksum }), - ).resolves.toBeUndefined(); - }); - - it('rejects when the header checksum contradicts the manifest checksum', async () => { - const checksum = createHash('sha256').update(fileContent).digest('hex'); - await expect( - verifyDownloadedFile({ - filePath, - checksum, - manifestChecksum: 'a'.repeat(64), - }), - ).rejects.toMatchObject({ - code: ErrorCode.ChecksumMismatch, - message: 'Checksum mismatch.', - }); - }); - - it('ignores the manifest checksum when a public key is configured', async () => { - const { privateKeyPem, publicKeyPem } = generateRsaKeyPair(); - const signature = signBytes(fileContent, privateKeyPem); - // Wrong manifest checksum, valid signature: the signature path wins. - await expect( - verifyDownloadedFile({ - filePath, - publicKey: publicKeyPem, - signature, - manifestChecksum: 'a'.repeat(64), - }), - ).resolves.toBeUndefined(); - }); - it('verifies a valid signature', async () => { const { privateKeyPem, publicKeyPem } = generateRsaKeyPair(); const signature = signBytes(fileContent, privateKeyPem);