import { createHash } from "node:crypto"; import { createRequire } from "node:module"; import { lstat, open, readFile, } from "node:fs/promises"; import path from "node:path"; import type { IconReference, PermissionDeclaration, PluginManifest, PluginPermission, } from "@netcatty/plugin-contract"; import Ajv2020, { type ErrorObject } from "ajv/dist/2020.js"; import addFormats from "ajv-formats"; import { valid, validRange } from "semver"; import { PACKAGE_LIMITS } from "./constants.js"; import { assertSafePackagePath } from "./packagePath.js"; const require = createRequire(import.meta.url); const schemaPath = require.resolve( "@netcatty/plugin-contract/schema/plugin-contract.schema.json", ); interface PluginContractSchema extends Record { readonly $defs: { readonly JsonValueLimits: { readonly const: { readonly maxDepth: number; readonly maxNodes: number; }; }; readonly ResourceScopedPermission: { readonly enum: readonly PluginPermission[]; }; }; } const contractSchema = JSON.parse( await readFile(schemaPath, "utf8"), ) as PluginContractSchema; const manifestJsonLimits = contractSchema.$defs.JsonValueLimits.const; if (!Number.isSafeInteger(manifestJsonLimits.maxDepth) || manifestJsonLimits.maxDepth < 1 || !Number.isSafeInteger(manifestJsonLimits.maxNodes) || manifestJsonLimits.maxNodes < 1) { throw new Error("Plugin contract JSON value limits are invalid"); } const ajv = new Ajv2020({ allErrors: true, strict: true }); addFormats(ajv); const validateSchema = ajv.compile(contractSchema); const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); const MAX_SETTING_PATTERN_LENGTH = 512; const MAX_RESTRICTED_SCHEMA_DEPTH = 8; const MAX_RESTRICTED_SCHEMA_NODES = 256; const RESTRICTED_SCHEMA_TYPES = new Set(["array", "boolean", "integer", "null", "number", "object", "string"]); const RESTRICTED_SCHEMA_KEYWORDS = new Set([ "additionalProperties", "const", "enum", "items", "maxItems", "maxLength", "maximum", "minItems", "minLength", "minimum", "properties", "required", "type", ]); const FORBIDDEN_SCHEMA_PROPERTY_NAMES = new Set(["__proto__", "constructor", "prototype"]); export interface ManifestValidationResult { readonly valid: boolean; readonly manifest?: PluginManifest; readonly errors: readonly string[]; } export interface ValidatedManifestSource { readonly manifest: PluginManifest; readonly size: number; readonly sha256: string; } function formatAjvError(error: ErrorObject): string { const location = error.instancePath || "/"; return `${location} ${error.message ?? "is invalid"}`; } function plainRecord(value: unknown): value is Record { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; } function settingPatternError(source: string): string | null { if (source.length < 1 || source.length > MAX_SETTING_PATTERN_LENGTH || /\(\?/u.test(source) || /\\(?:[1-9]|k<)/u.test(source) || /\)(?:[*+?]|\{\d+(?:,\d*)?\})/u.test(source)) { return "pattern uses an unsafe regular-expression feature"; } try { new RegExp(source, "u"); } catch { return "pattern is invalid"; } return null; } function restrictedSettingSchemaErrors(root: unknown, rootType: "array" | null = "array"): string[] { const errors: string[] = []; const stack: Array<{ schema: unknown; depth: number }> = [{ schema: root, depth: 0 }]; let nodes = 0; while (stack.length > 0) { const current = stack.pop(); if (!current || !plainRecord(current.schema)) { errors.push("valueSchema must contain plain schema objects"); continue; } nodes += 1; if (nodes > MAX_RESTRICTED_SCHEMA_NODES || current.depth > MAX_RESTRICTED_SCHEMA_DEPTH) { errors.push("valueSchema is too complex"); break; } const schema = current.schema; for (const keyword of Object.keys(schema)) { if (!RESTRICTED_SCHEMA_KEYWORDS.has(keyword)) errors.push(`valueSchema keyword is not allowed: ${keyword}`); } if (!RESTRICTED_SCHEMA_TYPES.has(schema.type as string)) { errors.push("every valueSchema node must declare one supported type"); } if (current.depth === 0 && rootType && schema.type !== rootType) { errors.push(`valueSchema root must use type ${rootType}`); } if (schema.type === "array") { if (!plainRecord(schema.items)) errors.push("array valueSchema nodes require one items schema"); else stack.push({ schema: schema.items, depth: current.depth + 1 }); } else if (schema.items !== undefined || schema.minItems !== undefined || schema.maxItems !== undefined) { errors.push("valueSchema array keywords require type array"); } if (schema.type === "object") { if (!plainRecord(schema.properties)) errors.push("object valueSchema nodes require properties"); else { for (const [name, child] of Object.entries(schema.properties)) { if (name.length < 1 || name.length > 128 || name.includes("\0") || FORBIDDEN_SCHEMA_PROPERTY_NAMES.has(name)) { errors.push(`valueSchema property name is invalid: ${name}`); } stack.push({ schema: child, depth: current.depth + 1 }); } if (schema.required !== undefined && (!Array.isArray(schema.required) || new Set(schema.required).size !== schema.required.length || schema.required.some((name) => typeof name !== "string" || !Object.hasOwn(schema.properties as object, name)))) { errors.push("valueSchema required fields are invalid"); } } if (schema.additionalProperties !== false) errors.push("object valueSchema nodes must deny additionalProperties"); } else if (schema.properties !== undefined || schema.required !== undefined || schema.additionalProperties !== undefined) { errors.push("valueSchema object keywords require type object"); } for (const name of ["minItems", "maxItems", "minLength", "maxLength"] as const) { if (schema[name] !== undefined && (!Number.isSafeInteger(schema[name]) || (schema[name] as number) < 0)) { errors.push(`valueSchema ${name} must be a non-negative safe integer`); } } for (const name of ["minimum", "maximum"] as const) { if (schema[name] !== undefined && (typeof schema[name] !== "number" || !Number.isFinite(schema[name]))) { errors.push(`valueSchema ${name} must be finite`); } } if (typeof schema.minItems === "number" && typeof schema.maxItems === "number" && schema.minItems > schema.maxItems) { errors.push("valueSchema minItems must not exceed maxItems"); } if (typeof schema.minLength === "number" && typeof schema.maxLength === "number" && schema.minLength > schema.maxLength) { errors.push("valueSchema minLength must not exceed maxLength"); } if (typeof schema.minimum === "number" && typeof schema.maximum === "number" && schema.minimum > schema.maximum) { errors.push("valueSchema minimum must not exceed maximum"); } if (schema.type === "integer" && typeof schema.minimum === "number" && typeof schema.maximum === "number" && Math.ceil(schema.minimum) > Math.floor(schema.maximum)) { errors.push("valueSchema integer range must contain a valid integer"); } if (schema.type !== "string" && (schema.minLength !== undefined || schema.maxLength !== undefined)) { errors.push("valueSchema string keywords require type string"); } if (!["integer", "number"].includes(String(schema.type)) && (schema.minimum !== undefined || schema.maximum !== undefined)) { errors.push("valueSchema numeric keywords require type number or integer"); } if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length < 1 || schema.enum.length > 256)) { errors.push("valueSchema enum is invalid"); } } return [...new Set(errors)]; } function assertManifestJsonStructure(root: unknown): void { const stack: Array<{ value: unknown; depth: number }> = [{ value: root, depth: 0 }]; const seen = new WeakSet(); let nodes = 0; while (stack.length > 0) { const current = stack.pop(); if (!current) break; if (current.depth > manifestJsonLimits.maxDepth) { throw new RangeError( `JSON values must not exceed ${manifestJsonLimits.maxDepth} levels of nesting`, ); } nodes += 1; if (nodes > manifestJsonLimits.maxNodes) { throw new RangeError( `JSON values must not contain more than ${manifestJsonLimits.maxNodes} nodes`, ); } const { value } = current; if (value === null || typeof value === "string" || typeof value === "boolean") continue; if (typeof value === "number") { if (!Number.isFinite(value)) throw new TypeError("JSON numbers must be finite"); continue; } if (typeof value !== "object") { throw new TypeError(`Unsupported JSON value type: ${typeof value}`); } if (seen.has(value)) throw new TypeError("JSON values must not contain shared references"); seen.add(value); const nextDepth = current.depth + 1; if (Array.isArray(value)) { const keys = Object.keys(value); const ownKeys = Reflect.ownKeys(value); if (keys.length !== value.length || ownKeys.length !== value.length + 1) { throw new TypeError("JSON arrays must be dense and contain no named properties"); } for (let index = value.length - 1; index >= 0; index -= 1) { const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) { throw new TypeError("JSON arrays must contain enumerable data properties only"); } stack.push({ value: descriptor.value, depth: nextDepth }); } continue; } const prototype = Object.getPrototypeOf(value); if (prototype !== Object.prototype && prototype !== null) { throw new TypeError("JSON objects must be plain records"); } const stringKeys = Object.keys(value); const ownKeys = Reflect.ownKeys(value); if (ownKeys.length !== stringKeys.length) { throw new TypeError("JSON objects must not contain symbols or non-enumerable properties"); } for (let index = stringKeys.length - 1; index >= 0; index -= 1) { const key = stringKeys[index]; const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor || !("value" in descriptor)) { throw new TypeError("JSON objects must not contain accessor properties"); } stack.push({ value: descriptor.value, depth: nextDepth }); } } } function permissionName(declaration: PermissionDeclaration): PluginPermission { return typeof declaration === "string" ? declaration : declaration.permission; } function permissionResourceLimit(permission: PluginPermission): number { if (permission === "filesystem.read" || permission === "filesystem.write") return 8_192; if (permission === "companion.execute") return 192; return 2_048; } const resourceScopedPermissions = new Set( contractSchema.$defs.ResourceScopedPermission.enum, ); function validateRequiredPermissionBounds(value: unknown): string[] { if (!value || typeof value !== "object" || Array.isArray(value)) return []; const permissions = (value as { permissions?: unknown }).permissions; if (!permissions || typeof permissions !== "object" || Array.isArray(permissions)) return []; const required = (permissions as { required?: unknown }).required; if (!Array.isArray(required)) return []; return required.flatMap((declaration) => { if (typeof declaration === "string") { return resourceScopedPermissions.has(declaration as PluginPermission) ? [`Required permission ${declaration} must declare explicit resources`] : []; } if (!declaration || typeof declaration !== "object" || Array.isArray(declaration)) return []; const permission = (declaration as { permission?: unknown }).permission; const resources = (declaration as { resources?: unknown }).resources; return typeof permission === "string" && resourceScopedPermissions.has(permission as PluginPermission) && Array.isArray(resources) && resources.includes("*") ? [`Required permission ${permission} must not use the wildcard resource`] : []; }); } function validatePermissionResourceLimits(manifest: PluginManifest): string[] { const errors: string[] = []; for (const declaration of [ ...(manifest.permissions?.required ?? []), ...(manifest.permissions?.optional ?? []), ]) { if (typeof declaration === "string") continue; const maximum = permissionResourceLimit(declaration.permission); for (const resource of declaration.resources) { if (resource.length > maximum) { errors.push( `Permission resource for ${declaration.permission} exceeds ${maximum} characters`, ); } } } return errors; } function findDuplicateIds(manifest: PluginManifest): string[] { const errors: string[] = []; const groups = [ ["settings", manifest.contributes?.settings], ["commands", manifest.contributes?.commands], ["views", manifest.contributes?.views], ["providers", manifest.contributes?.providers], ["companionExecutables", manifest.companionExecutables], ] as const; const ids = new Map(); for (const [groupName, contributions] of groups) { for (const contribution of contributions ?? []) { const previousGroup = ids.get(contribution.id); if (previousGroup) { errors.push( `Duplicate contribution id across ${previousGroup} and ${groupName}: ${contribution.id}`, ); } else { ids.set(contribution.id, groupName); } } } return errors; } function validateOwnedContributionIds(manifest: PluginManifest): string[] { const errors: string[] = []; const expectedPrefix = `${manifest.id}.`; const groups = [ ["setting", manifest.contributes?.settings], ["command", manifest.contributes?.commands], ["view", manifest.contributes?.views], ["provider", manifest.contributes?.providers], ["companion executable", manifest.companionExecutables], ] as const; for (const [kind, contributions] of groups) { for (const contribution of contributions ?? []) { if (!contribution.id.startsWith(expectedPrefix)) { errors.push( `${kind} id must start with the owning plugin id '${expectedPrefix}': ${contribution.id}`, ); } } } return errors; } function validateSemantics(manifest: PluginManifest): string[] { const errors = [ ...findDuplicateIds(manifest), ...validateOwnedContributionIds(manifest), ...validatePermissionResourceLimits(manifest), ]; for (const [engine, range] of Object.entries(manifest.engines)) { if (validRange(range) === null) { errors.push(`Invalid ${engine} engine semver range: ${range}`); } } if (valid(manifest.version) === null) { errors.push(`Invalid plugin semantic version: ${manifest.version}`); } const requiredFeatures = new Set(manifest.features?.required ?? []); for (const feature of manifest.features?.optional ?? []) { if (requiredFeatures.has(feature)) { errors.push(`Feature cannot be both required and optional: ${feature}`); } } const companionPaths = new Set(); for (const companion of manifest.companionExecutables ?? []) { const companionPlatforms = new Set(); for (const variant of companion.variants) { if (companionPaths.has(variant.path)) { errors.push(`Duplicate companion executable path: ${variant.path}`); } companionPaths.add(variant.path); for (const platform of variant.platforms) { if (companionPlatforms.has(platform)) { errors.push(`Duplicate companion platform for ${companion.id}: ${platform}`); } companionPlatforms.add(platform); } } } const requiredPermissions = new Set( (manifest.permissions?.required ?? []).map(permissionName), ); if (requiredPermissions.size !== (manifest.permissions?.required ?? []).length) { errors.push("Required permissions must not contain duplicate permission names"); } const optionalPermissions = new Set( (manifest.permissions?.optional ?? []).map(permissionName), ); if (optionalPermissions.size !== (manifest.permissions?.optional ?? []).length) { errors.push("Optional permissions must not contain duplicate permission names"); } const declaredPermissions = new Set([ ...requiredPermissions, ...optionalPermissions, ]); for (const declaration of manifest.permissions?.optional ?? []) { const permission = permissionName(declaration); if (requiredPermissions.has(permission)) { errors.push(`Permission cannot be both required and optional: ${permission}`); } } const requirePermission = (condition: boolean, permission: PluginPermission, reason: string) => { if (condition && !declaredPermissions.has(permission)) { errors.push(`${reason} requires declared permission: ${permission}`); } }; requirePermission( Boolean(manifest.contributes?.settings?.length), "settings.read", "Setting contributions", ); requirePermission( Boolean(manifest.main.node), "runtime.advanced", "Node utility entrypoints", ); requirePermission( Boolean(manifest.contributes?.commands?.length), "commands", "Command contributions", ); requirePermission(Boolean(manifest.contributes?.menus?.length), "menus", "Menu contributions"); requirePermission(Boolean(manifest.contributes?.views?.length), "views", "View contributions"); requirePermission( Boolean(manifest.companionExecutables?.length), "companion.execute", "Companion executables", ); requirePermission( Boolean(manifest.companionExecutables?.length), "runtime.advanced", "Companion executables", ); if (manifest.companionExecutables?.length && !manifest.main.node) { errors.push("Companion executables require a Node utility entrypoint"); } const providerPermissions = new Map([ ["terminal.completion", ["provider.terminal", "terminal.complete"]], ["terminal.decoration", ["provider.terminal", "terminal.output", "terminal.decorate"]], ["terminal.link", ["provider.terminal", "terminal.output", "terminal.decorate"]], ["terminal.hover", ["provider.terminal", "terminal.output", "terminal.decorate"]], ["terminal.matcher", ["provider.terminal", "terminal.output", "terminal.decorate"]], ["terminal.semantic", ["provider.terminal", "terminal.input", "terminal.decorate"]], ["terminal.prompt", ["provider.terminal", "terminal.output", "terminal.decorate"]], ["terminal.background", ["provider.terminal", "terminal.decorate"]], ["terminal.theme", ["provider.terminal", "terminal.decorate"]], ["terminal.interceptor.input", ["provider.terminal", "terminal.intercept.input"]], ["terminal.interceptor.output", ["provider.terminal", "terminal.intercept.output"]], ["connection", ["provider.connection"]], ["authentication", ["provider.authentication"]], ["sync", ["provider.sync"]], ["importer", ["provider.importer"]], ]); for (const provider of manifest.contributes?.providers ?? []) { for (const permission of providerPermissions.get(provider.kind) ?? ["provider.terminal"]) { requirePermission(true, permission, `Provider ${provider.id}`); } if (provider.kind === "terminal.interceptor.input" || provider.kind === "terminal.interceptor.output") { requirePermission(true, "runtime.advanced", `Provider ${provider.id}`); if (!manifest.main.node) { errors.push(`Provider ${provider.id} requires a Node utility entrypoint`); } } if (provider.configurationSchema !== undefined) { const schemaErrors = restrictedSettingSchemaErrors(provider.configurationSchema, null); for (const error of schemaErrors) { errors.push(`Provider ${provider.id} configurationSchema ${error}`); } } } for (const companion of manifest.companionExecutables ?? []) { for (const permission of companion.permissions ?? []) { requirePermission( true, permission, `Companion executable ${companion.id}`, ); } } const commandIds = new Set((manifest.contributes?.commands ?? []).map(({ id }) => id)); const viewIds = new Set((manifest.contributes?.views ?? []).map(({ id }) => id)); const providerIds = new Set((manifest.contributes?.providers ?? []).map(({ id }) => id)); for (const menu of manifest.contributes?.menus ?? []) { if (!commandIds.has(menu.command)) { errors.push(`Menu references an undeclared command: ${menu.command}`); } if (menu.alt && !commandIds.has(menu.alt)) { errors.push(`Menu references an undeclared alternate command: ${menu.alt}`); } } for (const keybinding of manifest.contributes?.keybindings ?? []) { if (!commandIds.has(keybinding.command)) { errors.push(`Keybinding references an undeclared command: ${keybinding.command}`); } } for (const activationEvent of manifest.activationEvents ?? []) { const targets = [ ["onCommand:", "command", commandIds], ["onView:", "view", viewIds], ["onProvider:", "provider", providerIds], ] as const; for (const [prefix, kind, ids] of targets) { if (activationEvent.startsWith(prefix)) { const id = activationEvent.slice(prefix.length); if (!ids.has(id)) errors.push(`Activation event references an undeclared ${kind}: ${id}`); } } } for (const setting of manifest.contributes?.settings ?? []) { if (setting.secret && setting.default !== undefined) { errors.push(`Secret setting must not declare a default value: ${setting.id}`); } if (["radio", "select", "multiselect"].includes(setting.control) && !setting.options?.length) { errors.push(`${setting.control} setting requires options: ${setting.id}`); } const optionValues = new Set(); for (const option of setting.options ?? []) { if (optionValues.has(option.value)) { errors.push(`${setting.control} setting has duplicate option value '${option.value}': ${setting.id}`); } optionValues.add(option.value); } if (!["radio", "select", "multiselect"].includes(setting.control) && setting.options !== undefined) { errors.push(`${setting.control} setting must not declare options: ${setting.id}`); } if (["number", "slider"].includes(setting.control) && setting.minimum !== undefined && setting.maximum !== undefined && setting.minimum > setting.maximum) { errors.push(`${setting.control} setting minimum exceeds maximum: ${setting.id}`); } if (setting.control === "slider" && (setting.minimum === undefined || setting.maximum === undefined)) { errors.push(`slider setting requires minimum and maximum: ${setting.id}`); } if (!["number", "slider"].includes(setting.control) && (setting.minimum !== undefined || setting.maximum !== undefined || setting.step !== undefined)) { errors.push(`${setting.control} setting must not declare numeric bounds: ${setting.id}`); } if (setting.pattern !== undefined && !["text", "textarea", "password"].includes(setting.control)) { errors.push(`${setting.control} setting must not declare a text pattern: ${setting.id}`); } if (setting.pattern !== undefined) { const patternError = settingPatternError(setting.pattern); if (patternError) errors.push(`${setting.control} setting ${patternError}: ${setting.id}`); else if (typeof setting.default === "string" && !new RegExp(setting.pattern, "u").test(setting.default)) { errors.push(`${setting.control} setting default does not match its pattern: ${setting.id}`); } } if (setting.control === "password" && !setting.secret) { errors.push(`password setting must be marked secret: ${setting.id}`); } if (setting.secret && setting.control !== "password") { errors.push(`Secret setting must use the password control: ${setting.id}`); } if (setting.secret && setting.sync) { errors.push(`Secret setting must not be cloud-synced: ${setting.id}`); } if (setting.sync && ["device", "session"].includes(setting.scope)) { errors.push(`${setting.scope}-scoped setting must not be cloud-synced: ${setting.id}`); } if (setting.sync && ["file", "directory"].includes(setting.control)) { errors.push(`${setting.control} setting paths must not be cloud-synced: ${setting.id}`); } if (["list", "table"].includes(setting.control) && setting.valueSchema === undefined) { errors.push(`${setting.control} setting requires valueSchema: ${setting.id}`); } if (["list", "table"].includes(setting.control) && setting.valueSchema !== undefined) { const schemaErrors = restrictedSettingSchemaErrors(setting.valueSchema); for (const error of schemaErrors) errors.push(`${setting.control} setting ${error}: ${setting.id}`); if (schemaErrors.length === 0) { try { const validateDefault = ajv.compile(setting.valueSchema as object); if (setting.default !== undefined && !validateDefault(setting.default)) { errors.push(`${setting.control} setting default does not match valueSchema: ${setting.id}`); } } catch (error) { errors.push(`${setting.control} setting valueSchema is invalid: ${setting.id}: ${error instanceof Error ? error.message : String(error)}`); } } } if (!["list", "table"].includes(setting.control) && setting.valueSchema !== undefined) { errors.push(`${setting.control} setting must not declare valueSchema: ${setting.id}`); } if (!["list", "table"].includes(setting.control) && setting.sortable) { errors.push(`${setting.control} setting must not be sortable: ${setting.id}`); } if (setting.default !== undefined) { const stringControls = [ "radio", "select", "text", "textarea", "password", "color", "font", "file", "directory", "keybinding", ]; if (setting.control === "switch" && typeof setting.default !== "boolean") { errors.push(`switch setting default must be boolean: ${setting.id}`); } if (stringControls.includes(setting.control) && typeof setting.default !== "string") { errors.push(`${setting.control} setting default must be a string: ${setting.id}`); } if (["number", "slider"].includes(setting.control) && (typeof setting.default !== "number" || !Number.isFinite(setting.default))) { errors.push(`${setting.control} setting default must be a finite number: ${setting.id}`); } if (["list", "table"].includes(setting.control) && !Array.isArray(setting.default)) { errors.push(`${setting.control} setting default must be an array: ${setting.id}`); } if (setting.control === "multiselect") { if (!Array.isArray(setting.default) || setting.default.some((value) => typeof value !== "string")) { errors.push(`multiselect setting default must be a string array: ${setting.id}`); } else { const selected = new Set(); for (const value of setting.default as string[]) { if (!optionValues.has(value)) { errors.push(`multiselect setting default uses an undeclared option '${value}': ${setting.id}`); } if (selected.has(value)) { errors.push(`multiselect setting default repeats option '${value}': ${setting.id}`); } selected.add(value); } } } if (["radio", "select"].includes(setting.control) && typeof setting.default === "string" && !optionValues.has(setting.default)) { errors.push(`${setting.control} setting default uses an undeclared option '${setting.default}': ${setting.id}`); } if (typeof setting.default === "number" && Number.isFinite(setting.default)) { if (setting.minimum !== undefined && setting.default < setting.minimum) { errors.push(`${setting.control} setting default is below minimum: ${setting.id}`); } if (setting.maximum !== undefined && setting.default > setting.maximum) { errors.push(`${setting.control} setting default is above maximum: ${setting.id}`); } if (setting.step !== undefined) { const offset = setting.default - (setting.minimum ?? 0); const steps = offset / setting.step; if (Math.abs(steps - Math.round(steps)) > 1e-9) { errors.push(`${setting.control} setting default does not align to step: ${setting.id}`); } } } } } for (const entryPath of [ manifest.main.browser, manifest.main.node, ...(manifest.contributes?.views ?? []).map(({ entry }) => entry), ...(manifest.contributes?.commands ?? []).flatMap(({ icon }) => packageIconPaths(icon)), ...(manifest.contributes?.menus ?? []).flatMap(({ icon }) => packageIconPaths(icon)), ...(manifest.contributes?.views ?? []).flatMap(({ icon }) => packageIconPaths(icon)), ...(manifest.companionExecutables ?? []).flatMap(({ variants }) => ( variants.map(({ path: companionPath }) => companionPath) )), ]) { if (entryPath) { try { assertSafePackagePath(entryPath); } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); } } } return errors; } function packageIconPaths(icon: IconReference | undefined): string[] { if (icon?.kind !== "package") return []; return icon.dark ? [icon.light, icon.dark] : [icon.light]; } export function validateManifestValue(value: unknown): ManifestValidationResult { try { assertManifestJsonStructure(value); } catch (error) { return { valid: false, errors: [error instanceof Error ? error.message : String(error)], }; } const requiredPermissionErrors = validateRequiredPermissionBounds(value); if (requiredPermissionErrors.length > 0) { return { valid: false, errors: requiredPermissionErrors }; } if (!validateSchema(value)) { return { valid: false, errors: (validateSchema.errors ?? []).map(formatAjvError), }; } const semanticErrors = validateSemantics(value); return semanticErrors.length === 0 ? { valid: true, manifest: value, errors: [] } : { valid: false, errors: semanticErrors }; } export function parseAndValidateManifestContents(contents: Uint8Array): PluginManifest { if (contents.byteLength > PACKAGE_LIMITS.manifestBytes) { throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`); } let value: unknown; try { value = JSON.parse(utf8Decoder.decode(contents)); } catch (error) { throw new Error( `Plugin manifest is not valid UTF-8 JSON: ${error instanceof Error ? error.message : String(error)}`, ); } const result = validateManifestValue(value); if (!result.valid || !result.manifest) { throw new Error(`Plugin manifest is invalid:\n- ${result.errors.join("\n- ")}`); } return result.manifest; } export async function readValidatedManifestSource( pluginDirectory: string, ): Promise { const manifestPath = path.join(pluginDirectory, "netcatty.plugin.json"); const initialStats = await lstat(manifestPath); if (!initialStats.isFile()) { throw new Error("Plugin manifest must be a regular file"); } if (initialStats.size > PACKAGE_LIMITS.manifestBytes) { throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`); } const handle = await open(manifestPath, "r"); let contents: Uint8Array; try { const [openedStats, currentStats] = await Promise.all([ handle.stat(), lstat(manifestPath), ]); if (!openedStats.isFile() || !currentStats.isFile()) { throw new Error("Plugin manifest must be a regular file"); } if (openedStats.dev !== currentStats.dev || openedStats.ino !== currentStats.ino) { throw new Error("Plugin manifest changed while being opened"); } if (openedStats.size > PACKAGE_LIMITS.manifestBytes) { throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`); } const buffer = new Uint8Array(PACKAGE_LIMITS.manifestBytes + 1); let bytesRead = 0; while (bytesRead < buffer.byteLength) { const result = await handle.read( buffer, bytesRead, buffer.byteLength - bytesRead, null, ); if (result.bytesRead === 0) break; bytesRead += result.bytesRead; } if (bytesRead > PACKAGE_LIMITS.manifestBytes) { throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`); } contents = buffer.subarray(0, bytesRead); } finally { await handle.close(); } return { manifest: parseAndValidateManifestContents(contents), size: contents.byteLength, sha256: createHash("sha256").update(contents).digest("hex"), }; } export async function readAndValidateManifest( pluginDirectory: string, ): Promise { return (await readValidatedManifestSource(pluginDirectory)).manifest; }