mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
145 lines
4.0 KiB
TypeScript
145 lines
4.0 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { lstatSync, readFileSync, realpathSync, type PathLike } from 'node:fs';
|
|
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
import { TextDecoder } from 'node:util';
|
|
|
|
export interface ClusterCopilotConsoleAssets {
|
|
readonly html: string;
|
|
readonly css: string;
|
|
readonly evidenceBundle: string;
|
|
readonly javascript: string;
|
|
}
|
|
|
|
export class ClusterCopilotConsoleAssetError extends Error {
|
|
readonly code = 'QL3_CLUSTER_COPILOT_CONSOLE_ASSET_INVALID';
|
|
|
|
constructor() {
|
|
super('Cluster Copilot Console asset is invalid');
|
|
this.name = 'ClusterCopilotConsoleAssetError';
|
|
}
|
|
}
|
|
|
|
const ASSETS = Object.freeze([
|
|
Object.freeze({
|
|
name: 'index.html',
|
|
field: 'html',
|
|
maximumBytes: 32 * 1024,
|
|
digest: '429d7b3dd2da4989865be6ac07180cc9c3ebdcbbac5028054cddaac870ad520c',
|
|
}),
|
|
Object.freeze({
|
|
name: 'app.css',
|
|
field: 'css',
|
|
maximumBytes: 64 * 1024,
|
|
digest: 'ddfe85971df0b8acfaed8b4bb5f5bcdf679347106294987d928bbb82dc6610ec',
|
|
}),
|
|
Object.freeze({
|
|
name: 'evidence-bundle.js',
|
|
field: 'evidenceBundle',
|
|
maximumBytes: 32 * 1024,
|
|
digest: '83d17dfa815c175161b35c1aca5f270b15005a16cb79be41c2884b490f617783',
|
|
}),
|
|
Object.freeze({
|
|
name: 'app.js',
|
|
field: 'javascript',
|
|
maximumBytes: 32 * 1024,
|
|
digest: '365ccd43ae2aa4b11a0ab3d142cd04e89ec0b96711bef253f63584e8182589ee',
|
|
}),
|
|
] as const);
|
|
|
|
function invalid(): never {
|
|
throw new ClusterCopilotConsoleAssetError();
|
|
}
|
|
|
|
function inside(parent: string, candidate: string): boolean {
|
|
const pathFromParent = relative(parent, candidate);
|
|
return (
|
|
pathFromParent !== '' &&
|
|
pathFromParent !== '..' &&
|
|
!pathFromParent.startsWith('..' + sep) &&
|
|
!isAbsolute(pathFromParent)
|
|
);
|
|
}
|
|
|
|
function readAsset(
|
|
assetRoot: string,
|
|
name: string,
|
|
maximumBytes: number,
|
|
digest: string,
|
|
): string {
|
|
const candidate = resolve(assetRoot, name);
|
|
const status = lstatSync(candidate, { throwIfNoEntry: false });
|
|
if (
|
|
status === undefined ||
|
|
!status.isFile() ||
|
|
status.isSymbolicLink() ||
|
|
status.size < 1 ||
|
|
status.size > maximumBytes
|
|
) {
|
|
return invalid();
|
|
}
|
|
const canonicalRoot = realpathSync(assetRoot);
|
|
const canonicalCandidate = realpathSync(candidate);
|
|
if (
|
|
!inside(canonicalRoot, canonicalCandidate) ||
|
|
canonicalCandidate !== resolve(canonicalRoot, name)
|
|
) {
|
|
return invalid();
|
|
}
|
|
let bytes: Buffer | undefined;
|
|
try {
|
|
bytes = readFileSync(candidate as PathLike);
|
|
if (
|
|
bytes.byteLength !== status.size ||
|
|
createHash('sha256').update(bytes).digest('hex') !== digest ||
|
|
bytes.includes(0)
|
|
) {
|
|
return invalid();
|
|
}
|
|
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
} catch (error) {
|
|
if (error instanceof ClusterCopilotConsoleAssetError) throw error;
|
|
return invalid();
|
|
} finally {
|
|
bytes?.fill(0);
|
|
}
|
|
}
|
|
|
|
export function loadClusterCopilotConsoleAssets(
|
|
moduleDirectory: string,
|
|
): Readonly<ClusterCopilotConsoleAssets> {
|
|
if (typeof moduleDirectory !== 'string' || !isAbsolute(moduleDirectory)) {
|
|
return invalid();
|
|
}
|
|
const packageRoot = resolve(moduleDirectory, '..', '..');
|
|
const assetRoot = resolve(packageRoot, 'assets', 'copilot-console');
|
|
const packageStatus = lstatSync(packageRoot, { throwIfNoEntry: false });
|
|
const assetStatus = lstatSync(assetRoot, { throwIfNoEntry: false });
|
|
if (
|
|
packageStatus === undefined ||
|
|
!packageStatus.isDirectory() ||
|
|
packageStatus.isSymbolicLink() ||
|
|
assetStatus === undefined ||
|
|
!assetStatus.isDirectory() ||
|
|
assetStatus.isSymbolicLink() ||
|
|
realpathSync(assetRoot) !==
|
|
resolve(realpathSync(packageRoot), 'assets', 'copilot-console')
|
|
) {
|
|
return invalid();
|
|
}
|
|
const result: Record<string, string> = {};
|
|
for (const asset of ASSETS) {
|
|
result[asset.field] = readAsset(
|
|
assetRoot,
|
|
asset.name,
|
|
asset.maximumBytes,
|
|
asset.digest,
|
|
);
|
|
}
|
|
return Object.freeze({
|
|
html: result.html!,
|
|
css: result.css!,
|
|
evidenceBundle: result.evidenceBundle!,
|
|
javascript: result.javascript!,
|
|
});
|
|
}
|