diff --git a/apps/buddy/platform/extensions/ExtensionPackageStore.ts b/apps/buddy/platform/extensions/ExtensionPackageStore.ts index e3c8e281..886d0dd4 100644 --- a/apps/buddy/platform/extensions/ExtensionPackageStore.ts +++ b/apps/buddy/platform/extensions/ExtensionPackageStore.ts @@ -27,6 +27,17 @@ export type ExtensionPackage = z.infer export type InstalledExtension = z.infer export interface ExtensionCandidate { token: string, package: ExtensionPackage, files: Map, development: boolean, expires: number } +async function verifyExistingPackage(root: string, pkg: ExtensionPackage, renameError: unknown): Promise { + const files = await readExtensionDirectory(root).catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + throw renameError + throw error + }) + const hashes = Object.fromEntries([...files].sort(([a], [b]) => a.localeCompare(b)).map(([name, bytes]) => [name, sha256(bytes)])) + if (!isDeepStrictEqual(hashes, pkg.hashes) || sha256(JSON.stringify(hashes)) !== pkg.revision) + throw new Error('EXTENSION_PACKAGE_CHANGED') +} + export class ExtensionPackageStore { readonly root: string readonly appVersion: string @@ -150,6 +161,7 @@ export class ExtensionPackageStore { if (current && !candidate.development && !gt(pkg.manifest.version, current.current.manifest.version)) throw new Error('EXTENSION_VERSION_NOT_NEWER') const parent = join(this.root, 'packages', pkg.manifest.id) + const target = this.packageRoot(pkg) const temporary = join(parent, `.install-${randomUUID()}`) let created = false let committed = false @@ -162,13 +174,11 @@ export class ExtensionPackageStore { await writeFile(path, bytes, { flag: 'wx', mode: 0o600, flush: true }) } try { - await rename(temporary, this.packageRoot(pkg)) + await rename(temporary, target) created = true } catch (error) { - if (!['EEXIST', 'ENOTEMPTY'].includes((error as NodeJS.ErrnoException).code ?? '')) - throw error - for (const [name, hash] of Object.entries(pkg.hashes)) await verifiedExtensionAsset(this.packageRoot(pkg), name, hash) + await verifyExistingPackage(target, pkg, error) } await this.#update((index) => { signal?.throwIfAborted() @@ -185,7 +195,7 @@ export class ExtensionPackageStore { finally { await rm(temporary, { recursive: true, force: true }) if (created && !committed) - await rm(this.packageRoot(pkg), { recursive: true, force: true }) + await rm(target, { recursive: true, force: true }) } } diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts index 5498a53c..b86cea07 100644 --- a/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts +++ b/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts @@ -60,6 +60,17 @@ describe('extension package contract', () => { expect(reloaded.installed['tests.reader']!.pending).toBeNull() await expect(store.install(update.token)).rejects.toThrow('EXTENSION_REVIEW_EXPIRED') }) + it('reuses only a complete matching immutable revision', async () => { + const { root, store } = await fixture() + await store.install((await reviewPackage(root, store)).token) + const pkg = store.installed['tests.reader']!.current + await store.uninstall('tests.reader') + await store.install((await reviewPackage(root, store)).token) + expect(store.installed['tests.reader']!.current.revision).toBe(pkg.revision) + await store.uninstall('tests.reader') + await writeFile(join(store.packageRoot(pkg), 'unexpected.js'), 'unexpected') + await expect(store.install((await reviewPackage(root, store)).token)).rejects.toThrow('EXTENSION_PACKAGE_CHANGED') + }) it('refuses incompatible versions and missing entry modules before installation', async () => { const { root, store } = await fixture() await expect(reviewPackage(root, store, manifest({ engines: { lexora: '>=1' } }))).rejects.toThrow('EXTENSION_INCOMPATIBLE')