mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,796 @@
|
||||
// Legacy Adoption owns bounded inspection and semantic classification of legacy crontab rows.
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import {
|
||||
BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} from '@qinglong/runtime-core/task-spec-semantic';
|
||||
import type { TaskDefinitionSpec } from '@qinglong/runtime-core/task-definition';
|
||||
import {
|
||||
BUILT_IN_CRON_TRIGGER_SPEC_SCHEMA,
|
||||
createBuiltInTriggerSpecSemanticRegistry,
|
||||
type TriggerSpec,
|
||||
} from '@qinglong/runtime-core/trigger';
|
||||
|
||||
export const MAX_LEGACY_CRONTAB_ROWS = 100_000;
|
||||
export const MAX_LEGACY_CRONTAB_DIAGNOSTIC_PAGE_SIZE = 128;
|
||||
|
||||
export const LEGACY_CRONTAB_ADOPTION_CLASSIFICATIONS = Object.freeze([
|
||||
'lossless',
|
||||
'requires_shell_compatibility',
|
||||
'requires_manual_action',
|
||||
'malformed',
|
||||
] as const);
|
||||
|
||||
export const LEGACY_CRONTAB_ADOPTION_REASONS = Object.freeze([
|
||||
'legacy_id_invalid',
|
||||
'command_invalid',
|
||||
'legacy_field_invalid',
|
||||
'schedule_invalid',
|
||||
'extra_schedules_invalid',
|
||||
'timezone_required',
|
||||
'schedule_once_unsupported',
|
||||
'schedule_boot_unsupported',
|
||||
'schedule_macro_unsupported',
|
||||
'concurrency_policy_unmodeled',
|
||||
'system_task_requires_review',
|
||||
'labels_require_mapping',
|
||||
'subscription_binding_requires_mapping',
|
||||
'legacy_task_wrapper_required',
|
||||
'task_hooks_require_shell_compatibility',
|
||||
'work_directory_requires_shell_compatibility',
|
||||
'log_name_requires_shell_compatibility',
|
||||
] as const);
|
||||
|
||||
export type LegacyCrontabAdoptionClassification =
|
||||
(typeof LEGACY_CRONTAB_ADOPTION_CLASSIFICATIONS)[number];
|
||||
export type LegacyCrontabAdoptionReason =
|
||||
(typeof LEGACY_CRONTAB_ADOPTION_REASONS)[number];
|
||||
|
||||
export interface LegacyCrontabAdoptionClassificationCounts {
|
||||
readonly lossless: number;
|
||||
readonly requires_shell_compatibility: number;
|
||||
readonly requires_manual_action: number;
|
||||
readonly malformed: number;
|
||||
}
|
||||
|
||||
export interface LegacyCrontabAdoptionInventory {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: 'qinglong3-legacy-crontab-adoption-inventory';
|
||||
readonly timezone: string | null;
|
||||
readonly rowCount: number;
|
||||
readonly classifications: LegacyCrontabAdoptionClassificationCounts;
|
||||
readonly inventoryDigest: string;
|
||||
readonly mutationReady: boolean;
|
||||
}
|
||||
|
||||
export interface LegacyCrontabAdoptionDiagnostic {
|
||||
readonly rowOrdinal: number;
|
||||
readonly legacyId: number | null;
|
||||
readonly taskId: string | null;
|
||||
readonly classification: LegacyCrontabAdoptionClassification;
|
||||
readonly reasons: readonly LegacyCrontabAdoptionReason[];
|
||||
readonly enabled: boolean | null;
|
||||
readonly triggerCount: number;
|
||||
readonly sourceDigest: string;
|
||||
readonly taskSpecDigest?: string;
|
||||
readonly triggerSpecDigests?: readonly string[];
|
||||
}
|
||||
|
||||
export interface LegacyCrontabAdoptionDiagnosticCursor {
|
||||
readonly rowOrdinal: number;
|
||||
}
|
||||
|
||||
export interface LegacyCrontabAdoptionDiagnosticPage {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: 'qinglong3-legacy-crontab-adoption-diagnostics';
|
||||
readonly timezone: string | null;
|
||||
readonly diagnostics: readonly LegacyCrontabAdoptionDiagnostic[];
|
||||
readonly truncated: boolean;
|
||||
readonly next?: LegacyCrontabAdoptionDiagnosticCursor;
|
||||
readonly inventory: LegacyCrontabAdoptionInventory;
|
||||
}
|
||||
|
||||
export interface LegacyCrontabAdoptionCandidate {
|
||||
readonly rowOrdinal: number;
|
||||
readonly sourceDigest: string;
|
||||
readonly task: Readonly<{
|
||||
taskId: string;
|
||||
name: string;
|
||||
kind: 'command';
|
||||
spec: TaskDefinitionSpec;
|
||||
labels: Readonly<Record<string, string>>;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
readonly triggers: readonly Readonly<{
|
||||
triggerId: string;
|
||||
spec: TriggerSpec;
|
||||
enabled: boolean;
|
||||
}>[];
|
||||
}
|
||||
|
||||
export interface LegacyCrontabAdoptionInspection {
|
||||
readonly diagnostic: LegacyCrontabAdoptionDiagnostic;
|
||||
readonly candidate?: LegacyCrontabAdoptionCandidate;
|
||||
}
|
||||
|
||||
export class LegacyCrontabAdoptionClassificationError extends Error {
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Legacy Crontab adoption classification failed: ${message}`);
|
||||
this.name = 'LegacyCrontabAdoptionClassificationError';
|
||||
}
|
||||
}
|
||||
|
||||
const CONFIGURATION_COLUMNS = Object.freeze([
|
||||
'id',
|
||||
'name',
|
||||
'command',
|
||||
'schedule',
|
||||
'saved',
|
||||
'isSystem',
|
||||
'isDisabled',
|
||||
'isPinned',
|
||||
'labels',
|
||||
'sub_id',
|
||||
'extra_schedules',
|
||||
'task_before',
|
||||
'task_after',
|
||||
'log_name',
|
||||
'allow_multiple_instances',
|
||||
'work_dir',
|
||||
] as const);
|
||||
const CRON_FIELD_PATTERN = /^[0-9A-Za-z*,/#LW-]+$/;
|
||||
const taskSpecRegistry = createBuiltInTaskSpecSemanticRegistry();
|
||||
const triggerSpecRegistry = createBuiltInTriggerSpecSemanticRegistry();
|
||||
|
||||
type ConfigurationColumn = (typeof CONFIGURATION_COLUMNS)[number];
|
||||
type LegacyRow = Record<ConfigurationColumn, unknown>;
|
||||
|
||||
function sha256Json(domain: string, value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update('\0')
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function scalarEvidence(value: unknown): readonly unknown[] {
|
||||
if (value === null) return Object.freeze(['null']);
|
||||
if (typeof value === 'string') {
|
||||
return Object.freeze([
|
||||
'text',
|
||||
Buffer.byteLength(value, 'utf8'),
|
||||
createHash('sha256').update(value).digest('hex'),
|
||||
]);
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Object.freeze([
|
||||
'number',
|
||||
Number.isFinite(value)
|
||||
? String(Object.is(value, -0) ? 0 : value)
|
||||
: 'invalid',
|
||||
]);
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return Object.freeze(['bigint', value.toString()]);
|
||||
}
|
||||
if (value instanceof Uint8Array) {
|
||||
return Object.freeze([
|
||||
'blob',
|
||||
value.byteLength,
|
||||
createHash('sha256').update(value).digest('hex'),
|
||||
]);
|
||||
}
|
||||
return Object.freeze(['unsupported', typeof value]);
|
||||
}
|
||||
|
||||
function sourceDigest(row: LegacyRow): string {
|
||||
return sha256Json(
|
||||
'qinglong3.legacy-crontab-source.v1',
|
||||
CONFIGURATION_COLUMNS.map((column) => [
|
||||
column,
|
||||
scalarEvidence(row[column]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeLegacyAdoptionTimezone(
|
||||
value: string | undefined,
|
||||
): string | null {
|
||||
if (value === undefined) return null;
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
Buffer.byteLength(value, 'utf8') > 128 ||
|
||||
value.includes('\0')
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionClassificationError(
|
||||
'legacyTimezone is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: value,
|
||||
}).resolvedOptions().timeZone;
|
||||
} catch (error) {
|
||||
throw new LegacyCrontabAdoptionClassificationError(
|
||||
'legacyTimezone is unsupported',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function selectSql(client: DatabaseSync): string {
|
||||
const columns = new Set(
|
||||
(
|
||||
client.prepare('PRAGMA table_info("Crontabs")').all() as {
|
||||
name?: unknown;
|
||||
}[]
|
||||
)
|
||||
.map(({ name }) => name)
|
||||
.filter((name): name is string => typeof name === 'string'),
|
||||
);
|
||||
for (const required of ['id', 'command', 'schedule']) {
|
||||
if (!columns.has(required)) {
|
||||
throw new LegacyCrontabAdoptionClassificationError(
|
||||
`legacy column Crontabs.${required} is missing`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const projections = CONFIGURATION_COLUMNS.map((column) =>
|
||||
columns.has(column) ? `"${column}"` : `NULL AS "${column}"`,
|
||||
);
|
||||
return `SELECT ${projections.join(', ')} FROM "Crontabs" ORDER BY "id"`;
|
||||
}
|
||||
|
||||
function validLegacyId(value: unknown): number | null {
|
||||
return Number.isSafeInteger(value) && (value as number) > 0
|
||||
? (value as number)
|
||||
: null;
|
||||
}
|
||||
|
||||
function optionalText(
|
||||
value: unknown,
|
||||
maximumBytes: number,
|
||||
): string | null | undefined {
|
||||
if (value === null) return undefined;
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > maximumBytes
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function flag(value: unknown): boolean | null | undefined {
|
||||
if (value === null) return undefined;
|
||||
if (value === 0) return false;
|
||||
if (value === 1) return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJson(value: unknown): unknown | undefined {
|
||||
if (value === null) return undefined;
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
Buffer.byteLength(value, 'utf8') > 64 * 1024
|
||||
) {
|
||||
return Symbol.for('invalid-legacy-json');
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return Symbol.for('invalid-legacy-json');
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReason(
|
||||
expression: string,
|
||||
): LegacyCrontabAdoptionReason | null {
|
||||
if (expression === '@once') return 'schedule_once_unsupported';
|
||||
if (expression === '@boot') return 'schedule_boot_unsupported';
|
||||
if (expression.startsWith('@')) return 'schedule_macro_unsupported';
|
||||
const fields = expression.trim().split(/\s+/u);
|
||||
if (
|
||||
(fields.length !== 5 && fields.length !== 6) ||
|
||||
fields.some(
|
||||
(field) =>
|
||||
field.length < 1 ||
|
||||
Buffer.byteLength(field, 'utf8') > 128 ||
|
||||
field.includes('?') ||
|
||||
field.startsWith('/') ||
|
||||
!CRON_FIELD_PATTERN.test(field),
|
||||
)
|
||||
) {
|
||||
return 'schedule_invalid';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function quoteShellValue(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function shellAssignment(
|
||||
name: string,
|
||||
value: string | number | boolean,
|
||||
): string {
|
||||
return `${name}=${quoteShellValue(String(value))}`;
|
||||
}
|
||||
|
||||
function normalizeHook(value: string): string {
|
||||
return value.replace(/;? *\r?\n/g, ';').trim();
|
||||
}
|
||||
|
||||
function compatibilityCommand(
|
||||
id: number,
|
||||
command: string,
|
||||
values: Readonly<{
|
||||
taskBefore?: string;
|
||||
taskAfter?: string;
|
||||
logName?: string;
|
||||
workDirectory?: string;
|
||||
}>,
|
||||
): string {
|
||||
const assignments = [
|
||||
shellAssignment('real_time', true),
|
||||
shellAssignment('no_tee', true),
|
||||
shellAssignment('ID', id),
|
||||
];
|
||||
if (values.logName)
|
||||
assignments.push(shellAssignment('log_name', values.logName));
|
||||
if (values.taskBefore) {
|
||||
assignments.push(
|
||||
shellAssignment('task_before', normalizeHook(values.taskBefore)),
|
||||
);
|
||||
}
|
||||
if (values.taskAfter) {
|
||||
assignments.push(
|
||||
shellAssignment('task_after', normalizeHook(values.taskAfter)),
|
||||
);
|
||||
}
|
||||
if (values.workDirectory) {
|
||||
assignments.push(shellAssignment('work_dir', values.workDirectory));
|
||||
}
|
||||
const trimmed = command.trim();
|
||||
const executable =
|
||||
trimmed.startsWith('task ') || trimmed.startsWith('ql ')
|
||||
? trimmed
|
||||
: `task ${trimmed}`;
|
||||
return `${assignments.join(' ')} ${executable}`;
|
||||
}
|
||||
|
||||
function classificationFor(
|
||||
reasons: ReadonlySet<LegacyCrontabAdoptionReason>,
|
||||
): LegacyCrontabAdoptionClassification {
|
||||
if (
|
||||
[...reasons].some((reason) =>
|
||||
[
|
||||
'legacy_id_invalid',
|
||||
'command_invalid',
|
||||
'legacy_field_invalid',
|
||||
'schedule_invalid',
|
||||
'extra_schedules_invalid',
|
||||
].includes(reason),
|
||||
)
|
||||
) {
|
||||
return 'malformed';
|
||||
}
|
||||
if (
|
||||
[...reasons].some((reason) =>
|
||||
[
|
||||
'timezone_required',
|
||||
'schedule_once_unsupported',
|
||||
'schedule_boot_unsupported',
|
||||
'schedule_macro_unsupported',
|
||||
'concurrency_policy_unmodeled',
|
||||
'system_task_requires_review',
|
||||
'labels_require_mapping',
|
||||
'subscription_binding_requires_mapping',
|
||||
].includes(reason),
|
||||
)
|
||||
) {
|
||||
return 'requires_manual_action';
|
||||
}
|
||||
return reasons.size > 0 ? 'requires_shell_compatibility' : 'lossless';
|
||||
}
|
||||
|
||||
function classifyRow(
|
||||
row: LegacyRow,
|
||||
rowOrdinal: number,
|
||||
timezone: string | null,
|
||||
): LegacyCrontabAdoptionInspection {
|
||||
const reasons = new Set<LegacyCrontabAdoptionReason>();
|
||||
const legacyId = validLegacyId(row.id);
|
||||
if (legacyId === null) reasons.add('legacy_id_invalid');
|
||||
|
||||
const legacyName = optionalText(row.name, 255);
|
||||
if (
|
||||
legacyName === null ||
|
||||
(typeof legacyName === 'string' &&
|
||||
/[\u0000-\u001f\u007f-\u009f]/u.test(legacyName))
|
||||
) {
|
||||
reasons.add('legacy_field_invalid');
|
||||
}
|
||||
|
||||
const command = optionalText(row.command, 64 * 1024);
|
||||
if (command === null || command === undefined || command.trim().length < 1) {
|
||||
reasons.add('command_invalid');
|
||||
}
|
||||
const taskBefore = optionalText(row.task_before, 16 * 1024);
|
||||
const taskAfter = optionalText(row.task_after, 16 * 1024);
|
||||
const logName = optionalText(row.log_name, 4096);
|
||||
const workDirectory = optionalText(row.work_dir, 4096);
|
||||
if ([taskBefore, taskAfter, logName, workDirectory].includes(null)) {
|
||||
reasons.add('legacy_field_invalid');
|
||||
}
|
||||
if (taskBefore) reasons.add('task_hooks_require_shell_compatibility');
|
||||
if (taskAfter) reasons.add('task_hooks_require_shell_compatibility');
|
||||
if (logName) reasons.add('log_name_requires_shell_compatibility');
|
||||
if (workDirectory) reasons.add('work_directory_requires_shell_compatibility');
|
||||
if (
|
||||
typeof command === 'string' &&
|
||||
command.trim().length > 0 &&
|
||||
!command.trim().startsWith('task ') &&
|
||||
!command.trim().startsWith('ql ')
|
||||
) {
|
||||
reasons.add('legacy_task_wrapper_required');
|
||||
}
|
||||
|
||||
const booleanFields = [row.saved, row.isSystem, row.isDisabled, row.isPinned];
|
||||
if (booleanFields.some((value) => flag(value) === null)) {
|
||||
reasons.add('legacy_field_invalid');
|
||||
}
|
||||
if (flag(row.isSystem) === true) reasons.add('system_task_requires_review');
|
||||
const pinned = flag(row.isPinned);
|
||||
const enabledFlag = flag(row.isDisabled);
|
||||
const enabled = enabledFlag === null ? null : enabledFlag !== true;
|
||||
|
||||
if (row.sub_id !== null) {
|
||||
if (!Number.isSafeInteger(row.sub_id) || (row.sub_id as number) < 1) {
|
||||
reasons.add('legacy_field_invalid');
|
||||
} else {
|
||||
reasons.add('subscription_binding_requires_mapping');
|
||||
}
|
||||
}
|
||||
|
||||
const concurrency = flag(row.allow_multiple_instances);
|
||||
if (concurrency === null) reasons.add('legacy_field_invalid');
|
||||
if (concurrency !== undefined && concurrency !== null) {
|
||||
reasons.add('concurrency_policy_unmodeled');
|
||||
}
|
||||
|
||||
const legacyLabels = parseJson(row.labels);
|
||||
if (
|
||||
typeof legacyLabels === 'symbol' ||
|
||||
(legacyLabels !== undefined &&
|
||||
(!Array.isArray(legacyLabels) ||
|
||||
legacyLabels.some((label) => typeof label !== 'string')))
|
||||
) {
|
||||
reasons.add('legacy_field_invalid');
|
||||
} else if (Array.isArray(legacyLabels) && legacyLabels.length > 0) {
|
||||
reasons.add('labels_require_mapping');
|
||||
}
|
||||
|
||||
const schedules: string[] = [];
|
||||
const primarySchedule = optionalText(row.schedule, 1024);
|
||||
if (
|
||||
primarySchedule === null ||
|
||||
primarySchedule === undefined ||
|
||||
primarySchedule.trim().length < 1
|
||||
) {
|
||||
reasons.add('schedule_invalid');
|
||||
} else {
|
||||
schedules.push(primarySchedule.trim());
|
||||
}
|
||||
const extraSchedules = parseJson(row.extra_schedules);
|
||||
if (
|
||||
typeof extraSchedules === 'symbol' ||
|
||||
(extraSchedules !== undefined && !Array.isArray(extraSchedules))
|
||||
) {
|
||||
reasons.add('extra_schedules_invalid');
|
||||
} else if (Array.isArray(extraSchedules)) {
|
||||
if (extraSchedules.length > 64) {
|
||||
reasons.add('extra_schedules_invalid');
|
||||
} else {
|
||||
for (const entry of extraSchedules) {
|
||||
if (
|
||||
!entry ||
|
||||
typeof entry !== 'object' ||
|
||||
Array.isArray(entry) ||
|
||||
Object.keys(entry).length !== 1 ||
|
||||
typeof (entry as { schedule?: unknown }).schedule !== 'string' ||
|
||||
Buffer.byteLength((entry as { schedule: string }).schedule, 'utf8') >
|
||||
1024
|
||||
) {
|
||||
reasons.add('extra_schedules_invalid');
|
||||
continue;
|
||||
}
|
||||
schedules.push((entry as { schedule: string }).schedule.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const expression of schedules) {
|
||||
const reason = scheduleReason(expression);
|
||||
if (reason) reasons.add(reason);
|
||||
}
|
||||
if (
|
||||
timezone === null &&
|
||||
schedules.some((value) => scheduleReason(value) === null)
|
||||
) {
|
||||
reasons.add('timezone_required');
|
||||
}
|
||||
const validTriggerCount = schedules.filter(
|
||||
(value) => scheduleReason(value) === null,
|
||||
).length;
|
||||
|
||||
const taskId = legacyId === null ? null : `legacy-cron:${legacyId}`;
|
||||
let taskSpec: TaskDefinitionSpec | undefined;
|
||||
let taskSpecDigest: string | undefined;
|
||||
if (
|
||||
taskId &&
|
||||
legacyId !== null &&
|
||||
typeof command === 'string' &&
|
||||
command.trim().length > 0 &&
|
||||
!reasons.has('legacy_field_invalid')
|
||||
) {
|
||||
try {
|
||||
taskSpec = taskSpecRegistry.normalize({
|
||||
projectId: 'legacy-adoption',
|
||||
taskId,
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
|
||||
config: {
|
||||
command: {
|
||||
kind: 'shell',
|
||||
command: compatibilityCommand(legacyId, command, {
|
||||
...(taskBefore ? { taskBefore } : {}),
|
||||
...(taskAfter ? { taskAfter } : {}),
|
||||
...(logName ? { logName } : {}),
|
||||
...(workDirectory ? { workDirectory } : {}),
|
||||
}),
|
||||
shell: '/bin/bash',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
taskSpecDigest = sha256Json('qinglong3.legacy-task-spec.v1', taskSpec);
|
||||
} catch {
|
||||
reasons.add('command_invalid');
|
||||
}
|
||||
}
|
||||
|
||||
const triggerCandidates: {
|
||||
triggerId: string;
|
||||
spec: TriggerSpec;
|
||||
enabled: boolean;
|
||||
}[] = [];
|
||||
const triggerSpecDigests: string[] = [];
|
||||
if (taskId && timezone !== null) {
|
||||
for (const [index, expression] of schedules.entries()) {
|
||||
if (scheduleReason(expression) !== null) continue;
|
||||
try {
|
||||
const triggerId = `${taskId}:cron:${index + 1}`;
|
||||
const spec = triggerSpecRegistry.normalize({
|
||||
projectId: 'legacy-adoption',
|
||||
triggerId,
|
||||
taskId,
|
||||
taskRevision: 1,
|
||||
spec: {
|
||||
schema: BUILT_IN_CRON_TRIGGER_SPEC_SCHEMA,
|
||||
config: { expression, timezone, misfirePolicy: 'skip' },
|
||||
},
|
||||
});
|
||||
triggerSpecDigests.push(
|
||||
sha256Json('qinglong3.legacy-trigger-spec.v1', spec),
|
||||
);
|
||||
triggerCandidates.push({
|
||||
triggerId,
|
||||
spec,
|
||||
enabled: enabled === true,
|
||||
});
|
||||
} catch {
|
||||
reasons.add('schedule_invalid');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orderedReasons = LEGACY_CRONTAB_ADOPTION_REASONS.filter((reason) =>
|
||||
reasons.has(reason),
|
||||
);
|
||||
const classification = classificationFor(reasons);
|
||||
const digest = sourceDigest(row);
|
||||
const diagnostic = Object.freeze({
|
||||
rowOrdinal,
|
||||
legacyId,
|
||||
taskId,
|
||||
classification,
|
||||
reasons: Object.freeze(orderedReasons),
|
||||
enabled,
|
||||
triggerCount: validTriggerCount,
|
||||
sourceDigest: digest,
|
||||
...(taskSpecDigest === undefined ? {} : { taskSpecDigest }),
|
||||
...(triggerSpecDigests.length === 0
|
||||
? {}
|
||||
: { triggerSpecDigests: Object.freeze(triggerSpecDigests) }),
|
||||
});
|
||||
if (
|
||||
taskId === null ||
|
||||
taskSpec === undefined ||
|
||||
enabled === null ||
|
||||
validTriggerCount !== schedules.length ||
|
||||
triggerCandidates.length !== schedules.length ||
|
||||
(classification !== 'lossless' &&
|
||||
classification !== 'requires_shell_compatibility')
|
||||
) {
|
||||
return Object.freeze({ diagnostic });
|
||||
}
|
||||
const candidateLabels = Object.freeze({
|
||||
...(pinned === true ? { 'qinglong.io/legacy-pinned': 'true' } : {}),
|
||||
});
|
||||
return Object.freeze({
|
||||
diagnostic,
|
||||
candidate: Object.freeze({
|
||||
rowOrdinal,
|
||||
sourceDigest: digest,
|
||||
task: Object.freeze({
|
||||
taskId,
|
||||
name:
|
||||
typeof legacyName === 'string' && legacyName.trim().length > 0
|
||||
? legacyName.trim()
|
||||
: `Legacy Crontab ${legacyId}`,
|
||||
kind: 'command' as const,
|
||||
spec: taskSpec,
|
||||
labels: candidateLabels,
|
||||
enabled,
|
||||
}),
|
||||
triggers: Object.freeze(
|
||||
triggerCandidates.map((trigger) => Object.freeze(trigger)),
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function visitLegacyCrontabAdoptionInspections(
|
||||
client: DatabaseSync,
|
||||
timezone: string | null,
|
||||
visitor: (inspection: LegacyCrontabAdoptionInspection) => void,
|
||||
): LegacyCrontabAdoptionInventory {
|
||||
if (typeof visitor !== 'function') {
|
||||
throw new LegacyCrontabAdoptionClassificationError(
|
||||
'diagnostic visitor is invalid',
|
||||
);
|
||||
}
|
||||
const counts: Record<LegacyCrontabAdoptionClassification, number> = {
|
||||
lossless: 0,
|
||||
requires_shell_compatibility: 0,
|
||||
requires_manual_action: 0,
|
||||
malformed: 0,
|
||||
};
|
||||
const hash = createHash('sha256')
|
||||
.update('qinglong3.legacy-crontab-inventory.v1\0')
|
||||
.update(JSON.stringify({ timezone }));
|
||||
let rowCount = 0;
|
||||
for (const inspection of iterateLegacyCrontabAdoptionInspections(
|
||||
client,
|
||||
timezone,
|
||||
)) {
|
||||
rowCount += 1;
|
||||
const { diagnostic } = inspection;
|
||||
counts[diagnostic.classification] += 1;
|
||||
hash.update('\0').update(
|
||||
JSON.stringify({
|
||||
rowOrdinal: diagnostic.rowOrdinal,
|
||||
sourceDigest: diagnostic.sourceDigest,
|
||||
classification: diagnostic.classification,
|
||||
reasons: diagnostic.reasons,
|
||||
enabled: diagnostic.enabled,
|
||||
triggerCount: diagnostic.triggerCount,
|
||||
taskSpecDigest: diagnostic.taskSpecDigest ?? null,
|
||||
triggerSpecDigests: diagnostic.triggerSpecDigests ?? [],
|
||||
}),
|
||||
);
|
||||
visitor(inspection);
|
||||
}
|
||||
const classifications = Object.freeze({ ...counts });
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
kind: 'qinglong3-legacy-crontab-adoption-inventory' as const,
|
||||
timezone,
|
||||
rowCount,
|
||||
classifications,
|
||||
inventoryDigest: hash.digest('hex'),
|
||||
mutationReady:
|
||||
counts.requires_shell_compatibility === 0 &&
|
||||
counts.requires_manual_action === 0 &&
|
||||
counts.malformed === 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function* iterateLegacyCrontabAdoptionInspections(
|
||||
client: DatabaseSync,
|
||||
timezone: string | null,
|
||||
): Iterable<LegacyCrontabAdoptionInspection> {
|
||||
let rowOrdinal = 0;
|
||||
for (const value of client
|
||||
.prepare(selectSql(client))
|
||||
.iterate() as Iterable<LegacyRow>) {
|
||||
rowOrdinal += 1;
|
||||
if (rowOrdinal > MAX_LEGACY_CRONTAB_ROWS) {
|
||||
throw new LegacyCrontabAdoptionClassificationError(
|
||||
`Crontabs row budget exceeds ${MAX_LEGACY_CRONTAB_ROWS}`,
|
||||
);
|
||||
}
|
||||
yield classifyRow(value, rowOrdinal, timezone);
|
||||
}
|
||||
}
|
||||
|
||||
export function visitLegacyCrontabAdoptionDiagnostics(
|
||||
client: DatabaseSync,
|
||||
timezone: string | null,
|
||||
visitor: (diagnostic: LegacyCrontabAdoptionDiagnostic) => void,
|
||||
): LegacyCrontabAdoptionInventory {
|
||||
return visitLegacyCrontabAdoptionInspections(
|
||||
client,
|
||||
timezone,
|
||||
({ diagnostic }) => visitor(diagnostic),
|
||||
);
|
||||
}
|
||||
|
||||
export function inspectLegacyCrontabInventory(
|
||||
client: DatabaseSync,
|
||||
timezone: string | null,
|
||||
): LegacyCrontabAdoptionInventory {
|
||||
return visitLegacyCrontabAdoptionDiagnostics(client, timezone, () => {});
|
||||
}
|
||||
|
||||
export function inspectLegacyCrontabDiagnosticPage(
|
||||
client: DatabaseSync,
|
||||
timezone: string | null,
|
||||
options: Readonly<{ afterRowOrdinal?: number; limit?: number }> = {},
|
||||
): LegacyCrontabAdoptionDiagnosticPage {
|
||||
const afterRowOrdinal = options.afterRowOrdinal ?? 0;
|
||||
const limit = options.limit ?? MAX_LEGACY_CRONTAB_DIAGNOSTIC_PAGE_SIZE;
|
||||
if (
|
||||
!Number.isSafeInteger(afterRowOrdinal) ||
|
||||
afterRowOrdinal < 0 ||
|
||||
afterRowOrdinal > MAX_LEGACY_CRONTAB_ROWS ||
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > MAX_LEGACY_CRONTAB_DIAGNOSTIC_PAGE_SIZE
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionClassificationError(
|
||||
'diagnostic cursor or page size is invalid',
|
||||
);
|
||||
}
|
||||
const diagnostics: LegacyCrontabAdoptionDiagnostic[] = [];
|
||||
const inventory = visitLegacyCrontabAdoptionDiagnostics(
|
||||
client,
|
||||
timezone,
|
||||
(diagnostic) => {
|
||||
if (
|
||||
diagnostic.rowOrdinal > afterRowOrdinal &&
|
||||
diagnostics.length < limit
|
||||
) {
|
||||
diagnostics.push(diagnostic);
|
||||
}
|
||||
},
|
||||
);
|
||||
const last = diagnostics.at(-1);
|
||||
const truncated = inventory.rowCount > afterRowOrdinal + diagnostics.length;
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-crontab-adoption-diagnostics',
|
||||
timezone,
|
||||
diagnostics: Object.freeze(diagnostics),
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? { next: Object.freeze({ rowOrdinal: last.rowOrdinal }) }
|
||||
: {}),
|
||||
inventory,
|
||||
});
|
||||
}
|
||||
+1114
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
// Legacy Adoption owns the reviewed decision issuer's stable public surface.
|
||||
export {
|
||||
LegacyCrontabDecisionIssuerKeyringConfigurationError,
|
||||
LegacyCrontabDecisionIssuerKeyringConflictError,
|
||||
LegacyCrontabDecisionIssuerKeyringFileProvider,
|
||||
LegacyCrontabDecisionIssuerKeyringUnavailableError,
|
||||
MAX_LEGACY_CRONTAB_DECISION_ISSUER_KEYS,
|
||||
provisionLegacyCrontabDecisionIssuerKeyring,
|
||||
rotateLegacyCrontabDecisionIssuerKeyring,
|
||||
type LegacyCrontabDecisionIssuerKeyringSummary,
|
||||
type RotateLegacyCrontabDecisionIssuerKeyringOptions,
|
||||
} from './legacyCrontabDecisionIssuerKeyring';
|
||||
|
||||
export {
|
||||
issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
|
||||
type IssueReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
|
||||
} from './localSqliteAdoption';
|
||||
|
||||
export {
|
||||
LegacyCrontabAdoptionDecisionReviewFileError,
|
||||
MAX_LEGACY_CRONTAB_DECISION_REVIEW_FILE_BYTES,
|
||||
withPrivateLegacyCrontabAdoptionDecisionReviewFile,
|
||||
type LegacyCrontabAdoptionDecisionReviewFileEvidence,
|
||||
type LegacyCrontabAdoptionDecisionReviewFileScope,
|
||||
type OpenLegacyCrontabAdoptionDecisionReviewFileOptions,
|
||||
} from './legacyCrontabDecisionReviewFile';
|
||||
@@ -0,0 +1,609 @@
|
||||
// Legacy Adoption owns the bounded issuer-key lifecycle for reviewed decisions.
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import fs, { constants } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
assertLocalSecretKeyId,
|
||||
type LocalSecretKeyMaterial,
|
||||
type LocalSecretKeyProvider,
|
||||
} from '@qinglong/runtime-core/local-secret';
|
||||
|
||||
export const MAX_LEGACY_CRONTAB_DECISION_ISSUER_KEYS = 8;
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const MAX_KEYRING_BYTES = 16 * 1024;
|
||||
const KEY_BYTES = 32;
|
||||
const KEY_ID_PREFIX = 'qladk-';
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
interface DirectoryIdentity {
|
||||
readonly path: string;
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
readonly uid: number;
|
||||
readonly mode: number;
|
||||
}
|
||||
|
||||
interface FileIdentity {
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
readonly uid: number;
|
||||
readonly mode: number;
|
||||
readonly size: bigint;
|
||||
}
|
||||
|
||||
interface LegacyCrontabDecisionIssuerKeyringManifest {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: 'qinglong3-legacy-crontab-decision-issuer-keyring';
|
||||
readonly activeKeyId: string;
|
||||
readonly keys: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
interface LoadedManifest {
|
||||
readonly manifest: LegacyCrontabDecisionIssuerKeyringManifest;
|
||||
readonly identity: FileIdentity;
|
||||
}
|
||||
|
||||
export interface LegacyCrontabDecisionIssuerKeyringSummary {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: 'qinglong3-legacy-crontab-decision-issuer-keyring-summary';
|
||||
readonly activeKeyId: string;
|
||||
readonly keyIds: readonly string[];
|
||||
readonly keyCount: number;
|
||||
readonly keyringDigest: string;
|
||||
}
|
||||
|
||||
export interface RotateLegacyCrontabDecisionIssuerKeyringOptions {
|
||||
readonly filePath: string;
|
||||
readonly expectedActiveKeyId: string;
|
||||
readonly expectedKeyringDigest: string;
|
||||
}
|
||||
|
||||
export class LegacyCrontabDecisionIssuerKeyringConfigurationError extends TypeError {
|
||||
readonly code =
|
||||
'LEGACY_CRONTAB_DECISION_ISSUER_KEYRING_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(
|
||||
`Legacy Crontab decision issuer keyring configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'LegacyCrontabDecisionIssuerKeyringConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LegacyCrontabDecisionIssuerKeyringUnavailableError extends Error {
|
||||
readonly code = 'LEGACY_CRONTAB_DECISION_ISSUER_KEYRING_UNAVAILABLE';
|
||||
|
||||
constructor(readonly cause?: unknown) {
|
||||
super('Legacy Crontab decision issuer keyring is unavailable');
|
||||
this.name = 'LegacyCrontabDecisionIssuerKeyringUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LegacyCrontabDecisionIssuerKeyringConflictError extends Error {
|
||||
readonly code = 'LEGACY_CRONTAB_DECISION_ISSUER_KEYRING_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Legacy Crontab decision issuer keyring state changed');
|
||||
this.name = 'LegacyCrontabDecisionIssuerKeyringConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function keyringPath(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') < 1 ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringConfigurationError(
|
||||
'filePath must be normalized, bounded and absolute',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function'
|
||||
) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringConfigurationError(
|
||||
'POSIX user identity is unavailable',
|
||||
);
|
||||
}
|
||||
const uid = process.getuid();
|
||||
const effectiveUid = process.geteuid();
|
||||
if (!Number.isSafeInteger(uid) || uid < 0 || uid !== effectiveUid) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringConfigurationError(
|
||||
'real and effective POSIX users must match',
|
||||
);
|
||||
}
|
||||
return uid;
|
||||
}
|
||||
|
||||
function directoryIdentity(filePath: string): DirectoryIdentity {
|
||||
const uid = currentUid();
|
||||
const directory = path.dirname(filePath);
|
||||
let stat: fs.BigIntStats;
|
||||
try {
|
||||
stat = fs.lstatSync(directory, { bigint: true });
|
||||
} catch (error) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError(error);
|
||||
}
|
||||
const mode = Number(stat.mode) & 0o777;
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== uid ||
|
||||
mode !== 0o700
|
||||
) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
path: directory,
|
||||
device: stat.dev,
|
||||
inode: stat.ino,
|
||||
uid,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDirectory(expected: DirectoryIdentity): void {
|
||||
const current = directoryIdentity(path.join(expected.path, '.identity'));
|
||||
if (
|
||||
current.path !== expected.path ||
|
||||
current.device !== expected.device ||
|
||||
current.inode !== expected.inode ||
|
||||
current.uid !== expected.uid ||
|
||||
current.mode !== expected.mode
|
||||
) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function keyId(value: unknown): string {
|
||||
try {
|
||||
assertLocalSecretKeyId(value as string);
|
||||
} catch {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
|
||||
}
|
||||
if (typeof value !== 'string' || !value.startsWith(KEY_ID_PREFIX)) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function newKeyId(): string {
|
||||
return `${KEY_ID_PREFIX}${randomBytes(12).toString('base64url')}`;
|
||||
}
|
||||
|
||||
function parseManifest(
|
||||
contents: Buffer,
|
||||
): LegacyCrontabDecisionIssuerKeyringManifest {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(contents.toString('utf8'));
|
||||
} catch {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
|
||||
}
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['activeKeyId', 'keys', 'kind', 'schemaVersion'])
|
||||
) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (
|
||||
candidate.schemaVersion !== 1 ||
|
||||
candidate.kind !== 'qinglong3-legacy-crontab-decision-issuer-keyring' ||
|
||||
!candidate.keys ||
|
||||
typeof candidate.keys !== 'object' ||
|
||||
Array.isArray(candidate.keys)
|
||||
) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
|
||||
}
|
||||
const entries = Object.entries(candidate.keys as Record<string, unknown>);
|
||||
if (
|
||||
entries.length < 1 ||
|
||||
entries.length > MAX_LEGACY_CRONTAB_DECISION_ISSUER_KEYS
|
||||
) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
|
||||
}
|
||||
const keys: Record<string, string> = Object.create(null);
|
||||
for (const [candidateKeyId, encoded] of entries) {
|
||||
let decoded: Buffer | undefined;
|
||||
try {
|
||||
const normalizedKeyId = keyId(candidateKeyId);
|
||||
decoded =
|
||||
typeof encoded === 'string'
|
||||
? Buffer.from(encoded, 'base64url')
|
||||
: Buffer.alloc(0);
|
||||
if (
|
||||
typeof encoded !== 'string' ||
|
||||
!BASE64URL_PATTERN.test(encoded) ||
|
||||
decoded.byteLength !== KEY_BYTES ||
|
||||
decoded.toString('base64url') !== encoded
|
||||
) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
|
||||
}
|
||||
keys[normalizedKeyId] = encoded;
|
||||
} finally {
|
||||
decoded?.fill(0);
|
||||
}
|
||||
}
|
||||
const activeKeyId = keyId(candidate.activeKeyId);
|
||||
if (!keys[activeKeyId]) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-crontab-decision-issuer-keyring',
|
||||
activeKeyId,
|
||||
keys: Object.freeze(keys),
|
||||
});
|
||||
}
|
||||
|
||||
function canonicalManifest(
|
||||
manifest: LegacyCrontabDecisionIssuerKeyringManifest,
|
||||
): Buffer {
|
||||
return Buffer.from(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-crontab-decision-issuer-keyring',
|
||||
activeKeyId: manifest.activeKeyId,
|
||||
keys: Object.fromEntries(
|
||||
Object.entries(manifest.keys).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
),
|
||||
})}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
function summarize(
|
||||
manifest: LegacyCrontabDecisionIssuerKeyringManifest,
|
||||
): Readonly<LegacyCrontabDecisionIssuerKeyringSummary> {
|
||||
const canonical = canonicalManifest(manifest);
|
||||
try {
|
||||
const keyIds = Object.freeze(Object.keys(manifest.keys).sort());
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-crontab-decision-issuer-keyring-summary',
|
||||
activeKeyId: manifest.activeKeyId,
|
||||
keyIds,
|
||||
keyCount: keyIds.length,
|
||||
keyringDigest: createHash('sha256')
|
||||
.update('qinglong3.legacy-crontab-decision-issuer-keyring.v1\0', 'utf8')
|
||||
.update(canonical)
|
||||
.digest('hex'),
|
||||
});
|
||||
} finally {
|
||||
canonical.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function fileIdentity(stat: fs.BigIntStats): FileIdentity {
|
||||
return Object.freeze({
|
||||
device: stat.dev,
|
||||
inode: stat.ino,
|
||||
uid: Number(stat.uid),
|
||||
mode: Number(stat.mode) & 0o777,
|
||||
size: stat.size,
|
||||
});
|
||||
}
|
||||
|
||||
function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
|
||||
return (
|
||||
left.device === right.device &&
|
||||
left.inode === right.inode &&
|
||||
left.uid === right.uid &&
|
||||
left.mode === right.mode &&
|
||||
left.size === right.size
|
||||
);
|
||||
}
|
||||
|
||||
function loadManifest(
|
||||
filePath: string,
|
||||
parent: DirectoryIdentity,
|
||||
): LoadedManifest {
|
||||
confirmDirectory(parent);
|
||||
const uid = currentUid();
|
||||
let descriptor: number | undefined;
|
||||
let contents: Buffer | undefined;
|
||||
try {
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
const beforeIdentity = fileIdentity(before);
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
beforeIdentity.uid !== uid ||
|
||||
beforeIdentity.mode !== 0o600 ||
|
||||
beforeIdentity.size < 1n ||
|
||||
beforeIdentity.size > BigInt(MAX_KEYRING_BYTES)
|
||||
) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
|
||||
}
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
const openedIdentity = fileIdentity(opened);
|
||||
if (!opened.isFile() || !sameFileIdentity(beforeIdentity, openedIdentity)) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
|
||||
}
|
||||
contents = fs.readFileSync(descriptor);
|
||||
if (contents.byteLength !== Number(openedIdentity.size)) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
|
||||
}
|
||||
const manifest = parseManifest(contents);
|
||||
confirmDirectory(parent);
|
||||
return Object.freeze({ manifest, identity: openedIdentity });
|
||||
} catch (error) {
|
||||
if (error instanceof LegacyCrontabDecisionIssuerKeyringUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError(error);
|
||||
} finally {
|
||||
contents?.fill(0);
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function writeTemporary(filePath: string, contents: Buffer): string {
|
||||
const temporaryPath = `${filePath}.tmp-${randomBytes(12).toString('hex')}`;
|
||||
const descriptor = fs.openSync(
|
||||
temporaryPath,
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
constants.O_WRONLY |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
fs.writeFileSync(descriptor, contents);
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
return temporaryPath;
|
||||
}
|
||||
|
||||
function syncDirectory(directory: string): void {
|
||||
const descriptor = fs.openSync(directory, constants.O_RDONLY);
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function assertCurrentFileIdentity(
|
||||
filePath: string,
|
||||
expected: FileIdentity,
|
||||
): void {
|
||||
let current: fs.BigIntStats;
|
||||
try {
|
||||
current = fs.lstatSync(filePath, { bigint: true });
|
||||
} catch {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringConflictError();
|
||||
}
|
||||
if (
|
||||
!current.isFile() ||
|
||||
current.isSymbolicLink() ||
|
||||
!sameFileIdentity(expected, fileIdentity(current))
|
||||
) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
function material(
|
||||
manifest: LegacyCrontabDecisionIssuerKeyringManifest,
|
||||
candidateKeyId: string,
|
||||
): LocalSecretKeyMaterial | null {
|
||||
const encoded = manifest.keys[candidateKeyId];
|
||||
return encoded
|
||||
? Object.freeze({
|
||||
keyId: candidateKeyId,
|
||||
key: Uint8Array.from(Buffer.from(encoded, 'base64url')),
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
export class LegacyCrontabDecisionIssuerKeyringFileProvider
|
||||
implements LocalSecretKeyProvider
|
||||
{
|
||||
private readonly filePath: string;
|
||||
private readonly parent: DirectoryIdentity;
|
||||
|
||||
constructor(candidatePath: string) {
|
||||
this.filePath = keyringPath(candidatePath);
|
||||
this.parent = directoryIdentity(this.filePath);
|
||||
}
|
||||
|
||||
async active(): Promise<LocalSecretKeyMaterial> {
|
||||
const manifest = loadManifest(this.filePath, this.parent).manifest;
|
||||
return material(manifest, manifest.activeKeyId)!;
|
||||
}
|
||||
|
||||
async resolve(
|
||||
candidateKeyId: string,
|
||||
): Promise<LocalSecretKeyMaterial | null> {
|
||||
const normalizedKeyId = keyId(candidateKeyId);
|
||||
return material(
|
||||
loadManifest(this.filePath, this.parent).manifest,
|
||||
normalizedKeyId,
|
||||
);
|
||||
}
|
||||
|
||||
async inspect(): Promise<
|
||||
Readonly<LegacyCrontabDecisionIssuerKeyringSummary>
|
||||
> {
|
||||
return summarize(loadManifest(this.filePath, this.parent).manifest);
|
||||
}
|
||||
}
|
||||
|
||||
export async function provisionLegacyCrontabDecisionIssuerKeyring(
|
||||
candidatePath: string,
|
||||
): Promise<Readonly<LegacyCrontabDecisionIssuerKeyringSummary>> {
|
||||
const filePath = keyringPath(candidatePath);
|
||||
const parent = directoryIdentity(filePath);
|
||||
const generatedKeyId = newKeyId();
|
||||
const key = randomBytes(KEY_BYTES);
|
||||
const manifest: LegacyCrontabDecisionIssuerKeyringManifest = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-crontab-decision-issuer-keyring',
|
||||
activeKeyId: generatedKeyId,
|
||||
keys: Object.freeze({ [generatedKeyId]: key.toString('base64url') }),
|
||||
});
|
||||
const contents = canonicalManifest(manifest);
|
||||
let temporaryPath: string | undefined;
|
||||
try {
|
||||
confirmDirectory(parent);
|
||||
temporaryPath = writeTemporary(filePath, contents);
|
||||
fs.linkSync(temporaryPath, filePath);
|
||||
fs.unlinkSync(temporaryPath);
|
||||
temporaryPath = undefined;
|
||||
syncDirectory(parent.path);
|
||||
return summarize(manifest);
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
error.code === 'EEXIST'
|
||||
) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringConflictError();
|
||||
}
|
||||
if (error instanceof LegacyCrontabDecisionIssuerKeyringConflictError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError(error);
|
||||
} finally {
|
||||
key.fill(0);
|
||||
contents.fill(0);
|
||||
if (temporaryPath) {
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
} catch {
|
||||
// Best-effort cleanup of an unpublished private inode.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function rotateLegacyCrontabDecisionIssuerKeyring(
|
||||
options: RotateLegacyCrontabDecisionIssuerKeyringOptions,
|
||||
): Promise<Readonly<LegacyCrontabDecisionIssuerKeyringSummary>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!exactKeys(options, [
|
||||
'expectedActiveKeyId',
|
||||
'expectedKeyringDigest',
|
||||
'filePath',
|
||||
]) ||
|
||||
typeof options.expectedKeyringDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(options.expectedKeyringDigest)
|
||||
) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringConfigurationError(
|
||||
'rotation options are invalid',
|
||||
);
|
||||
}
|
||||
const filePath = keyringPath(options.filePath);
|
||||
const expectedActiveKeyId = keyId(options.expectedActiveKeyId);
|
||||
const parent = directoryIdentity(filePath);
|
||||
const lockPath = `${filePath}.lock`;
|
||||
let lockDescriptor: number | undefined;
|
||||
let temporaryPath: string | undefined;
|
||||
let key: Buffer | undefined;
|
||||
let contents: Buffer | undefined;
|
||||
try {
|
||||
lockDescriptor = fs.openSync(
|
||||
lockPath,
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
constants.O_WRONLY |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
fs.fsyncSync(lockDescriptor);
|
||||
const loaded = loadManifest(filePath, parent);
|
||||
const currentSummary = summarize(loaded.manifest);
|
||||
if (
|
||||
currentSummary.activeKeyId !== expectedActiveKeyId ||
|
||||
currentSummary.keyringDigest !== options.expectedKeyringDigest
|
||||
) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringConflictError();
|
||||
}
|
||||
if (
|
||||
Object.keys(loaded.manifest.keys).length >=
|
||||
MAX_LEGACY_CRONTAB_DECISION_ISSUER_KEYS
|
||||
) {
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
|
||||
}
|
||||
const nextKeyId = newKeyId();
|
||||
key = randomBytes(KEY_BYTES);
|
||||
const next: LegacyCrontabDecisionIssuerKeyringManifest = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-crontab-decision-issuer-keyring',
|
||||
activeKeyId: nextKeyId,
|
||||
keys: Object.freeze({
|
||||
...loaded.manifest.keys,
|
||||
[nextKeyId]: key.toString('base64url'),
|
||||
}),
|
||||
});
|
||||
contents = canonicalManifest(next);
|
||||
temporaryPath = writeTemporary(filePath, contents);
|
||||
confirmDirectory(parent);
|
||||
assertCurrentFileIdentity(filePath, loaded.identity);
|
||||
fs.renameSync(temporaryPath, filePath);
|
||||
temporaryPath = undefined;
|
||||
syncDirectory(parent.path);
|
||||
return summarize(next);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof LegacyCrontabDecisionIssuerKeyringConflictError ||
|
||||
error instanceof LegacyCrontabDecisionIssuerKeyringConfigurationError ||
|
||||
error instanceof LegacyCrontabDecisionIssuerKeyringUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError(error);
|
||||
} finally {
|
||||
key?.fill(0);
|
||||
contents?.fill(0);
|
||||
if (temporaryPath) {
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
} catch {
|
||||
// Best-effort cleanup of an unpublished private inode.
|
||||
}
|
||||
}
|
||||
if (lockDescriptor !== undefined) {
|
||||
fs.closeSync(lockDescriptor);
|
||||
try {
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch {
|
||||
// Best-effort cleanup after releasing our own lock descriptor.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
// Legacy Adoption owns canonical reviewed decision receipts and verification.
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
visitLegacyCrontabAdoptionDiagnostics,
|
||||
type LegacyCrontabAdoptionClassification,
|
||||
type LegacyCrontabAdoptionDiagnostic,
|
||||
type LegacyCrontabAdoptionInventory,
|
||||
} from './legacyCrontabAdoption';
|
||||
|
||||
export const MAX_LEGACY_CRONTAB_DECISION_RECEIPT_LIFETIME_MS = 30 * 60 * 1_000;
|
||||
export const MAX_LEGACY_CRONTAB_DECISION_AUTHENTICATION_AGE_MS = 5 * 60 * 1_000;
|
||||
|
||||
export const LEGACY_CRONTAB_ADOPTION_DECISION_DISPOSITIONS = Object.freeze([
|
||||
'adopt',
|
||||
'adopt_shell_compatibility',
|
||||
'skip',
|
||||
] as const);
|
||||
|
||||
export const LEGACY_CRONTAB_ADOPTION_DECISION_REASONS = Object.freeze([
|
||||
'reviewed_lossless',
|
||||
'reviewed_shell_compatibility',
|
||||
'operator_excluded',
|
||||
'unsupported_semantics',
|
||||
'malformed_source',
|
||||
'security_review_required',
|
||||
] as const);
|
||||
|
||||
export type LegacyCrontabAdoptionDecisionDisposition =
|
||||
(typeof LEGACY_CRONTAB_ADOPTION_DECISION_DISPOSITIONS)[number];
|
||||
export type LegacyCrontabAdoptionDecisionReason =
|
||||
(typeof LEGACY_CRONTAB_ADOPTION_DECISION_REASONS)[number];
|
||||
|
||||
export interface LegacyCrontabAdoptionDecision {
|
||||
readonly rowOrdinal: number;
|
||||
readonly sourceDigest: string;
|
||||
readonly disposition: LegacyCrontabAdoptionDecisionDisposition;
|
||||
readonly reason: LegacyCrontabAdoptionDecisionReason;
|
||||
}
|
||||
|
||||
export interface LegacyCrontabAdoptionDecisionCounts {
|
||||
readonly adopt: number;
|
||||
readonly adopt_shell_compatibility: number;
|
||||
readonly skip: number;
|
||||
}
|
||||
|
||||
export interface LegacyCrontabAdoptionDecisionSetEvidence {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: 'qinglong3-legacy-crontab-adoption-decision-set';
|
||||
readonly rowCount: number;
|
||||
readonly dispositions: LegacyCrontabAdoptionDecisionCounts;
|
||||
readonly decisionDigest: string;
|
||||
}
|
||||
|
||||
export interface LegacyCrontabAdoptionDecisionReceiptPayload {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: 'qinglong3-legacy-crontab-adoption-decision-receipt';
|
||||
readonly decisionId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly planDigest: string;
|
||||
readonly inventoryDigest: string;
|
||||
readonly reviewer: Readonly<SecurityPrincipal>;
|
||||
readonly issuedAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
readonly decisions: LegacyCrontabAdoptionDecisionSetEvidence;
|
||||
}
|
||||
|
||||
export interface LegacyCrontabAdoptionDecisionReceipt
|
||||
extends LegacyCrontabAdoptionDecisionReceiptPayload {
|
||||
readonly receiptDigest: string;
|
||||
}
|
||||
|
||||
export interface CreateLegacyCrontabAdoptionDecisionReceiptContext {
|
||||
readonly decisionId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly planDigest: string;
|
||||
readonly inventoryDigest: string;
|
||||
readonly reviewer: SecurityPrincipal;
|
||||
readonly issuedAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
}
|
||||
|
||||
export class LegacyCrontabAdoptionDecisionReceiptError extends Error {
|
||||
readonly code = 'LEGACY_CRONTAB_ADOPTION_DECISION_RECEIPT_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Legacy Crontab adoption decision receipt is invalid: ${message}`);
|
||||
this.name = 'LegacyCrontabAdoptionDecisionReceiptError';
|
||||
}
|
||||
}
|
||||
|
||||
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}$/;
|
||||
|
||||
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 LegacyCrontabAdoptionDecisionReceiptError(
|
||||
`${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 LegacyCrontabAdoptionDecisionReceiptError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256Json(domain: string, value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update('\0')
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function normalizeContext(
|
||||
value: CreateLegacyCrontabAdoptionDecisionReceiptContext,
|
||||
): Readonly<CreateLegacyCrontabAdoptionDecisionReceiptContext> {
|
||||
exactKeys(
|
||||
value,
|
||||
[
|
||||
'decisionId',
|
||||
'expiresAtMs',
|
||||
'inventoryDigest',
|
||||
'issuedAtMs',
|
||||
'planDigest',
|
||||
'profile',
|
||||
'reviewer',
|
||||
],
|
||||
'receipt context',
|
||||
);
|
||||
if (!UUID_V7_PATTERN.test(value.decisionId)) {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
'decisionId must be a lowercase UUIDv7',
|
||||
);
|
||||
}
|
||||
if (value.profile !== 'edge' && value.profile !== 'standalone') {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError('profile is invalid');
|
||||
}
|
||||
if (
|
||||
!DIGEST_PATTERN.test(value.planDigest) ||
|
||||
!DIGEST_PATTERN.test(value.inventoryDigest)
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
'plan or inventory digest is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(value.issuedAtMs) ||
|
||||
value.issuedAtMs < 0 ||
|
||||
!Number.isSafeInteger(value.expiresAtMs) ||
|
||||
value.expiresAtMs <= value.issuedAtMs ||
|
||||
value.expiresAtMs - value.issuedAtMs >
|
||||
MAX_LEGACY_CRONTAB_DECISION_RECEIPT_LIFETIME_MS
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
'receipt lifetime is invalid',
|
||||
);
|
||||
}
|
||||
let reviewer: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
reviewer = normalizeSecurityPrincipal(value.reviewer, value.issuedAtMs);
|
||||
} catch (error) {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
'reviewer is invalid or inactive',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
reviewer.subject.type !== 'user' ||
|
||||
!['hardware', 'local_console', 'multi_factor'].includes(
|
||||
reviewer.assurance,
|
||||
) ||
|
||||
value.issuedAtMs - reviewer.authenticatedAtMs >
|
||||
MAX_LEGACY_CRONTAB_DECISION_AUTHENTICATION_AGE_MS ||
|
||||
value.expiresAtMs > reviewer.expiresAtMs
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
'reviewer lacks recent strong user authority',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
decisionId: value.decisionId,
|
||||
profile: value.profile,
|
||||
planDigest: value.planDigest,
|
||||
inventoryDigest: value.inventoryDigest,
|
||||
reviewer,
|
||||
issuedAtMs: value.issuedAtMs,
|
||||
expiresAtMs: value.expiresAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDecision(value: unknown): LegacyCrontabAdoptionDecision {
|
||||
exactKeys(
|
||||
value,
|
||||
['disposition', 'reason', 'rowOrdinal', 'sourceDigest'],
|
||||
'decision',
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(value.rowOrdinal) ||
|
||||
(value.rowOrdinal as number) < 1 ||
|
||||
!DIGEST_PATTERN.test(value.sourceDigest as string)
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
'decision identity is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!LEGACY_CRONTAB_ADOPTION_DECISION_DISPOSITIONS.includes(
|
||||
value.disposition as LegacyCrontabAdoptionDecisionDisposition,
|
||||
) ||
|
||||
!LEGACY_CRONTAB_ADOPTION_DECISION_REASONS.includes(
|
||||
value.reason as LegacyCrontabAdoptionDecisionReason,
|
||||
)
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
'decision disposition or reason is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
rowOrdinal: value.rowOrdinal as number,
|
||||
sourceDigest: value.sourceDigest as string,
|
||||
disposition: value.disposition as LegacyCrontabAdoptionDecisionDisposition,
|
||||
reason: value.reason as LegacyCrontabAdoptionDecisionReason,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseLegacyCrontabAdoptionDecision(
|
||||
value: unknown,
|
||||
): LegacyCrontabAdoptionDecision {
|
||||
return normalizeDecision(value);
|
||||
}
|
||||
|
||||
function assertDecisionAllowed(
|
||||
classification: LegacyCrontabAdoptionClassification,
|
||||
decision: LegacyCrontabAdoptionDecision,
|
||||
): void {
|
||||
const pair = `${decision.disposition}:${decision.reason}`;
|
||||
const allowed: Readonly<
|
||||
Record<LegacyCrontabAdoptionClassification, readonly string[]>
|
||||
> = {
|
||||
lossless: [
|
||||
'adopt:reviewed_lossless',
|
||||
'skip:operator_excluded',
|
||||
'skip:security_review_required',
|
||||
],
|
||||
requires_shell_compatibility: [
|
||||
'adopt_shell_compatibility:reviewed_shell_compatibility',
|
||||
'skip:operator_excluded',
|
||||
'skip:security_review_required',
|
||||
],
|
||||
requires_manual_action: [
|
||||
'skip:operator_excluded',
|
||||
'skip:security_review_required',
|
||||
'skip:unsupported_semantics',
|
||||
],
|
||||
malformed: ['skip:malformed_source'],
|
||||
};
|
||||
if (!allowed[classification].includes(pair)) {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
`decision is not allowed for ${classification}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function decisionSemanticEvidence(
|
||||
diagnostic: LegacyCrontabAdoptionDiagnostic,
|
||||
decision: LegacyCrontabAdoptionDecision,
|
||||
): object {
|
||||
return {
|
||||
rowOrdinal: diagnostic.rowOrdinal,
|
||||
sourceDigest: diagnostic.sourceDigest,
|
||||
classification: diagnostic.classification,
|
||||
reasons: diagnostic.reasons,
|
||||
enabled: diagnostic.enabled,
|
||||
triggerCount: diagnostic.triggerCount,
|
||||
taskSpecDigest: diagnostic.taskSpecDigest ?? null,
|
||||
triggerSpecDigests: diagnostic.triggerSpecDigests ?? [],
|
||||
disposition: decision.disposition,
|
||||
decisionReason: decision.reason,
|
||||
};
|
||||
}
|
||||
|
||||
function decisionIterator(
|
||||
value: Iterable<LegacyCrontabAdoptionDecision>,
|
||||
): Iterator<LegacyCrontabAdoptionDecision> {
|
||||
if (
|
||||
!value ||
|
||||
(typeof value !== 'object' && typeof value !== 'function') ||
|
||||
typeof value[Symbol.iterator] !== 'function'
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
'decisions must be an iterable',
|
||||
);
|
||||
}
|
||||
const iterator = value[Symbol.iterator]();
|
||||
if (!iterator || typeof iterator.next !== 'function') {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
'decision iterator is invalid',
|
||||
);
|
||||
}
|
||||
return iterator;
|
||||
}
|
||||
|
||||
function summarizeDecisions(
|
||||
client: DatabaseSync,
|
||||
timezone: string | null,
|
||||
expectedInventoryDigest: string,
|
||||
values: Iterable<LegacyCrontabAdoptionDecision>,
|
||||
): Readonly<{
|
||||
inventory: LegacyCrontabAdoptionInventory;
|
||||
evidence: LegacyCrontabAdoptionDecisionSetEvidence;
|
||||
}> {
|
||||
const iterator = decisionIterator(values);
|
||||
const counts: Record<LegacyCrontabAdoptionDecisionDisposition, number> = {
|
||||
adopt: 0,
|
||||
adopt_shell_compatibility: 0,
|
||||
skip: 0,
|
||||
};
|
||||
const hash = createHash('sha256').update(
|
||||
'qinglong3.legacy-crontab-adoption-decision-set.v1\0',
|
||||
);
|
||||
let complete = false;
|
||||
try {
|
||||
const inventory = visitLegacyCrontabAdoptionDiagnostics(
|
||||
client,
|
||||
timezone,
|
||||
(diagnostic) => {
|
||||
const next = iterator.next();
|
||||
if (next.done) {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
`decision for row ${diagnostic.rowOrdinal} is missing`,
|
||||
);
|
||||
}
|
||||
const decision = normalizeDecision(next.value);
|
||||
if (
|
||||
decision.rowOrdinal !== diagnostic.rowOrdinal ||
|
||||
decision.sourceDigest !== diagnostic.sourceDigest
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
`decision for row ${diagnostic.rowOrdinal} does not match source`,
|
||||
);
|
||||
}
|
||||
assertDecisionAllowed(diagnostic.classification, decision);
|
||||
counts[decision.disposition] += 1;
|
||||
hash
|
||||
.update('\0')
|
||||
.update(
|
||||
JSON.stringify(decisionSemanticEvidence(diagnostic, decision)),
|
||||
);
|
||||
},
|
||||
);
|
||||
const extra = iterator.next();
|
||||
if (!extra.done) {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
'decision set contains an extra row',
|
||||
);
|
||||
}
|
||||
if (inventory.inventoryDigest !== expectedInventoryDigest) {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
'source inventory no longer matches the reviewed plan',
|
||||
);
|
||||
}
|
||||
complete = true;
|
||||
return Object.freeze({
|
||||
inventory,
|
||||
evidence: Object.freeze({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-crontab-adoption-decision-set',
|
||||
rowCount: inventory.rowCount,
|
||||
dispositions: Object.freeze({ ...counts }),
|
||||
decisionDigest: hash.digest('hex'),
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LegacyCrontabAdoptionDecisionReceiptError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
'decision set inspection failed',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
if (!complete && typeof iterator.return === 'function') {
|
||||
try {
|
||||
iterator.return();
|
||||
} catch {
|
||||
// The original validation error remains authoritative.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createLegacyCrontabAdoptionDecisionReceipt(
|
||||
client: DatabaseSync,
|
||||
timezone: string | null,
|
||||
context: CreateLegacyCrontabAdoptionDecisionReceiptContext,
|
||||
decisions: Iterable<LegacyCrontabAdoptionDecision>,
|
||||
): LegacyCrontabAdoptionDecisionReceipt {
|
||||
const normalized = normalizeContext(context);
|
||||
const summarized = summarizeDecisions(
|
||||
client,
|
||||
timezone,
|
||||
normalized.inventoryDigest,
|
||||
decisions,
|
||||
);
|
||||
const payload: LegacyCrontabAdoptionDecisionReceiptPayload = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-crontab-adoption-decision-receipt',
|
||||
decisionId: normalized.decisionId,
|
||||
profile: normalized.profile,
|
||||
planDigest: normalized.planDigest,
|
||||
inventoryDigest: normalized.inventoryDigest,
|
||||
reviewer: normalized.reviewer,
|
||||
issuedAtMs: normalized.issuedAtMs,
|
||||
expiresAtMs: normalized.expiresAtMs,
|
||||
decisions: summarized.evidence,
|
||||
});
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
receiptDigest: sha256Json(
|
||||
'qinglong3.legacy-crontab-adoption-decision-receipt.v1',
|
||||
payload,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyLegacyCrontabAdoptionDecisionReceipt(
|
||||
client: DatabaseSync,
|
||||
timezone: string | null,
|
||||
value: unknown,
|
||||
decisions: Iterable<LegacyCrontabAdoptionDecision>,
|
||||
observedAtMs: number,
|
||||
): LegacyCrontabAdoptionDecisionReceipt {
|
||||
exactKeys(
|
||||
value,
|
||||
[
|
||||
'decisionId',
|
||||
'decisions',
|
||||
'expiresAtMs',
|
||||
'inventoryDigest',
|
||||
'issuedAtMs',
|
||||
'kind',
|
||||
'planDigest',
|
||||
'profile',
|
||||
'receiptDigest',
|
||||
'reviewer',
|
||||
'schemaVersion',
|
||||
],
|
||||
'receipt',
|
||||
);
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
value.kind !== 'qinglong3-legacy-crontab-adoption-decision-receipt' ||
|
||||
!Number.isSafeInteger(observedAtMs) ||
|
||||
observedAtMs < (value.issuedAtMs as number) ||
|
||||
observedAtMs >= (value.expiresAtMs as number)
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
'receipt version or active lifetime is invalid',
|
||||
);
|
||||
}
|
||||
const computed = createLegacyCrontabAdoptionDecisionReceipt(
|
||||
client,
|
||||
timezone,
|
||||
{
|
||||
decisionId: value.decisionId as string,
|
||||
profile: value.profile as 'edge' | 'standalone',
|
||||
planDigest: value.planDigest as string,
|
||||
inventoryDigest: value.inventoryDigest as string,
|
||||
reviewer: value.reviewer as SecurityPrincipal,
|
||||
issuedAtMs: value.issuedAtMs as number,
|
||||
expiresAtMs: value.expiresAtMs as number,
|
||||
},
|
||||
decisions,
|
||||
);
|
||||
exactKeys(
|
||||
value.decisions,
|
||||
['decisionDigest', 'dispositions', 'kind', 'rowCount', 'schemaVersion'],
|
||||
'decision set evidence',
|
||||
);
|
||||
const suppliedDecisions = value.decisions;
|
||||
const suppliedCounts = suppliedDecisions.dispositions;
|
||||
exactKeys(
|
||||
suppliedCounts,
|
||||
['adopt', 'adopt_shell_compatibility', 'skip'],
|
||||
'decision disposition counts',
|
||||
);
|
||||
if (
|
||||
value.receiptDigest !== computed.receiptDigest ||
|
||||
suppliedDecisions.schemaVersion !== computed.decisions.schemaVersion ||
|
||||
suppliedDecisions.kind !== computed.decisions.kind ||
|
||||
suppliedDecisions.rowCount !== computed.decisions.rowCount ||
|
||||
suppliedDecisions.decisionDigest !== computed.decisions.decisionDigest ||
|
||||
suppliedCounts.adopt !== computed.decisions.dispositions.adopt ||
|
||||
suppliedCounts.adopt_shell_compatibility !==
|
||||
computed.decisions.dispositions.adopt_shell_compatibility ||
|
||||
suppliedCounts.skip !== computed.decisions.dispositions.skip
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionDecisionReceiptError(
|
||||
'receipt content or digest does not match',
|
||||
);
|
||||
}
|
||||
return computed;
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// Legacy Adoption owns policy-fenced publication into the local SQLite authority.
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import {
|
||||
openLocalSqliteAdoptionDatabase,
|
||||
type LocalLegacyAdoptionCandidate,
|
||||
type PublishLocalLegacyAdoptionResult,
|
||||
} from '@qinglong/local-sqlite/adoption';
|
||||
import type { LocalSqliteProfile } from '@qinglong/local-sqlite/runtime';
|
||||
import type { LocalSecretKeyProvider } from '@qinglong/runtime-core/local-secret';
|
||||
import {
|
||||
ProjectPolicyEngine,
|
||||
ProjectPolicyUnavailableError,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import type { SecurityPolicyDecision } from '@qinglong/runtime-core/security';
|
||||
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
import { iterateLegacyCrontabAdoptionInspections } from './legacyCrontabAdoption';
|
||||
import {
|
||||
withVerifiedLegacyCrontabDecisionAuthorizationFile,
|
||||
type VerifiedLegacyCrontabDecisionAuthorizationFileScope,
|
||||
} from './legacyCrontabDecisionAuthorizationFile';
|
||||
import {
|
||||
verifyLegacyCrontabAdoptionDecisionReceipt,
|
||||
type LegacyCrontabAdoptionDecision,
|
||||
} from './legacyCrontabDecisionReceipt';
|
||||
|
||||
export type PublishReviewedLegacyCrontabAdoptionResult =
|
||||
PublishLocalLegacyAdoptionResult;
|
||||
|
||||
export interface PublishReviewedLegacyCrontabAdoptionOptions {
|
||||
readonly sourceClient: DatabaseSync;
|
||||
readonly sourcePath: string;
|
||||
readonly targetPath: string;
|
||||
readonly authorizationPath: string;
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly timezone: string | null;
|
||||
readonly expectedDecisionId: string;
|
||||
readonly expectedPlanDigest: string;
|
||||
readonly expectedInventoryDigest: string;
|
||||
readonly projectId: string;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly keyProvider: LocalSecretKeyProvider;
|
||||
readonly observedAtMs: number;
|
||||
readonly confirmSourceIdentity: () => void;
|
||||
readonly confirmReviewerAuthority?: (
|
||||
reviewer: Readonly<SecurityPrincipal>,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export class LegacyCrontabPublicationAuthorizationError extends Error {
|
||||
readonly code = 'LEGACY_CRONTAB_PUBLICATION_NOT_AUTHORIZED';
|
||||
constructor() {
|
||||
super('Legacy Crontab publication is not authorized');
|
||||
this.name = 'LegacyCrontabPublicationAuthorizationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LegacyCrontabPublicationUnavailableError extends Error {
|
||||
readonly code = 'LEGACY_CRONTAB_PUBLICATION_UNAVAILABLE';
|
||||
constructor(message = 'Legacy Crontab publication is unavailable') {
|
||||
super(message);
|
||||
this.name = 'LegacyCrontabPublicationUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function auditRecord(
|
||||
options: PublishReviewedLegacyCrontabAdoptionOptions,
|
||||
scope: VerifiedLegacyCrontabDecisionAuthorizationFileScope,
|
||||
decision: Readonly<SecurityPolicyDecision> | null,
|
||||
outcome: SecurityAuditRecord['outcome'],
|
||||
reasons: readonly string[],
|
||||
): SecurityAuditRecord {
|
||||
const reviewer = scope.result.receipt.reviewer;
|
||||
return Object.freeze({
|
||||
eventId: options.mutationId,
|
||||
requestId: options.requestId,
|
||||
operationId: 'task.adopt',
|
||||
projectId: options.projectId,
|
||||
subject: reviewer.subject,
|
||||
authenticationId: reviewer.authenticationId,
|
||||
outcome,
|
||||
reasons,
|
||||
fence: decision?.fence ?? null,
|
||||
occurredAtMs: options.observedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function* reviewedCandidates(
|
||||
sourceClient: DatabaseSync,
|
||||
timezone: string | null,
|
||||
decisions: Iterable<LegacyCrontabAdoptionDecision>,
|
||||
): Iterable<LocalLegacyAdoptionCandidate> {
|
||||
const iterator = decisions[Symbol.iterator]();
|
||||
for (const inspection of iterateLegacyCrontabAdoptionInspections(
|
||||
sourceClient,
|
||||
timezone,
|
||||
)) {
|
||||
const next = iterator.next();
|
||||
if (
|
||||
next.done ||
|
||||
next.value.rowOrdinal !== inspection.diagnostic.rowOrdinal ||
|
||||
next.value.sourceDigest !== inspection.diagnostic.sourceDigest
|
||||
) {
|
||||
throw new LegacyCrontabPublicationUnavailableError(
|
||||
'Reviewed decision stream does not match the fenced source',
|
||||
);
|
||||
}
|
||||
if (next.value.disposition === 'skip') continue;
|
||||
if (
|
||||
!inspection.candidate ||
|
||||
(next.value.disposition === 'adopt' &&
|
||||
inspection.diagnostic.classification !== 'lossless') ||
|
||||
(next.value.disposition === 'adopt_shell_compatibility' &&
|
||||
inspection.diagnostic.classification !== 'requires_shell_compatibility')
|
||||
) {
|
||||
throw new LegacyCrontabPublicationUnavailableError(
|
||||
'Reviewed disposition cannot publish this source row',
|
||||
);
|
||||
}
|
||||
yield inspection.candidate;
|
||||
}
|
||||
if (!iterator.next().done) {
|
||||
throw new LegacyCrontabPublicationUnavailableError(
|
||||
'Reviewed decision stream contains excess rows',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishReviewedLegacyCrontabAdoption(
|
||||
options: PublishReviewedLegacyCrontabAdoptionOptions,
|
||||
): Promise<PublishLocalLegacyAdoptionResult> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
typeof options.confirmSourceIdentity !== 'function'
|
||||
) {
|
||||
throw new LegacyCrontabPublicationUnavailableError(
|
||||
'Legacy publication options are invalid',
|
||||
);
|
||||
}
|
||||
return withVerifiedLegacyCrontabDecisionAuthorizationFile(
|
||||
{
|
||||
filePath: options.authorizationPath,
|
||||
expectedDecisionId: options.expectedDecisionId,
|
||||
expectedProfile: options.profile,
|
||||
expectedPlanDigest: options.expectedPlanDigest,
|
||||
expectedInventoryDigest: options.expectedInventoryDigest,
|
||||
keyProvider: options.keyProvider,
|
||||
verifyReceipt: (receipt, decisions) =>
|
||||
verifyLegacyCrontabAdoptionDecisionReceipt(
|
||||
options.sourceClient,
|
||||
options.timezone,
|
||||
receipt,
|
||||
decisions,
|
||||
options.observedAtMs,
|
||||
),
|
||||
},
|
||||
async (scope) => {
|
||||
options.confirmSourceIdentity();
|
||||
await options.confirmReviewerAuthority?.(scope.result.receipt.reviewer);
|
||||
const target = await openLocalSqliteAdoptionDatabase({
|
||||
databasePath: options.targetPath,
|
||||
profile: options.profile,
|
||||
});
|
||||
try {
|
||||
const policy = new ProjectPolicyEngine(target.projectPolicy);
|
||||
let decision: Readonly<SecurityPolicyDecision>;
|
||||
try {
|
||||
decision = await policy.authorize(
|
||||
scope.result.receipt.reviewer,
|
||||
options.projectId,
|
||||
'project.manage',
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProjectPolicyUnavailableError)) {
|
||||
throw new LegacyCrontabPublicationUnavailableError();
|
||||
}
|
||||
try {
|
||||
await target.securityAudit.record(
|
||||
auditRecord(options, scope, null, 'authorization_unavailable', [
|
||||
'policy_unavailable',
|
||||
]),
|
||||
);
|
||||
} catch {
|
||||
throw new LegacyCrontabPublicationUnavailableError();
|
||||
}
|
||||
throw new LegacyCrontabPublicationUnavailableError();
|
||||
}
|
||||
if (decision.effect !== 'allow') {
|
||||
try {
|
||||
await target.securityAudit.record(
|
||||
auditRecord(
|
||||
options,
|
||||
scope,
|
||||
decision,
|
||||
decision.effect === 'require_approval'
|
||||
? 'approval_required'
|
||||
: 'denied',
|
||||
decision.reasons,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
throw new LegacyCrontabPublicationUnavailableError();
|
||||
}
|
||||
throw new LegacyCrontabPublicationAuthorizationError();
|
||||
}
|
||||
if (!decision.fence || decision.fence.bindingVersion === null) {
|
||||
throw new LegacyCrontabPublicationUnavailableError();
|
||||
}
|
||||
return await target.publisher.publish({
|
||||
mutationId: options.mutationId,
|
||||
decisionId: scope.result.receipt.decisionId,
|
||||
projectId: options.projectId,
|
||||
profile: options.profile,
|
||||
planDigest: scope.result.receipt.planDigest,
|
||||
inventoryDigest: scope.result.receipt.inventoryDigest,
|
||||
decisionDigest: scope.result.receipt.decisions.decisionDigest,
|
||||
receiptDigest: scope.result.receipt.receiptDigest,
|
||||
authorizationFileDigest: scope.result.file.fileDigest,
|
||||
rowCount: scope.result.receipt.decisions.rowCount,
|
||||
skippedCount: scope.result.receipt.decisions.dispositions.skip,
|
||||
subject: scope.result.receipt.reviewer.subject,
|
||||
fence: decision.fence,
|
||||
audit: auditRecord(
|
||||
options,
|
||||
scope,
|
||||
decision,
|
||||
'allowed',
|
||||
decision.reasons,
|
||||
),
|
||||
candidates: reviewedCandidates(
|
||||
options.sourceClient,
|
||||
options.timezone,
|
||||
scope.decisions,
|
||||
),
|
||||
async confirmExternalAuthority() {
|
||||
options.confirmSourceIdentity();
|
||||
scope.confirmIdentity();
|
||||
await options.confirmReviewerAuthority?.(
|
||||
scope.result.receipt.reviewer,
|
||||
);
|
||||
},
|
||||
createdAtMs: options.observedAtMs,
|
||||
});
|
||||
} finally {
|
||||
await target.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import fs from 'node:fs';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
DIGEST_PATTERN,
|
||||
MAX_MANIFEST_BYTES,
|
||||
LocalSqliteAdoptionError,
|
||||
type AcquireLocalSqliteActivationOptions,
|
||||
type FileIdentity,
|
||||
type LocalSqliteActivation,
|
||||
type LocalSqliteActivationFence,
|
||||
type LocalSqliteActivationPayload,
|
||||
type LocalSqliteAdoptionManifest,
|
||||
type PrepareLocalSqliteActivationOptions,
|
||||
} from './contracts';
|
||||
import {
|
||||
assertAbsolutePath,
|
||||
assertClock,
|
||||
assertDistinctPaths,
|
||||
assertMissing,
|
||||
assertProfile,
|
||||
assertRealParent,
|
||||
assertRegularFile,
|
||||
fileIdentity,
|
||||
sha256Text,
|
||||
writeManifestAtomically,
|
||||
} from './filesystem';
|
||||
import {
|
||||
acquireSourceWriteFence,
|
||||
releaseSourceWriteFence,
|
||||
verifySourceSnapshotWhileFenced,
|
||||
} from './sourceFence';
|
||||
import { verifyLocalSqliteAdoptionInternal } from './staging';
|
||||
|
||||
function parseActivation(value: unknown): LocalSqliteActivation {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LocalSqliteAdoptionError('activation document is invalid');
|
||||
}
|
||||
const activation = value as Partial<LocalSqliteActivation>;
|
||||
const keys = Object.keys(activation).sort();
|
||||
const expectedKeys = [
|
||||
'activationDigest',
|
||||
'adoptionManifestDigest',
|
||||
'createdAtMs',
|
||||
'kind',
|
||||
'planDigest',
|
||||
'profile',
|
||||
'recoverySha256',
|
||||
'schemaVersion',
|
||||
'sourcePathDigest',
|
||||
'state',
|
||||
'targetDevice',
|
||||
'targetInode',
|
||||
'targetPathDigest',
|
||||
'targetSha256',
|
||||
].sort();
|
||||
if (
|
||||
JSON.stringify(keys) !== JSON.stringify(expectedKeys) ||
|
||||
activation.schemaVersion !== 1 ||
|
||||
activation.kind !== 'qinglong3-local-sqlite-activation' ||
|
||||
activation.state !== 'prepared' ||
|
||||
!Number.isSafeInteger(activation.createdAtMs) ||
|
||||
(activation.createdAtMs as number) < 0 ||
|
||||
!DIGEST_PATTERN.test(activation.activationDigest ?? '') ||
|
||||
!DIGEST_PATTERN.test(activation.adoptionManifestDigest ?? '') ||
|
||||
!DIGEST_PATTERN.test(activation.planDigest ?? '') ||
|
||||
!DIGEST_PATTERN.test(activation.sourcePathDigest ?? '') ||
|
||||
!DIGEST_PATTERN.test(activation.recoverySha256 ?? '') ||
|
||||
!DIGEST_PATTERN.test(activation.targetSha256 ?? '') ||
|
||||
!DIGEST_PATTERN.test(activation.targetPathDigest ?? '') ||
|
||||
!/^(?:0|[1-9]\d*)$/.test(activation.targetDevice ?? '') ||
|
||||
!/^(?:0|[1-9]\d*)$/.test(activation.targetInode ?? '')
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError('activation document shape is invalid');
|
||||
}
|
||||
assertProfile(activation.profile);
|
||||
const { activationDigest, ...payload } = activation as LocalSqliteActivation;
|
||||
if (sha256Text(JSON.stringify(payload)) !== activationDigest) {
|
||||
throw new LocalSqliteAdoptionError('activation digest does not match');
|
||||
}
|
||||
return activation as LocalSqliteActivation;
|
||||
}
|
||||
|
||||
async function readActivation(
|
||||
activationPath: string,
|
||||
): Promise<LocalSqliteActivation> {
|
||||
assertAbsolutePath(activationPath, 'activationPath');
|
||||
assertRealParent(activationPath, 'activation');
|
||||
assertRegularFile(activationPath, 'activation');
|
||||
const stat = fs.statSync(activationPath);
|
||||
if (
|
||||
stat.size < 1 ||
|
||||
stat.size > MAX_MANIFEST_BYTES ||
|
||||
(stat.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'activation file size or mode is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return parseActivation(
|
||||
JSON.parse(await fs.promises.readFile(activationPath, 'utf8')),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSqliteAdoptionError) throw error;
|
||||
throw new LocalSqliteAdoptionError('activation JSON is invalid', error);
|
||||
}
|
||||
}
|
||||
|
||||
function assertActivationMatchesAdoption(
|
||||
activation: LocalSqliteActivation,
|
||||
adoption: LocalSqliteAdoptionManifest,
|
||||
): void {
|
||||
if (
|
||||
activation.profile !== adoption.profile ||
|
||||
activation.adoptionManifestDigest !== adoption.manifestDigest ||
|
||||
activation.planDigest !== adoption.planDigest ||
|
||||
activation.sourcePathDigest !== adoption.source.pathDigest ||
|
||||
activation.recoverySha256 !== adoption.recovery.sha256 ||
|
||||
activation.targetSha256 !== adoption.target.sha256
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'activation does not match the staged adoption',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertActivationMatchesTarget(
|
||||
activation: LocalSqliteActivation,
|
||||
targetIdentity: FileIdentity,
|
||||
): void {
|
||||
if (
|
||||
activation.targetPathDigest !== targetIdentity.pathDigest ||
|
||||
activation.targetDevice !== targetIdentity.device ||
|
||||
activation.targetInode !== targetIdentity.inode
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'target database identity does not match the activation',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertActivatedTargetPath(
|
||||
activation: LocalSqliteActivation,
|
||||
targetPath: string,
|
||||
): void {
|
||||
assertRealParent(targetPath, 'target');
|
||||
assertRegularFile(targetPath, 'target');
|
||||
assertActivationMatchesTarget(activation, fileIdentity(targetPath));
|
||||
}
|
||||
|
||||
export async function prepareLocalSqliteActivation(
|
||||
options: PrepareLocalSqliteActivationOptions,
|
||||
): Promise<LocalSqliteActivation> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'activation preparation options are invalid',
|
||||
);
|
||||
}
|
||||
for (const [label, value] of [
|
||||
['sourcePath', options.sourcePath],
|
||||
['targetPath', options.targetPath],
|
||||
['recoveryPath', options.recoveryPath],
|
||||
['manifestPath', options.manifestPath],
|
||||
['activationPath', options.activationPath],
|
||||
] as const) {
|
||||
assertAbsolutePath(value, label);
|
||||
}
|
||||
if (!DIGEST_PATTERN.test(options.expectedManifestDigest)) {
|
||||
throw new LocalSqliteAdoptionError('expectedManifestDigest is invalid');
|
||||
}
|
||||
assertDistinctPaths([
|
||||
options.sourcePath,
|
||||
options.targetPath,
|
||||
options.recoveryPath,
|
||||
options.manifestPath,
|
||||
options.activationPath,
|
||||
]);
|
||||
assertRealParent(options.activationPath, 'activation');
|
||||
assertMissing(options.activationPath, 'activation');
|
||||
const verified = await verifyLocalSqliteAdoptionInternal(options, true);
|
||||
const adoption = verified.manifest;
|
||||
if (adoption.manifestDigest !== options.expectedManifestDigest) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'staged adoption no longer matches the reviewed manifest',
|
||||
);
|
||||
}
|
||||
const fence = acquireSourceWriteFence(options.sourcePath);
|
||||
let targetFence: DatabaseSync | undefined;
|
||||
try {
|
||||
targetFence = acquireSourceWriteFence(
|
||||
options.targetPath,
|
||||
undefined,
|
||||
'target database',
|
||||
);
|
||||
await verifySourceSnapshotWhileFenced(
|
||||
options.sourcePath,
|
||||
options.recoveryPath,
|
||||
adoption,
|
||||
);
|
||||
const activationVerified = await verifyLocalSqliteAdoptionInternal(
|
||||
options,
|
||||
true,
|
||||
);
|
||||
if (
|
||||
activationVerified.manifest.manifestDigest !== adoption.manifestDigest
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'staged adoption changed during activation preparation',
|
||||
);
|
||||
}
|
||||
const payload: LocalSqliteActivationPayload = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-sqlite-activation',
|
||||
state: 'prepared',
|
||||
profile: adoption.profile,
|
||||
createdAtMs: assertClock(options.clock ?? Date.now),
|
||||
adoptionManifestDigest: adoption.manifestDigest,
|
||||
planDigest: adoption.planDigest,
|
||||
sourcePathDigest: adoption.source.pathDigest,
|
||||
recoverySha256: adoption.recovery.sha256,
|
||||
targetSha256: adoption.target.sha256,
|
||||
targetPathDigest: activationVerified.targetIdentity.pathDigest,
|
||||
targetDevice: activationVerified.targetIdentity.device,
|
||||
targetInode: activationVerified.targetIdentity.inode,
|
||||
});
|
||||
const activation = Object.freeze({
|
||||
...payload,
|
||||
activationDigest: sha256Text(JSON.stringify(payload)),
|
||||
});
|
||||
await writeManifestAtomically(options.activationPath, activation);
|
||||
return activation;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSqliteAdoptionError) throw error;
|
||||
throw new LocalSqliteAdoptionError('activation preparation failed', error);
|
||||
} finally {
|
||||
if (targetFence) releaseSourceWriteFence(targetFence);
|
||||
releaseSourceWriteFence(fence);
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireLocalSqliteActivation(
|
||||
options: AcquireLocalSqliteActivationOptions,
|
||||
): Promise<LocalSqliteActivationFence> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'activation acquisition options are invalid',
|
||||
);
|
||||
}
|
||||
for (const [label, value] of [
|
||||
['sourcePath', options.sourcePath],
|
||||
['targetPath', options.targetPath],
|
||||
['recoveryPath', options.recoveryPath],
|
||||
['manifestPath', options.manifestPath],
|
||||
['activationPath', options.activationPath],
|
||||
] as const) {
|
||||
assertAbsolutePath(value, label);
|
||||
}
|
||||
if (!DIGEST_PATTERN.test(options.expectedActivationDigest)) {
|
||||
throw new LocalSqliteAdoptionError('expectedActivationDigest is invalid');
|
||||
}
|
||||
assertDistinctPaths([
|
||||
options.sourcePath,
|
||||
options.targetPath,
|
||||
options.recoveryPath,
|
||||
options.manifestPath,
|
||||
options.activationPath,
|
||||
]);
|
||||
const activation = await readActivation(options.activationPath);
|
||||
if (activation.activationDigest !== options.expectedActivationDigest) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'activation no longer matches the reviewed digest',
|
||||
);
|
||||
}
|
||||
const verified = await verifyLocalSqliteAdoptionInternal(options, false);
|
||||
const adoption = verified.manifest;
|
||||
assertActivationMatchesAdoption(activation, adoption);
|
||||
assertActivationMatchesTarget(activation, verified.targetIdentity);
|
||||
const fence = acquireSourceWriteFence(
|
||||
options.sourcePath,
|
||||
options.busyTimeoutMs,
|
||||
);
|
||||
try {
|
||||
await verifySourceSnapshotWhileFenced(
|
||||
options.sourcePath,
|
||||
options.recoveryPath,
|
||||
adoption,
|
||||
);
|
||||
} catch (error) {
|
||||
releaseSourceWriteFence(fence);
|
||||
if (error instanceof LocalSqliteAdoptionError) throw error;
|
||||
throw new LocalSqliteAdoptionError('activation acquisition failed', error);
|
||||
}
|
||||
let releasePromise: Promise<'released'> | undefined;
|
||||
return Object.freeze({
|
||||
activation,
|
||||
adoption,
|
||||
state: 'fenced' as const,
|
||||
assertTargetIdentity() {
|
||||
assertActivatedTargetPath(activation, options.targetPath);
|
||||
},
|
||||
release() {
|
||||
if (releasePromise) return releasePromise;
|
||||
releasePromise = Promise.resolve().then(() => {
|
||||
releaseSourceWriteFence(fence);
|
||||
return 'released' as const;
|
||||
});
|
||||
return releasePromise;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import type {
|
||||
localSqliteMigrationManifest,
|
||||
LocalSqliteProfile,
|
||||
LocalSqliteReadinessEvidence,
|
||||
} from '@qinglong/local-sqlite/runtime';
|
||||
import type {
|
||||
LegacyCrontabAdoptionDiagnosticPage,
|
||||
LegacyCrontabAdoptionInventory,
|
||||
} from '../legacyCrontabAdoption';
|
||||
import type {
|
||||
CreateLegacyCrontabAdoptionDecisionReceiptContext,
|
||||
LegacyCrontabAdoptionDecision,
|
||||
LegacyCrontabAdoptionDecisionReceipt,
|
||||
} from '../legacyCrontabDecisionReceipt';
|
||||
import type {
|
||||
PublishLegacyCrontabDecisionAuthorizationFileOptions,
|
||||
VerifyLegacyCrontabDecisionAuthorizationFileOptions,
|
||||
} from '../legacyCrontabDecisionAuthorizationFile';
|
||||
|
||||
export const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
export const MAX_MANIFEST_BYTES = 256 * 1024;
|
||||
export const MAX_SCHEMA_OBJECTS = 4096;
|
||||
|
||||
export interface FileIdentity {
|
||||
readonly fileName: string;
|
||||
readonly pathDigest: string;
|
||||
readonly bytes: number;
|
||||
readonly device: string;
|
||||
readonly inode: string;
|
||||
readonly modifiedAtNs: string;
|
||||
}
|
||||
|
||||
export interface LegacySqliteCatalogEvidence {
|
||||
readonly digest: string;
|
||||
readonly objectCount: number;
|
||||
readonly tableNames: readonly string[];
|
||||
}
|
||||
|
||||
export interface LegacySqliteAdoptionPlan {
|
||||
readonly schemaVersion: 2;
|
||||
readonly kind: 'qinglong3-local-sqlite-adoption-plan';
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly source: FileIdentity;
|
||||
readonly catalog: LegacySqliteCatalogEvidence;
|
||||
readonly tasks: LegacyCrontabAdoptionInventory;
|
||||
readonly planDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalSqliteAdoptionManifestPayload {
|
||||
readonly schemaVersion: 2;
|
||||
readonly kind: 'qinglong3-local-sqlite-adoption';
|
||||
readonly state: 'staged';
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly createdAtMs: number;
|
||||
readonly planDigest: string;
|
||||
readonly source: FileIdentity;
|
||||
readonly catalog: LegacySqliteCatalogEvidence;
|
||||
readonly tasks: LegacyCrontabAdoptionInventory;
|
||||
readonly recovery: {
|
||||
readonly fileName: string;
|
||||
readonly bytes: number;
|
||||
readonly sha256: string;
|
||||
};
|
||||
readonly target: {
|
||||
readonly fileName: string;
|
||||
readonly bytes: number;
|
||||
readonly sha256: string;
|
||||
};
|
||||
readonly migration: typeof localSqliteMigrationManifest;
|
||||
readonly readiness: LocalSqliteReadinessEvidence;
|
||||
}
|
||||
|
||||
export interface LocalSqliteAdoptionManifest
|
||||
extends LocalSqliteAdoptionManifestPayload {
|
||||
readonly manifestDigest: string;
|
||||
}
|
||||
|
||||
export interface InspectLegacySqliteOptions {
|
||||
readonly sourcePath: string;
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly legacyTimezone?: string;
|
||||
}
|
||||
|
||||
export interface InspectLegacyCrontabDiagnosticsOptions
|
||||
extends InspectLegacySqliteOptions {
|
||||
readonly expectedPlanDigest: string;
|
||||
readonly afterRowOrdinal?: number;
|
||||
readonly limit?: number;
|
||||
}
|
||||
|
||||
export interface ReviewedLegacyCrontabAdoptionDiagnosticPage
|
||||
extends LegacyCrontabAdoptionDiagnosticPage {
|
||||
readonly reviewedPlanDigest: string;
|
||||
}
|
||||
|
||||
export interface CreateReviewedLegacyCrontabAdoptionDecisionReceiptOptions
|
||||
extends InspectLegacySqliteOptions {
|
||||
readonly expectedPlanDigest: string;
|
||||
readonly decisionId: string;
|
||||
readonly reviewer: CreateLegacyCrontabAdoptionDecisionReceiptContext['reviewer'];
|
||||
readonly issuedAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
readonly decisions: Iterable<LegacyCrontabAdoptionDecision>;
|
||||
}
|
||||
|
||||
export interface VerifyReviewedLegacyCrontabAdoptionDecisionReceiptOptions
|
||||
extends InspectLegacySqliteOptions {
|
||||
readonly expectedPlanDigest: string;
|
||||
readonly receipt: unknown;
|
||||
readonly decisions: Iterable<LegacyCrontabAdoptionDecision>;
|
||||
readonly observedAtMs: number;
|
||||
}
|
||||
|
||||
export interface PublishReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions
|
||||
extends CreateReviewedLegacyCrontabAdoptionDecisionReceiptOptions {
|
||||
readonly authorizationPath: string;
|
||||
readonly keyProvider: PublishLegacyCrontabDecisionAuthorizationFileOptions['keyProvider'];
|
||||
readonly confirmExternalAuthority?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface IssueReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions
|
||||
extends InspectLegacySqliteOptions {
|
||||
readonly expectedPlanDigest: string;
|
||||
readonly decisionId: string;
|
||||
readonly authorizationPath: string;
|
||||
readonly issuerKeyringPath: string;
|
||||
readonly decisions: Iterable<LegacyCrontabAdoptionDecision>;
|
||||
readonly authenticateReviewer: () =>
|
||||
| CreateLegacyCrontabAdoptionDecisionReceiptContext['reviewer']
|
||||
| Promise<CreateLegacyCrontabAdoptionDecisionReceiptContext['reviewer']>;
|
||||
readonly confirmIssuerAuthority: () => void | Promise<void>;
|
||||
readonly confirmDecisionStreamAuthority?: () => void | Promise<void>;
|
||||
readonly lifetimeMs?: number;
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
|
||||
export interface VerifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions
|
||||
extends InspectLegacySqliteOptions {
|
||||
readonly expectedPlanDigest: string;
|
||||
readonly expectedDecisionId: string;
|
||||
readonly authorizationPath: string;
|
||||
readonly keyProvider: VerifyLegacyCrontabDecisionAuthorizationFileOptions['keyProvider'];
|
||||
readonly observedAtMs: number;
|
||||
}
|
||||
|
||||
export interface CommitReviewedLegacyCrontabAdoptionOptions
|
||||
extends VerifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions {
|
||||
readonly targetPath: string;
|
||||
readonly projectId: string;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
readonly confirmReviewerAuthority?: (
|
||||
reviewer: CreateLegacyCrontabAdoptionDecisionReceiptContext['reviewer'],
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface StageLocalSqliteAdoptionOptions
|
||||
extends InspectLegacySqliteOptions {
|
||||
readonly targetPath: string;
|
||||
readonly recoveryPath: string;
|
||||
readonly manifestPath: string;
|
||||
readonly expectedPlanDigest: string;
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
|
||||
export interface VerifyLocalSqliteAdoptionOptions {
|
||||
readonly targetPath: string;
|
||||
readonly recoveryPath: string;
|
||||
readonly manifestPath: string;
|
||||
}
|
||||
|
||||
export interface LocalSqliteActivationPayload {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: 'qinglong3-local-sqlite-activation';
|
||||
readonly state: 'prepared';
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly createdAtMs: number;
|
||||
readonly adoptionManifestDigest: string;
|
||||
readonly planDigest: string;
|
||||
readonly sourcePathDigest: string;
|
||||
readonly recoverySha256: string;
|
||||
readonly targetSha256: string;
|
||||
readonly targetPathDigest: string;
|
||||
readonly targetDevice: string;
|
||||
readonly targetInode: string;
|
||||
}
|
||||
|
||||
export interface LocalSqliteActivation extends LocalSqliteActivationPayload {
|
||||
readonly activationDigest: string;
|
||||
}
|
||||
|
||||
export interface PrepareLocalSqliteActivationOptions
|
||||
extends VerifyLocalSqliteAdoptionOptions {
|
||||
readonly sourcePath: string;
|
||||
readonly activationPath: string;
|
||||
readonly expectedManifestDigest: string;
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
|
||||
export interface AcquireLocalSqliteActivationOptions
|
||||
extends VerifyLocalSqliteAdoptionOptions {
|
||||
readonly sourcePath: string;
|
||||
readonly activationPath: string;
|
||||
readonly expectedActivationDigest: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface LocalSqliteActivationFence {
|
||||
readonly activation: LocalSqliteActivation;
|
||||
readonly adoption: LocalSqliteAdoptionManifest;
|
||||
readonly state: 'fenced';
|
||||
assertTargetIdentity(): void;
|
||||
release(): Promise<'released'>;
|
||||
}
|
||||
|
||||
export interface VerifiedLocalSqliteAdoption {
|
||||
readonly manifest: LocalSqliteAdoptionManifest;
|
||||
readonly targetIdentity: FileIdentity;
|
||||
}
|
||||
|
||||
export class LocalSqliteAdoptionError extends Error {
|
||||
readonly code = 'LOCAL_SQLITE_ADOPTION_FAILED';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local SQLite adoption failed: ${message}`);
|
||||
this.name = 'LocalSqliteAdoptionError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { LocalSqliteProfile } from '@qinglong/local-sqlite/runtime';
|
||||
import { LocalSqliteAdoptionError, type FileIdentity } from './contracts';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
|
||||
export function sha256Text(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
export async function sha256File(filePath: string): Promise<string> {
|
||||
const before = fs.statSync(filePath, { bigint: true });
|
||||
const hash = createHash('sha256');
|
||||
for await (const chunk of fs.createReadStream(filePath)) hash.update(chunk);
|
||||
const after = fs.statSync(filePath, { bigint: true });
|
||||
if (
|
||||
before.dev !== after.dev ||
|
||||
before.ino !== after.ino ||
|
||||
before.size !== after.size ||
|
||||
before.mtimeNs !== after.mtimeNs
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError('file changed while hashing');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export function assertProfile(
|
||||
profile: unknown,
|
||||
): asserts profile is LocalSqliteProfile {
|
||||
if (profile !== 'edge' && profile !== 'standalone') {
|
||||
throw new LocalSqliteAdoptionError('profile must be edge or standalone');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAbsolutePath(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): asserts value is string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
Buffer.byteLength(value) < 1 ||
|
||||
Buffer.byteLength(value) > MAX_PATH_BYTES ||
|
||||
value.includes('\0')
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
`${label} must be a bounded absolute path`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertRealParent(filePath: string, label: string): void {
|
||||
const parent = fs.lstatSync(path.dirname(filePath));
|
||||
if (!parent.isDirectory() || parent.isSymbolicLink()) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
`${label} parent must be a real directory`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertRegularFile(filePath: string, label: string): void {
|
||||
const target = fs.lstatSync(filePath);
|
||||
if (!target.isFile() || target.isSymbolicLink()) {
|
||||
throw new LocalSqliteAdoptionError(`${label} must be a regular file`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertMissing(filePath: string, label: string): void {
|
||||
try {
|
||||
fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new LocalSqliteAdoptionError(`${label} already exists`);
|
||||
}
|
||||
|
||||
export function fileIdentity(filePath: string): FileIdentity {
|
||||
const stat = fs.statSync(filePath, { bigint: true });
|
||||
if (stat.size < 0n || stat.size > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
throw new LocalSqliteAdoptionError('source size is unsupported');
|
||||
}
|
||||
return Object.freeze({
|
||||
fileName: path.basename(filePath),
|
||||
pathDigest: sha256Text(path.resolve(filePath)),
|
||||
bytes: Number(stat.size),
|
||||
device: stat.dev.toString(),
|
||||
inode: stat.ino.toString(),
|
||||
modifiedAtNs: stat.mtimeNs.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
export function sameFileIdentity(
|
||||
left: FileIdentity,
|
||||
right: FileIdentity,
|
||||
): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
export function assertDistinctPaths(paths: readonly string[]): void {
|
||||
const normalized = paths.map((value) => path.resolve(value));
|
||||
if (new Set(normalized).size !== normalized.length) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'source and output paths must be distinct',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCreatedFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
} catch (error) {
|
||||
if (
|
||||
!error ||
|
||||
typeof error !== 'object' ||
|
||||
!('code' in error) ||
|
||||
error.code !== 'ENOENT'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertClock(clock: () => number): number {
|
||||
const value = clock();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LocalSqliteAdoptionError('clock returned an invalid timestamp');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function writeManifestAtomically(
|
||||
manifestPath: string,
|
||||
manifest: object,
|
||||
): Promise<void> {
|
||||
const temporaryPath = path.join(
|
||||
path.dirname(manifestPath),
|
||||
`.${path.basename(manifestPath)}.${randomUUID()}.tmp`,
|
||||
);
|
||||
const handle = await fs.promises.open(temporaryPath, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify(manifest)}\n`, 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
let destinationCreated = false;
|
||||
try {
|
||||
await fs.promises.copyFile(
|
||||
temporaryPath,
|
||||
manifestPath,
|
||||
fs.constants.COPYFILE_EXCL,
|
||||
);
|
||||
destinationCreated = true;
|
||||
await fs.promises.chmod(manifestPath, 0o600);
|
||||
} catch (error) {
|
||||
if (destinationCreated) await removeCreatedFile(manifestPath);
|
||||
throw error;
|
||||
} finally {
|
||||
await removeCreatedFile(temporaryPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import type { LocalSqliteProfile } from '@qinglong/local-sqlite/runtime';
|
||||
import type { LegacyCrontabAdoptionInventory } from '../legacyCrontabAdoption';
|
||||
import {
|
||||
DIGEST_PATTERN,
|
||||
MAX_SCHEMA_OBJECTS,
|
||||
LocalSqliteAdoptionError,
|
||||
type InspectLegacyCrontabDiagnosticsOptions,
|
||||
type InspectLegacySqliteOptions,
|
||||
type LegacySqliteAdoptionPlan,
|
||||
type LegacySqliteCatalogEvidence,
|
||||
type ReviewedLegacyCrontabAdoptionDiagnosticPage,
|
||||
type FileIdentity,
|
||||
} from './contracts';
|
||||
import {
|
||||
assertAbsolutePath,
|
||||
assertProfile,
|
||||
assertRealParent,
|
||||
assertRegularFile,
|
||||
fileIdentity,
|
||||
sameFileIdentity,
|
||||
sha256Text,
|
||||
} from './filesystem';
|
||||
|
||||
type LegacyCrontabAdoptionModule = typeof import('../legacyCrontabAdoption');
|
||||
|
||||
export function legacyCrontabAdoptionModule(): LegacyCrontabAdoptionModule {
|
||||
return require('../legacyCrontabAdoption') as LegacyCrontabAdoptionModule;
|
||||
}
|
||||
|
||||
const MAX_SCHEMA_SQL_BYTES = 16 * 1024 * 1024;
|
||||
const LEGACY_SENTINELS = Object.freeze({
|
||||
Auths: Object.freeze(['id', 'type', 'info']),
|
||||
Crontabs: Object.freeze(['id', 'command', 'schedule']),
|
||||
Envs: Object.freeze(['id', 'name', 'value']),
|
||||
});
|
||||
const CONFLICTING_QL3_OBJECTS = new Set([
|
||||
'QingLong3SchemaCapabilities',
|
||||
'QingLong3SchemaMigrations',
|
||||
'RunAttempts',
|
||||
'RunEvents',
|
||||
'RunRetryPolicies',
|
||||
'Runs',
|
||||
]);
|
||||
|
||||
type SchemaObjectType = 'index' | 'table' | 'trigger' | 'view';
|
||||
|
||||
interface SchemaObjectRow {
|
||||
type: unknown;
|
||||
name: unknown;
|
||||
table_name: unknown;
|
||||
sql: unknown;
|
||||
}
|
||||
|
||||
export function isCanonicalLegacyTimezone(value: string): boolean {
|
||||
try {
|
||||
return (
|
||||
legacyCrontabAdoptionModule().normalizeLegacyAdoptionTimezone(value) ===
|
||||
value
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function requiredText(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 1024 ||
|
||||
/[\0\r\n]/.test(value)
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function catalogEvidence(
|
||||
client: DatabaseSync,
|
||||
): LegacySqliteCatalogEvidence {
|
||||
const quickCheck = client.prepare('PRAGMA quick_check(1)').get();
|
||||
if (!quickCheck || Object.values(quickCheck)[0] !== 'ok') {
|
||||
throw new LocalSqliteAdoptionError('source quick_check failed');
|
||||
}
|
||||
if (client.prepare('SELECT * FROM pragma_foreign_key_check LIMIT 1').get()) {
|
||||
throw new LocalSqliteAdoptionError('source foreign_key_check failed');
|
||||
}
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT type, name, tbl_name AS table_name, sql
|
||||
FROM sqlite_schema
|
||||
WHERE type IN ('table', 'index', 'trigger', 'view')
|
||||
ORDER BY type, name`,
|
||||
)
|
||||
.all() as unknown as SchemaObjectRow[];
|
||||
if (rows.length < 1 || rows.length > MAX_SCHEMA_OBJECTS) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'source schema object budget is invalid',
|
||||
);
|
||||
}
|
||||
let sqlBytes = 0;
|
||||
const canonicalRows = rows.map((row) => {
|
||||
const type = requiredText(
|
||||
row.type,
|
||||
'schema object type',
|
||||
) as SchemaObjectType;
|
||||
if (!['index', 'table', 'trigger', 'view'].includes(type)) {
|
||||
throw new LocalSqliteAdoptionError('schema object type is unsupported');
|
||||
}
|
||||
const name = requiredText(row.name, 'schema object name');
|
||||
const tableName = requiredText(row.table_name, 'schema table name');
|
||||
if (CONFLICTING_QL3_OBJECTS.has(name)) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
`source already contains conflicting 3.0 object ${name}`,
|
||||
);
|
||||
}
|
||||
if (row.sql !== null && typeof row.sql !== 'string') {
|
||||
throw new LocalSqliteAdoptionError('schema SQL is invalid');
|
||||
}
|
||||
const sql = row.sql as string | null;
|
||||
sqlBytes += Buffer.byteLength(sql ?? '');
|
||||
if (sqlBytes > MAX_SCHEMA_SQL_BYTES) {
|
||||
throw new LocalSqliteAdoptionError('source schema SQL budget exceeded');
|
||||
}
|
||||
return Object.freeze({ type, name, tableName, sql });
|
||||
});
|
||||
const tableNames = canonicalRows
|
||||
.filter(({ type }) => type === 'table')
|
||||
.map(({ name }) => name)
|
||||
.sort();
|
||||
for (const [tableName, requiredColumns] of Object.entries(LEGACY_SENTINELS)) {
|
||||
if (!tableNames.includes(tableName)) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
`legacy table ${tableName} is missing`,
|
||||
);
|
||||
}
|
||||
const columns = (
|
||||
client.prepare(`PRAGMA table_info("${tableName}")`).all() as unknown as {
|
||||
name?: unknown;
|
||||
}[]
|
||||
).map(({ name }) => requiredText(name, `${tableName} column`));
|
||||
for (const column of requiredColumns) {
|
||||
if (!columns.includes(column)) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
`legacy column ${tableName}.${column} is missing`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
digest: sha256Text(JSON.stringify(canonicalRows)),
|
||||
objectCount: canonicalRows.length,
|
||||
tableNames: Object.freeze(tableNames),
|
||||
});
|
||||
}
|
||||
|
||||
export function openLegacySource(sourcePath: string): DatabaseSync {
|
||||
const client = new DatabaseSync(sourcePath, {
|
||||
allowExtension: false,
|
||||
defensive: true,
|
||||
enableDoubleQuotedStringLiterals: false,
|
||||
enableForeignKeyConstraints: true,
|
||||
readOnly: true,
|
||||
timeout: 5_000,
|
||||
});
|
||||
try {
|
||||
client.enableDefensive(true);
|
||||
client.exec('PRAGMA trusted_schema = OFF');
|
||||
client.exec('PRAGMA query_only = ON');
|
||||
return client;
|
||||
} catch (error) {
|
||||
client.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function planPayload(
|
||||
profile: LocalSqliteProfile,
|
||||
source: FileIdentity,
|
||||
catalog: LegacySqliteCatalogEvidence,
|
||||
tasks: LegacyCrontabAdoptionInventory,
|
||||
): Omit<LegacySqliteAdoptionPlan, 'planDigest'> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 2 as const,
|
||||
kind: 'qinglong3-local-sqlite-adoption-plan' as const,
|
||||
profile,
|
||||
source,
|
||||
catalog,
|
||||
tasks,
|
||||
});
|
||||
}
|
||||
|
||||
export function inspectLegacySqlitePath(
|
||||
options: InspectLegacySqliteOptions,
|
||||
): LegacySqliteAdoptionPlan {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new LocalSqliteAdoptionError('inspection options are invalid');
|
||||
}
|
||||
assertProfile(options.profile);
|
||||
assertAbsolutePath(options.sourcePath, 'sourcePath');
|
||||
assertRealParent(options.sourcePath, 'source');
|
||||
assertRegularFile(options.sourcePath, 'source');
|
||||
let timezone: string | null;
|
||||
try {
|
||||
timezone = legacyCrontabAdoptionModule().normalizeLegacyAdoptionTimezone(
|
||||
options.legacyTimezone,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new LocalSqliteAdoptionError('legacy timezone is invalid', error);
|
||||
}
|
||||
const client = openLegacySource(options.sourcePath);
|
||||
try {
|
||||
const sourceBefore = fileIdentity(options.sourcePath);
|
||||
const payload = planPayload(
|
||||
options.profile,
|
||||
sourceBefore,
|
||||
catalogEvidence(client),
|
||||
legacyCrontabAdoptionModule().inspectLegacyCrontabInventory(
|
||||
client,
|
||||
timezone,
|
||||
),
|
||||
);
|
||||
const sourceAfter = fileIdentity(options.sourcePath);
|
||||
if (!sameFileIdentity(sourceBefore, sourceAfter)) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'source changed during task inspection',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
planDigest: sha256Text(JSON.stringify(payload)),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSqliteAdoptionError) throw error;
|
||||
throw new LocalSqliteAdoptionError('source inspection failed', error);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectLegacyCrontabAdoptionDiagnostics(
|
||||
options: InspectLegacyCrontabDiagnosticsOptions,
|
||||
): ReviewedLegacyCrontabAdoptionDiagnosticPage {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new LocalSqliteAdoptionError('diagnostic options are invalid');
|
||||
}
|
||||
if (!DIGEST_PATTERN.test(options.expectedPlanDigest)) {
|
||||
throw new LocalSqliteAdoptionError('expectedPlanDigest is invalid');
|
||||
}
|
||||
const plan = inspectLegacySqlitePath(options);
|
||||
if (plan.planDigest !== options.expectedPlanDigest) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'source no longer matches the reviewed plan',
|
||||
);
|
||||
}
|
||||
const client = openLegacySource(options.sourcePath);
|
||||
try {
|
||||
const sourceBefore = fileIdentity(options.sourcePath);
|
||||
const page =
|
||||
legacyCrontabAdoptionModule().inspectLegacyCrontabDiagnosticPage(
|
||||
client,
|
||||
plan.tasks.timezone,
|
||||
{
|
||||
...(options.afterRowOrdinal === undefined
|
||||
? {}
|
||||
: { afterRowOrdinal: options.afterRowOrdinal }),
|
||||
...(options.limit === undefined ? {} : { limit: options.limit }),
|
||||
},
|
||||
);
|
||||
const sourceAfter = fileIdentity(options.sourcePath);
|
||||
if (
|
||||
!sameFileIdentity(sourceBefore, sourceAfter) ||
|
||||
page.inventory.inventoryDigest !== plan.tasks.inventoryDigest
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'source changed during diagnostic inspection',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...page,
|
||||
reviewedPlanDigest: plan.planDigest,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSqliteAdoptionError) throw error;
|
||||
throw new LocalSqliteAdoptionError('task diagnostics failed', error);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import type {
|
||||
CreateLegacyCrontabAdoptionDecisionReceiptContext,
|
||||
LegacyCrontabAdoptionDecisionReceipt,
|
||||
} from '../legacyCrontabDecisionReceipt';
|
||||
import type {
|
||||
LegacyCrontabDecisionAuthorizationFileResult,
|
||||
PublishLegacyCrontabDecisionAuthorizationFileOptions,
|
||||
} from '../legacyCrontabDecisionAuthorizationFile';
|
||||
import type { PublishReviewedLegacyCrontabAdoptionResult } from '../legacyCrontabPublisher';
|
||||
import {
|
||||
DIGEST_PATTERN,
|
||||
LocalSqliteAdoptionError,
|
||||
type CommitReviewedLegacyCrontabAdoptionOptions,
|
||||
type CreateReviewedLegacyCrontabAdoptionDecisionReceiptOptions,
|
||||
type IssueReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
|
||||
type PublishReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
|
||||
type VerifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
|
||||
type VerifyReviewedLegacyCrontabAdoptionDecisionReceiptOptions,
|
||||
} from './contracts';
|
||||
import {
|
||||
assertAbsolutePath,
|
||||
assertDistinctPaths,
|
||||
fileIdentity,
|
||||
sameFileIdentity,
|
||||
} from './filesystem';
|
||||
import { inspectLegacySqlitePath, openLegacySource } from './inspection';
|
||||
import {
|
||||
acquireSourceWriteFence,
|
||||
releaseSourceWriteFence,
|
||||
} from './sourceFence';
|
||||
|
||||
type LegacyCrontabDecisionReceiptModule =
|
||||
typeof import('../legacyCrontabDecisionReceipt');
|
||||
type LegacyCrontabDecisionAuthorizationFileModule =
|
||||
typeof import('../legacyCrontabDecisionAuthorizationFile');
|
||||
type LegacyCrontabPublisherModule = typeof import('../legacyCrontabPublisher');
|
||||
type LegacyCrontabDecisionIssuerKeyringModule =
|
||||
typeof import('../legacyCrontabDecisionIssuerKeyring');
|
||||
|
||||
function legacyCrontabDecisionReceiptModule(): LegacyCrontabDecisionReceiptModule {
|
||||
return require('../legacyCrontabDecisionReceipt') as LegacyCrontabDecisionReceiptModule;
|
||||
}
|
||||
|
||||
function legacyCrontabDecisionAuthorizationFileModule(): LegacyCrontabDecisionAuthorizationFileModule {
|
||||
return require('../legacyCrontabDecisionAuthorizationFile') as LegacyCrontabDecisionAuthorizationFileModule;
|
||||
}
|
||||
|
||||
function legacyCrontabPublisherModule(): LegacyCrontabPublisherModule {
|
||||
return require('../legacyCrontabPublisher') as LegacyCrontabPublisherModule;
|
||||
}
|
||||
|
||||
function legacyCrontabDecisionIssuerKeyringModule(): LegacyCrontabDecisionIssuerKeyringModule {
|
||||
return require('../legacyCrontabDecisionIssuerKeyring') as LegacyCrontabDecisionIssuerKeyringModule;
|
||||
}
|
||||
|
||||
export function createReviewedLegacyCrontabAdoptionDecisionReceipt(
|
||||
options: CreateReviewedLegacyCrontabAdoptionDecisionReceiptOptions,
|
||||
): LegacyCrontabAdoptionDecisionReceipt {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new LocalSqliteAdoptionError('decision receipt options are invalid');
|
||||
}
|
||||
if (!DIGEST_PATTERN.test(options.expectedPlanDigest)) {
|
||||
throw new LocalSqliteAdoptionError('expectedPlanDigest is invalid');
|
||||
}
|
||||
const plan = inspectLegacySqlitePath(options);
|
||||
if (plan.planDigest !== options.expectedPlanDigest) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'source no longer matches the reviewed plan',
|
||||
);
|
||||
}
|
||||
const client = openLegacySource(options.sourcePath);
|
||||
try {
|
||||
const sourceBefore = fileIdentity(options.sourcePath);
|
||||
const receipt =
|
||||
legacyCrontabDecisionReceiptModule().createLegacyCrontabAdoptionDecisionReceipt(
|
||||
client,
|
||||
plan.tasks.timezone,
|
||||
{
|
||||
decisionId: options.decisionId,
|
||||
profile: plan.profile,
|
||||
planDigest: plan.planDigest,
|
||||
inventoryDigest: plan.tasks.inventoryDigest,
|
||||
reviewer: options.reviewer,
|
||||
issuedAtMs: options.issuedAtMs,
|
||||
expiresAtMs: options.expiresAtMs,
|
||||
},
|
||||
options.decisions,
|
||||
);
|
||||
const sourceAfter = fileIdentity(options.sourcePath);
|
||||
if (!sameFileIdentity(sourceBefore, sourceAfter)) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'source changed during decision receipt creation',
|
||||
);
|
||||
}
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSqliteAdoptionError) throw error;
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'decision receipt creation failed',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyReviewedLegacyCrontabAdoptionDecisionReceipt(
|
||||
options: VerifyReviewedLegacyCrontabAdoptionDecisionReceiptOptions,
|
||||
): LegacyCrontabAdoptionDecisionReceipt {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'decision receipt verification options are invalid',
|
||||
);
|
||||
}
|
||||
if (!DIGEST_PATTERN.test(options.expectedPlanDigest)) {
|
||||
throw new LocalSqliteAdoptionError('expectedPlanDigest is invalid');
|
||||
}
|
||||
const plan = inspectLegacySqlitePath(options);
|
||||
if (plan.planDigest !== options.expectedPlanDigest) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'source no longer matches the reviewed plan',
|
||||
);
|
||||
}
|
||||
const client = openLegacySource(options.sourcePath);
|
||||
try {
|
||||
const sourceBefore = fileIdentity(options.sourcePath);
|
||||
const receipt =
|
||||
legacyCrontabDecisionReceiptModule().verifyLegacyCrontabAdoptionDecisionReceipt(
|
||||
client,
|
||||
plan.tasks.timezone,
|
||||
options.receipt,
|
||||
options.decisions,
|
||||
options.observedAtMs,
|
||||
);
|
||||
const sourceAfter = fileIdentity(options.sourcePath);
|
||||
if (
|
||||
!sameFileIdentity(sourceBefore, sourceAfter) ||
|
||||
receipt.profile !== plan.profile ||
|
||||
receipt.planDigest !== plan.planDigest ||
|
||||
receipt.inventoryDigest !== plan.tasks.inventoryDigest
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'decision receipt does not match the reviewed source',
|
||||
);
|
||||
}
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSqliteAdoptionError) throw error;
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'decision receipt verification failed',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile(
|
||||
options: PublishReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
|
||||
): Promise<LegacyCrontabDecisionAuthorizationFileResult> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'decision authorization publication options are invalid',
|
||||
);
|
||||
}
|
||||
if (!DIGEST_PATTERN.test(options.expectedPlanDigest)) {
|
||||
throw new LocalSqliteAdoptionError('expectedPlanDigest is invalid');
|
||||
}
|
||||
const plan = inspectLegacySqlitePath(options);
|
||||
if (plan.planDigest !== options.expectedPlanDigest) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'source no longer matches the reviewed plan',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await legacyCrontabDecisionAuthorizationFileModule().publishLegacyCrontabDecisionAuthorizationFile(
|
||||
{
|
||||
filePath: options.authorizationPath,
|
||||
decisionId: options.decisionId,
|
||||
profile: plan.profile,
|
||||
planDigest: plan.planDigest,
|
||||
inventoryDigest: plan.tasks.inventoryDigest,
|
||||
decisions: options.decisions,
|
||||
keyProvider: options.keyProvider,
|
||||
...(options.confirmExternalAuthority === undefined
|
||||
? {}
|
||||
: { confirmExternalAuthority: options.confirmExternalAuthority }),
|
||||
createReceipt: (decisions) =>
|
||||
createReviewedLegacyCrontabAdoptionDecisionReceipt({
|
||||
sourcePath: options.sourcePath,
|
||||
profile: options.profile,
|
||||
...(options.legacyTimezone === undefined
|
||||
? {}
|
||||
: { legacyTimezone: options.legacyTimezone }),
|
||||
expectedPlanDigest: options.expectedPlanDigest,
|
||||
decisionId: options.decisionId,
|
||||
reviewer: options.reviewer,
|
||||
issuedAtMs: options.issuedAtMs,
|
||||
expiresAtMs: options.expiresAtMs,
|
||||
decisions,
|
||||
}),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSqliteAdoptionError) throw error;
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'decision authorization publication failed',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile(
|
||||
options: IssueReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
|
||||
): Promise<LegacyCrontabDecisionAuthorizationFileResult> {
|
||||
const optionalKeys = [
|
||||
...(options?.legacyTimezone === undefined ? [] : ['legacyTimezone']),
|
||||
...(options?.lifetimeMs === undefined ? [] : ['lifetimeMs']),
|
||||
...(options?.clock === undefined ? [] : ['clock']),
|
||||
...(options?.confirmDecisionStreamAuthority === undefined
|
||||
? []
|
||||
: ['confirmDecisionStreamAuthority']),
|
||||
];
|
||||
const expectedKeys = [
|
||||
'authenticateReviewer',
|
||||
'authorizationPath',
|
||||
'confirmIssuerAuthority',
|
||||
'decisionId',
|
||||
'decisions',
|
||||
'expectedPlanDigest',
|
||||
'issuerKeyringPath',
|
||||
'profile',
|
||||
'sourcePath',
|
||||
...optionalKeys,
|
||||
].sort();
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options)
|
||||
.sort()
|
||||
.some((key, index) => key !== expectedKeys[index]) ||
|
||||
Object.keys(options).length !== expectedKeys.length ||
|
||||
typeof options.authenticateReviewer !== 'function' ||
|
||||
typeof options.confirmIssuerAuthority !== 'function' ||
|
||||
(options.confirmDecisionStreamAuthority !== undefined &&
|
||||
typeof options.confirmDecisionStreamAuthority !== 'function') ||
|
||||
(options.clock !== undefined && typeof options.clock !== 'function')
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError('decision issuer options are invalid');
|
||||
}
|
||||
const lifetimeMs = options.lifetimeMs ?? 5 * 60 * 1_000;
|
||||
if (
|
||||
!Number.isSafeInteger(lifetimeMs) ||
|
||||
lifetimeMs < 1_000 ||
|
||||
lifetimeMs > 30 * 60 * 1_000
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError('decision issuer lifetime is invalid');
|
||||
}
|
||||
const clock = options.clock ?? Date.now;
|
||||
try {
|
||||
await options.confirmIssuerAuthority();
|
||||
const reviewer = await options.authenticateReviewer();
|
||||
const issuedAtMs = clock();
|
||||
if (!Number.isSafeInteger(issuedAtMs) || issuedAtMs < 0) {
|
||||
throw new LocalSqliteAdoptionError('decision issuer clock is invalid');
|
||||
}
|
||||
if (
|
||||
!reviewer ||
|
||||
typeof reviewer !== 'object' ||
|
||||
Array.isArray(reviewer) ||
|
||||
!Number.isSafeInteger(reviewer.expiresAtMs) ||
|
||||
reviewer.expiresAtMs <= issuedAtMs
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'decision issuer authentication failed',
|
||||
);
|
||||
}
|
||||
const expiresAtMs = Math.min(reviewer.expiresAtMs, issuedAtMs + lifetimeMs);
|
||||
await options.confirmIssuerAuthority();
|
||||
const keyring =
|
||||
new (legacyCrontabDecisionIssuerKeyringModule().LegacyCrontabDecisionIssuerKeyringFileProvider)(
|
||||
options.issuerKeyringPath,
|
||||
);
|
||||
const guardedKeyProvider: PublishLegacyCrontabDecisionAuthorizationFileOptions['keyProvider'] =
|
||||
Object.freeze({
|
||||
async active() {
|
||||
await options.confirmIssuerAuthority();
|
||||
return keyring.active();
|
||||
},
|
||||
async resolve(keyId: string) {
|
||||
await options.confirmIssuerAuthority();
|
||||
return keyring.resolve(keyId);
|
||||
},
|
||||
});
|
||||
return await publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
|
||||
sourcePath: options.sourcePath,
|
||||
profile: options.profile,
|
||||
...(options.legacyTimezone === undefined
|
||||
? {}
|
||||
: { legacyTimezone: options.legacyTimezone }),
|
||||
expectedPlanDigest: options.expectedPlanDigest,
|
||||
decisionId: options.decisionId,
|
||||
reviewer,
|
||||
issuedAtMs,
|
||||
expiresAtMs,
|
||||
decisions: options.decisions,
|
||||
authorizationPath: options.authorizationPath,
|
||||
keyProvider: guardedKeyProvider,
|
||||
confirmExternalAuthority: async () => {
|
||||
await options.confirmIssuerAuthority();
|
||||
await options.confirmDecisionStreamAuthority?.();
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSqliteAdoptionError) throw error;
|
||||
throw new LocalSqliteAdoptionError('decision issuer failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile(
|
||||
options: VerifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
|
||||
): Promise<LegacyCrontabDecisionAuthorizationFileResult> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'decision authorization verification options are invalid',
|
||||
);
|
||||
}
|
||||
if (!DIGEST_PATTERN.test(options.expectedPlanDigest)) {
|
||||
throw new LocalSqliteAdoptionError('expectedPlanDigest is invalid');
|
||||
}
|
||||
const plan = inspectLegacySqlitePath(options);
|
||||
if (plan.planDigest !== options.expectedPlanDigest) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'source no longer matches the reviewed plan',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await legacyCrontabDecisionAuthorizationFileModule().verifyLegacyCrontabDecisionAuthorizationFile(
|
||||
{
|
||||
filePath: options.authorizationPath,
|
||||
expectedDecisionId: options.expectedDecisionId,
|
||||
expectedProfile: plan.profile,
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
expectedInventoryDigest: plan.tasks.inventoryDigest,
|
||||
keyProvider: options.keyProvider,
|
||||
verifyReceipt: (receipt, decisions) =>
|
||||
verifyReviewedLegacyCrontabAdoptionDecisionReceipt({
|
||||
sourcePath: options.sourcePath,
|
||||
profile: options.profile,
|
||||
...(options.legacyTimezone === undefined
|
||||
? {}
|
||||
: { legacyTimezone: options.legacyTimezone }),
|
||||
expectedPlanDigest: options.expectedPlanDigest,
|
||||
receipt,
|
||||
decisions,
|
||||
observedAtMs: options.observedAtMs,
|
||||
}),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSqliteAdoptionError) throw error;
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'decision authorization verification failed',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishReviewedLegacyCrontabAdoption(
|
||||
options: CommitReviewedLegacyCrontabAdoptionOptions,
|
||||
): Promise<PublishReviewedLegacyCrontabAdoptionResult> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'reviewed task publication options are invalid',
|
||||
);
|
||||
}
|
||||
assertAbsolutePath(options.sourcePath, 'sourcePath');
|
||||
assertAbsolutePath(options.targetPath, 'targetPath');
|
||||
assertAbsolutePath(options.authorizationPath, 'authorizationPath');
|
||||
assertDistinctPaths([
|
||||
options.sourcePath,
|
||||
options.targetPath,
|
||||
options.authorizationPath,
|
||||
]);
|
||||
const source = acquireSourceWriteFence(
|
||||
options.sourcePath,
|
||||
options.busyTimeoutMs,
|
||||
);
|
||||
try {
|
||||
const sourceIdentity = fileIdentity(options.sourcePath);
|
||||
const plan = inspectLegacySqlitePath({
|
||||
sourcePath: options.sourcePath,
|
||||
profile: options.profile,
|
||||
...(options.legacyTimezone === undefined
|
||||
? {}
|
||||
: { legacyTimezone: options.legacyTimezone }),
|
||||
});
|
||||
if (
|
||||
plan.planDigest !== options.expectedPlanDigest ||
|
||||
plan.tasks.inventoryDigest.length !== 64
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'fenced source no longer matches the reviewed plan',
|
||||
);
|
||||
}
|
||||
return await legacyCrontabPublisherModule().publishReviewedLegacyCrontabAdoption(
|
||||
{
|
||||
sourceClient: source,
|
||||
sourcePath: options.sourcePath,
|
||||
targetPath: options.targetPath,
|
||||
authorizationPath: options.authorizationPath,
|
||||
profile: plan.profile,
|
||||
timezone: plan.tasks.timezone,
|
||||
expectedDecisionId: options.expectedDecisionId,
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
expectedInventoryDigest: plan.tasks.inventoryDigest,
|
||||
projectId: options.projectId,
|
||||
mutationId: options.mutationId,
|
||||
requestId: options.requestId,
|
||||
keyProvider: options.keyProvider,
|
||||
observedAtMs: options.observedAtMs,
|
||||
...(options.confirmReviewerAuthority === undefined
|
||||
? {}
|
||||
: { confirmReviewerAuthority: options.confirmReviewerAuthority }),
|
||||
confirmSourceIdentity() {
|
||||
if (
|
||||
!sameFileIdentity(sourceIdentity, fileIdentity(options.sourcePath))
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'legacy source identity changed during task publication',
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSqliteAdoptionError) throw error;
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'reviewed task publication failed',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
releaseSourceWriteFence(source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { backup, DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
LocalSqliteAdoptionError,
|
||||
type LocalSqliteAdoptionManifest,
|
||||
} from './contracts';
|
||||
import {
|
||||
assertRealParent,
|
||||
assertRegularFile,
|
||||
removeCreatedFile,
|
||||
sha256File,
|
||||
} from './filesystem';
|
||||
import { inspectLegacySqlitePath, openLegacySource } from './inspection';
|
||||
import { verifyLegacyBackup } from './staging';
|
||||
|
||||
export function assertBusyTimeout(value: number | undefined): number {
|
||||
const timeout = value ?? 5_000;
|
||||
if (!Number.isSafeInteger(timeout) || timeout < 100 || timeout > 30_000) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'busyTimeoutMs must be between 100 and 30000',
|
||||
);
|
||||
}
|
||||
return timeout;
|
||||
}
|
||||
|
||||
export function acquireSourceWriteFence(
|
||||
sourcePath: string,
|
||||
busyTimeoutMs?: number,
|
||||
label: 'legacy source' | 'target database' = 'legacy source',
|
||||
): DatabaseSync {
|
||||
assertRealParent(sourcePath, 'source');
|
||||
assertRegularFile(sourcePath, 'source');
|
||||
const client = new DatabaseSync(sourcePath, {
|
||||
allowExtension: false,
|
||||
defensive: true,
|
||||
enableDoubleQuotedStringLiterals: false,
|
||||
enableForeignKeyConstraints: true,
|
||||
timeout: assertBusyTimeout(busyTimeoutMs),
|
||||
});
|
||||
try {
|
||||
client.enableDefensive(true);
|
||||
client.exec('PRAGMA trusted_schema = OFF');
|
||||
client.exec('PRAGMA recursive_triggers = OFF');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
return client;
|
||||
} catch (error) {
|
||||
client.close();
|
||||
throw new LocalSqliteAdoptionError(
|
||||
`${label} write fence could not be acquired`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseSourceWriteFence(client: DatabaseSync): void {
|
||||
try {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifySourceSnapshotWhileFenced(
|
||||
sourcePath: string,
|
||||
recoveryPath: string,
|
||||
adoption: LocalSqliteAdoptionManifest,
|
||||
): Promise<void> {
|
||||
const current = inspectLegacySqlitePath({
|
||||
sourcePath,
|
||||
profile: adoption.profile,
|
||||
...(adoption.tasks.timezone === null
|
||||
? {}
|
||||
: { legacyTimezone: adoption.tasks.timezone }),
|
||||
});
|
||||
if (
|
||||
current.source.pathDigest !== adoption.source.pathDigest ||
|
||||
current.catalog.digest !== adoption.catalog.digest ||
|
||||
current.tasks.inventoryDigest !== adoption.tasks.inventoryDigest
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'legacy source identity or catalog changed after staging',
|
||||
);
|
||||
}
|
||||
const temporaryPath = path.join(
|
||||
path.dirname(recoveryPath),
|
||||
`.${path.basename(recoveryPath)}.${randomUUID()}.verify`,
|
||||
);
|
||||
try {
|
||||
const source = openLegacySource(sourcePath);
|
||||
try {
|
||||
await backup(source, temporaryPath, { rate: 64 });
|
||||
} finally {
|
||||
source.close();
|
||||
}
|
||||
await verifyLegacyBackup(
|
||||
temporaryPath,
|
||||
adoption.catalog.digest,
|
||||
adoption.tasks,
|
||||
);
|
||||
const temporaryStat = fs.statSync(temporaryPath);
|
||||
const temporarySha256 = await sha256File(temporaryPath);
|
||||
if (
|
||||
temporaryStat.size !== adoption.recovery.bytes ||
|
||||
temporarySha256 !== adoption.recovery.sha256
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'legacy source content changed after staging',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await removeCreatedFile(temporaryPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { backup } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
auditLocalSqlitePath,
|
||||
localSqliteMigrationManifest,
|
||||
} from '@qinglong/local-sqlite/runtime';
|
||||
import type { LegacyCrontabAdoptionInventory } from '../legacyCrontabAdoption';
|
||||
import {
|
||||
DIGEST_PATTERN,
|
||||
MAX_MANIFEST_BYTES,
|
||||
MAX_SCHEMA_OBJECTS,
|
||||
LocalSqliteAdoptionError,
|
||||
type FileIdentity,
|
||||
type LegacySqliteCatalogEvidence,
|
||||
type LocalSqliteAdoptionManifest,
|
||||
type LocalSqliteAdoptionManifestPayload,
|
||||
type StageLocalSqliteAdoptionOptions,
|
||||
type VerifiedLocalSqliteAdoption,
|
||||
type VerifyLocalSqliteAdoptionOptions,
|
||||
} from './contracts';
|
||||
import {
|
||||
assertAbsolutePath,
|
||||
assertClock,
|
||||
assertDistinctPaths,
|
||||
assertMissing,
|
||||
assertProfile,
|
||||
assertRealParent,
|
||||
assertRegularFile,
|
||||
fileIdentity,
|
||||
removeCreatedFile,
|
||||
sha256File,
|
||||
sha256Text,
|
||||
writeManifestAtomically,
|
||||
} from './filesystem';
|
||||
import {
|
||||
catalogEvidence,
|
||||
inspectLegacySqlitePath,
|
||||
isCanonicalLegacyTimezone,
|
||||
legacyCrontabAdoptionModule,
|
||||
openLegacySource,
|
||||
} from './inspection';
|
||||
|
||||
export async function verifyLegacyBackup(
|
||||
backupPath: string,
|
||||
expectedCatalogDigest: string,
|
||||
expectedTasks: LegacyCrontabAdoptionInventory,
|
||||
): Promise<void> {
|
||||
assertRegularFile(backupPath, 'recovery backup');
|
||||
const client = openLegacySource(backupPath);
|
||||
try {
|
||||
const catalog = catalogEvidence(client);
|
||||
if (catalog.digest !== expectedCatalogDigest) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'recovery backup catalog does not match the reviewed plan',
|
||||
);
|
||||
}
|
||||
const tasks = legacyCrontabAdoptionModule().inspectLegacyCrontabInventory(
|
||||
client,
|
||||
expectedTasks.timezone,
|
||||
);
|
||||
if (tasks.inventoryDigest !== expectedTasks.inventoryDigest) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'recovery backup tasks do not match the reviewed plan',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSqliteAdoptionError) throw error;
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'recovery backup task inspection failed',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function stageLocalSqliteAdoption(
|
||||
options: StageLocalSqliteAdoptionOptions,
|
||||
): Promise<LocalSqliteAdoptionManifest> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new LocalSqliteAdoptionError('staging options are invalid');
|
||||
}
|
||||
assertProfile(options.profile);
|
||||
for (const [label, value] of [
|
||||
['sourcePath', options.sourcePath],
|
||||
['targetPath', options.targetPath],
|
||||
['recoveryPath', options.recoveryPath],
|
||||
['manifestPath', options.manifestPath],
|
||||
] as const) {
|
||||
assertAbsolutePath(value, label);
|
||||
}
|
||||
if (!DIGEST_PATTERN.test(options.expectedPlanDigest)) {
|
||||
throw new LocalSqliteAdoptionError('expectedPlanDigest is invalid');
|
||||
}
|
||||
assertDistinctPaths([
|
||||
options.sourcePath,
|
||||
options.targetPath,
|
||||
options.recoveryPath,
|
||||
options.manifestPath,
|
||||
]);
|
||||
for (const [label, value] of [
|
||||
['target', options.targetPath],
|
||||
['recovery', options.recoveryPath],
|
||||
['manifest', options.manifestPath],
|
||||
] as const) {
|
||||
assertRealParent(value, label);
|
||||
assertMissing(value, label);
|
||||
}
|
||||
const plan = inspectLegacySqlitePath(options);
|
||||
if (plan.planDigest !== options.expectedPlanDigest) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'source no longer matches the reviewed plan',
|
||||
);
|
||||
}
|
||||
|
||||
const temporaryBackupPath = path.join(
|
||||
path.dirname(options.recoveryPath),
|
||||
`.${path.basename(options.recoveryPath)}.${randomUUID()}.tmp`,
|
||||
);
|
||||
let recoveryCreated = false;
|
||||
let targetCreated = false;
|
||||
let manifestCreated = false;
|
||||
try {
|
||||
const source = openLegacySource(options.sourcePath);
|
||||
try {
|
||||
await backup(source, temporaryBackupPath, { rate: 64 });
|
||||
} finally {
|
||||
source.close();
|
||||
}
|
||||
await verifyLegacyBackup(
|
||||
temporaryBackupPath,
|
||||
plan.catalog.digest,
|
||||
plan.tasks,
|
||||
);
|
||||
await fs.promises.copyFile(
|
||||
temporaryBackupPath,
|
||||
options.recoveryPath,
|
||||
fs.constants.COPYFILE_EXCL,
|
||||
);
|
||||
recoveryCreated = true;
|
||||
await fs.promises.chmod(options.recoveryPath, 0o600);
|
||||
await fs.promises.copyFile(
|
||||
options.recoveryPath,
|
||||
options.targetPath,
|
||||
fs.constants.COPYFILE_EXCL,
|
||||
);
|
||||
targetCreated = true;
|
||||
await fs.promises.chmod(options.targetPath, 0o600);
|
||||
|
||||
const { migrateLocalSqlitePath } = await import(
|
||||
'@qinglong/local-sqlite/migration'
|
||||
);
|
||||
const migrated = await migrateLocalSqlitePath({
|
||||
databasePath: options.targetPath,
|
||||
profile: options.profile,
|
||||
});
|
||||
const [recoverySha256, targetSha256] = await Promise.all([
|
||||
sha256File(options.recoveryPath),
|
||||
sha256File(options.targetPath),
|
||||
]);
|
||||
const recoveryStat = fs.statSync(options.recoveryPath);
|
||||
const targetStat = fs.statSync(options.targetPath);
|
||||
const payload: LocalSqliteAdoptionManifestPayload = Object.freeze({
|
||||
schemaVersion: 2,
|
||||
kind: 'qinglong3-local-sqlite-adoption',
|
||||
state: 'staged',
|
||||
profile: options.profile,
|
||||
createdAtMs: assertClock(options.clock ?? Date.now),
|
||||
planDigest: plan.planDigest,
|
||||
source: plan.source,
|
||||
catalog: plan.catalog,
|
||||
tasks: plan.tasks,
|
||||
recovery: Object.freeze({
|
||||
fileName: path.basename(options.recoveryPath),
|
||||
bytes: recoveryStat.size,
|
||||
sha256: recoverySha256,
|
||||
}),
|
||||
target: Object.freeze({
|
||||
fileName: path.basename(options.targetPath),
|
||||
bytes: targetStat.size,
|
||||
sha256: targetSha256,
|
||||
}),
|
||||
migration: localSqliteMigrationManifest,
|
||||
readiness: migrated.readiness,
|
||||
});
|
||||
const manifest = Object.freeze({
|
||||
...payload,
|
||||
manifestDigest: sha256Text(JSON.stringify(payload)),
|
||||
});
|
||||
await writeManifestAtomically(options.manifestPath, manifest);
|
||||
manifestCreated = true;
|
||||
return manifest;
|
||||
} catch (error) {
|
||||
const cleanupErrors: unknown[] = [];
|
||||
for (const [created, filePath] of [
|
||||
[manifestCreated, options.manifestPath],
|
||||
[targetCreated, options.targetPath],
|
||||
[recoveryCreated, options.recoveryPath],
|
||||
] as const) {
|
||||
if (!created) continue;
|
||||
try {
|
||||
await removeCreatedFile(filePath);
|
||||
} catch (cleanupError) {
|
||||
cleanupErrors.push(cleanupError);
|
||||
}
|
||||
}
|
||||
if (cleanupErrors.length > 0) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'staging failed and cleanup was incomplete',
|
||||
new AggregateError([error, ...cleanupErrors]),
|
||||
);
|
||||
}
|
||||
if (error instanceof LocalSqliteAdoptionError) throw error;
|
||||
throw new LocalSqliteAdoptionError('staging failed', error);
|
||||
} finally {
|
||||
await removeCreatedFile(temporaryBackupPath);
|
||||
}
|
||||
}
|
||||
|
||||
function parseManifest(value: unknown): LocalSqliteAdoptionManifest {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LocalSqliteAdoptionError('manifest is invalid');
|
||||
}
|
||||
const manifest = value as Partial<LocalSqliteAdoptionManifest>;
|
||||
const keys = Object.keys(manifest).sort();
|
||||
const expectedKeys = [
|
||||
'catalog',
|
||||
'createdAtMs',
|
||||
'kind',
|
||||
'manifestDigest',
|
||||
'migration',
|
||||
'planDigest',
|
||||
'profile',
|
||||
'readiness',
|
||||
'recovery',
|
||||
'schemaVersion',
|
||||
'source',
|
||||
'state',
|
||||
'target',
|
||||
'tasks',
|
||||
].sort();
|
||||
if (
|
||||
JSON.stringify(keys) !== JSON.stringify(expectedKeys) ||
|
||||
manifest.schemaVersion !== 2 ||
|
||||
manifest.kind !== 'qinglong3-local-sqlite-adoption' ||
|
||||
manifest.state !== 'staged' ||
|
||||
!DIGEST_PATTERN.test(manifest.manifestDigest ?? '') ||
|
||||
!DIGEST_PATTERN.test(manifest.planDigest ?? '')
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError('manifest shape is invalid');
|
||||
}
|
||||
assertProfile(manifest.profile);
|
||||
if (
|
||||
!Number.isSafeInteger(manifest.createdAtMs) ||
|
||||
(manifest.createdAtMs as number) < 0 ||
|
||||
JSON.stringify(manifest.migration) !==
|
||||
JSON.stringify(localSqliteMigrationManifest)
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError('manifest authority is invalid');
|
||||
}
|
||||
const source = manifest.source as Partial<FileIdentity> | undefined;
|
||||
const catalog = manifest.catalog as
|
||||
| Partial<LegacySqliteCatalogEvidence>
|
||||
| undefined;
|
||||
const recovery = manifest.recovery as
|
||||
| Partial<LocalSqliteAdoptionManifest['recovery']>
|
||||
| undefined;
|
||||
const target = manifest.target as
|
||||
| Partial<LocalSqliteAdoptionManifest['target']>
|
||||
| undefined;
|
||||
const tasks = manifest.tasks as
|
||||
| Partial<LegacyCrontabAdoptionInventory>
|
||||
| undefined;
|
||||
if (
|
||||
!source ||
|
||||
JSON.stringify(Object.keys(source).sort()) !==
|
||||
JSON.stringify(
|
||||
[
|
||||
'bytes',
|
||||
'device',
|
||||
'fileName',
|
||||
'inode',
|
||||
'modifiedAtNs',
|
||||
'pathDigest',
|
||||
].sort(),
|
||||
) ||
|
||||
typeof source.fileName !== 'string' ||
|
||||
!Number.isSafeInteger(source.bytes) ||
|
||||
(source.bytes as number) < 0 ||
|
||||
typeof source.device !== 'string' ||
|
||||
typeof source.inode !== 'string' ||
|
||||
typeof source.modifiedAtNs !== 'string' ||
|
||||
!DIGEST_PATTERN.test(source.pathDigest ?? '')
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError('manifest source evidence is invalid');
|
||||
}
|
||||
if (
|
||||
!catalog ||
|
||||
JSON.stringify(Object.keys(catalog).sort()) !==
|
||||
JSON.stringify(['digest', 'objectCount', 'tableNames'].sort()) ||
|
||||
!DIGEST_PATTERN.test(catalog.digest ?? '') ||
|
||||
!Number.isSafeInteger(catalog.objectCount) ||
|
||||
(catalog.objectCount as number) < 1 ||
|
||||
!Array.isArray(catalog.tableNames) ||
|
||||
catalog.tableNames.length > MAX_SCHEMA_OBJECTS ||
|
||||
catalog.tableNames.some(
|
||||
(name) =>
|
||||
typeof name !== 'string' || name.length < 1 || name.length > 1024,
|
||||
) ||
|
||||
JSON.stringify(catalog.tableNames) !==
|
||||
JSON.stringify([...catalog.tableNames].sort()) ||
|
||||
new Set(catalog.tableNames).size !== catalog.tableNames.length
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError('manifest catalog evidence is invalid');
|
||||
}
|
||||
const classifications = tasks?.classifications as
|
||||
| Partial<LegacyCrontabAdoptionInventory['classifications']>
|
||||
| undefined;
|
||||
const classificationValues = classifications
|
||||
? [
|
||||
classifications.lossless,
|
||||
classifications.requires_shell_compatibility,
|
||||
classifications.requires_manual_action,
|
||||
classifications.malformed,
|
||||
]
|
||||
: [];
|
||||
if (
|
||||
!tasks ||
|
||||
JSON.stringify(Object.keys(tasks).sort()) !==
|
||||
JSON.stringify(
|
||||
[
|
||||
'classifications',
|
||||
'inventoryDigest',
|
||||
'kind',
|
||||
'mutationReady',
|
||||
'rowCount',
|
||||
'schemaVersion',
|
||||
'timezone',
|
||||
].sort(),
|
||||
) ||
|
||||
tasks.schemaVersion !== 1 ||
|
||||
tasks.kind !== 'qinglong3-legacy-crontab-adoption-inventory' ||
|
||||
(tasks.timezone !== null && typeof tasks.timezone !== 'string') ||
|
||||
(typeof tasks.timezone === 'string' &&
|
||||
!isCanonicalLegacyTimezone(tasks.timezone)) ||
|
||||
!Number.isSafeInteger(tasks.rowCount) ||
|
||||
(tasks.rowCount as number) < 0 ||
|
||||
!DIGEST_PATTERN.test(tasks.inventoryDigest ?? '') ||
|
||||
typeof tasks.mutationReady !== 'boolean' ||
|
||||
!classifications ||
|
||||
JSON.stringify(Object.keys(classifications).sort()) !==
|
||||
JSON.stringify(
|
||||
[
|
||||
'lossless',
|
||||
'malformed',
|
||||
'requires_manual_action',
|
||||
'requires_shell_compatibility',
|
||||
].sort(),
|
||||
) ||
|
||||
classificationValues.some(
|
||||
(value) => !Number.isSafeInteger(value) || (value as number) < 0,
|
||||
) ||
|
||||
classificationValues.reduce<number>(
|
||||
(sum, value) => sum + (value as number),
|
||||
0,
|
||||
) !== tasks.rowCount ||
|
||||
tasks.mutationReady !==
|
||||
(classifications.requires_shell_compatibility === 0 &&
|
||||
classifications.requires_manual_action === 0 &&
|
||||
classifications.malformed === 0)
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError('manifest task evidence is invalid');
|
||||
}
|
||||
for (const [label, evidence] of [
|
||||
['recovery', recovery],
|
||||
['target', target],
|
||||
] as const) {
|
||||
if (
|
||||
!evidence ||
|
||||
JSON.stringify(Object.keys(evidence).sort()) !==
|
||||
JSON.stringify(['bytes', 'fileName', 'sha256'].sort()) ||
|
||||
typeof evidence.fileName !== 'string' ||
|
||||
evidence.fileName.length < 1 ||
|
||||
evidence.fileName.length > 1024 ||
|
||||
!Number.isSafeInteger(evidence.bytes) ||
|
||||
(evidence.bytes as number) < 1 ||
|
||||
!DIGEST_PATTERN.test(evidence.sha256 ?? '')
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
`manifest ${label} evidence is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return manifest as LocalSqliteAdoptionManifest;
|
||||
}
|
||||
|
||||
export async function verifyLocalSqliteAdoptionInternal(
|
||||
options: VerifyLocalSqliteAdoptionOptions,
|
||||
requireTargetSnapshot: boolean,
|
||||
): Promise<VerifiedLocalSqliteAdoption> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new LocalSqliteAdoptionError('verification options are invalid');
|
||||
}
|
||||
for (const [label, value] of [
|
||||
['targetPath', options.targetPath],
|
||||
['recoveryPath', options.recoveryPath],
|
||||
['manifestPath', options.manifestPath],
|
||||
] as const) {
|
||||
assertAbsolutePath(value, label);
|
||||
assertRealParent(value, label);
|
||||
assertRegularFile(value, label);
|
||||
}
|
||||
const manifestStat = fs.statSync(options.manifestPath);
|
||||
if (manifestStat.size < 1 || manifestStat.size > MAX_MANIFEST_BYTES) {
|
||||
throw new LocalSqliteAdoptionError('manifest size is invalid');
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(
|
||||
await fs.promises.readFile(options.manifestPath, 'utf8'),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new LocalSqliteAdoptionError('manifest JSON is invalid', error);
|
||||
}
|
||||
const manifest = parseManifest(parsed);
|
||||
const { manifestDigest, ...payload } = manifest;
|
||||
if (sha256Text(JSON.stringify(payload)) !== manifestDigest) {
|
||||
throw new LocalSqliteAdoptionError('manifest digest does not match');
|
||||
}
|
||||
if (
|
||||
manifest.recovery.fileName !== path.basename(options.recoveryPath) ||
|
||||
manifest.target.fileName !== path.basename(options.targetPath)
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError('manifest file identity does not match');
|
||||
}
|
||||
const targetIdentityBefore = fileIdentity(options.targetPath);
|
||||
const recoverySha256 = await sha256File(options.recoveryPath);
|
||||
const targetSha256 = requireTargetSnapshot
|
||||
? await sha256File(options.targetPath)
|
||||
: undefined;
|
||||
const recoveryStat = fs.statSync(options.recoveryPath);
|
||||
if (
|
||||
recoverySha256 !== manifest.recovery.sha256 ||
|
||||
recoveryStat.size !== manifest.recovery.bytes
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError('staged database digest does not match');
|
||||
}
|
||||
if (requireTargetSnapshot) {
|
||||
const targetStat = fs.statSync(options.targetPath);
|
||||
if (
|
||||
targetSha256 !== manifest.target.sha256 ||
|
||||
targetStat.size !== manifest.target.bytes
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'staged database digest does not match',
|
||||
);
|
||||
}
|
||||
}
|
||||
await verifyLegacyBackup(
|
||||
options.recoveryPath,
|
||||
manifest.catalog.digest,
|
||||
manifest.tasks,
|
||||
);
|
||||
const readiness = await auditLocalSqlitePath({
|
||||
databasePath: options.targetPath,
|
||||
profile: manifest.profile,
|
||||
});
|
||||
const { tableCount: currentTableCount, ...currentContract } = readiness;
|
||||
const { tableCount: stagedTableCount, ...stagedContract } =
|
||||
manifest.readiness;
|
||||
const readinessMatches =
|
||||
JSON.stringify(readiness) === JSON.stringify(manifest.readiness);
|
||||
const activatedReadinessIsCompatible =
|
||||
currentTableCount >= stagedTableCount &&
|
||||
JSON.stringify(currentContract) === JSON.stringify(stagedContract);
|
||||
if (
|
||||
requireTargetSnapshot ? !readinessMatches : !activatedReadinessIsCompatible
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError('target readiness evidence has drifted');
|
||||
}
|
||||
const targetIdentityAfter = fileIdentity(options.targetPath);
|
||||
if (
|
||||
targetIdentityBefore.pathDigest !== targetIdentityAfter.pathDigest ||
|
||||
targetIdentityBefore.device !== targetIdentityAfter.device ||
|
||||
targetIdentityBefore.inode !== targetIdentityAfter.inode
|
||||
) {
|
||||
throw new LocalSqliteAdoptionError(
|
||||
'target database identity changed during verification',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
manifest,
|
||||
targetIdentity: targetIdentityAfter,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyLocalSqliteAdoption(
|
||||
options: VerifyLocalSqliteAdoptionOptions,
|
||||
): Promise<LocalSqliteAdoptionManifest> {
|
||||
return (await verifyLocalSqliteAdoptionInternal(options, true)).manifest;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export type {
|
||||
LegacyCrontabAdoptionClassification,
|
||||
LegacyCrontabAdoptionClassificationCounts,
|
||||
LegacyCrontabAdoptionDiagnostic,
|
||||
LegacyCrontabAdoptionDiagnosticCursor,
|
||||
LegacyCrontabAdoptionDiagnosticPage,
|
||||
LegacyCrontabAdoptionInventory,
|
||||
LegacyCrontabAdoptionReason,
|
||||
} from './legacyCrontabAdoption';
|
||||
export type {
|
||||
CreateLegacyCrontabAdoptionDecisionReceiptContext,
|
||||
LegacyCrontabAdoptionDecision,
|
||||
LegacyCrontabAdoptionDecisionCounts,
|
||||
LegacyCrontabAdoptionDecisionDisposition,
|
||||
LegacyCrontabAdoptionDecisionReason,
|
||||
LegacyCrontabAdoptionDecisionReceipt,
|
||||
LegacyCrontabAdoptionDecisionReceiptPayload,
|
||||
LegacyCrontabAdoptionDecisionSetEvidence,
|
||||
} from './legacyCrontabDecisionReceipt';
|
||||
export type {
|
||||
LegacyCrontabDecisionAuthorizationFileEvidence,
|
||||
LegacyCrontabDecisionAuthorizationFileResult,
|
||||
} from './legacyCrontabDecisionAuthorizationFile';
|
||||
export { LocalSqliteAdoptionError } from './local-sqlite-adoption/contracts';
|
||||
export type {
|
||||
AcquireLocalSqliteActivationOptions,
|
||||
CommitReviewedLegacyCrontabAdoptionOptions,
|
||||
CreateReviewedLegacyCrontabAdoptionDecisionReceiptOptions,
|
||||
InspectLegacyCrontabDiagnosticsOptions,
|
||||
InspectLegacySqliteOptions,
|
||||
IssueReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
|
||||
LegacySqliteAdoptionPlan,
|
||||
LegacySqliteCatalogEvidence,
|
||||
LocalSqliteActivation,
|
||||
LocalSqliteActivationFence,
|
||||
LocalSqliteActivationPayload,
|
||||
LocalSqliteAdoptionManifest,
|
||||
LocalSqliteAdoptionManifestPayload,
|
||||
PrepareLocalSqliteActivationOptions,
|
||||
PublishReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
|
||||
ReviewedLegacyCrontabAdoptionDiagnosticPage,
|
||||
StageLocalSqliteAdoptionOptions,
|
||||
VerifyLocalSqliteAdoptionOptions,
|
||||
VerifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
|
||||
VerifyReviewedLegacyCrontabAdoptionDecisionReceiptOptions,
|
||||
} from './local-sqlite-adoption/contracts';
|
||||
export {
|
||||
inspectLegacyCrontabAdoptionDiagnostics,
|
||||
inspectLegacySqlitePath,
|
||||
} from './local-sqlite-adoption/inspection';
|
||||
export {
|
||||
createReviewedLegacyCrontabAdoptionDecisionReceipt,
|
||||
issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
|
||||
publishReviewedLegacyCrontabAdoption,
|
||||
publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
|
||||
verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
|
||||
verifyReviewedLegacyCrontabAdoptionDecisionReceipt,
|
||||
} from './local-sqlite-adoption/review';
|
||||
export {
|
||||
stageLocalSqliteAdoption,
|
||||
verifyLocalSqliteAdoption,
|
||||
} from './local-sqlite-adoption/staging';
|
||||
export {
|
||||
acquireLocalSqliteActivation,
|
||||
prepareLocalSqliteActivation,
|
||||
} from './local-sqlite-adoption/activation';
|
||||
Reference in New Issue
Block a user