253 lines
9.5 KiB
TypeScript
253 lines
9.5 KiB
TypeScript
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;
|