Files
qinglong/packages/ql3-local-admin/src/legacy-adoption/legacyCrontabDecisionReviewFile.ts
T

517 lines
15 KiB
TypeScript

// Legacy Adoption owns the private streaming review-file boundary.
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { MAX_LEGACY_CRONTAB_ROWS } from './legacyCrontabAdoption';
import {
parseLegacyCrontabAdoptionDecision,
type LegacyCrontabAdoptionDecision,
} from './legacyCrontabDecisionReceipt';
export const MAX_LEGACY_CRONTAB_DECISION_REVIEW_FILE_BYTES = 32 * 1024 * 1024;
const MAX_PATH_BYTES = 4096;
const MAX_LINE_BYTES = 64 * 1024;
const READ_CHUNK_BYTES = 64 * 1024;
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const UUID_V7_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
interface ReviewFileHeader {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-legacy-crontab-decision-review-file-header';
readonly decisionId: string;
readonly profile: 'edge' | 'standalone';
readonly planDigest: string;
readonly inventoryDigest: string;
}
interface FileLine {
readonly start: number;
readonly end: number;
readonly value: Buffer;
readonly framed: Buffer;
}
interface PrivatePathIdentity {
readonly device: bigint;
readonly inode: bigint;
readonly size: bigint;
readonly modifiedAtNs: bigint;
readonly changedAtNs: bigint;
}
interface PrivateParentIdentity {
readonly path: string;
readonly device: bigint;
readonly inode: bigint;
readonly uid: number;
}
export interface OpenLegacyCrontabAdoptionDecisionReviewFileOptions {
readonly filePath: string;
readonly expectedDecisionId: string;
readonly expectedProfile: 'edge' | 'standalone';
readonly expectedPlanDigest: string;
readonly expectedInventoryDigest: string;
}
export interface LegacyCrontabAdoptionDecisionReviewFileEvidence {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-legacy-crontab-decision-review-file';
readonly decisionId: string;
readonly profile: 'edge' | 'standalone';
readonly planDigest: string;
readonly inventoryDigest: string;
readonly decisionCount: number;
readonly fileBytes: number;
readonly fileDigest: string;
}
export interface LegacyCrontabAdoptionDecisionReviewFileScope {
readonly evidence: LegacyCrontabAdoptionDecisionReviewFileEvidence;
readonly decisions: Iterable<LegacyCrontabAdoptionDecision>;
confirmIdentity(): void;
}
export class LegacyCrontabAdoptionDecisionReviewFileError extends Error {
readonly code = 'LEGACY_CRONTAB_DECISION_REVIEW_FILE_INVALID';
constructor(message: string, readonly cause?: unknown) {
super(`Legacy Crontab decision review file is invalid: ${message}`);
this.name = 'LegacyCrontabAdoptionDecisionReviewFileError';
}
}
function exactKeys(
value: unknown,
expected: readonly string[],
label: string,
): asserts value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
`${label} must be an object`,
);
}
const keys = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
keys.length !== canonical.length ||
keys.some((key, index) => key !== canonical[index])
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
`${label} shape is invalid`,
);
}
}
function currentUid(): number {
if (
typeof process.getuid !== 'function' ||
typeof process.geteuid !== 'function' ||
process.getuid() !== process.geteuid()
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'real and effective POSIX users must match',
);
}
return process.getuid();
}
function reviewPath(value: string): string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.parse(value).root === value ||
path.normalize(value) !== value ||
value.includes('\0') ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'path must be normalized, bounded, absolute and non-root',
);
}
return value;
}
function privateParent(filePath: string, uid: number): PrivateParentIdentity {
const parentPath = path.dirname(filePath);
let stat: fs.BigIntStats;
try {
stat = fs.lstatSync(parentPath, { bigint: true });
} catch (error) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'private parent directory is unavailable',
error,
);
}
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
Number(stat.uid) !== uid ||
(Number(stat.mode) & 0o777) !== 0o700
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'parent must be an owner-only real directory',
);
}
return Object.freeze({
path: parentPath,
device: stat.dev,
inode: stat.ino,
uid,
});
}
function openedIdentity(stat: fs.BigIntStats): PrivatePathIdentity {
return Object.freeze({
device: stat.dev,
inode: stat.ino,
size: stat.size,
modifiedAtNs: stat.mtimeNs,
changedAtNs: stat.ctimeNs,
});
}
function sameFile(
stat: fs.BigIntStats,
expected: PrivatePathIdentity,
): boolean {
return (
stat.dev === expected.device &&
stat.ino === expected.inode &&
stat.size === expected.size &&
stat.mtimeNs === expected.modifiedAtNs &&
stat.ctimeNs === expected.changedAtNs
);
}
function parseJsonLine(line: Buffer, label: string): unknown {
if (line.length < 2 || line.length > MAX_LINE_BYTES) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
`${label} exceeds its line bound`,
);
}
try {
return JSON.parse(line.toString('utf8')) as unknown;
} catch (error) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
`${label} is not valid JSON`,
error,
);
}
}
function* readLines(
descriptor: number,
start: number,
end: number,
): Iterable<FileLine> {
let position = start;
let pending = Buffer.alloc(0);
let pendingStart = start;
try {
while (position < end) {
const chunk = Buffer.allocUnsafe(
Math.min(READ_CHUNK_BYTES, end - position),
);
const bytesRead = fs.readSync(
descriptor,
chunk,
0,
chunk.length,
position,
);
if (bytesRead < 1) {
chunk.fill(0);
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'file ended unexpectedly',
);
}
position += bytesRead;
const material = pending.length
? Buffer.concat([pending, chunk.subarray(0, bytesRead)])
: Buffer.from(chunk.subarray(0, bytesRead));
pending.fill(0);
chunk.fill(0);
let cursor = 0;
for (;;) {
const newline = material.indexOf(0x0a, cursor);
if (newline < 0) break;
const lineLength = newline - cursor;
if (lineLength < 1 || lineLength > MAX_LINE_BYTES) {
material.fill(0);
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'file contains an invalid line',
);
}
yield Object.freeze({
start: pendingStart + cursor,
end: pendingStart + newline + 1,
value: Buffer.from(material.subarray(cursor, newline)),
framed: Buffer.from(material.subarray(cursor, newline + 1)),
});
cursor = newline + 1;
}
const next = Buffer.from(material.subarray(cursor));
pendingStart += cursor;
material.fill(0);
pending = next;
if (pending.length > MAX_LINE_BYTES) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'file contains an overlong line',
);
}
}
if (pending.length !== 0) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'file must end with a newline',
);
}
} finally {
pending.fill(0);
}
}
function parseHeader(value: unknown): ReviewFileHeader {
exactKeys(
value,
[
'decisionId',
'inventoryDigest',
'kind',
'planDigest',
'profile',
'schemaVersion',
],
'header record',
);
if (
value.schemaVersion !== 1 ||
value.kind !== 'qinglong3-legacy-crontab-decision-review-file-header' ||
typeof value.decisionId !== 'string' ||
!UUID_V7_PATTERN.test(value.decisionId) ||
(value.profile !== 'edge' && value.profile !== 'standalone') ||
typeof value.planDigest !== 'string' ||
!DIGEST_PATTERN.test(value.planDigest) ||
typeof value.inventoryDigest !== 'string' ||
!DIGEST_PATTERN.test(value.inventoryDigest)
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'header content is invalid',
);
}
return Object.freeze({
schemaVersion: 1,
kind: 'qinglong3-legacy-crontab-decision-review-file-header',
decisionId: value.decisionId,
profile: value.profile,
planDigest: value.planDigest,
inventoryDigest: value.inventoryDigest,
});
}
function parseDecision(value: unknown): LegacyCrontabAdoptionDecision {
exactKeys(value, ['decision', 'kind', 'schemaVersion'], 'decision record');
if (
value.schemaVersion !== 1 ||
value.kind !== 'qinglong3-legacy-crontab-decision-review-file-row'
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'decision record version or kind is invalid',
);
}
try {
return parseLegacyCrontabAdoptionDecision(value.decision);
} catch (error) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'decision record content is invalid',
error,
);
}
}
function digestDescriptor(descriptor: number, size: number): string {
const hash = createHash('sha256');
let position = 0;
while (position < size) {
const chunk = Buffer.allocUnsafe(
Math.min(READ_CHUNK_BYTES, size - position),
);
try {
const bytesRead = fs.readSync(
descriptor,
chunk,
0,
chunk.length,
position,
);
if (bytesRead < 1) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'file ended while confirming its digest',
);
}
hash.update(chunk.subarray(0, bytesRead));
position += bytesRead;
} finally {
chunk.fill(0);
}
}
return hash.digest('hex');
}
export async function withPrivateLegacyCrontabAdoptionDecisionReviewFile<T>(
options: OpenLegacyCrontabAdoptionDecisionReviewFileOptions,
consumer: (
scope: LegacyCrontabAdoptionDecisionReviewFileScope,
) => T | Promise<T>,
): Promise<T> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).sort().join('\0') !==
[
'expectedDecisionId',
'expectedInventoryDigest',
'expectedPlanDigest',
'expectedProfile',
'filePath',
]
.sort()
.join('\0') ||
typeof consumer !== 'function'
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'open options are invalid',
);
}
const filePath = reviewPath(options.filePath);
const uid = currentUid();
const parent = privateParent(filePath, uid);
let descriptor: number | undefined;
try {
const before = fs.lstatSync(filePath, { bigint: true });
if (
!before.isFile() ||
before.isSymbolicLink() ||
Number(before.uid) !== uid ||
(Number(before.mode) & 0o777) !== 0o600 ||
before.size < 1n ||
before.size > BigInt(MAX_LEGACY_CRONTAB_DECISION_REVIEW_FILE_BYTES)
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'file must be a bounded owner-only regular file',
);
}
descriptor = fs.openSync(
filePath,
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
);
const opened = fs.fstatSync(descriptor, { bigint: true });
const identity = openedIdentity(opened);
if (!opened.isFile() || !sameFile(before, identity)) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'file identity changed while opening',
);
}
const size = Number(opened.size);
const fileHash = createHash('sha256');
let header: ReviewFileHeader | undefined;
let decisionStart = -1;
let decisionCount = 0;
for (const line of readLines(descriptor, 0, size)) {
try {
fileHash.update(line.framed);
const value = parseJsonLine(line.value, 'review record');
if (!header) {
header = parseHeader(value);
decisionStart = line.end;
continue;
}
if (decisionCount >= MAX_LEGACY_CRONTAB_ROWS) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'decision row count exceeds its hard bound',
);
}
parseDecision(value);
decisionCount += 1;
} finally {
line.value.fill(0);
line.framed.fill(0);
}
}
if (!header || decisionStart < 0) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'header record is missing',
);
}
if (
header.decisionId !== options.expectedDecisionId ||
header.profile !== options.expectedProfile ||
header.planDigest !== options.expectedPlanDigest ||
header.inventoryDigest !== options.expectedInventoryDigest
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'header does not match the reviewed source',
);
}
const fileDigest = fileHash.digest('hex');
const confirmIdentity = (): void => {
const afterOpen = fs.fstatSync(descriptor!, { bigint: true });
const afterPath = fs.lstatSync(filePath, { bigint: true });
const afterParent = privateParent(filePath, uid);
if (
!sameFile(afterOpen, identity) ||
!sameFile(afterPath, identity) ||
Number(afterPath.uid) !== uid ||
(Number(afterPath.mode) & 0o777) !== 0o600 ||
afterParent.path !== parent.path ||
afterParent.device !== parent.device ||
afterParent.inode !== parent.inode ||
afterParent.uid !== parent.uid ||
digestDescriptor(descriptor!, size) !== fileDigest
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'file identity or content changed during review',
);
}
};
const decisions = Object.freeze({
*[Symbol.iterator](): Iterator<LegacyCrontabAdoptionDecision> {
for (const line of readLines(descriptor!, decisionStart, size)) {
try {
yield parseDecision(parseJsonLine(line.value, 'decision record'));
} finally {
line.value.fill(0);
line.framed.fill(0);
}
}
},
});
const evidence = Object.freeze({
schemaVersion: 1 as const,
kind: 'qinglong3-legacy-crontab-decision-review-file' as const,
decisionId: header.decisionId,
profile: header.profile,
planDigest: header.planDigest,
inventoryDigest: header.inventoryDigest,
decisionCount,
fileBytes: size,
fileDigest,
});
const result = await consumer(
Object.freeze({ evidence, decisions, confirmIdentity }),
);
confirmIdentity();
return result;
} catch (error) {
if (error instanceof LegacyCrontabAdoptionDecisionReviewFileError) {
throw error;
}
throw error;
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}