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,785 @@
// Local lifecycle owns reviewed legacy adoption ceremonies.
import fs from 'node:fs';
import path from 'node:path';
import {
PrivateLocalCommandFileError,
readPrivateLocalCommandFile,
} from '@qinglong/local-command-file';
import {
inspectLegacySqlitePath,
publishReviewedLegacyCrontabAdoption,
type CommitReviewedLegacyCrontabAdoptionOptions,
} from '@qinglong/local-admin';
import {
issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
LegacyCrontabDecisionIssuerKeyringFileProvider,
withPrivateLegacyCrontabAdoptionDecisionReviewFile,
} from '@qinglong/local-admin/decision-issuer';
import { establishAuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
import { openLocalSqliteBootstrapDatabase } from '@qinglong/local-sqlite/bootstrap';
const MAX_PATH_BYTES = 4096;
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}$/;
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const LOCAL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
interface PathIdentity {
readonly path: string;
readonly kind: 'directory' | 'file';
readonly device: bigint;
readonly inode: bigint;
readonly size: bigint;
readonly modifiedAtNs: bigint;
readonly changedAtNs: bigint;
readonly uid: number;
readonly mode: number;
}
interface DeploymentProofOptions {
readonly deploymentRoot: string;
readonly credentialFilePath: string;
readonly filePaths: readonly string[];
readonly mutableFilePaths?: readonly string[];
readonly directoryPaths: readonly string[];
readonly missingPaths?: readonly string[];
}
export interface IssueLegacyCrontabAdoptionDecisionCommand {
readonly schemaVersion: 1;
readonly operation: 'legacy-crontab.decision.issue';
readonly options: {
readonly deploymentRoot: string;
readonly databasePath: string;
readonly profile: 'edge' | 'standalone';
readonly ownerPepperKeyringDirectory: string;
readonly issuerKeyringPath: string;
readonly credentialFilePath: string;
readonly sourcePath: string;
readonly reviewFilePath: string;
readonly authorizationPath: string;
readonly expectedPlanDigest: string;
readonly decisionId: string;
readonly legacyTimezone?: string;
readonly busyTimeoutMs?: number;
readonly lifetimeMs?: number;
};
}
export interface CommitLegacyCrontabAdoptionCommand {
readonly schemaVersion: 1;
readonly operation: 'legacy-crontab.adoption.commit';
readonly options: {
readonly deploymentRoot: string;
readonly targetPath: string;
readonly profile: 'edge' | 'standalone';
readonly ownerPepperKeyringDirectory: string;
readonly issuerKeyringPath: string;
readonly credentialFilePath: string;
readonly sourcePath: string;
readonly authorizationPath: string;
readonly expectedPlanDigest: string;
readonly expectedDecisionId: string;
readonly projectId: string;
readonly mutationId: string;
readonly requestId: string;
readonly legacyTimezone?: string;
readonly busyTimeoutMs?: number;
};
}
export interface IssueLegacyCrontabAdoptionDecisionCommandResult {
readonly schemaVersion: 1;
readonly operation: 'legacy-crontab.decision.issue';
readonly review: {
readonly decisionCount: number;
readonly fileBytes: number;
readonly fileDigest: string;
};
readonly authorization: {
readonly decisionId: string;
readonly decisionCount: number;
readonly fileBytes: number;
readonly fileDigest: string;
readonly keyId: string;
};
readonly receipt: {
readonly reviewerSubjectId: string;
readonly issuedAtMs: number;
readonly expiresAtMs: number;
readonly decisionDigest: string;
};
}
export interface CommitLegacyCrontabAdoptionCommandResult {
readonly schemaVersion: 1;
readonly operation: 'legacy-crontab.adoption.commit';
readonly status: 'inserted' | 'existing';
readonly adoption: {
readonly mutationId: string;
readonly decisionId: string;
readonly projectId: string;
readonly publicationDigest: string;
readonly adoptedTaskCount: number;
readonly adoptedTriggerCount: number;
readonly skippedCount: number;
readonly auditEventId: string;
readonly createdAtMs: number;
};
}
export type LegacyCrontabAdoptionCommand =
| IssueLegacyCrontabAdoptionDecisionCommand
| CommitLegacyCrontabAdoptionCommand;
export type LegacyCrontabAdoptionCommandResult =
| IssueLegacyCrontabAdoptionDecisionCommandResult
| CommitLegacyCrontabAdoptionCommandResult;
export class LegacyCrontabAdoptionCliConfigurationError extends TypeError {
readonly code = 'LEGACY_CRONTAB_ADOPTION_CLI_CONFIGURATION_INVALID';
constructor(message: string, readonly cause?: unknown) {
super(`Legacy Crontab adoption CLI configuration is invalid: ${message}`);
this.name = 'LegacyCrontabAdoptionCliConfigurationError';
}
}
export class LegacyCrontabAdoptionCliAuthenticationError extends Error {
readonly code = 'LEGACY_CRONTAB_ADOPTION_CLI_AUTHENTICATION_FAILED';
constructor(message: string) {
super(`Legacy Crontab adoption CLI authentication failed: ${message}`);
this.name = 'LegacyCrontabAdoptionCliAuthenticationError';
}
}
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 boundedPath(value: unknown, label: 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 LegacyCrontabAdoptionCliConfigurationError(
`${label} must be a normalized bounded absolute non-root path`,
);
}
return value;
}
function currentUid(): number {
if (
typeof process.getuid !== 'function' ||
typeof process.geteuid !== 'function' ||
process.getuid() !== process.geteuid()
) {
throw new LegacyCrontabAdoptionCliConfigurationError(
'real and effective POSIX users must match',
);
}
return process.getuid();
}
function identity(
targetPath: string,
kind: PathIdentity['kind'],
uid: number,
): PathIdentity {
let stat: fs.BigIntStats;
try {
stat = fs.lstatSync(targetPath, { bigint: true });
} catch (error) {
throw new LegacyCrontabAdoptionCliConfigurationError(
`${kind} is unavailable`,
error,
);
}
const expectedKind =
kind === 'directory' ? stat.isDirectory() : stat.isFile();
const mode = Number(stat.mode) & 0o777;
if (
!expectedKind ||
stat.isSymbolicLink() ||
Number(stat.uid) !== uid ||
mode !== (kind === 'directory' ? 0o700 : 0o600)
) {
throw new LegacyCrontabAdoptionCliConfigurationError(
`${kind} ownership or private mode is invalid`,
);
}
return Object.freeze({
path: targetPath,
kind,
device: stat.dev,
inode: stat.ino,
size: stat.size,
modifiedAtNs: stat.mtimeNs,
changedAtNs: stat.ctimeNs,
uid,
mode,
});
}
function sameIdentity(expected: PathIdentity, mutableFile: boolean): void {
const actual = identity(expected.path, expected.kind, expected.uid);
if (
actual.device !== expected.device ||
actual.inode !== expected.inode ||
actual.mode !== expected.mode ||
(expected.kind === 'file' &&
!mutableFile &&
(actual.size !== expected.size ||
actual.modifiedAtNs !== expected.modifiedAtNs ||
actual.changedAtNs !== expected.changedAtNs))
) {
throw new LegacyCrontabAdoptionCliConfigurationError(
`${expected.kind} identity changed during command execution`,
);
}
}
function descendants(deploymentRoot: string, targetPath: string): string[] {
const relative = path.relative(deploymentRoot, targetPath);
if (
relative.length === 0 ||
relative === '..' ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative)
) {
throw new LegacyCrontabAdoptionCliConfigurationError(
'authority paths must be descendants of deploymentRoot',
);
}
const directories: string[] = [];
let current = deploymentRoot;
for (const part of relative.split(path.sep).slice(0, -1)) {
current = path.join(current, part);
directories.push(current);
}
return directories;
}
function assertMissing(targetPath: string): void {
try {
fs.lstatSync(targetPath);
} catch (error) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
return;
}
throw new LegacyCrontabAdoptionCliConfigurationError(
'authorization destination cannot be inspected',
error,
);
}
throw new LegacyCrontabAdoptionCliConfigurationError(
'authorization destination must not exist',
);
}
function optionalRuntimeOptions(options: Record<string, unknown>): string[] {
return [
...(options.busyTimeoutMs === undefined ? [] : ['busyTimeoutMs']),
...(options.legacyTimezone === undefined ? [] : ['legacyTimezone']),
];
}
function validRuntimeOptions(options: Record<string, unknown>): boolean {
return !(
(options.legacyTimezone !== undefined &&
(typeof options.legacyTimezone !== 'string' ||
options.legacyTimezone.length < 1 ||
options.legacyTimezone.length > 128)) ||
(options.busyTimeoutMs !== undefined &&
(!Number.isSafeInteger(options.busyTimeoutMs) ||
(options.busyTimeoutMs as number) < 1 ||
(options.busyTimeoutMs as number) > 60_000))
);
}
function normalizeCommand(
value: unknown,
): Readonly<LegacyCrontabAdoptionCommand> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, ['schemaVersion', 'operation', 'options'])
) {
throw new LegacyCrontabAdoptionCliConfigurationError(
'command shape is invalid',
);
}
const command = value as Record<string, unknown>;
if (
command.schemaVersion !== 1 ||
(command.operation !== 'legacy-crontab.decision.issue' &&
command.operation !== 'legacy-crontab.adoption.commit') ||
!command.options ||
typeof command.options !== 'object' ||
Array.isArray(command.options)
) {
throw new LegacyCrontabAdoptionCliConfigurationError(
'command value is invalid',
);
}
const options = command.options as Record<string, unknown>;
const issue = command.operation === 'legacy-crontab.decision.issue';
const expected = issue
? [
'authorizationPath',
...optionalRuntimeOptions(options),
'credentialFilePath',
'databasePath',
'decisionId',
'deploymentRoot',
'expectedPlanDigest',
'issuerKeyringPath',
...(options.lifetimeMs === undefined ? [] : ['lifetimeMs']),
'ownerPepperKeyringDirectory',
'profile',
'reviewFilePath',
'sourcePath',
]
: [
'authorizationPath',
...optionalRuntimeOptions(options),
'credentialFilePath',
'deploymentRoot',
'expectedDecisionId',
'expectedPlanDigest',
'issuerKeyringPath',
'mutationId',
'ownerPepperKeyringDirectory',
'profile',
'projectId',
'requestId',
'sourcePath',
'targetPath',
];
if (
!exactKeys(options, expected) ||
(options.profile !== 'edge' && options.profile !== 'standalone') ||
typeof options.expectedPlanDigest !== 'string' ||
!DIGEST_PATTERN.test(options.expectedPlanDigest) ||
!validRuntimeOptions(options) ||
(issue &&
(typeof options.decisionId !== 'string' ||
!UUID_V7_PATTERN.test(options.decisionId))) ||
(issue &&
options.lifetimeMs !== undefined &&
(!Number.isSafeInteger(options.lifetimeMs) ||
(options.lifetimeMs as number) < 1_000 ||
(options.lifetimeMs as number) > 30 * 60 * 1_000)) ||
(!issue &&
(typeof options.expectedDecisionId !== 'string' ||
!UUID_V7_PATTERN.test(options.expectedDecisionId) ||
typeof options.mutationId !== 'string' ||
!UUID_V4_PATTERN.test(options.mutationId) ||
typeof options.projectId !== 'string' ||
!LOCAL_ID_PATTERN.test(options.projectId) ||
typeof options.requestId !== 'string' ||
!LOCAL_ID_PATTERN.test(options.requestId)))
) {
throw new LegacyCrontabAdoptionCliConfigurationError(
'command options are invalid',
);
}
const pathKeys = [
'authorizationPath',
'credentialFilePath',
'deploymentRoot',
'issuerKeyringPath',
'ownerPepperKeyringDirectory',
'sourcePath',
...(issue ? ['databasePath', 'reviewFilePath'] : ['targetPath']),
];
for (const key of pathKeys) {
boundedPath(options[key], key);
}
return Object.freeze(value as LegacyCrontabAdoptionCommand);
}
function readCommandFile(
commandFilePath: string,
): Readonly<LegacyCrontabAdoptionCommand> {
try {
return normalizeCommand(readPrivateLocalCommandFile(commandFilePath));
} catch (error) {
if (error instanceof LegacyCrontabAdoptionCliConfigurationError) {
throw error;
}
if (error instanceof PrivateLocalCommandFileError) {
throw new LegacyCrontabAdoptionCliConfigurationError(
'command file cannot be read',
error,
);
}
throw error;
}
}
function deploymentProof(options: DeploymentProofOptions): {
verify(): void;
} {
const uid = currentUid();
const rootPath = boundedPath(options.deploymentRoot, 'deploymentRoot');
const root = identity(rootPath, 'directory', uid);
const filePaths = options.filePaths.map((candidate) =>
boundedPath(candidate, 'authority file'),
);
const directoryPaths = options.directoryPaths.map((candidate) =>
boundedPath(candidate, 'authority directory'),
);
const missingPaths = (options.missingPaths ?? []).map((candidate) =>
boundedPath(candidate, 'missing authority path'),
);
const mutableFilePaths = new Set(
(options.mutableFilePaths ?? []).map((candidate) =>
boundedPath(candidate, 'mutable authority file'),
),
);
if (
[...mutableFilePaths].some((candidate) => !filePaths.includes(candidate))
) {
throw new LegacyCrontabAdoptionCliConfigurationError(
'mutable authority files must be declared files',
);
}
const nestedDirectories = new Set<string>();
for (const target of [...filePaths, ...directoryPaths]) {
for (const directory of descendants(rootPath, target)) {
nestedDirectories.add(directory);
}
}
const directories = [
root,
...[...nestedDirectories]
.filter((candidate) => candidate !== rootPath)
.map((candidate) => identity(candidate, 'directory', uid)),
...directoryPaths
.filter(
(candidate, index) =>
directoryPaths.indexOf(candidate) === index &&
candidate !== rootPath &&
!nestedDirectories.has(candidate),
)
.map((candidate) => identity(candidate, 'directory', uid)),
];
const files = filePaths.map((candidate) => identity(candidate, 'file', uid));
const uniqueFiles = new Set(
files.map((entry) => `${entry.device}:${entry.inode}`),
);
if (uniqueFiles.size !== files.length) {
throw new LegacyCrontabAdoptionCliConfigurationError(
'authority files must not share an inode',
);
}
for (const missingPath of missingPaths) assertMissing(missingPath);
if (!files.some((entry) => entry.path === options.credentialFilePath)) {
throw new LegacyCrontabAdoptionCliConfigurationError(
'credential file must be part of the deployment proof',
);
}
return Object.freeze({
verify() {
if (currentUid() !== uid) {
throw new LegacyCrontabAdoptionCliConfigurationError(
'POSIX user changed during command execution',
);
}
for (const expected of [...directories, ...files]) {
sameIdentity(expected, mutableFilePaths.has(expected.path));
}
for (const missingPath of missingPaths) assertMissing(missingPath);
},
});
}
type LocalBootstrapDatabase = Awaited<
ReturnType<typeof openLocalSqliteBootstrapDatabase>
>;
type CommitReviewer = Parameters<
NonNullable<
CommitReviewedLegacyCrontabAdoptionOptions['confirmReviewerAuthority']
>
>[0];
async function createReviewerAuthority(
database: LocalBootstrapDatabase,
deploymentRoot: string,
databasePath: string,
ownerPepperKeyringDirectory: string,
credentialFilePath: string,
proof: ReturnType<typeof deploymentProof>,
): Promise<{
readonly reviewer: Readonly<CommitReviewer>;
readonly confirm: () => Promise<void>;
}> {
proof.verify();
let authenticated: Awaited<
ReturnType<typeof establishAuthenticatedLocalCommand>
>;
try {
authenticated = await establishAuthenticatedLocalCommand(database, {
deploymentRoot,
databasePath,
ownerPepperKeyringDirectory,
credentialFilePath,
authenticationNamespace: 'local_adoption',
});
} catch {
throw new LegacyCrontabAdoptionCliAuthenticationError(
'credential authority is unavailable',
);
}
const reviewer: Readonly<CommitReviewer> = authenticated.principal;
return Object.freeze({
reviewer,
async confirm() {
proof.verify();
try {
await authenticated.confirm();
} catch {
throw new LegacyCrontabAdoptionCliAuthenticationError(
'credential authority changed during adoption',
);
}
},
});
}
async function issueDecision(
command: Readonly<IssueLegacyCrontabAdoptionDecisionCommand>,
): Promise<Readonly<IssueLegacyCrontabAdoptionDecisionCommandResult>> {
const options = command.options;
const proof = deploymentProof({
deploymentRoot: options.deploymentRoot,
credentialFilePath: options.credentialFilePath,
filePaths: [
options.databasePath,
options.issuerKeyringPath,
options.credentialFilePath,
options.sourcePath,
options.reviewFilePath,
],
directoryPaths: [
options.ownerPepperKeyringDirectory,
path.dirname(options.authorizationPath),
],
missingPaths: [options.authorizationPath],
});
const plan = inspectLegacySqlitePath({
sourcePath: options.sourcePath,
profile: options.profile,
...(options.legacyTimezone === undefined
? {}
: { legacyTimezone: options.legacyTimezone }),
});
if (plan.planDigest !== options.expectedPlanDigest) {
throw new LegacyCrontabAdoptionCliConfigurationError(
'source no longer matches expectedPlanDigest',
);
}
return withPrivateLegacyCrontabAdoptionDecisionReviewFile(
{
filePath: options.reviewFilePath,
expectedDecisionId: options.decisionId,
expectedProfile: options.profile,
expectedPlanDigest: plan.planDigest,
expectedInventoryDigest: plan.tasks.inventoryDigest,
},
async (review) => {
const database = await openLocalSqliteBootstrapDatabase({
databasePath: options.databasePath,
profile: options.profile,
...(options.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: options.busyTimeoutMs }),
});
try {
const authority = await createReviewerAuthority(
database,
options.deploymentRoot,
options.databasePath,
options.ownerPepperKeyringDirectory,
options.credentialFilePath,
proof,
);
const issued =
await issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
sourcePath: options.sourcePath,
profile: options.profile,
...(options.legacyTimezone === undefined
? {}
: { legacyTimezone: options.legacyTimezone }),
expectedPlanDigest: plan.planDigest,
decisionId: options.decisionId,
authorizationPath: options.authorizationPath,
issuerKeyringPath: options.issuerKeyringPath,
decisions: review.decisions,
authenticateReviewer: () => authority.reviewer,
confirmIssuerAuthority: authority.confirm,
confirmDecisionStreamAuthority: review.confirmIdentity,
...(options.lifetimeMs === undefined
? {}
: { lifetimeMs: options.lifetimeMs }),
});
return Object.freeze({
schemaVersion: 1,
operation: 'legacy-crontab.decision.issue',
review: Object.freeze({
decisionCount: review.evidence.decisionCount,
fileBytes: review.evidence.fileBytes,
fileDigest: review.evidence.fileDigest,
}),
authorization: Object.freeze({
decisionId: issued.file.decisionId,
decisionCount: issued.file.decisionCount,
fileBytes: issued.file.fileBytes,
fileDigest: issued.file.fileDigest,
keyId: issued.file.keyId,
}),
receipt: Object.freeze({
reviewerSubjectId: issued.receipt.reviewer.subject.id,
issuedAtMs: issued.receipt.issuedAtMs,
expiresAtMs: issued.receipt.expiresAtMs,
decisionDigest: issued.receipt.decisions.decisionDigest,
}),
});
} finally {
await database.close();
}
},
);
}
async function commitAdoption(
command: Readonly<CommitLegacyCrontabAdoptionCommand>,
): Promise<Readonly<CommitLegacyCrontabAdoptionCommandResult>> {
const options = command.options;
const proof = deploymentProof({
deploymentRoot: options.deploymentRoot,
credentialFilePath: options.credentialFilePath,
filePaths: [
options.targetPath,
options.issuerKeyringPath,
options.credentialFilePath,
options.sourcePath,
options.authorizationPath,
],
mutableFilePaths: [options.targetPath],
directoryPaths: [options.ownerPepperKeyringDirectory],
});
const plan = inspectLegacySqlitePath({
sourcePath: options.sourcePath,
profile: options.profile,
...(options.legacyTimezone === undefined
? {}
: { legacyTimezone: options.legacyTimezone }),
});
if (plan.planDigest !== options.expectedPlanDigest) {
throw new LegacyCrontabAdoptionCliConfigurationError(
'source no longer matches expectedPlanDigest',
);
}
const database = await openLocalSqliteBootstrapDatabase({
databasePath: options.targetPath,
profile: options.profile,
...(options.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: options.busyTimeoutMs }),
});
try {
const authority = await createReviewerAuthority(
database,
options.deploymentRoot,
options.targetPath,
options.ownerPepperKeyringDirectory,
options.credentialFilePath,
proof,
);
const confirmReviewerAuthority = async (
reviewer: Readonly<CommitReviewer>,
): Promise<void> => {
await authority.confirm();
if (
reviewer.subject.type !== authority.reviewer.subject.type ||
reviewer.subject.id !== authority.reviewer.subject.id ||
reviewer.authenticationId !== authority.reviewer.authenticationId ||
reviewer.assurance !== authority.reviewer.assurance
) {
throw new LegacyCrontabAdoptionCliAuthenticationError(
'current operator does not match the signed reviewer',
);
}
};
const published = await publishReviewedLegacyCrontabAdoption({
sourcePath: options.sourcePath,
targetPath: options.targetPath,
authorizationPath: options.authorizationPath,
profile: options.profile,
...(options.legacyTimezone === undefined
? {}
: { legacyTimezone: options.legacyTimezone }),
expectedPlanDigest: plan.planDigest,
expectedDecisionId: options.expectedDecisionId,
projectId: options.projectId,
mutationId: options.mutationId,
requestId: options.requestId,
keyProvider: new LegacyCrontabDecisionIssuerKeyringFileProvider(
options.issuerKeyringPath,
),
observedAtMs: Date.now(),
...(options.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: options.busyTimeoutMs }),
confirmReviewerAuthority,
});
return Object.freeze({
schemaVersion: 1,
operation: 'legacy-crontab.adoption.commit',
status: published.status,
adoption: Object.freeze({
mutationId: published.adoption.mutationId,
decisionId: published.adoption.decisionId,
projectId: published.adoption.projectId,
publicationDigest: published.adoption.publicationDigest,
adoptedTaskCount: published.adoption.adoptedTaskCount,
adoptedTriggerCount: published.adoption.adoptedTriggerCount,
skippedCount: published.adoption.skippedCount,
auditEventId: published.adoption.auditEventId,
createdAtMs: published.adoption.createdAtMs,
}),
});
} finally {
await database.close();
}
}
export async function runLegacyCrontabAdoptionCommandFile(
commandFilePath: string,
): Promise<Readonly<LegacyCrontabAdoptionCommandResult>> {
const command = readCommandFile(commandFilePath);
return command.operation === 'legacy-crontab.decision.issue'
? issueDecision(command)
: commitAdoption(command);
}
@@ -0,0 +1,65 @@
#!/usr/bin/env node
// Keep the one-shot adoption binary beside its lifecycle command.
import { runLegacyCrontabAdoptionCommandFile } from './adoption';
const USAGE =
'Usage: ql3-adoption run --command-file /absolute/private-command.json';
function publicErrorCode(error: unknown): string {
let current = error;
for (let depth = 0; depth < 8; depth += 1) {
if (!current || typeof current !== 'object') break;
const candidate = current as { readonly code?: unknown; cause?: unknown };
if (
candidate.code === 'LEGACY_CRONTAB_ADOPTION_CLI_AUTHENTICATION_FAILED'
) {
return candidate.code;
}
current = candidate.cause;
}
const candidate = error as { readonly code?: unknown };
return typeof candidate?.code === 'string'
? candidate.code
: 'LEGACY_CRONTAB_ADOPTION_CLI_FAILED';
}
async function main(argv: readonly string[]): Promise<void> {
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
process.stdout.write(`${USAGE}\n`);
return;
}
if (argv.length !== 3 || argv[0] !== 'run' || argv[1] !== '--command-file') {
process.stderr.write(
`${JSON.stringify({
code: 'LEGACY_CRONTAB_ADOPTION_CLI_USAGE_INVALID',
message: USAGE,
})}\n`,
);
process.exitCode = 64;
return;
}
try {
const result = await runLegacyCrontabAdoptionCommandFile(argv[2]!);
process.stdout.write(`${JSON.stringify(result)}\n`);
} catch (error) {
const candidate = error as {
readonly code?: unknown;
readonly name?: unknown;
readonly message?: unknown;
};
process.stderr.write(
`${JSON.stringify({
code: publicErrorCode(error),
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
message:
typeof candidate.message === 'string'
? candidate.message
: 'Legacy Crontab adoption command failed',
})}\n`,
);
process.exitCode = 1;
}
}
void main(process.argv.slice(2));
@@ -0,0 +1,213 @@
// Local lifecycle owns short-lived readiness inspection.
import fs from 'node:fs';
import path from 'node:path';
import {
inspectLocalSqliteReadinessPath,
type LocalSqliteDatabaseOptions,
type LocalSqliteReadinessEvidence,
} from '@qinglong/local-sqlite/readiness-inspection';
const MAX_PATH_BYTES = 4_096;
export interface LocalReadinessResult {
readonly schemaVersion: 1;
readonly operation: 'local.readiness.inspect';
readonly status: 'ready';
readonly profile: 'edge' | 'standalone';
readonly storage: Readonly<{
contractName: string;
contractVersion: number;
migrationCount: number;
tableCount: number;
sqliteVersion: string;
journalMode: 'delete' | 'wal';
}>;
}
export class LocalReadinessConfigurationError extends TypeError {
readonly code = 'QL3_LOCAL_READINESS_CONFIGURATION_INVALID';
constructor(message: string, options?: ErrorOptions) {
super(`Local readiness configuration is invalid: ${message}`, options);
this.name = 'LocalReadinessConfigurationError';
}
}
export class LocalReadinessIncompatibleError extends Error {
readonly code = 'QL3_LOCAL_READINESS_INCOMPATIBLE';
constructor(message: string, options?: ErrorOptions) {
super(`Local readiness is incompatible: ${message}`, options);
this.name = 'LocalReadinessIncompatibleError';
}
}
function currentUid(): number {
if (
typeof process.getuid !== 'function' ||
typeof process.geteuid !== 'function' ||
process.getuid() !== process.geteuid()
) {
throw new LocalReadinessConfigurationError(
'real and effective POSIX users must match',
);
}
return process.getuid();
}
function normalizedDatabasePath(value: unknown): string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.normalize(value) !== value ||
path.parse(value).root === value ||
value.includes('\0') ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
) {
throw new LocalReadinessConfigurationError(
'databasePath must be a normalized bounded absolute non-root path',
);
}
return value;
}
function inspectPrivateDatabaseFile(databasePath: string): void {
let stat: fs.Stats;
try {
stat = fs.lstatSync(databasePath);
} catch (error) {
throw new LocalReadinessConfigurationError('database is unavailable', {
cause: error,
});
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== currentUid() ||
(stat.mode & 0o777) !== 0o600 ||
fs.realpathSync(databasePath) !== databasePath
) {
throw new LocalReadinessConfigurationError(
'database must be a canonical current-UID mode-0600 regular file',
);
}
}
export function normalizeLocalReadinessOptions(
value: unknown,
): Readonly<LocalSqliteDatabaseOptions> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new LocalReadinessConfigurationError('options must be an object');
}
const candidate = value as Record<string, unknown>;
const expectedKeys = [
'databasePath',
'profile',
...(Object.hasOwn(candidate, 'busyTimeoutMs') ? ['busyTimeoutMs'] : []),
].sort();
const actualKeys = Object.keys(candidate).sort();
if (
actualKeys.length !== expectedKeys.length ||
actualKeys.some((key, index) => key !== expectedKeys[index])
) {
throw new LocalReadinessConfigurationError('options shape is invalid');
}
if (candidate.profile !== 'edge' && candidate.profile !== 'standalone') {
throw new LocalReadinessConfigurationError(
'profile must be edge or standalone',
);
}
const busyTimeoutMs = candidate.busyTimeoutMs;
if (
busyTimeoutMs !== undefined &&
(!Number.isSafeInteger(busyTimeoutMs) ||
(busyTimeoutMs as number) < 100 ||
(busyTimeoutMs as number) > 30_000)
) {
throw new LocalReadinessConfigurationError(
'busyTimeoutMs must be between 100 and 30000',
);
}
return Object.freeze({
databasePath: normalizedDatabasePath(candidate.databasePath),
profile: candidate.profile,
...(busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: busyTimeoutMs as number }),
});
}
export function parseLocalReadinessArguments(
argv: readonly string[],
): Readonly<LocalSqliteDatabaseOptions> {
const values = new Map<string, string>();
for (const argument of argv) {
if (argument === '--') continue;
const match = /^--(database|profile|busy-timeout-ms)=(.+)$/.exec(argument);
if (!match || values.has(match[1]!)) {
throw new LocalReadinessConfigurationError(
'arguments must contain one --database and one --profile, with optional --busy-timeout-ms',
);
}
values.set(match[1]!, match[2]!);
}
if (
values.size < 2 ||
values.size > 3 ||
!values.has('database') ||
!values.has('profile')
) {
throw new LocalReadinessConfigurationError(
'arguments must contain one --database and one --profile, with optional --busy-timeout-ms',
);
}
const rawTimeout = values.get('busy-timeout-ms');
return normalizeLocalReadinessOptions({
databasePath: values.get('database'),
profile: values.get('profile'),
...(rawTimeout === undefined ? {} : { busyTimeoutMs: Number(rawTimeout) }),
});
}
function resultFromEvidence(
profile: 'edge' | 'standalone',
evidence: LocalSqliteReadinessEvidence,
): Readonly<LocalReadinessResult> {
const expectedJournalMode = profile === 'edge' ? 'delete' : 'wal';
if (evidence.journalMode !== expectedJournalMode) {
throw new LocalReadinessIncompatibleError(
`${profile} requires ${expectedJournalMode} journal mode`,
);
}
return Object.freeze({
schemaVersion: 1 as const,
operation: 'local.readiness.inspect' as const,
status: 'ready' as const,
profile,
storage: Object.freeze({
contractName: evidence.contractName,
contractVersion: evidence.contractVersion,
migrationCount: evidence.migrationIds.length,
tableCount: evidence.tableCount,
sqliteVersion: evidence.sqliteVersion,
journalMode: expectedJournalMode,
}),
});
}
export async function inspectLocalReadiness(
value: unknown,
): Promise<Readonly<LocalReadinessResult>> {
const options = normalizeLocalReadinessOptions(value);
inspectPrivateDatabaseFile(options.databasePath);
let evidence: LocalSqliteReadinessEvidence;
try {
evidence = await inspectLocalSqliteReadinessPath(options);
} catch (error) {
throw new LocalReadinessIncompatibleError('storage audit failed', {
cause: error,
});
}
return resultFromEvidence(options.profile, evidence);
}
@@ -0,0 +1,41 @@
#!/usr/bin/env node
// Keep the readiness binary beside its lifecycle inspection.
import {
inspectLocalReadiness,
parseLocalReadinessArguments,
} from './localReadiness';
const USAGE =
'Usage: ql3-local-readiness --database=/absolute/qinglong3.sqlite --profile=<edge|standalone> [--busy-timeout-ms=5000]';
async function main(argv: readonly string[]): Promise<void> {
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
process.stdout.write(`${USAGE}\n`);
return;
}
try {
process.stdout.write(
`${JSON.stringify(
await inspectLocalReadiness(parseLocalReadinessArguments(argv)),
)}\n`,
);
} catch (error) {
const candidate = error as {
readonly code?: unknown;
readonly name?: unknown;
};
process.stderr.write(
`${JSON.stringify({
code:
typeof candidate.code === 'string'
? candidate.code
: 'QL3_LOCAL_READINESS_FAILED',
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
})}\n`,
);
process.exitCode = 1;
}
}
void main(process.argv.slice(2));
@@ -0,0 +1,491 @@
// Local lifecycle owns first-run storage and key-material setup.
import fs from 'node:fs';
import path from 'node:path';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import {
LocalOwnerPepperKeyringFileProvider,
backupLocalOwnerPepperKey,
provisionLocalOwnerPepperKey,
} from '@qinglong/local-owner-console/pepper-custody';
import {
LocalSecretKeyringFileProvider,
provisionLocalSecretKeyring,
} from '@qinglong/local-secret';
import { openLocalSqliteBootstrapDatabase } from '@qinglong/local-sqlite/bootstrap';
import { migrateLocalSqlitePath } from '@qinglong/local-sqlite/migration';
const MAX_PATH_BYTES = 4_096;
export interface LocalSetupCommand {
readonly schemaVersion: 1;
readonly operation: 'local.setup.prepare';
readonly options: Readonly<{
deploymentRoot: string;
databasePath: string;
profile: 'edge' | 'standalone';
ownerPepperKeyringDirectory: string;
ownerPepperBackupDirectory: string;
ownerPepperKeyId: string;
localSecretKeyringPath: string;
busyTimeoutMs?: number;
}>;
readonly request: Readonly<{
registerMutationId: string;
activateMutationId: string;
registeredAtMs: number;
activatedAtMs: number;
}>;
}
export interface LocalSetupResult {
readonly schemaVersion: 1;
readonly status: 'prepared' | 'existing';
readonly profile: 'edge' | 'standalone';
readonly storage: Readonly<{
contractName: string;
contractVersion: number;
migrationCount: number;
}>;
readonly ownerPepper: Readonly<{
registerStatus: 'inserted' | 'existing';
activateStatus: 'inserted' | 'existing';
generation: number;
}>;
readonly envelopeKeyring: Readonly<{
version: 1;
keyCount: number;
}>;
}
export class LocalSetupConfigurationError extends TypeError {
readonly code = 'QL3_LOCAL_SETUP_CONFIGURATION_INVALID';
constructor(message: string, options?: ErrorOptions) {
super(`Local setup configuration is invalid: ${message}`, options);
this.name = 'LocalSetupConfigurationError';
}
}
function object(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
throw new LocalSetupConfigurationError(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function exact(
value: Record<string, unknown>,
keys: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
throw new LocalSetupConfigurationError(`${label} shape is invalid`);
}
}
function absolute(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.normalize(value) !== value ||
path.parse(value).root === value ||
value.includes('\0') ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
) {
throw new LocalSetupConfigurationError(
`${label} must be a normalized bounded absolute non-root path`,
);
}
return value;
}
function currentUid(): number {
if (
typeof process.getuid !== 'function' ||
typeof process.geteuid !== 'function' ||
process.getuid() !== process.geteuid()
) {
throw new LocalSetupConfigurationError(
'real and effective POSIX users must match',
);
}
return process.getuid();
}
function privateDirectory(value: unknown, label: string): string {
const directory = absolute(value, label);
let stat: fs.Stats;
try {
stat = fs.lstatSync(directory);
} catch (error) {
throw new LocalSetupConfigurationError(`${label} is unavailable`, {
cause: error,
});
}
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
stat.uid !== currentUid() ||
(stat.mode & 0o777) !== 0o700 ||
fs.realpathSync(directory) !== directory
) {
throw new LocalSetupConfigurationError(
`${label} must be a canonical current-UID 0700 directory`,
);
}
return directory;
}
function child(root: string, value: unknown, label: string): string {
const candidate = absolute(value, label);
const relative = path.relative(root, candidate);
if (
relative === '' ||
relative === '..' ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative)
) {
throw new LocalSetupConfigurationError(
`${label} must be a distinct child of deploymentRoot`,
);
}
return candidate;
}
function safeInteger(
value: unknown,
minimum: number,
maximum: number,
label: string,
): number {
if (
!Number.isSafeInteger(value) ||
(value as number) < minimum ||
(value as number) > maximum
) {
throw new LocalSetupConfigurationError(`${label} is invalid`);
}
return value as number;
}
export function normalizeLocalSetupCommand(
value: unknown,
): Readonly<LocalSetupCommand> {
const command = object(value, 'command');
exact(command, ['operation', 'options', 'request', 'schemaVersion'], 'command');
if (
command.schemaVersion !== 1 ||
command.operation !== 'local.setup.prepare'
) {
throw new LocalSetupConfigurationError(
'schemaVersion or operation is invalid',
);
}
const rawOptions = object(command.options, 'options');
const optionalKeys = Object.hasOwn(rawOptions, 'busyTimeoutMs')
? ['busyTimeoutMs']
: [];
exact(
rawOptions,
[
'databasePath',
'deploymentRoot',
'localSecretKeyringPath',
'ownerPepperBackupDirectory',
'ownerPepperKeyId',
'ownerPepperKeyringDirectory',
'profile',
...optionalKeys,
],
'options',
);
const deploymentRoot = privateDirectory(
rawOptions.deploymentRoot,
'deploymentRoot',
);
const databasePath = child(
deploymentRoot,
rawOptions.databasePath,
'databasePath',
);
const localSecretKeyringPath = child(
deploymentRoot,
rawOptions.localSecretKeyringPath,
'localSecretKeyringPath',
);
const ownerPepperKeyringDirectory = privateDirectory(
child(
deploymentRoot,
rawOptions.ownerPepperKeyringDirectory,
'ownerPepperKeyringDirectory',
),
'ownerPepperKeyringDirectory',
);
const ownerPepperBackupDirectory = privateDirectory(
child(
deploymentRoot,
rawOptions.ownerPepperBackupDirectory,
'ownerPepperBackupDirectory',
),
'ownerPepperBackupDirectory',
);
if (
new Set([
databasePath,
localSecretKeyringPath,
ownerPepperKeyringDirectory,
ownerPepperBackupDirectory,
]).size !== 4
) {
throw new LocalSetupConfigurationError(
'setup authority paths must be distinct',
);
}
if (
rawOptions.profile !== 'edge' &&
rawOptions.profile !== 'standalone'
) {
throw new LocalSetupConfigurationError('profile is invalid');
}
if (
typeof rawOptions.ownerPepperKeyId !== 'string' ||
rawOptions.ownerPepperKeyId.length < 1 ||
rawOptions.ownerPepperKeyId.length > 128
) {
throw new LocalSetupConfigurationError('ownerPepperKeyId is invalid');
}
const request = object(command.request, 'request');
exact(
request,
[
'activateMutationId',
'activatedAtMs',
'registerMutationId',
'registeredAtMs',
],
'request',
);
if (
typeof request.registerMutationId !== 'string' ||
typeof request.activateMutationId !== 'string'
) {
throw new LocalSetupConfigurationError('mutation identity is invalid');
}
const registeredAtMs = safeInteger(
request.registeredAtMs,
0,
Number.MAX_SAFE_INTEGER,
'registeredAtMs',
);
const activatedAtMs = safeInteger(
request.activatedAtMs,
registeredAtMs,
Number.MAX_SAFE_INTEGER,
'activatedAtMs',
);
const busyTimeoutMs =
rawOptions.busyTimeoutMs === undefined
? undefined
: safeInteger(rawOptions.busyTimeoutMs, 100, 30_000, 'busyTimeoutMs');
return Object.freeze({
schemaVersion: 1 as const,
operation: 'local.setup.prepare' as const,
options: Object.freeze({
deploymentRoot,
databasePath,
profile: rawOptions.profile,
ownerPepperKeyringDirectory,
ownerPepperBackupDirectory,
ownerPepperKeyId: rawOptions.ownerPepperKeyId,
localSecretKeyringPath,
...(busyTimeoutMs === undefined ? {} : { busyTimeoutMs }),
}),
request: Object.freeze({
registerMutationId: request.registerMutationId,
activateMutationId: request.activateMutationId,
registeredAtMs,
activatedAtMs,
}),
});
}
function existingOrProvisionPepper(
command: Readonly<LocalSetupCommand>,
): Readonly<{ digest: string; created: boolean }> {
const provider = new LocalOwnerPepperKeyringFileProvider(
command.options.ownerPepperKeyringDirectory,
);
const inspected = provider.inspect();
if (inspected.keyIds.length === 0) {
const created = provisionLocalOwnerPepperKey({
keyringDirectory: command.options.ownerPepperKeyringDirectory,
pepperKeyId: command.options.ownerPepperKeyId,
});
return Object.freeze({ digest: created.digest, created: true });
}
if (
inspected.keyIds.length !== 1 ||
inspected.keyIds[0] !== command.options.ownerPepperKeyId
) {
throw new LocalSetupConfigurationError(
'Owner pepper keyring is not an exact setup replay',
);
}
const material = provider.resolve(command.options.ownerPepperKeyId);
if (!material) {
throw new LocalSetupConfigurationError(
'Owner pepper material is unavailable',
);
}
return Object.freeze({
digest: material.summary.digest,
created: false,
});
}
function existingOrBackupPepper(
command: Readonly<LocalSetupCommand>,
expectedDigest: string,
): Readonly<{ digest: string; created: boolean }> {
const provider = new LocalOwnerPepperKeyringFileProvider(
command.options.ownerPepperBackupDirectory,
);
const inspected = provider.inspect();
if (inspected.keyIds.length === 0) {
const created = backupLocalOwnerPepperKey({
keyringDirectory: command.options.ownerPepperKeyringDirectory,
backupDirectory: command.options.ownerPepperBackupDirectory,
pepperKeyId: command.options.ownerPepperKeyId,
});
if (created.digest !== expectedDigest) {
throw new LocalSetupConfigurationError(
'Owner pepper backup digest changed',
);
}
return Object.freeze({ digest: created.digest, created: true });
}
if (
inspected.keyIds.length !== 1 ||
inspected.keyIds[0] !== command.options.ownerPepperKeyId
) {
throw new LocalSetupConfigurationError(
'Owner pepper backup is not an exact setup replay',
);
}
const material = provider.resolve(command.options.ownerPepperKeyId);
if (!material || material.summary.digest !== expectedDigest) {
throw new LocalSetupConfigurationError(
'Owner pepper backup does not match primary material',
);
}
return Object.freeze({
digest: material.summary.digest,
created: false,
});
}
async function existingOrProvisionEnvelopeKeyring(
filePath: string,
): Promise<Readonly<{ version: 1; keyCount: number; created: boolean }>> {
if (!fs.existsSync(filePath)) {
const created = await provisionLocalSecretKeyring(filePath);
return Object.freeze({
version: 1,
keyCount: created.keyIds.length,
created: true,
});
}
const existing = await new LocalSecretKeyringFileProvider(filePath).inspect();
return Object.freeze({
version: 1,
keyCount: existing.keyIds.length,
created: false,
});
}
export async function executeLocalSetup(
input: unknown,
): Promise<Readonly<LocalSetupResult>> {
const command = normalizeLocalSetupCommand(input);
const migration = await migrateLocalSqlitePath({
databasePath: command.options.databasePath,
profile: command.options.profile,
...(command.options.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: command.options.busyTimeoutMs }),
});
const pepper = existingOrProvisionPepper(command);
const backup = existingOrBackupPepper(command, pepper.digest);
const database = await openLocalSqliteBootstrapDatabase({
databasePath: command.options.databasePath,
profile: command.options.profile,
...(command.options.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: command.options.busyTimeoutMs }),
});
let registered;
let activated;
try {
registered = await database.ownerPepper.register({
mutationId: command.request.registerMutationId,
pepperKeyId: command.options.ownerPepperKeyId,
materialDigest: pepper.digest,
backupDigest: backup.digest,
registeredAtMs: command.request.registeredAtMs,
});
activated = await database.ownerPepper.activate({
mutationId: command.request.activateMutationId,
pepperKeyId: command.options.ownerPepperKeyId,
expectedGeneration: 0,
activatedAtMs: command.request.activatedAtMs,
});
} finally {
await database.close();
}
const envelopeKeyring = await existingOrProvisionEnvelopeKeyring(
command.options.localSecretKeyringPath,
);
const created =
pepper.created ||
backup.created ||
registered.status === 'inserted' ||
activated.status === 'inserted' ||
envelopeKeyring.created;
return Object.freeze({
schemaVersion: 1 as const,
status: created ? ('prepared' as const) : ('existing' as const),
profile: command.options.profile,
storage: Object.freeze({
contractName: migration.readiness.contractName,
contractVersion: migration.readiness.contractVersion,
migrationCount: migration.readiness.migrationIds.length,
}),
ownerPepper: Object.freeze({
registerStatus: registered.status,
activateStatus: activated.status,
generation: activated.activation.generation,
}),
envelopeKeyring: Object.freeze({
version: 1 as const,
keyCount: envelopeKeyring.keyCount,
}),
});
}
export function executeLocalSetupCommandFile(
filePath: string,
): Promise<Readonly<LocalSetupResult>> {
return executeLocalSetup(readPrivateLocalCommandFile(filePath));
}
@@ -0,0 +1,46 @@
#!/usr/bin/env node
// Keep the one-shot setup binary beside its lifecycle command.
import { executeLocalSetupCommandFile } from './localSetup';
const USAGE =
'Usage: ql3-local-setup run --command-file /absolute/private-command.json';
async function main(argv: readonly string[]): Promise<void> {
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
process.stdout.write(`${USAGE}\n`);
return;
}
if (argv.length !== 3 || argv[0] !== 'run' || argv[1] !== '--command-file') {
process.stderr.write(
`${JSON.stringify({
code: 'QL3_LOCAL_SETUP_CLI_USAGE_INVALID',
message: USAGE,
})}\n`,
);
process.exitCode = 64;
return;
}
try {
process.stdout.write(
`${JSON.stringify(await executeLocalSetupCommandFile(argv[2]!))}\n`,
);
} catch (error) {
const candidate = error as {
readonly code?: unknown;
readonly name?: unknown;
};
process.stderr.write(
`${JSON.stringify({
code:
typeof candidate.code === 'string'
? candidate.code
: 'QL3_LOCAL_SETUP_FAILED',
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
})}\n`,
);
process.exitCode = 1;
}
}
void main(process.argv.slice(2));