diff --git a/src/main/plugins/plugin-content-hash.ts b/src/main/plugins/plugin-content-hash.ts index 719c23fcb87..1237b6c47fa 100644 --- a/src/main/plugins/plugin-content-hash.ts +++ b/src/main/plugins/plugin-content-hash.ts @@ -12,8 +12,8 @@ import { pluginPathSegmentError } from '../../shared/plugins/plugin-path-safety' * are refused outright (installed trees must be self-contained). */ -const MAX_PLUGIN_FILES = 2_000 -const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024 +export const MAX_PLUGIN_FILES = 2_000 +export const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024 type PluginFile = { path: string; size: number } diff --git a/src/main/plugins/plugin-content-integrity.ts b/src/main/plugins/plugin-content-integrity.ts index 19f68cae680..2fecb145927 100644 --- a/src/main/plugins/plugin-content-integrity.ts +++ b/src/main/plugins/plugin-content-integrity.ts @@ -7,6 +7,12 @@ export type HashAddressedPluginContent = { export type PluginContentIntegrityResult = { ok: true } | { ok: false; error: string } +/** Whether a computed tree hash is the recorded identity. Early P0 installs used a + * 128-bit SHA-256 prefix as the directory name; new installs use the full digest. */ +export function pluginTreeHashMatches(actual: string, expected: string): boolean { + return actual === expected || (expected.length === 32 && actual.startsWith(expected)) +} + /** Dev trees are intentionally mutable; installed hash-addressed trees are not. */ export async function verifyHashAddressedPluginContent( plugin: HashAddressedPluginContent @@ -18,12 +24,7 @@ export async function verifyHashAddressedPluginContent( if (!actual.ok) { return { ok: false, error: actual.error } } - const matchesCurrentHash = actual.hash === plugin.contentHash - // Early P0 installs used a 128-bit SHA-256 prefix as the directory name. - // Honor that existing address while all new installs use the full digest. - const matchesLegacyPrefix = - plugin.contentHash.length === 32 && actual.hash.startsWith(plugin.contentHash) - if (!matchesCurrentHash && !matchesLegacyPrefix) { + if (!pluginTreeHashMatches(actual.hash, plugin.contentHash)) { return { ok: false, error: `content hash mismatch (expected ${plugin.contentHash}, got ${actual.hash})` diff --git a/src/main/plugins/plugin-instructional-content-integrity.ts b/src/main/plugins/plugin-instructional-content-integrity.ts index fe2c9a3dc01..5c361dd330b 100644 --- a/src/main/plugins/plugin-instructional-content-integrity.ts +++ b/src/main/plugins/plugin-instructional-content-integrity.ts @@ -1,5 +1,6 @@ import { hasInstructionalPluginContributions } from '../../shared/plugins/plugin-consent-fingerprint' import { hashPluginTree } from './plugin-content-hash' +import { pluginTreeHashMatches } from './plugin-content-integrity' import type { ValidDiscoveredPlugin } from './plugin-discovery' /** Instructional bytes execute later, so every read must still match the tree @@ -19,10 +20,7 @@ export async function verifyInstructionalPluginContent( `plugin ${plugin.pluginKey} instructional content is unreadable: ${actual.error}` ) } - const matches = - actual.hash === plugin.consentContentHash || - (plugin.consentContentHash.length === 32 && actual.hash.startsWith(plugin.consentContentHash)) - if (!matches) { + if (!pluginTreeHashMatches(actual.hash, plugin.consentContentHash)) { throw new Error( `plugin ${plugin.pluginKey} instructional content changed since it was reviewed` ) diff --git a/src/main/plugins/plugin-marketplace-fetch.ts b/src/main/plugins/plugin-marketplace-fetch.ts index d035841eb84..bfc719c61c8 100644 --- a/src/main/plugins/plugin-marketplace-fetch.ts +++ b/src/main/plugins/plugin-marketplace-fetch.ts @@ -40,24 +40,32 @@ export async function fetchPluginMarketplace( export async function readPluginMarketplaceIndex( rootDirectory: string ): Promise { - const path = join(rootDirectory, PLUGIN_MARKETPLACE_FILENAME) + const raw = await readMarketplaceJson(rootDirectory, PLUGIN_MARKETPLACE_FILENAME) + return parseMarketplaceFile(PLUGIN_MARKETPLACE_FILENAME, () => pluginMarketplaceSchema.parse(raw)) +} + +async function readMarketplaceJson(rootDirectory: string, filename: string): Promise { const chunks: Buffer[] = [] let totalBytes = 0 - for await (const chunk of createReadStream(path)) { + for await (const chunk of createReadStream(join(rootDirectory, filename))) { const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) totalBytes += bytes.byteLength if (totalBytes > MARKETPLACE_INDEX_MAX_BYTES) { - throw new Error(`${PLUGIN_MARKETPLACE_FILENAME} exceeds its size limit`) + throw new Error(`${filename} exceeds its size limit`) } chunks.push(bytes) } + return parseMarketplaceFile(filename, () => + JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8')) + ) +} + +function parseMarketplaceFile(filename: string, parse: () => T): T { try { - return pluginMarketplaceSchema.parse( - JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8')) - ) + return parse() } catch (error) { throw new Error( - `invalid ${PLUGIN_MARKETPLACE_FILENAME}: ${error instanceof Error ? error.message : String(error)}` + `invalid ${filename}: ${error instanceof Error ? error.message : String(error)}` ) } } diff --git a/src/shared/plugins/plugin-manifest.ts b/src/shared/plugins/plugin-manifest.ts index aa7bae64fc4..5c30d974a65 100644 --- a/src/shared/plugins/plugin-manifest.ts +++ b/src/shared/plugins/plugin-manifest.ts @@ -73,62 +73,61 @@ const eventContributionSchema = z.object({ on: z.enum(PLUGIN_EVENT_NAMES) }) -export const pluginManifestSchema = z - .object({ - manifestVersion: z.literal(1), - id: pluginIdSchema, - /** Publisher slug; canonical identity is `.` — bare-id - * global uniqueness is unverifiable without a registry. */ - publisher: pluginIdSchema, - name: z.string().min(1).max(256), - version: z.string().regex(SEMVER_RE, 'must be semver'), - description: z.string().max(4096).optional(), - author: z - .object({ name: z.string().min(1).max(256), url: z.string().max(2048).optional() }) - .optional(), - repository: z.string().max(2048).optional(), - icon: pluginRelativePathSchema.optional(), - /** Minimum host version gate; the host refuses to load below it. */ - engines: z.object({ orca: orcaEngineRangeSchema }), - /** Host-API major version this plugin targets. */ - pluginApi: z.literal(1), - /** Node entry executed inside the out-of-process plugin worker. */ - main: pluginRelativePathSchema.optional(), - contributes: z - .object({ - panels: z.array(panelContributionSchema).max(PLUGIN_PANEL_LIMIT).default([]), - commands: z.array(commandContributionSchema).max(PLUGIN_COMMAND_LIMIT).default([]), - events: z.array(eventContributionSchema).max(PLUGIN_EVENT_SUBSCRIPTION_LIMIT).default([]), - languagePacks: z - .array(pluginLanguagePackContributionSchema) - .max(PLUGIN_LANGUAGE_PACK_LIMIT) - .default([]), - keybindings: z - .array(pluginKeybindingContributionSchema) - .max(PLUGIN_KEYBINDING_LIMIT) - .default([]), - vmRecipes: z - .array(pluginVmRecipeContributionSchema) - .max(PLUGIN_VM_RECIPE_LIMIT) - .default([]), - agents: z - .array(pluginAgentProfileContributionSchema) - .max(PLUGIN_AGENT_PROFILE_LIMIT) - .default([]) - }) - .strict() - .default(() => ({ - panels: [], - commands: [], - events: [], - languagePacks: [], - keybindings: [], - vmRecipes: [], - agents: [] - })), - capabilities: z.array(pluginCapabilitySchema).max(32).default([]) - }) - .superRefine(validatePluginManifestContributions) +const pluginManifestV1Object = z.object({ + manifestVersion: z.literal(1), + id: pluginIdSchema, + /** Publisher slug; canonical identity is `.` — bare-id + * global uniqueness is unverifiable without a registry. */ + publisher: pluginIdSchema, + name: z.string().min(1).max(256), + version: z.string().regex(SEMVER_RE, 'must be semver'), + description: z.string().max(4096).optional(), + author: z + .object({ name: z.string().min(1).max(256), url: z.string().max(2048).optional() }) + .optional(), + repository: z.string().max(2048).optional(), + icon: pluginRelativePathSchema.optional(), + /** Minimum host version gate; the host refuses to load below it. */ + engines: z.object({ orca: orcaEngineRangeSchema }), + /** Host-API major version this plugin targets. */ + pluginApi: z.literal(1), + /** Node entry executed inside the out-of-process plugin worker. */ + main: pluginRelativePathSchema.optional(), + contributes: z + .object({ + panels: z.array(panelContributionSchema).max(PLUGIN_PANEL_LIMIT).default([]), + commands: z.array(commandContributionSchema).max(PLUGIN_COMMAND_LIMIT).default([]), + events: z.array(eventContributionSchema).max(PLUGIN_EVENT_SUBSCRIPTION_LIMIT).default([]), + languagePacks: z + .array(pluginLanguagePackContributionSchema) + .max(PLUGIN_LANGUAGE_PACK_LIMIT) + .default([]), + keybindings: z + .array(pluginKeybindingContributionSchema) + .max(PLUGIN_KEYBINDING_LIMIT) + .default([]), + vmRecipes: z.array(pluginVmRecipeContributionSchema).max(PLUGIN_VM_RECIPE_LIMIT).default([]), + agents: z + .array(pluginAgentProfileContributionSchema) + .max(PLUGIN_AGENT_PROFILE_LIMIT) + .default([]) + }) + .strict() + .default(() => ({ + panels: [], + commands: [], + events: [], + languagePacks: [], + keybindings: [], + vmRecipes: [], + agents: [] + })), + capabilities: z.array(pluginCapabilitySchema).max(32).default([]) +}) + +export const pluginManifestSchema = pluginManifestV1Object.superRefine( + validatePluginManifestContributions +) export type PluginManifest = z.infer export type PluginPanelContribution = z.infer diff --git a/src/shared/plugins/plugin-marketplace.ts b/src/shared/plugins/plugin-marketplace.ts index 98d7eb8059b..3fc2ea732c6 100644 --- a/src/shared/plugins/plugin-marketplace.ts +++ b/src/shared/plugins/plugin-marketplace.ts @@ -53,50 +53,63 @@ export const pluginMarketplaceGitSourceSchema = z.strictObject({ ref: z.string().trim().min(1).max(4_096) }) -export const pluginMarketplaceEntrySchema = z - .strictObject({ - /** Canonical `.` identity expected in the source manifest. */ - id: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'), - source: pluginMarketplaceGitSourceSchema, - description: z.string().min(1).max(4_096).optional(), - categories: z - .array(marketplaceCategorySchema) - .max(PLUGIN_MARKETPLACE_CATEGORY_LIMIT) - .default([]) - }) - .superRefine((entry, context) => { - const seen = new Set() - for (const [index, category] of entry.categories.entries()) { - if (seen.has(category)) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['categories', index], - message: `duplicate category: ${category}` - }) - } - seen.add(category) +const marketplaceEntryFields = { + /** Canonical `.` identity expected in the source manifest. */ + id: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'), + source: pluginMarketplaceGitSourceSchema, + description: z.string().min(1).max(4_096).optional(), + categories: z.array(marketplaceCategorySchema).max(PLUGIN_MARKETPLACE_CATEGORY_LIMIT).default([]) +} + +function rejectDuplicateCategories( + entry: { categories: string[] }, + context: z.RefinementCtx +): void { + const seen = new Set() + for (const [index, category] of entry.categories.entries()) { + if (seen.has(category)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['categories', index], + message: `duplicate category: ${category}` + }) } - }) + seen.add(category) + } +} + +function rejectDuplicatePluginIds( + marketplace: { plugins: { id: string }[] }, + context: z.RefinementCtx +): void { + const seen = new Set() + for (const [index, plugin] of marketplace.plugins.entries()) { + if (seen.has(plugin.id)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['plugins', index, 'id'], + message: `duplicate plugin id: ${plugin.id}` + }) + } + seen.add(plugin.id) + } +} + +export const pluginMarketplaceEntrySchema = z + .strictObject(marketplaceEntryFields) + .superRefine(rejectDuplicateCategories) + +const marketplaceHeaderFields = { + name: z.string().min(1).max(256), + owner: marketplaceOwnerSchema +} export const pluginMarketplaceSchema = z .strictObject({ - name: z.string().min(1).max(256), - owner: marketplaceOwnerSchema, + ...marketplaceHeaderFields, plugins: z.array(pluginMarketplaceEntrySchema).max(PLUGIN_MARKETPLACE_ENTRY_LIMIT) }) - .superRefine((marketplace, context) => { - const seen = new Set() - for (const [index, plugin] of marketplace.plugins.entries()) { - if (seen.has(plugin.id)) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['plugins', index, 'id'], - message: `duplicate plugin id: ${plugin.id}` - }) - } - seen.add(plugin.id) - } - }) + .superRefine(rejectDuplicatePluginIds) /** Host-derived trust metadata. Marketplace JSON cannot self-award either bit. */ export const pluginMarketplaceTrustMetadataSchema = z