Files
SenMeshNet/packages/plugin-cli/src/manifest.ts

805 lines
33 KiB
TypeScript
Raw Normal View History

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<string, unknown> {
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<PluginManifest>(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<string, unknown> {
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<object>();
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<PluginPermission>(
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<string, string>();
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<string>();
for (const companion of manifest.companionExecutables ?? []) {
const companionPlatforms = new Set<string>();
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<string, readonly PluginPermission[]>([
["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<string>();
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<string>();
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<ValidatedManifestSource> {
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<PluginManifest> {
return (await readValidatedManifestSource(pluginDirectory)).manifest;
}