Extract plugin manifest shape validation seam

This commit is contained in:
OpenPets Dev 2026-06-19 20:57:15 +00:00
parent 70336c6e30
commit 161ec56bf0
7 changed files with 174 additions and 109 deletions

View file

@ -113,6 +113,7 @@ const behaviorTests = [
".test-dist/tests/plugin-service-local-support.test.js",
".test-dist/tests/plugin-service-ui-helpers.test.js",
".test-dist/tests/plugin-manifest-validation-seams.test.js",
".test-dist/tests/plugin-manifest-shape-validation.test.js",
".test-dist/tests/plugins-view-config-list-editor.test.js",
".test-dist/tests/plugins-view-config-fields.test.js",
".test-dist/tests/plugin-ui-static.test.js",

View file

@ -223,6 +223,7 @@ const pluginServiceSnapshotSource = readFileSync(join(appDir, "src", "plugin-ser
const pluginServiceTextHelpersSource = readFileSync(join(appDir, "src", "plugin-service-text-helpers.ts"), "utf8");
const pluginManifestSource = readFileSync(join(appDir, "src", "plugin-manifest.ts"), "utf8");
const pluginManifestConfigValidationSource = readFileSync(join(appDir, "src", "plugin-manifest-config-validation.ts"), "utf8");
const pluginManifestShapeValidationSource = readFileSync(join(appDir, "src", "plugin-manifest-shape-validation.ts"), "utf8");
const loggerSource = readFileSync(join(appDir, "src", "logger.ts"), "utf8");
const mainSource = readFileSync(join(appDir, "src", "main.ts"), "utf8");
const localIpcSourceForLogging = readFileSync(join(appDir, "src", "local-ipc.ts"), "utf8");
@ -587,8 +588,19 @@ assert.match(pluginServiceSnapshotSource, /export function validatePluginService
assert.match(pluginServiceTextHelpersSource, /export async function ensurePluginCommandLocale/, "plugin-service text helper must export plugin locale preloading.");
assert.match(pluginServiceTextHelpersSource, /export function safeCommandError/, "plugin-service text helper must export command error sanitization.");
assert.match(pluginManifestSource, /from "\.\/plugin-manifest-config-validation(?:\.js)?"/, "plugin-manifest must import the extracted config/action validation seam.");
assert.match(pluginManifestSource, /from "\.\/plugin-manifest-shape-validation(?:\.js)?"/, "plugin-manifest must import the extracted shape validation seam.");
assert.match(pluginManifestConfigValidationSource, /export function validateConfigSchema/, "plugin-manifest config validation seam must export config schema validation.");
assert.match(pluginManifestConfigValidationSource, /export function validateTriggers/, "plugin-manifest config validation seam must export trigger/action validation.");
assert.match(pluginManifestShapeValidationSource, /export function validateAssets/, "plugin-manifest shape validation seam must export asset validation.");
assert.match(pluginManifestShapeValidationSource, /export function validatePanels/, "plugin-manifest shape validation seam must export panel validation.");
assert.match(pluginManifestShapeValidationSource, /export function validatePluginIcon/, "plugin-manifest shape validation seam must export icon validation.");
assert.match(pluginManifestShapeValidationSource, /export function validateEntryPath/, "plugin-manifest shape validation seam must export entry path validation.");
assert.match(pluginManifestShapeValidationSource, /export function validateNetwork/, "plugin-manifest shape validation seam must export network validation.");
assert.match(pluginManifestShapeValidationSource, /export function validatePermissions/, "plugin-manifest shape validation seam must export shared permission validation.");
assert.match(pluginManifestShapeValidationSource, /export function rejectUnknownFields/, "plugin-manifest shape validation seam must export unknown-field rejection.");
assert.match(pluginManifestShapeValidationSource, /export function validateString/, "plugin-manifest shape validation seam must export string validation.");
assert.match(pluginManifestShapeValidationSource, /export function addError/, "plugin-manifest shape validation seam must export manifest error shaping.");
assert.match(pluginManifestShapeValidationSource, /export function isRecord/, "plugin-manifest shape validation seam must export record guards.");
assert.match(controlCenterPluginsViewSource, /from "\.\/plugins-view-presentation"/, "plugins route must import the extracted presentation seam.");
assert.match(controlCenterPluginsViewSource, /from "\.\/plugins-view-config-helpers"/, "plugins route must import the extracted config helper seam.");
assert.match(controlCenterPluginsViewSource, /from "\.\/plugins-view-state"/, "plugins route must import the extracted route state seam.");

View file

@ -290,8 +290,9 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo
- `zip-safety.ts`: ZIP entry path validation (traversal prevention, case collision detection)
**Plugins**:
- `plugin-manifest.ts`: Manifest V1/V2 schema/types plus the top-level declarative/JavaScript entrypoint validation, permissions, asset/panel limits, and exported manifest constants.
- `plugin-manifest.ts`: Manifest V1/V2 schema/types plus the top-level declarative/JavaScript entrypoint validation, permissions, and exported manifest constants.
- `plugin-manifest-config-validation.ts`: Extracted config-schema plus timer trigger/action validation for plugin-manifest, including reaction-select config checks.
- `plugin-manifest-shape-validation.ts`: Extracted manifest shape validation for shared string/object guards, entry/icon/network checks, and v3 asset/panel declarations.
- `plugin-manifest-reader.ts`: Safe manifest reader with realpath/allowed-root checks, root filename enforcement, size limit, and expected id/version matching.
- `plugin-config.ts`: Config defaulting, replacement validation, and runtime resolution for string/number config references.
- `plugin-state.ts`: Persistent plugin state store (`familiaros-plugin-state.json`) with atomic temp+rename writes, normalized records, approved permissions, config, source, and broken reason.

View file

@ -0,0 +1,119 @@
type PluginAssetKind = "icons" | "images" | "svgs" | "sprites" | "sounds";
type PluginManifestValidationError = {
path: string;
code: string;
message: string;
};
type PluginAssetValidationOptions = {
readonly assetKinds: readonly PluginAssetKind[];
readonly assetExtensions: Record<PluginAssetKind, readonly string[]>;
readonly assetNamePattern: RegExp;
};
export function validateAssets(value: unknown, errors: PluginManifestValidationError[], options: PluginAssetValidationOptions): void {
if (value === undefined) return;
if (!isRecord(value)) return addError(errors, "$.assets", "invalid_assets", "assets must be an object.");
rejectUnknownFields(value, new Set(options.assetKinds), "$.assets", errors);
for (const kind of options.assetKinds) {
const group = value[kind];
if (group === undefined) continue;
if (!isRecord(group)) {
addError(errors, `$.assets.${kind}`, "invalid_assets", `assets.${kind} must be an object.`);
continue;
}
const entries = Object.entries(group);
if (entries.length > 32) addError(errors, `$.assets.${kind}`, "too_many_assets", `assets.${kind} may declare at most 32 entries.`);
for (const [name, assetPath] of entries) {
const path = `$.assets.${kind}.${name}`;
if (!options.assetNamePattern.test(name)) addError(errors, path, "invalid_asset_name", "Asset names must be simple lowercase identifiers.");
validateRelativeAssetPath(assetPath, options.assetExtensions[kind], path, errors);
}
}
}
export function validatePanels(value: unknown, errors: PluginManifestValidationError[], assetNamePattern: RegExp): void {
if (value === undefined) return;
if (!isRecord(value)) return addError(errors, "$.panels", "invalid_panels", "panels must be an object.");
const entries = Object.entries(value);
if (entries.length > 8) addError(errors, "$.panels", "too_many_panels", "panels may declare at most 8 entries.");
for (const [name, panelPath] of entries) {
const path = `$.panels.${name}`;
if (!assetNamePattern.test(name)) addError(errors, path, "invalid_panel_name", "Panel names must be simple lowercase identifiers.");
validateRelativeAssetPath(panelPath, [".html"], path, errors);
}
}
export function validatePluginIcon(value: unknown, errors: PluginManifestValidationError[], supportedPluginIcons: ReadonlySet<string>): void {
if (value === undefined) return;
if (typeof value !== "string" || !supportedPluginIcons.has(value)) addError(errors, "$.icon", "invalid_icon", "icon must be one of plugin, bell, timer, github, heart, sparkles, coffee, focus, or droplet.");
}
export function validateEntryPath(value: unknown, errors: PluginManifestValidationError[]): void {
validateString(value, "$.entry", "entry", errors);
if (typeof value !== "string") return;
if (value.startsWith("/") || value.includes("\\") || value.split("/").includes("..") || !/\.(?:mjs|js)$/.test(value)) addError(errors, "$.entry", "invalid_entry", "entry must be a relative .js or .mjs path.");
}
export function validateNetwork(value: unknown, errors: PluginManifestValidationError[]): void {
if (value === undefined) return;
if (!isRecord(value) || !Array.isArray(value.hosts)) return addError(errors, "$.network.hosts", "invalid_network_hosts", "network.hosts must be an array.");
rejectUnknownFields(value, new Set(["hosts"]), "$.network", errors);
const hosts = value.hosts;
hosts.forEach((host, index) => {
if (typeof host !== "string" || !/^[a-z0-9.-]+(?::\d{1,5})?$/i.test(host) || host.includes("*") || host.trim() !== host) {
addError(errors, `$.network.hosts[${index}]`, "invalid_network_host", "network hosts must be exact host names.");
} else if (hosts.indexOf(host) !== index) {
addError(errors, `$.network.hosts[${index}]`, "duplicate_network_host", "Duplicate network host.");
}
});
}
export function validatePermissions(value: unknown, errors: PluginManifestValidationError[], allowedPermissions: ReadonlySet<string>): Set<string> {
const permissions = new Set<string>();
if (!Array.isArray(value)) {
addError(errors, "$.permissions", "invalid_permissions", "permissions must be an array.");
return permissions;
}
value.forEach((permission, index) => {
if (typeof permission !== "string" || !allowedPermissions.has(permission)) {
addError(errors, `$.permissions[${index}]`, "invalid_permission", `Permission must be one of ${[...allowedPermissions].join(", ")}.`);
return;
}
if (permissions.has(permission)) {
addError(errors, `$.permissions[${index}]`, "duplicate_permission", `Duplicate permission ${permission}.`);
return;
}
permissions.add(permission);
});
return permissions;
}
export function rejectUnknownFields(record: Record<string, unknown>, allowed: Set<string>, path: string, errors: PluginManifestValidationError[]): void {
for (const key of Object.keys(record)) {
if (!allowed.has(key)) addError(errors, `${path}.${key}`, "unknown_field", `Unknown field ${key}.`);
}
}
export function validateString(value: unknown, path: string, label: string, errors: PluginManifestValidationError[], pattern?: RegExp): void {
if (typeof value !== "string" || value.trim() === "") return addError(errors, path, "invalid_string", `${label} must be a non-empty string.`);
if (pattern && !pattern.test(value)) addError(errors, path, "invalid_format", `${label} has an invalid format.`);
}
export function addError(errors: PluginManifestValidationError[], path: string, code: string, message: string): void {
errors.push({ path, code, message });
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function validateRelativeAssetPath(value: unknown, allowedExtensions: readonly string[], path: string, errors: PluginManifestValidationError[]): void {
if (typeof value !== "string" || value.trim() === "") return addError(errors, path, "invalid_asset_path", "Asset path must be a non-empty string.");
if (value.startsWith("/") || value.includes("\\") || value.split("/").includes("..") || value.split("/").includes(".")) {
return addError(errors, path, "invalid_asset_path", "Asset path must be a safe relative path.");
}
if (!allowedExtensions.some((extension) => value.toLowerCase().endsWith(extension))) {
addError(errors, path, "invalid_asset_format", `Asset must end with one of ${allowedExtensions.join(", ")}.`);
}
}

View file

@ -2,6 +2,18 @@ import {
validateConfigSchema,
validateTriggers,
} from "./plugin-manifest-config-validation.js";
import {
addError,
isRecord,
rejectUnknownFields,
validateAssets,
validateEntryPath,
validateNetwork,
validatePanels,
validatePermissions,
validatePluginIcon,
validateString,
} from "./plugin-manifest-shape-validation.js";
export const FAMILIAROS_PLUGIN_MANIFEST_FILENAME = "familiaros.plugin.json";
export const openPetsPluginManifestFilename = FAMILIAROS_PLUGIN_MANIFEST_FILENAME;
@ -195,7 +207,7 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation
validateString(input.name, "$.name", "name", errors);
if (input.description !== undefined) validateString(input.description, "$.description", "description", errors);
validateString(input.version, "$.version", "version", errors, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
validatePluginIcon(input.icon, errors);
validatePluginIcon(input.icon, errors, supportedPluginIcons);
if (input.runtime === "javascript") {
addError(errors, "$.runtime", "unsupported_runtime", 'Runtime "javascript" is recognized but unsupported in manifest v1. Use "declarative".');
@ -218,7 +230,7 @@ function validateJavascriptPluginManifest(input: Record<string, unknown>, manife
validateString(input.name, "$.name", "name", errors);
if (input.description !== undefined) validateString(input.description, "$.description", "description", errors);
validateString(input.version, "$.version", "version", errors, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
validatePluginIcon(input.icon, errors);
validatePluginIcon(input.icon, errors, supportedPluginIcons);
if (input.runtime !== "javascript") addError(errors, "$.runtime", "invalid_runtime", `manifestVersion ${manifestVersion} runtime must be "javascript".`);
validateString(input.sdkVersion, "$.sdkVersion", "sdkVersion", errors, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
if (manifestVersion === 3 && typeof input.sdkVersion === "string" && !input.sdkVersion.startsWith("3.")) {
@ -229,8 +241,8 @@ function validateJavascriptPluginManifest(input: Record<string, unknown>, manife
validateConfigSchema(input.configSchema, errors, manifestVersion);
validateNetwork(input.network, errors);
if (manifestVersion === 3) {
validateAssets(input.assets, errors);
validatePanels(input.panels, errors);
validateAssets(input.assets, errors, { assetKinds: pluginAssetKinds, assetExtensions: pluginAssetExtensions, assetNamePattern });
validatePanels(input.panels, errors, assetNamePattern);
}
if (errors.length > 0) return { ok: false, errors };
return { ok: true, manifest: input as FamiliarOSPluginManifest, errors: [] };
@ -238,109 +250,7 @@ function validateJavascriptPluginManifest(input: Record<string, unknown>, manife
function validateJavascriptPermissions(value: unknown, manifestVersion: 2 | 3, errors: PluginManifestValidationError[]): Set<string> {
const allowed = new Set<string>(manifestVersion === 3 ? javascriptPluginPermissionsV3 : javascriptPluginPermissionsV2);
const permissions = validatePermissions(value, errors);
const permissions = validatePermissions(value, errors, allowed);
for (const permission of permissions) if (!allowed.has(permission)) addError(errors, "$.permissions", "invalid_permission", `Permission ${permission} is not valid for manifestVersion ${manifestVersion} javascript plugins.`);
return permissions;
}
function validateRelativeAssetPath(value: unknown, allowedExtensions: readonly string[], path: string, errors: PluginManifestValidationError[]): void {
if (typeof value !== "string" || value.trim() === "") return addError(errors, path, "invalid_asset_path", "Asset path must be a non-empty string.");
if (value.startsWith("/") || value.includes("\\") || value.split("/").includes("..") || value.split("/").includes(".")) {
return addError(errors, path, "invalid_asset_path", "Asset path must be a safe relative path.");
}
if (!allowedExtensions.some((extension) => value.toLowerCase().endsWith(extension))) {
addError(errors, path, "invalid_asset_format", `Asset must end with one of ${allowedExtensions.join(", ")}.`);
}
}
function validateAssets(value: unknown, errors: PluginManifestValidationError[]): void {
if (value === undefined) return;
if (!isRecord(value)) return addError(errors, "$.assets", "invalid_assets", "assets must be an object.");
rejectUnknownFields(value, new Set(pluginAssetKinds), "$.assets", errors);
for (const kind of pluginAssetKinds) {
const group = value[kind];
if (group === undefined) continue;
if (!isRecord(group)) { addError(errors, `$.assets.${kind}`, "invalid_assets", `assets.${kind} must be an object.`); continue; }
const entries = Object.entries(group);
if (entries.length > 32) addError(errors, `$.assets.${kind}`, "too_many_assets", `assets.${kind} may declare at most 32 entries.`);
for (const [name, assetPath] of entries) {
const path = `$.assets.${kind}.${name}`;
if (!assetNamePattern.test(name)) addError(errors, path, "invalid_asset_name", "Asset names must be simple lowercase identifiers.");
validateRelativeAssetPath(assetPath, pluginAssetExtensions[kind], path, errors);
}
}
}
function validatePanels(value: unknown, errors: PluginManifestValidationError[]): void {
if (value === undefined) return;
if (!isRecord(value)) return addError(errors, "$.panels", "invalid_panels", "panels must be an object.");
const entries = Object.entries(value);
if (entries.length > 8) addError(errors, "$.panels", "too_many_panels", "panels may declare at most 8 entries.");
for (const [name, panelPath] of entries) {
const path = `$.panels.${name}`;
if (!assetNamePattern.test(name)) addError(errors, path, "invalid_panel_name", "Panel names must be simple lowercase identifiers.");
validateRelativeAssetPath(panelPath, [".html"], path, errors);
}
}
function validatePluginIcon(value: unknown, errors: PluginManifestValidationError[]): void {
if (value === undefined) return;
if (typeof value !== "string" || !supportedPluginIcons.has(value)) addError(errors, "$.icon", "invalid_icon", "icon must be one of plugin, bell, timer, github, heart, sparkles, coffee, focus, or droplet.");
}
function validateEntryPath(value: unknown, errors: PluginManifestValidationError[]): void {
validateString(value, "$.entry", "entry", errors);
if (typeof value !== "string") return;
if (value.startsWith("/") || value.includes("\\") || value.split("/").includes("..") || !/\.(?:mjs|js)$/.test(value)) addError(errors, "$.entry", "invalid_entry", "entry must be a relative .js or .mjs path.");
}
function validateNetwork(value: unknown, errors: PluginManifestValidationError[]): void {
if (value === undefined) return;
if (!isRecord(value) || !Array.isArray(value.hosts)) return addError(errors, "$.network.hosts", "invalid_network_hosts", "network.hosts must be an array.");
rejectUnknownFields(value, new Set(["hosts"]), "$.network", errors);
const seen = new Set<string>();
value.hosts.forEach((host, index) => {
if (typeof host !== "string" || !/^[a-z0-9.-]+(?::\d{1,5})?$/i.test(host) || host.includes("*") || host.trim() !== host) addError(errors, `$.network.hosts[${index}]`, "invalid_network_host", "network hosts must be exact host names.");
else if (seen.has(host)) addError(errors, `$.network.hosts[${index}]`, "duplicate_network_host", "Duplicate network host.");
seen.add(String(host));
});
}
function validatePermissions(value: unknown, errors: PluginManifestValidationError[], allowedPermissions: ReadonlySet<string> = pluginPermissionSet): Set<string> {
const permissions = new Set<string>();
if (!Array.isArray(value)) {
addError(errors, "$.permissions", "invalid_permissions", "permissions must be an array.");
return permissions;
}
value.forEach((permission, index) => {
if (typeof permission !== "string" || !allowedPermissions.has(permission)) {
addError(errors, `$.permissions[${index}]`, "invalid_permission", `Permission must be one of ${[...allowedPermissions].join(", ")}.`);
return;
}
if (permissions.has(permission)) {
addError(errors, `$.permissions[${index}]`, "duplicate_permission", `Duplicate permission ${permission}.`);
return;
}
permissions.add(permission);
});
return permissions;
}
function rejectUnknownFields(record: Record<string, unknown>, allowed: Set<string>, path: string, errors: PluginManifestValidationError[]): void {
for (const key of Object.keys(record)) {
if (!allowed.has(key)) addError(errors, `${path}.${key}`, "unknown_field", `Unknown field ${key}.`);
}
}
function validateString(value: unknown, path: string, label: string, errors: PluginManifestValidationError[], pattern?: RegExp): void {
if (typeof value !== "string" || value.trim() === "") return addError(errors, path, "invalid_string", `${label} must be a non-empty string.`);
if (pattern && !pattern.test(value)) addError(errors, path, "invalid_format", `${label} has an invalid format.`);
}
function addError(errors: PluginManifestValidationError[], path: string, code: string, message: string): void {
errors.push({ path, code, message });
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
const pluginManifestSource = readFileSync(resolve(desktopRoot, "src/plugin-manifest.ts"), "utf8");
const pluginManifestShapeValidationSource = readFileSync(resolve(desktopRoot, "src/plugin-manifest-shape-validation.ts"), "utf8");
assert.match(pluginManifestSource, /from "\.\/plugin-manifest-shape-validation(?:\.js)?"/, "plugin-manifest must import the extracted shape validation seam.");
assert.match(pluginManifestShapeValidationSource, /export function validateAssets/, "plugin-manifest shape validation seam must export asset validation.");
assert.match(pluginManifestShapeValidationSource, /export function validatePanels/, "plugin-manifest shape validation seam must export panel validation.");
assert.match(pluginManifestShapeValidationSource, /export function validatePluginIcon/, "plugin-manifest shape validation seam must export icon validation.");
assert.match(pluginManifestShapeValidationSource, /export function validateEntryPath/, "plugin-manifest shape validation seam must export entry path validation.");
assert.match(pluginManifestShapeValidationSource, /export function validateNetwork/, "plugin-manifest shape validation seam must export network validation.");
assert.match(pluginManifestShapeValidationSource, /export function validatePermissions/, "plugin-manifest shape validation seam must export shared permission validation.");
assert.match(pluginManifestShapeValidationSource, /export function rejectUnknownFields/, "plugin-manifest shape validation seam must export unknown-field rejection.");
assert.match(pluginManifestShapeValidationSource, /export function validateString/, "plugin-manifest shape validation seam must export string validation.");
assert.match(pluginManifestShapeValidationSource, /export function addError/, "plugin-manifest shape validation seam must export manifest error shaping.");
assert.match(pluginManifestShapeValidationSource, /export function isRecord/, "plugin-manifest shape validation seam must export record guards.");
console.error("Plugin manifest shape validation seam passed.");

View file

@ -12,4 +12,4 @@ assert.match(pluginManifestConfigValidationSource, /export function validateConf
assert.match(pluginManifestConfigValidationSource, /export function validateTriggers/, "plugin-manifest config validation seam must export trigger and action validation.");
assert.match(pluginManifestConfigValidationSource, /allowedReactions/, "plugin-manifest config validation seam must own reaction-select validation.");
console.error("Plugin manifest validation seam passed.");
console.error("Plugin manifest config validation seam passed.");