feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -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,
});
}
@@ -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';