From 09f4461206ecbf00d881790192c52fa52a1558d7 Mon Sep 17 00:00:00 2001 From: Dr_rOot Date: Fri, 24 Jul 2026 16:15:04 +0800 Subject: [PATCH] fix(scripts): derive consent-preview fields from the signed manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit entry-from-release regenerated a builtin's registry entry but wrote only version + package, leaving permissions / optionalPermissions / hostPermissions and engines as hand-authored leftovers that silently drifted from the plugin's actual manifest. Incident: motrix.filename-template@1.1.0 dropped hostPermissions from its manifest, yet the entry kept advertising a broad wildcard host grant. The website and app showed a healthy update that then failed to install client-side — the registry lied to users about what the plugin requests. buildPackageBlock now mirrors engines + the three permission arrays off the already-verified SIGNED manifest and carries them in the returned block; patchEntry writes them unconditionally so a stale value can never survive. Absent optional manifest fields (hostPermissions / optionalPermissions) land as [], which the registry wire schema's .default([]) accepts and materializes identically for consumers. A missing engines.motrix is treated as a corrupt manifest and aborts. --- scripts/entry-from-release.ts | 78 ++++++++++++- tests/entry-from-release.test.ts | 194 +++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+), 1 deletion(-) diff --git a/scripts/entry-from-release.ts b/scripts/entry-from-release.ts index ca5d960..84096bb 100644 --- a/scripts/entry-from-release.ts +++ b/scripts/entry-from-release.ts @@ -16,10 +16,44 @@ export interface ReleaseArtifacts { signatureB64: string } +/** + * Consent-preview + compatibility fields the registry entry mirrors from the + * SIGNED manifest. The entry's `permissions` / `optionalPermissions` / + * `hostPermissions` are shown to users before install and `engines` gates + * whether an update is offered — but they used to be hand-authored, so they + * silently drifted from what the plugin actually requests (e.g. + * filename-template@1.1.0 dropped `hostPermissions` from its manifest while + * the entry kept advertising a broad wildcard host grant it no longer + * requested). Re-deriving them here on every regenerate makes that drift + * impossible. + */ +export interface ManifestMirror { + engines: { motrix: string } + permissions: string[] + optionalPermissions: string[] + hostPermissions: string[] +} + export interface PackageBlock { id: string version: string package: { url: string; sha256: string; size: number; signature: string } + mirror: ManifestMirror +} + +/** + * Read a manifest field that must be a `string[]` when present. Absent + * optional fields (the manifest schema makes `optionalPermissions` / + * `hostPermissions` optional) become `[]` — the same value the wire schema's + * `.default([])` materializes for consumers, and an unconditional value so a + * stale entry field is always overwritten, never left to drift. + */ +function manifestStringArray(value: unknown, field: string): string[] { + if (value === undefined) return [] + if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) { + throw new Error(`signed manifest ${field} must be an array of strings`) + } + return value } const RELEASE_BASE = 'https://github.com/motrixapp/builtin-plugins/releases/download' @@ -41,6 +75,11 @@ const TAG_RE = /^[^@]+@[^@]+$/ * client-side, but here at publish time so a mislabeled entry never lands in * the registry at all. * + * The same signed manifest is also the source for the entry's consent-preview + * (`permissions` / `optionalPermissions` / `hostPermissions`) and + * compatibility (`engines`) fields — see {@link ManifestMirror} — so the entry + * can never advertise something the plugin no longer requests. + * * Any mismatch throws — nothing downstream writes an unverified package. */ export function buildPackageBlock( @@ -105,7 +144,14 @@ export function buildPackageBlock( `motrix-plugin.json exceeds ${MANIFEST_SIZE_MAX} bytes (${manifestBytes.byteLength})` ) } - let manifest: { id?: unknown; version?: unknown } + let manifest: { + id?: unknown + version?: unknown + engines?: { motrix?: unknown } + permissions?: unknown + optionalPermissions?: unknown + hostPermissions?: unknown + } try { manifest = JSON.parse(strFromU8(manifestBytes)) } catch (err) { @@ -119,6 +165,27 @@ export function buildPackageBlock( ) } + // Mirror the consent-preview + compatibility fields off the SIGNED manifest. + // `engines.motrix` is required by both the manifest schema and the registry + // wire schema, so a manifest without it is corrupt — fail loudly rather than + // write an entry the registry would reject. + const motrixRange = manifest.engines?.motrix + if (typeof motrixRange !== 'string' || motrixRange.length === 0) { + throw new Error(`signed manifest for ${tag} has no engines.motrix range`) + } + const mirror: ManifestMirror = { + engines: { motrix: motrixRange }, + permissions: manifestStringArray(manifest.permissions, 'permissions'), + optionalPermissions: manifestStringArray( + manifest.optionalPermissions, + 'optionalPermissions' + ), + hostPermissions: manifestStringArray( + manifest.hostPermissions, + 'hostPermissions' + ), + } + return { id: tagId, version: a.metadata.version, @@ -128,6 +195,7 @@ export function buildPackageBlock( size: a.metadata.size, signature: a.signatureB64, }, + mirror, } } @@ -147,6 +215,14 @@ export async function patchEntry( const entry = JSON.parse(await readFile(file, 'utf8')) entry.version = block.version entry.package = block.package + // Re-derive the consent-preview + compatibility fields from the signed + // manifest on every regenerate. These assignments are unconditional so a + // stale hand-authored value (the hostPermissions drift incident) can never + // survive — an absent manifest field lands here as [] via the mirror. + entry.engines = block.mirror.engines + entry.permissions = block.mirror.permissions + entry.optionalPermissions = block.mirror.optionalPermissions + entry.hostPermissions = block.mirror.hostPermissions const problems = validateEntry(`${id}.json`, entry).problems if (problems.length > 0) { throw new Error( diff --git a/tests/entry-from-release.test.ts b/tests/entry-from-release.test.ts index 1b602e9..25a8382 100644 --- a/tests/entry-from-release.test.ts +++ b/tests/entry-from-release.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { strToU8, zipSync } from 'fflate' import { afterEach, describe, expect, it } from 'vitest' +import { RegistryPluginSchema } from '../schema/registry.ts' import { buildPackageBlock, patchEntry, @@ -28,11 +29,17 @@ function sha256(buf: Buffer): string { return createHash('sha256').update(buf).digest('hex') } +// A faithful in-bundle manifest: the real signed manifest always carries +// engines (required + strict in the manifest schema) plus the consent fields +// the generator now mirrors into the entry. const URL_RESOLVER_MANIFEST = { manifestVersion: 1, id: 'motrix.url-resolver', version: '1.0.0', main: 'dist/plugin.js', + engines: { motrix: '>=2.0.0 <3.0.0' }, + permissions: ['http'], + hostPermissions: ['*://commons.wikimedia.org/*'], } const moext = makeMoext(URL_RESOLVER_MANIFEST) @@ -54,6 +61,34 @@ function artifacts(over: Partial = {}): ReleaseArtifacts { } const TAG = 'motrix.url-resolver@1.0.0' +/** + * Build signed ReleaseArtifacts + matching tag for an arbitrary in-bundle + * manifest, so a test can exercise how the generator mirrors the manifest's + * consent-preview / compatibility fields into the entry. + */ +function releaseFor(manifest: Record): { + rel: ReleaseArtifacts + tag: string +} { + const id = manifest.id as string + const version = manifest.version as string + const bytes = makeMoext(manifest) + return { + rel: { + moext: bytes, + metadata: { + id, + version, + file: `${id}-${version}.moext`, + sha256: sha256(bytes), + size: bytes.byteLength, + }, + signatureB64: sign(null, bytes, privateKey).toString('base64'), + }, + tag: `${id}@${version}`, + } +} + describe('buildPackageBlock', () => { it('returns a verified package block for a good release', () => { const r = buildPackageBlock(artifacts(), TAG, PUB) @@ -138,6 +173,56 @@ describe('buildPackageBlock', () => { }) }) +describe('buildPackageBlock mirrors the signed manifest consent+compat fields', () => { + it('carries permissions, optionalPermissions, hostPermissions and engines from the manifest', () => { + const { rel, tag } = releaseFor({ + manifestVersion: 1, + id: 'motrix.url-resolver', + version: '1.0.0', + main: 'dist/plugin.js', + engines: { motrix: '>=2.0.0 <3.0.0' }, + permissions: ['http'], + optionalPermissions: ['notifications'], + hostPermissions: ['*://commons.wikimedia.org/*'], + }) + const r = buildPackageBlock(rel, tag, PUB) + expect(r.mirror.engines).toEqual({ motrix: '>=2.0.0 <3.0.0' }) + expect(r.mirror.permissions).toEqual(['http']) + expect(r.mirror.optionalPermissions).toEqual(['notifications']) + expect(r.mirror.hostPermissions).toEqual(['*://commons.wikimedia.org/*']) + }) + + // The incident shape: filename-template@1.1.0 dropped hostPermissions from + // its manifest entirely (they are optional in the manifest schema). The + // mirror must materialize the absent fields as [] so the entry advertises + // nothing the plugin no longer requests. + it('materializes absent optional consent fields as [] (manifest without hostPermissions)', () => { + const { rel, tag } = releaseFor({ + manifestVersion: 1, + id: 'motrix.filename-template', + version: '1.1.0', + main: 'dist/plugin.js', + engines: { motrix: '>=2.0.0 <3.0.0' }, + permissions: ['fs.task.write'], + }) + const r = buildPackageBlock(rel, tag, PUB) + expect(r.mirror.permissions).toEqual(['fs.task.write']) + expect(r.mirror.optionalPermissions).toEqual([]) + expect(r.mirror.hostPermissions).toEqual([]) + }) + + it('aborts when the signed manifest has no engines.motrix range', () => { + const { rel, tag } = releaseFor({ + manifestVersion: 1, + id: 'motrix.url-resolver', + version: '1.0.0', + main: 'dist/plugin.js', + permissions: [], + }) + expect(() => buildPackageBlock(rel, tag, PUB)).toThrow(/engines/i) + }) +}) + describe('patchEntry', () => { let dir: string @@ -182,3 +267,112 @@ describe('patchEntry', () => { await expect(patchEntry('motrix.other', block(), dir)).rejects.toThrow(/identity|id/i) }) }) + +describe('patchEntry syncs consent+compat fields from the signed manifest', () => { + let dir: string + + afterEach(async () => { + if (dir) await rm(dir, { recursive: true, force: true }) + }) + + /** + * Write a builtin entry that already carries hand-authored consent/compat + * fields, so a patch can be shown to overwrite them from the manifest. + */ + async function writeStaleEntry( + id: string, + stale: Record + ): Promise { + dir = await mkdtemp(path.join(tmpdir(), 'plugin-registry-test-')) + const entry = { + id, + name: { en: 'Filename Template' }, + description: { en: 'Renames finished downloads.' }, + version: '1.0.0', + author: { name: 'Motrix Team' }, + origin: 'builtin', + categories: ['post-action'], + engines: { motrix: '>=1.0.0 <2.0.0' }, + updatedAt: '2026-07-01', + ...stale, + } + await writeFile(path.join(dir, `${id}.json`), JSON.stringify(entry, null, 2)) + return dir + } + + it('overwrites a stale hostPermissions with the value from the manifest', async () => { + const id = 'motrix.filename-template' + await writeStaleEntry(id, { hostPermissions: ['*://*/*'] }) + const { rel, tag } = releaseFor({ + manifestVersion: 1, + id, + version: '1.1.0', + main: 'dist/plugin.js', + engines: { motrix: '>=2.0.0 <3.0.0' }, + permissions: ['fs.task.write'], + hostPermissions: ['*://commons.wikimedia.org/*'], + }) + await patchEntry(id, buildPackageBlock(rel, tag, PUB), dir) + const patched = JSON.parse(await readFile(path.join(dir, `${id}.json`), 'utf8')) + expect(patched.hostPermissions).toEqual(['*://commons.wikimedia.org/*']) + expect(patched.permissions).toEqual(['fs.task.write']) + }) + + // THE incident case: 1.1.0 dropped hostPermissions from the manifest, yet the + // registry entry kept advertising ["*://*/*"] — the registry lied about what + // the plugin requests. The patch MUST clear the stale value to [] and the + // result must stay schema-valid. + it('clears a stale hostPermissions when the manifest requests none, and stays schema-valid', async () => { + const id = 'motrix.filename-template' + await writeStaleEntry(id, { hostPermissions: ['*://*/*'] }) + const { rel, tag } = releaseFor({ + manifestVersion: 1, + id, + version: '1.1.0', + main: 'dist/plugin.js', + engines: { motrix: '>=2.0.0 <3.0.0' }, + permissions: ['fs.task.write'], + }) + await patchEntry(id, buildPackageBlock(rel, tag, PUB), dir) + const patched = JSON.parse(await readFile(path.join(dir, `${id}.json`), 'utf8')) + expect(patched.hostPermissions).toEqual([]) + expect(patched.optionalPermissions).toEqual([]) + expect(RegistryPluginSchema.safeParse(patched).success).toBe(true) + }) + + it('syncs the engines range from the manifest', async () => { + const id = 'motrix.filename-template' + await writeStaleEntry(id, { engines: { motrix: '>=1.0.0 <2.0.0' } }) + const { rel, tag } = releaseFor({ + manifestVersion: 1, + id, + version: '1.1.0', + main: 'dist/plugin.js', + engines: { motrix: '>=2.0.0 <3.0.0' }, + permissions: ['fs.task.write'], + }) + await patchEntry(id, buildPackageBlock(rel, tag, PUB), dir) + const patched = JSON.parse(await readFile(path.join(dir, `${id}.json`), 'utf8')) + expect(patched.engines).toEqual({ motrix: '>=2.0.0 <3.0.0' }) + }) + + it('leaves editorial fields (name, description, categories, updatedAt) untouched', async () => { + const id = 'motrix.filename-template' + await writeStaleEntry(id, { hostPermissions: ['*://*/*'] }) + const before = JSON.parse(await readFile(path.join(dir, `${id}.json`), 'utf8')) + const { rel, tag } = releaseFor({ + manifestVersion: 1, + id, + version: '1.1.0', + main: 'dist/plugin.js', + engines: { motrix: '>=2.0.0 <3.0.0' }, + permissions: ['fs.task.write'], + }) + await patchEntry(id, buildPackageBlock(rel, tag, PUB), dir) + const patched = JSON.parse(await readFile(path.join(dir, `${id}.json`), 'utf8')) + expect(patched.name).toEqual(before.name) + expect(patched.description).toEqual(before.description) + expect(patched.categories).toEqual(before.categories) + expect(patched.updatedAt).toEqual(before.updatedAt) + }) +})