mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 09:58:46 +08:00
399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { readPrivateLocalJsonFile } from '@qinglong/local-command-file';
|
|
import { createLocalPluginPackageFileStageProvider } from '@qinglong/local-admin/package-installation';
|
|
import { assertLocalPluginPackagePublisherKeyPublicationAllowed } from '@qinglong/local-admin/package-publisher-trust';
|
|
import type { PluginPackageManifest } from '@qinglong/runtime-core/plugin-package';
|
|
import {
|
|
PluginPackagePublisherTrustRegistry,
|
|
type PluginPackagePublisherKeyDefinition,
|
|
type PluginPackageSignature,
|
|
} from '@qinglong/runtime-core/plugin-package-bundle';
|
|
import {
|
|
normalizePluginPackageLock,
|
|
type PluginPackageLock,
|
|
type PluginPackageSourceLock,
|
|
} from '@qinglong/runtime-core/plugin-package-install';
|
|
import type { PluginPackageStageProvider } from '@qinglong/runtime-core/plugin-package-installation';
|
|
|
|
export const LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA =
|
|
'qinglong/local-plugin-package-recovery-source@v1' as const;
|
|
export const LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA =
|
|
'qinglong/plugin-package-publisher-trust@v1' as const;
|
|
export const MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES = 64;
|
|
export const MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_BUNDLES = 64;
|
|
|
|
const MAX_PATH_BYTES = 4_096;
|
|
const MAX_SOURCE_FILE_BYTES = 256 * 1024;
|
|
const MAX_TRUST_FILE_BYTES = 256 * 1024;
|
|
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
|
const SOURCE_FILE_PATTERN = /^([0-9a-f]{64})\.json$/;
|
|
const BUNDLE_FILE_PATTERN = /^([0-9a-f]{64})\.bundle$/;
|
|
|
|
export interface LocalPluginPackageRecoveryCatalogOptions {
|
|
readonly catalogRoot: string;
|
|
readonly bundleRoot: string;
|
|
readonly publisherTrustFilePath: string;
|
|
readonly stagingRoot: string;
|
|
}
|
|
|
|
interface LocalPluginPackageRecoverySource {
|
|
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA;
|
|
readonly lockDigest: string;
|
|
readonly source: Readonly<PluginPackageSourceLock>;
|
|
readonly bundlePath: string;
|
|
readonly manifest: PluginPackageManifest;
|
|
readonly signature: PluginPackageSignature;
|
|
}
|
|
|
|
export class LocalPluginPackageRecoveryCatalogError extends Error {
|
|
readonly code = 'QL3_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_INVALID';
|
|
|
|
constructor(message: string, options?: ErrorOptions) {
|
|
super(
|
|
`Local Plugin Package recovery catalog is invalid: ${message}`,
|
|
options,
|
|
);
|
|
this.name = 'LocalPluginPackageRecoveryCatalogError';
|
|
}
|
|
}
|
|
|
|
function record(value: unknown, label: string): Record<string, unknown> {
|
|
if (
|
|
!value ||
|
|
typeof value !== 'object' ||
|
|
Array.isArray(value) ||
|
|
(Object.getPrototypeOf(value) !== Object.prototype &&
|
|
Object.getPrototypeOf(value) !== null)
|
|
) {
|
|
throw new LocalPluginPackageRecoveryCatalogError(
|
|
`${label} must be an object`,
|
|
);
|
|
}
|
|
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
if (
|
|
Object.values(descriptors).some(
|
|
(descriptor) =>
|
|
descriptor.get !== undefined ||
|
|
descriptor.set !== undefined ||
|
|
descriptor.enumerable !== true,
|
|
)
|
|
) {
|
|
throw new LocalPluginPackageRecoveryCatalogError(
|
|
`${label} must contain enumerable data properties`,
|
|
);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function exactKeys(
|
|
value: Record<string, unknown>,
|
|
expected: readonly string[],
|
|
label: string,
|
|
): void {
|
|
const actual = Object.keys(value).sort();
|
|
const canonical = [...expected].sort();
|
|
if (
|
|
actual.length !== canonical.length ||
|
|
actual.some((key, index) => key !== canonical[index])
|
|
) {
|
|
throw new LocalPluginPackageRecoveryCatalogError(
|
|
`${label} shape is invalid`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function absolutePath(value: unknown, label: string): string {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length === 0 ||
|
|
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES ||
|
|
value.includes('\0') ||
|
|
!path.isAbsolute(value) ||
|
|
path.normalize(value) !== value ||
|
|
path.parse(value).root === value
|
|
) {
|
|
throw new LocalPluginPackageRecoveryCatalogError(
|
|
`${label} must be a normalized bounded absolute non-root path`,
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function currentUid(): number {
|
|
if (
|
|
typeof process.getuid !== 'function' ||
|
|
typeof process.geteuid !== 'function' ||
|
|
process.getuid() !== process.geteuid()
|
|
) {
|
|
throw new LocalPluginPackageRecoveryCatalogError(
|
|
'real and effective POSIX users must match',
|
|
);
|
|
}
|
|
return process.getuid();
|
|
}
|
|
|
|
interface CatalogDirectoryIdentity {
|
|
readonly path: string;
|
|
readonly uid: number;
|
|
readonly device: bigint;
|
|
readonly inode: bigint;
|
|
}
|
|
|
|
function privateDirectory(
|
|
candidate: string,
|
|
kind: 'catalog' | 'bundle',
|
|
): Readonly<CatalogDirectoryIdentity> {
|
|
const directoryPath = absolutePath(candidate, `${kind}Root`);
|
|
const uid = currentUid();
|
|
let stat: fs.BigIntStats;
|
|
try {
|
|
stat = fs.lstatSync(directoryPath, { bigint: true });
|
|
} catch (error) {
|
|
throw new LocalPluginPackageRecoveryCatalogError(
|
|
`${kind} root is unavailable`,
|
|
{ cause: error instanceof Error ? error : undefined },
|
|
);
|
|
}
|
|
if (
|
|
!stat.isDirectory() ||
|
|
stat.isSymbolicLink() ||
|
|
Number(stat.uid) !== uid ||
|
|
(Number(stat.mode) & 0o777) !== 0o700 ||
|
|
fs.realpathSync(directoryPath) !== directoryPath
|
|
) {
|
|
throw new LocalPluginPackageRecoveryCatalogError(
|
|
`${kind} root must be an owner-only non-symlink directory`,
|
|
);
|
|
}
|
|
const entries = fs.readdirSync(directoryPath);
|
|
const pattern =
|
|
kind === 'catalog' ? SOURCE_FILE_PATTERN : BUNDLE_FILE_PATTERN;
|
|
const maximum =
|
|
kind === 'catalog'
|
|
? MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES
|
|
: MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_BUNDLES;
|
|
if (
|
|
entries.length > maximum ||
|
|
entries.some((entry) => !pattern.test(entry))
|
|
) {
|
|
throw new LocalPluginPackageRecoveryCatalogError(
|
|
`${kind} root contains unbounded or unknown entries`,
|
|
);
|
|
}
|
|
return Object.freeze({
|
|
path: directoryPath,
|
|
uid,
|
|
device: stat.dev,
|
|
inode: stat.ino,
|
|
});
|
|
}
|
|
|
|
function revalidateCatalogDirectory(
|
|
identity: Readonly<CatalogDirectoryIdentity>,
|
|
): void {
|
|
const stat = fs.lstatSync(identity.path, { bigint: true });
|
|
if (
|
|
!stat.isDirectory() ||
|
|
stat.isSymbolicLink() ||
|
|
Number(stat.uid) !== identity.uid ||
|
|
(Number(stat.mode) & 0o777) !== 0o700 ||
|
|
stat.dev !== identity.device ||
|
|
stat.ino !== identity.inode ||
|
|
fs.realpathSync(identity.path) !== identity.path
|
|
) {
|
|
throw new LocalPluginPackageRecoveryCatalogError(
|
|
'catalog root identity changed while reading',
|
|
);
|
|
}
|
|
}
|
|
|
|
function sourceLock(value: unknown): Readonly<PluginPackageSourceLock> {
|
|
const source = record(value, 'source');
|
|
exactKeys(
|
|
source,
|
|
['artifactBytes', 'artifactDigest', 'contentDigest', 'kind', 'locator'],
|
|
'source',
|
|
);
|
|
if (
|
|
(source.kind !== 'offline' && source.kind !== 'oci') ||
|
|
typeof source.locator !== 'string' ||
|
|
source.locator.length === 0 ||
|
|
Buffer.byteLength(source.locator, 'utf8') > MAX_PATH_BYTES ||
|
|
typeof source.artifactDigest !== 'string' ||
|
|
!DIGEST_PATTERN.test(source.artifactDigest) ||
|
|
!Number.isSafeInteger(source.artifactBytes) ||
|
|
(source.artifactBytes as number) < 1 ||
|
|
typeof source.contentDigest !== 'string' ||
|
|
!DIGEST_PATTERN.test(source.contentDigest)
|
|
) {
|
|
throw new LocalPluginPackageRecoveryCatalogError('source lock is invalid');
|
|
}
|
|
return Object.freeze({
|
|
kind: source.kind,
|
|
locator: source.locator,
|
|
artifactDigest: source.artifactDigest,
|
|
artifactBytes: source.artifactBytes as number,
|
|
contentDigest: source.contentDigest,
|
|
});
|
|
}
|
|
|
|
function sourceMatches(
|
|
left: Readonly<PluginPackageSourceLock>,
|
|
right: Readonly<PluginPackageSourceLock>,
|
|
): boolean {
|
|
return (
|
|
left.kind === right.kind &&
|
|
left.locator === right.locator &&
|
|
left.artifactDigest === right.artifactDigest &&
|
|
left.artifactBytes === right.artifactBytes &&
|
|
left.contentDigest === right.contentDigest
|
|
);
|
|
}
|
|
|
|
function loadSource(
|
|
catalogRoot: string,
|
|
bundleRoot: string,
|
|
lock: Readonly<PluginPackageLock>,
|
|
): Readonly<LocalPluginPackageRecoverySource> {
|
|
const directory = privateDirectory(catalogRoot, 'catalog');
|
|
const bundles = privateDirectory(bundleRoot, 'bundle');
|
|
const fileName = `${lock.lockDigest}.json`;
|
|
const sourcePath = path.join(directory.path, fileName);
|
|
let value: unknown;
|
|
try {
|
|
value = readPrivateLocalJsonFile(sourcePath, {
|
|
maxBytes: MAX_SOURCE_FILE_BYTES,
|
|
});
|
|
} catch (error) {
|
|
throw new LocalPluginPackageRecoveryCatalogError(
|
|
'locked source entry is unavailable',
|
|
{ cause: error instanceof Error ? error : undefined },
|
|
);
|
|
}
|
|
revalidateCatalogDirectory(directory);
|
|
const entry = record(value, 'source entry');
|
|
exactKeys(
|
|
entry,
|
|
['bundlePath', 'lockDigest', 'manifest', 'schema', 'signature', 'source'],
|
|
'source entry',
|
|
);
|
|
const source = sourceLock(entry.source);
|
|
const expectedBundlePath = path.join(
|
|
bundles.path,
|
|
`${source.artifactDigest}.bundle`,
|
|
);
|
|
if (
|
|
entry.schema !== LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA ||
|
|
entry.lockDigest !== lock.lockDigest ||
|
|
!sourceMatches(source, lock.source) ||
|
|
entry.bundlePath !== expectedBundlePath
|
|
) {
|
|
throw new LocalPluginPackageRecoveryCatalogError(
|
|
'source entry does not match its durable PackageLock',
|
|
);
|
|
}
|
|
revalidateCatalogDirectory(bundles);
|
|
return Object.freeze({
|
|
schema: LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA,
|
|
lockDigest: lock.lockDigest,
|
|
source,
|
|
bundlePath: expectedBundlePath,
|
|
manifest: entry.manifest as PluginPackageManifest,
|
|
signature: entry.signature as PluginPackageSignature,
|
|
});
|
|
}
|
|
|
|
function loadTrust(filePath: string): PluginPackagePublisherTrustRegistry {
|
|
let value: unknown;
|
|
try {
|
|
value = readPrivateLocalJsonFile(filePath, {
|
|
maxBytes: MAX_TRUST_FILE_BYTES,
|
|
});
|
|
} catch (error) {
|
|
throw new LocalPluginPackageRecoveryCatalogError(
|
|
'publisher trust file is unavailable',
|
|
{ cause: error instanceof Error ? error : undefined },
|
|
);
|
|
}
|
|
const trust = record(value, 'publisher trust');
|
|
exactKeys(trust, ['keys', 'schema'], 'publisher trust');
|
|
if (
|
|
trust.schema !== LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA ||
|
|
!Array.isArray(trust.keys)
|
|
) {
|
|
throw new LocalPluginPackageRecoveryCatalogError(
|
|
'publisher trust file shape is invalid',
|
|
);
|
|
}
|
|
try {
|
|
return new PluginPackagePublisherTrustRegistry(
|
|
trust.keys as PluginPackagePublisherKeyDefinition[],
|
|
);
|
|
} catch (error) {
|
|
throw new LocalPluginPackageRecoveryCatalogError(
|
|
'publisher trust keys are invalid',
|
|
{ cause: error instanceof Error ? error : undefined },
|
|
);
|
|
}
|
|
}
|
|
|
|
export function createLocalPluginPackageRecoveryCatalogStageProvider(
|
|
value: LocalPluginPackageRecoveryCatalogOptions,
|
|
): PluginPackageStageProvider {
|
|
const options = record(value, 'catalog options');
|
|
exactKeys(
|
|
options,
|
|
['bundleRoot', 'catalogRoot', 'publisherTrustFilePath', 'stagingRoot'],
|
|
'catalog options',
|
|
);
|
|
const catalogRoot = absolutePath(value.catalogRoot, 'catalogRoot');
|
|
const bundleRoot = absolutePath(value.bundleRoot, 'bundleRoot');
|
|
const publisherTrustFilePath = absolutePath(
|
|
value.publisherTrustFilePath,
|
|
'publisherTrustFilePath',
|
|
);
|
|
const stagingRoot = absolutePath(value.stagingRoot, 'stagingRoot');
|
|
const trustRoot = path.dirname(publisherTrustFilePath);
|
|
if (
|
|
path.basename(publisherTrustFilePath) !== 'current.json' ||
|
|
catalogRoot === publisherTrustFilePath ||
|
|
catalogRoot === stagingRoot ||
|
|
catalogRoot === bundleRoot ||
|
|
bundleRoot === publisherTrustFilePath ||
|
|
bundleRoot === stagingRoot ||
|
|
publisherTrustFilePath === stagingRoot
|
|
) {
|
|
throw new LocalPluginPackageRecoveryCatalogError(
|
|
'catalog authorities are invalid',
|
|
);
|
|
}
|
|
return Object.freeze({
|
|
async stage(lockValue: Readonly<PluginPackageLock>) {
|
|
const lock = normalizePluginPackageLock(lockValue);
|
|
const source = loadSource(catalogRoot, bundleRoot, lock);
|
|
assertLocalPluginPackagePublisherKeyPublicationAllowed({
|
|
trustRoot,
|
|
publisher: source.signature.publisher,
|
|
keyId: source.signature.keyId,
|
|
});
|
|
const trust = loadTrust(publisherTrustFilePath);
|
|
const staged = await createLocalPluginPackageFileStageProvider({
|
|
bundlePath: source.bundlePath,
|
|
stagingRoot,
|
|
manifest: source.manifest,
|
|
signature: source.signature,
|
|
trust,
|
|
observedAtMs: lock.createdAtMs,
|
|
}).stage(lock);
|
|
assertLocalPluginPackagePublisherKeyPublicationAllowed({
|
|
trustRoot,
|
|
publisher: source.signature.publisher,
|
|
keyId: source.signature.keyId,
|
|
});
|
|
return staged;
|
|
},
|
|
});
|
|
}
|