feat(coding): add marketplace package trust primitives
This commit is contained in:
252
electron/coding-plugins/release-descriptor.ts
Normal file
252
electron/coding-plugins/release-descriptor.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
export const PLUGIN_RELEASE_DESCRIPTOR_SCHEMA = 'makelore-plugin-release.v1' as const;
|
||||
|
||||
const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u;
|
||||
const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
|
||||
const DIGEST_PATTERN = /^[a-f0-9]{64}$/u;
|
||||
const DESCRIPTOR_INPUT_KEYS = [
|
||||
'pluginId',
|
||||
'version',
|
||||
'packageSchemaVersion',
|
||||
'contractVersion',
|
||||
'minMakeloreVersion',
|
||||
'maxMakeloreVersion',
|
||||
'artifact',
|
||||
] as const;
|
||||
|
||||
export type PluginReleaseDescriptorInput = {
|
||||
readonly pluginId: string;
|
||||
readonly version: string;
|
||||
readonly packageSchemaVersion: number;
|
||||
readonly contractVersion: number;
|
||||
readonly minMakeloreVersion: string;
|
||||
readonly maxMakeloreVersion: string | null;
|
||||
readonly artifact: {
|
||||
readonly sha256: string;
|
||||
readonly sizeBytes: number;
|
||||
};
|
||||
};
|
||||
|
||||
export interface PluginReleaseDescriptor extends PluginReleaseDescriptorInput {
|
||||
readonly schema: typeof PLUGIN_RELEASE_DESCRIPTOR_SCHEMA;
|
||||
}
|
||||
|
||||
export class PluginReleaseDescriptorError extends Error {
|
||||
readonly code = 'plugin_descriptor_invalid' as const;
|
||||
|
||||
constructor(readonly field: string, message: string) {
|
||||
super(`Invalid plugin release descriptor (${field}): ${message}`);
|
||||
this.name = 'PluginReleaseDescriptorError';
|
||||
}
|
||||
}
|
||||
|
||||
interface ParsedSemVer {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
prerelease: readonly (number | string)[];
|
||||
}
|
||||
|
||||
function fail(field: string, message: string): never {
|
||||
throw new PluginReleaseDescriptorError(field, message);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertDescriptorInputKeys(value: Record<string, unknown>, includeSchema: boolean): void {
|
||||
const allowed = new Set<string>([
|
||||
...DESCRIPTOR_INPUT_KEYS,
|
||||
...(includeSchema ? ['schema'] : []),
|
||||
]);
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) fail(`root.${key}`, 'unknown field');
|
||||
}
|
||||
}
|
||||
|
||||
function parseSemVer(value: unknown, field: string): ParsedSemVer {
|
||||
if (typeof value !== 'string' || value.length > 128) fail(field, 'must be a SemVer string');
|
||||
const match = SEMVER_PATTERN.exec(value);
|
||||
if (!match) fail(field, 'must be a valid SemVer');
|
||||
const prerelease = (match[4] ?? '').split('.').filter(Boolean).map((item) => {
|
||||
if (/^\d+$/u.test(item)) {
|
||||
if (item.length > 1 && item.startsWith('0')) fail(field, 'numeric prerelease identifiers cannot have leading zeroes');
|
||||
return Number(item);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
prerelease,
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidSemVer(value: unknown): value is string {
|
||||
try {
|
||||
parseSemVer(value, 'version');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function compareSemVer(left: string, right: string): -1 | 0 | 1 {
|
||||
const a = parseSemVer(left, 'left');
|
||||
const b = parseSemVer(right, 'right');
|
||||
for (const key of ['major', 'minor', 'patch'] as const) {
|
||||
if (a[key] < b[key]) return -1;
|
||||
if (a[key] > b[key]) return 1;
|
||||
}
|
||||
if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0;
|
||||
if (a.prerelease.length === 0) return 1;
|
||||
if (b.prerelease.length === 0) return -1;
|
||||
const length = Math.max(a.prerelease.length, b.prerelease.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftPart = a.prerelease[index];
|
||||
const rightPart = b.prerelease[index];
|
||||
if (leftPart === undefined) return -1;
|
||||
if (rightPart === undefined) return 1;
|
||||
if (leftPart === rightPart) continue;
|
||||
if (typeof leftPart === 'number' && typeof rightPart === 'string') return -1;
|
||||
if (typeof leftPart === 'string' && typeof rightPart === 'number') return 1;
|
||||
return leftPart < rightPart ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isMakeLoreVersionCompatible(
|
||||
clientVersion: string,
|
||||
minimumVersion: string,
|
||||
maximumVersion: string | null,
|
||||
): boolean {
|
||||
if (!isValidSemVer(clientVersion) || !isValidSemVer(minimumVersion)
|
||||
|| (maximumVersion !== null && !isValidSemVer(maximumVersion))) return false;
|
||||
if (compareSemVer(clientVersion, minimumVersion) < 0) return false;
|
||||
return maximumVersion === null || compareSemVer(clientVersion, maximumVersion) <= 0;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, field: string, allowZero = false): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < (allowZero ? 0 : 1)) {
|
||||
fail(field, allowZero ? 'must be a non-negative safe integer' : 'must be a positive safe integer');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function descriptorFromInput(input: PluginReleaseDescriptorInput): PluginReleaseDescriptor {
|
||||
if (!isRecord(input)) fail('root', 'must be an object');
|
||||
assertDescriptorInputKeys(input, 'schema' in input);
|
||||
if ('schema' in input && input.schema !== PLUGIN_RELEASE_DESCRIPTOR_SCHEMA) {
|
||||
fail('schema', `must equal ${PLUGIN_RELEASE_DESCRIPTOR_SCHEMA}`);
|
||||
}
|
||||
if (typeof input.pluginId !== 'string' || !PLUGIN_ID_PATTERN.test(input.pluginId)) {
|
||||
fail('plugin_id', 'must be a stable Plugin ID');
|
||||
}
|
||||
parseSemVer(input.version, 'version');
|
||||
if (input.packageSchemaVersion !== 2) fail('package_schema_version', 'must equal 2 for an artifact Release');
|
||||
positiveInteger(input.contractVersion, 'contract_version');
|
||||
parseSemVer(input.minMakeloreVersion, 'min_makelore_version');
|
||||
if (input.maxMakeloreVersion !== null) {
|
||||
parseSemVer(input.maxMakeloreVersion, 'max_makelore_version');
|
||||
if (compareSemVer(input.maxMakeloreVersion, input.minMakeloreVersion) < 0) {
|
||||
fail('max_makelore_version', 'must not be lower than min_makelore_version');
|
||||
}
|
||||
}
|
||||
if (!isRecord(input.artifact)) fail('artifact', 'must be an object');
|
||||
if (typeof input.artifact.sha256 !== 'string' || !DIGEST_PATTERN.test(input.artifact.sha256)) {
|
||||
fail('artifact.sha256', 'must be 64 lowercase hexadecimal characters');
|
||||
}
|
||||
positiveInteger(input.artifact.sizeBytes, 'artifact.size_bytes', true);
|
||||
return Object.freeze({
|
||||
schema: PLUGIN_RELEASE_DESCRIPTOR_SCHEMA,
|
||||
pluginId: input.pluginId,
|
||||
version: input.version,
|
||||
packageSchemaVersion: input.packageSchemaVersion,
|
||||
contractVersion: input.contractVersion,
|
||||
minMakeloreVersion: input.minMakeloreVersion,
|
||||
maxMakeloreVersion: input.maxMakeloreVersion,
|
||||
artifact: Object.freeze({
|
||||
sha256: input.artifact.sha256,
|
||||
sizeBytes: input.artifact.sizeBytes,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildPluginReleaseDescriptor(input: PluginReleaseDescriptorInput): PluginReleaseDescriptor {
|
||||
return descriptorFromInput(input);
|
||||
}
|
||||
|
||||
export const createPluginReleaseDescriptor = buildPluginReleaseDescriptor;
|
||||
|
||||
export function serializePluginReleaseDescriptor(descriptor: PluginReleaseDescriptor): Buffer {
|
||||
if (!isRecord(descriptor) || descriptor.schema !== PLUGIN_RELEASE_DESCRIPTOR_SCHEMA) {
|
||||
fail('schema', `must equal ${PLUGIN_RELEASE_DESCRIPTOR_SCHEMA}`);
|
||||
}
|
||||
const valid = descriptorFromInput(descriptor);
|
||||
return Buffer.from(JSON.stringify({
|
||||
schema: valid.schema,
|
||||
plugin_id: valid.pluginId,
|
||||
version: valid.version,
|
||||
package_schema_version: valid.packageSchemaVersion,
|
||||
contract_version: valid.contractVersion,
|
||||
min_makelore_version: valid.minMakeloreVersion,
|
||||
max_makelore_version: valid.maxMakeloreVersion,
|
||||
artifact: {
|
||||
sha256: valid.artifact.sha256,
|
||||
size_bytes: valid.artifact.sizeBytes,
|
||||
},
|
||||
}), 'utf8');
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, keys: readonly string[], field: string): void {
|
||||
const allowed = new Set(keys);
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) fail(`${field}.${key}`, 'unknown field');
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (!(key in value)) fail(`${field}.${key}`, 'required field is missing');
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePluginReleaseDescriptor(input: Uint8Array | string): PluginReleaseDescriptor {
|
||||
const bytes = typeof input === 'string' ? Buffer.from(input, 'utf8') : Buffer.from(input);
|
||||
if (bytes.length === 0 || bytes.length > 16_384) fail('root', 'descriptor size is outside the supported bound');
|
||||
const source = bytes.toString('utf8');
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(source) as unknown;
|
||||
} catch {
|
||||
fail('root', 'must be valid UTF-8 JSON');
|
||||
}
|
||||
const root = isRecord(value) ? value : fail('root', 'must be an object');
|
||||
exactKeys(root, [
|
||||
'schema', 'plugin_id', 'version', 'package_schema_version', 'contract_version',
|
||||
'min_makelore_version', 'max_makelore_version', 'artifact',
|
||||
], 'root');
|
||||
if (root.schema !== PLUGIN_RELEASE_DESCRIPTOR_SCHEMA) {
|
||||
fail('schema', `must equal ${PLUGIN_RELEASE_DESCRIPTOR_SCHEMA}`);
|
||||
}
|
||||
const artifact = isRecord(root.artifact) ? root.artifact : fail('artifact', 'must be an object');
|
||||
exactKeys(artifact, ['sha256', 'size_bytes'], 'artifact');
|
||||
const descriptor = descriptorFromInput({
|
||||
pluginId: root.plugin_id as string,
|
||||
version: root.version as string,
|
||||
packageSchemaVersion: root.package_schema_version as number,
|
||||
contractVersion: root.contract_version as number,
|
||||
minMakeloreVersion: root.min_makelore_version as string,
|
||||
maxMakeloreVersion: root.max_makelore_version as string | null,
|
||||
artifact: {
|
||||
sha256: artifact.sha256 as string,
|
||||
sizeBytes: artifact.size_bytes as number,
|
||||
},
|
||||
});
|
||||
const canonical = serializePluginReleaseDescriptor(descriptor);
|
||||
if (!Buffer.from(canonical).equals(bytes)) fail('root', 'descriptor must use the canonical compact byte representation');
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
export const parseReleaseDescriptor = parsePluginReleaseDescriptor;
|
||||
export const serializeReleaseDescriptor = serializePluginReleaseDescriptor;
|
||||
Reference in New Issue
Block a user