mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
269 lines
8.1 KiB
TypeScript
269 lines
8.1 KiB
TypeScript
import { createHash } from 'crypto';
|
|
|
|
export const MANUAL_PRIMARY_RUNTIME_RECEIPT_SCHEMA =
|
|
'qinglong/manual-primary-runtime-receipt@v1';
|
|
export const MANUAL_PRIMARY_RUNTIME_RECEIPT_FILE =
|
|
'qinglong3-manual-primary-runtime.json';
|
|
export const MAX_MANUAL_PRIMARY_RUNTIME_RECEIPT_BYTES = 8 * 1024;
|
|
|
|
export type ManualPrimaryRuntimeReceiptState =
|
|
| 'active'
|
|
| 'stopping'
|
|
| 'stopped'
|
|
| 'failed';
|
|
|
|
export type ManualPrimaryRuntimeProcessIdentity =
|
|
| {
|
|
kind: 'linux-proc';
|
|
platform: 'linux';
|
|
pid: number;
|
|
processGroupId: number;
|
|
bootId: string;
|
|
startTimeTicks: string;
|
|
}
|
|
| {
|
|
kind: 'portable';
|
|
platform: string;
|
|
pid: number;
|
|
};
|
|
|
|
export interface ManualPrimaryRuntimeReceipt {
|
|
schema: typeof MANUAL_PRIMARY_RUNTIME_RECEIPT_SCHEMA;
|
|
schemaVersion: 1;
|
|
activationId: string;
|
|
profile: 'edge' | 'standalone';
|
|
revision: string;
|
|
rolloutSourceSha256: string;
|
|
activatedAtMs: number;
|
|
updatedAtMs: number;
|
|
state: ManualPrimaryRuntimeReceiptState;
|
|
process: ManualPrimaryRuntimeProcessIdentity;
|
|
receiptSha256: string;
|
|
}
|
|
|
|
type ReceiptProjection = Omit<ManualPrimaryRuntimeReceipt, 'receiptSha256'>;
|
|
|
|
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
|
const ACTIVATION_ID_PATTERN = /^[a-f0-9]{32}$/u;
|
|
const BOOT_ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/u;
|
|
const START_TICKS_PATTERN = /^\d{1,32}$/u;
|
|
const PLATFORM_PATTERN = /^[a-z0-9_-]{1,32}$/u;
|
|
|
|
function record(value: unknown, name: string): Record<string, unknown> {
|
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new TypeError(`${name} must be an object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function exactKeys(
|
|
value: Record<string, unknown>,
|
|
keys: readonly string[],
|
|
name: string,
|
|
): void {
|
|
const actual = Object.keys(value).sort();
|
|
const expected = [...keys].sort();
|
|
if (
|
|
actual.length !== expected.length ||
|
|
actual.some((key, index) => key !== expected[index])
|
|
) {
|
|
throw new TypeError(`${name} has an invalid shape`);
|
|
}
|
|
}
|
|
|
|
function safePositiveInteger(value: unknown, name: string): number {
|
|
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
|
throw new TypeError(`${name} must be a positive safe integer`);
|
|
}
|
|
return value as number;
|
|
}
|
|
|
|
function safeTimestamp(value: unknown, name: string): number {
|
|
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
throw new TypeError(`${name} must be a non-negative safe integer`);
|
|
}
|
|
return value as number;
|
|
}
|
|
|
|
function parseProcessIdentity(
|
|
value: unknown,
|
|
): ManualPrimaryRuntimeProcessIdentity {
|
|
const identity = record(value, 'receipt.process');
|
|
if (identity.kind === 'linux-proc') {
|
|
exactKeys(
|
|
identity,
|
|
['kind', 'platform', 'pid', 'processGroupId', 'bootId', 'startTimeTicks'],
|
|
'receipt.process',
|
|
);
|
|
if (
|
|
identity.platform !== 'linux' ||
|
|
typeof identity.bootId !== 'string' ||
|
|
!BOOT_ID_PATTERN.test(identity.bootId) ||
|
|
typeof identity.startTimeTicks !== 'string' ||
|
|
!START_TICKS_PATTERN.test(identity.startTimeTicks) ||
|
|
BigInt(identity.startTimeTicks) < BigInt(1)
|
|
) {
|
|
throw new TypeError('receipt.process Linux identity is invalid');
|
|
}
|
|
return {
|
|
kind: 'linux-proc',
|
|
platform: 'linux',
|
|
pid: safePositiveInteger(identity.pid, 'receipt.process.pid'),
|
|
processGroupId: safePositiveInteger(
|
|
identity.processGroupId,
|
|
'receipt.process.processGroupId',
|
|
),
|
|
bootId: identity.bootId,
|
|
startTimeTicks: identity.startTimeTicks,
|
|
};
|
|
}
|
|
exactKeys(identity, ['kind', 'platform', 'pid'], 'receipt.process');
|
|
if (
|
|
identity.kind !== 'portable' ||
|
|
typeof identity.platform !== 'string' ||
|
|
!PLATFORM_PATTERN.test(identity.platform)
|
|
) {
|
|
throw new TypeError('receipt.process portable identity is invalid');
|
|
}
|
|
return {
|
|
kind: 'portable',
|
|
platform: identity.platform,
|
|
pid: safePositiveInteger(identity.pid, 'receipt.process.pid'),
|
|
};
|
|
}
|
|
|
|
function canonical(value: unknown): unknown {
|
|
if (Array.isArray(value)) return value.map(canonical);
|
|
if (value !== null && typeof value === 'object') {
|
|
return Object.fromEntries(
|
|
Object.entries(value as Record<string, unknown>)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, nested]) => [key, canonical(nested)]),
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function digest(projection: ReceiptProjection): string {
|
|
return createHash('sha256')
|
|
.update('qinglong.manual-primary-runtime-receipt.v1\0', 'utf8')
|
|
.update(JSON.stringify(canonical(projection)), 'utf8')
|
|
.digest('hex');
|
|
}
|
|
|
|
export function createManualPrimaryRuntimeReceipt(input: {
|
|
activationId: string;
|
|
profile: 'edge' | 'standalone';
|
|
revision: string;
|
|
rolloutSourceSha256: string;
|
|
activatedAtMs: number;
|
|
process: ManualPrimaryRuntimeProcessIdentity;
|
|
}): ManualPrimaryRuntimeReceipt {
|
|
const projection: ReceiptProjection = {
|
|
schema: MANUAL_PRIMARY_RUNTIME_RECEIPT_SCHEMA,
|
|
schemaVersion: 1,
|
|
activationId: input.activationId,
|
|
profile: input.profile,
|
|
revision: input.revision,
|
|
rolloutSourceSha256: input.rolloutSourceSha256,
|
|
activatedAtMs: input.activatedAtMs,
|
|
updatedAtMs: input.activatedAtMs,
|
|
state: 'active',
|
|
process: input.process,
|
|
};
|
|
return parseManualPrimaryRuntimeReceipt({
|
|
...projection,
|
|
receiptSha256: digest(projection),
|
|
});
|
|
}
|
|
|
|
export function transitionManualPrimaryRuntimeReceipt(
|
|
receipt: ManualPrimaryRuntimeReceipt,
|
|
state: Exclude<ManualPrimaryRuntimeReceiptState, 'active'>,
|
|
updatedAtMs: number,
|
|
): ManualPrimaryRuntimeReceipt {
|
|
const current = parseManualPrimaryRuntimeReceipt(receipt);
|
|
const projection: ReceiptProjection = {
|
|
schema: current.schema,
|
|
schemaVersion: 1,
|
|
activationId: current.activationId,
|
|
profile: current.profile,
|
|
revision: current.revision,
|
|
rolloutSourceSha256: current.rolloutSourceSha256,
|
|
activatedAtMs: current.activatedAtMs,
|
|
updatedAtMs,
|
|
state,
|
|
process: current.process,
|
|
};
|
|
return parseManualPrimaryRuntimeReceipt({
|
|
...projection,
|
|
receiptSha256: digest(projection),
|
|
});
|
|
}
|
|
|
|
export function parseManualPrimaryRuntimeReceipt(
|
|
value: unknown,
|
|
): ManualPrimaryRuntimeReceipt {
|
|
const receipt = record(value, 'receipt');
|
|
exactKeys(
|
|
receipt,
|
|
[
|
|
'schema',
|
|
'schemaVersion',
|
|
'activationId',
|
|
'profile',
|
|
'revision',
|
|
'rolloutSourceSha256',
|
|
'activatedAtMs',
|
|
'updatedAtMs',
|
|
'state',
|
|
'process',
|
|
'receiptSha256',
|
|
],
|
|
'receipt',
|
|
);
|
|
const activatedAtMs = safeTimestamp(
|
|
receipt.activatedAtMs,
|
|
'receipt.activatedAtMs',
|
|
);
|
|
const updatedAtMs = safeTimestamp(receipt.updatedAtMs, 'receipt.updatedAtMs');
|
|
if (
|
|
receipt.schema !== MANUAL_PRIMARY_RUNTIME_RECEIPT_SCHEMA ||
|
|
receipt.schemaVersion !== 1 ||
|
|
typeof receipt.activationId !== 'string' ||
|
|
!ACTIVATION_ID_PATTERN.test(receipt.activationId) ||
|
|
(receipt.profile !== 'edge' && receipt.profile !== 'standalone') ||
|
|
typeof receipt.revision !== 'string' ||
|
|
receipt.revision.length < 1 ||
|
|
receipt.revision.length > 128 ||
|
|
/[\u0000-\u001f\u007f]/u.test(receipt.revision) ||
|
|
typeof receipt.rolloutSourceSha256 !== 'string' ||
|
|
!SHA256_PATTERN.test(receipt.rolloutSourceSha256) ||
|
|
!['active', 'stopping', 'stopped', 'failed'].includes(
|
|
receipt.state as string,
|
|
) ||
|
|
typeof receipt.receiptSha256 !== 'string' ||
|
|
!SHA256_PATTERN.test(receipt.receiptSha256) ||
|
|
updatedAtMs < activatedAtMs ||
|
|
(receipt.state === 'active' && updatedAtMs !== activatedAtMs)
|
|
) {
|
|
throw new TypeError('Manual Primary runtime receipt is invalid');
|
|
}
|
|
const projection: ReceiptProjection = {
|
|
schema: MANUAL_PRIMARY_RUNTIME_RECEIPT_SCHEMA,
|
|
schemaVersion: 1,
|
|
activationId: receipt.activationId,
|
|
profile: receipt.profile,
|
|
revision: receipt.revision,
|
|
rolloutSourceSha256: receipt.rolloutSourceSha256,
|
|
activatedAtMs,
|
|
updatedAtMs,
|
|
state: receipt.state as ManualPrimaryRuntimeReceiptState,
|
|
process: parseProcessIdentity(receipt.process),
|
|
};
|
|
if (digest(projection) !== receipt.receiptSha256) {
|
|
throw new TypeError('Manual Primary runtime receipt digest is invalid');
|
|
}
|
|
return { ...projection, receiptSha256: receipt.receiptSha256 };
|
|
}
|