Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion scripts/entry-from-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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(
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -128,6 +195,7 @@ export function buildPackageBlock(
size: a.metadata.size,
signature: a.signatureB64,
},
mirror,
}
}

Expand All @@ -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(
Expand Down
194 changes: 194 additions & 0 deletions tests/entry-from-release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -54,6 +61,34 @@ function artifacts(over: Partial<ReleaseArtifacts> = {}): 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<string, unknown>): {
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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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<string, unknown>
): Promise<string> {
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)
})
})