From 22f58b1eff4dc4b200cbfce5947b3fc6e41dc748 Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 21 Aug 2026 19:34:29 +0800 Subject: [PATCH] feat(ql3): authorize reconciliation reviews --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 38 +- ...ation-diagnostics-and-reviewed-decision.md | 31 +- .../src/deployment/localDeployment.ts | 42 + .../src/deployment/localDeploymentCli.ts | 10 +- .../reconciliation/review/authorization.ts | 752 ++++++++++++++ .../reconciliation/review/completion.ts | 976 ++++++++++++++++++ .../review/completionContract.ts | 361 +++++++ .../reconciliation/review/decisionFile.ts | 439 ++++++++ .../reconciliation/review/diagnostics.ts | 77 +- .../reconciliation/review/issuerKeyring.ts | 404 ++++++++ .../reconciliation/review/preparation.ts | 112 +- .../reconciliation/review/terminalEvidence.ts | 396 +++++++ .../reconciliationCapturePrepare.test.cjs | 554 +++++++++- packages/ql3-local-sqlite/package.json | 5 + .../src/security/authenticationRead.ts | 58 ++ .../test/authenticationRead.test.cjs | 40 + scripts/ql3-cluster-dependency-audit.cjs | 30 + test/back/ql3ClusterDependencyAudit.test.cjs | 67 ++ test/back/ql3PackageBoundaryAudit.test.cjs | 8 +- 19 files changed, 4293 insertions(+), 107 deletions(-) create mode 100644 packages/ql3-local-owner-cli/src/deployment/reconciliation/review/authorization.ts create mode 100644 packages/ql3-local-owner-cli/src/deployment/reconciliation/review/completion.ts create mode 100644 packages/ql3-local-owner-cli/src/deployment/reconciliation/review/completionContract.ts create mode 100644 packages/ql3-local-owner-cli/src/deployment/reconciliation/review/decisionFile.ts create mode 100644 packages/ql3-local-owner-cli/src/deployment/reconciliation/review/issuerKeyring.ts create mode 100644 packages/ql3-local-owner-cli/src/deployment/reconciliation/review/terminalEvidence.ts create mode 100644 packages/ql3-local-sqlite/src/security/authenticationRead.ts create mode 100644 packages/ql3-local-sqlite/test/authenticationRead.test.cjs diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 8d069cb2..6a4f1ffe 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,21 +11,25 @@ 最新增量证据(2026-08-21): -- D-391/ADR-0484(第一切片已实现,commit/verify 待实现):`reconciliation_planned` 仍是内容无关 summary,不能为人工裁决泄漏明细或 - 获得 import authority。既有 Local Owner 已在 `deployment/reconciliation/review/` 增加 - `reconciliation.review.prepare|diagnostics`,并为后续 commit/verify 冻结 - `reconciliation_planned → reconciliation_review_prepared → reconciliation_reviewed` CAS 建立唯一 review fence。diagnostics 每次只把 - 一个 database/domain/fact-kind 的最多 64 条私有记录 no-replace 写入 caller 指定的 owner-only 文件;stdout 只返回 page digest、计数和 - offset,不含路径、名称或 fact digest。commit 不信任 page,而是从 exact sealed bundle 重新流式派生 canonical facts,与 Edge ≤8 MiB、 - Standalone ≤32 MiB 的 private NDJSON decision file 逐条匹配。unknown、Secret/credential/pepper、active Run 与 custody 缺口固定 blocked, - 人工选择也不能提升为自动可导入。authorization 必须来自 target Owner credential 建立的近期强认证 User,并以独立 issuer keyring、 - exact review file/plan/bundle/head fence 签名;terminal review 只保存签名 authorization、compact counts/digest 和 content-free receipt。 - verify 不打开 SQLite。实现不新增 package/dependency/binary/daemon,不把文件平铺回 `src/` 根,也不调用 DML、Secret 解密、Docker/init/ - network。后续领域 adapter 必须消费 exact `reviewDigest` 后重新认证和授权,分别定义 backup、prepare/commit、幂等与 rollback,不能把 - review completion 当作 reconciliation completion。当前聚焦套件 `28 total / 26 pass / 2 conditional Docker skip / 0 fail`,Local Owner - `250 total / 243 pass / 7 conditional skip / 0 fail`,tracked backend `1540 total / 1538 pass / 2 conditional skip / 0 fail`, - 18-package clean build/逐包测试、八项架构/发布审计、十四档 artifact 和真实 Docker readonly `2/2` 全通过。workspace 仍为 18 - packages,Local Owner `149 source / 148 nested / 1 root binary entry`;新增 3 个源码全部位于 review 子目录,基础常驻 closure 不增长。 +- D-391/ADR-0484(已接受):既有 Local Owner 已完整实现 + `reconciliation.review.prepare|diagnostics|commit|verify`,以 + `reconciliation_planned → reconciliation_review_prepared → reconciliation_reviewed` CAS 建立唯一 review fence。diagnostics 每次只把一个 + database/domain/fact-kind 的最多 64 条私有记录 no-replace 写入 caller 指定的 owner-only 文件;stdout 不含路径、名称或 fact digest。 + commit 不信任 page,而是从 exact sealed bundle 重新流式派生 canonical facts,与 Edge ≤8 MiB、Standalone ≤32 MiB 的稳定 descriptor + NDJSON decision file 逐条匹配。unknown、Secret/credential/pepper、active Run 与 custody 缺口固定 blocked,人工选择不能提升为自动可导入。 + 生产 composition 只用新的 Local SQLite authentication-read projection 加载 credential/pepper,通过既有 Owner authentication 建立最多 + 5 分钟的强 User principal;独立最多八代 issuer keyring 签发最长 30 分钟的 domain-separated authorization,并在签名前后重验 decision + file、credential、keyring、plan、bundle fingerprint 与 prepared head。authorization/review/receipt/seal/head 的 crash/response-loss 窗口均 + exact replay,terminal evidence 为 `0400/0500`;verify 只验证签名和 exact binding,不打开 SQLite、不写文件。实现不新增 package、dependency、 + binary 或 daemon,不平铺 `src/`,也不调用 DML、Secret 解密、Docker/init/network。后续领域 adapter 必须以独立 ADR 消费 exact + `reviewDigest` 后重新认证授权,不能把 review completion 当作 reconciliation completion。聚焦套件 + `32 total / 30 pass / 2 conditional Docker skip / 0 fail`,Local Owner `254 total / 247 pass / 7 conditional skip / 0 fail`,tracked + backend `1541 total / 1539 pass / 2 conditional skip / 0 fail`;18-package clean build/逐包测试、八项架构/部署/发布审计、十四档 artifact 与 + 真实 Docker readonly `2/2` 全通过。workspace 仍为 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner + `155 source / 154 nested / 1 root binary entry`,Local SQLite `203 source / 202 nested / 1 root public export`,新增 7 个生产源码全部位于既有 + 领域目录。基础 Edge/Standalone closure 精确保持 `2,611,978 / 2,612,056` bytes、319 files、58 modules。PostgreSQL 18.6 arm64 physical HA + 以 146 gates、timeline `1 → 2` 通过,private evidence SHA-256 为 + `3d6623465913d43e6f1a8838896d6deb6664dafd0c26970bddb4d6165fb60c00`,离线审计无 finding。 - D-390/ADR-0483(已接受):既有 Local Owner 已实现密封 capture 的严格只读消费与独立 `reconciliation.plan.prepare|commit|verify`。capture v2 使用 SQLite 可识别的固定 `target.sqlite* / legacy.sqlite* / recovery.sqlite` 物理名和 `0400/0500` terminal seal;main-only 走 immutable readonly,WAL+SHM 完整配对走普通 readonly,hot @@ -38,8 +42,8 @@ `246 total / 239 pass / 7 conditional skip / 0 fail`,tracked backend `1540 total / 1538 pass / 2 conditional skip / 0 fail`, 18-package clean build/逐包测试、八项架构/发布审计和十四档 artifact audit 全通过。workspace 仍为 18 packages,Local Owner `146 source / 145 nested / 1 root binary entry`;基础 Edge/Standalone closure 仍为 319 files、58 modules,不含一次性 plan authority。 - D-391/ADR-0484 已冻结消费 exact plan digest 的私有逐对象诊断、人工裁决与审批协议;它仍不能借 plan 获得自动 import 或 rollback - authority,完成实现与完整门禁前保持 Proposed。 + D-391/ADR-0484 已完成私有逐对象诊断、受认证人工裁决、签名 terminal review 与只读 verify;它仍不能借 plan 或 review 获得自动 import、 + rollback 或 restart authority,下一阶段领域 adapter 继续使用独立 ADR 和门禁。 - D-389/ADR-0482(已接受):target stopped 后的 `reconciliation_required` 不能直接逆迁移或覆盖 2.x source;既有 Local Owner 已实现独立 `reconciliation.capture.prepare|commit|verify`,只允许 exact stopped reconciliation head,以 instance CAS 建立唯一 capture fence,并把 target main/sidecars、Legacy source main/sidecars、activation recovery 与内容无关 lineage 以固定 64 KiB diff --git a/docs/adr/ADR-0484-private-reconciliation-diagnostics-and-reviewed-decision.md b/docs/adr/ADR-0484-private-reconciliation-diagnostics-and-reviewed-decision.md index 7e4d005c..1909a88e 100644 --- a/docs/adr/ADR-0484-private-reconciliation-diagnostics-and-reviewed-decision.md +++ b/docs/adr/ADR-0484-private-reconciliation-diagnostics-and-reviewed-decision.md @@ -1,6 +1,6 @@ # ADR-0484:私有 Reconciliation 诊断与受认证人工裁决 -- 状态:Proposed(D-391 契约冻结) +- 状态:Accepted(D-391 已实现并完成门禁) - 日期:2026-08-21 - 关联 RFC:QL-RFC-0001 D-05、D-06、D-17、D-64、D-87、D-184、D-259、D-383、D-389、D-390、D-391 - 关联 ADR:ADR-0064、ADR-0094、ADR-0095、ADR-0194、ADR-0201、ADR-0314、ADR-0315、ADR-0482、ADR-0483 @@ -121,20 +121,25 @@ output 不进入基础 Edge/Standalone artifact;Cluster/PostgreSQL/Kubernetes ## 当前实现进度 -D-391 第一切片已实现 `review.prepare` 与 `review.diagnostics`。prepare 读取并重验 exact terminal plan/bundle,以 instance CAS 建立 -`reconciliation_review_prepared` 唯一 fence,覆盖 head response loss、第二 review、rollback 和 restart 拒绝。diagnostics 在每次 -SQLite open 前后重验密封资产,只为一个 database/domain/fact-kind 发布最多 64 条 owner-only page;Secret、identity、history 和 unknown -facts 固定 blocked,未知表不读取 row,terminal result 不返回路径、名称或 fact digest。page 使用 256 KiB 固定上限、deterministic -stage、hard-link no-replace 与 fsync,重复请求只能得到 byte-exact existing page。 +D-391 已完整实现 `review.prepare`、`review.diagnostics`、`review.commit` 与 `review.verify`。prepare/diagnostics 保持第一切片的 exact +plan/bundle/head fence、64 条私有分页、blocked fact 与 byte-exact replay;commit 新增 Edge 8 MiB、Standalone 32 MiB 的稳定 descriptor +NDJSON 流,逐条重新派生密封 bundle facts,不读取 diagnostics page。生产 composition 只通过新的 Local SQLite authentication-read +projection 读取 credential/pepper,并使用既有 `establishAuthenticatedLocalCommand` 建立最多 5 分钟的强 User principal。独立 issuer +keyring 最多八代 key,authorization 生命周期最多 30 分钟,签名前后重验 decision file、credential、keyring、plan、bundle fingerprint 和 +prepared head。 -验证结果:聚焦套件 `28 total / 26 pass / 2 conditional Docker skip / 0 fail`;完整 Local Owner -`250 total / 243 pass / 7 conditional skip / 0 fail`;tracked backend `1540 total / 1538 pass / 2 conditional skip / 0 fail`; -18-package clean build/逐包测试、八项架构/发布审计、十四档 artifact audit 与真实 Docker readonly `2/2` 均通过。workspace 仍为 18 -packages,`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner 为 `149 source / 148 nested / 1 root binary entry`,新增 -3 个源码全部位于 `deployment/reconciliation/review/`,没有新增 dependency 或常驻对象,基础 Edge/Standalone closure 未增长。 +authorization、review、receipt、seal 与 head 的每个 crash/response-loss 窗口均已覆盖;terminal evidence 收敛为 `0400/0500`,verify +复验签名与全部 exact binding,不打开 SQLite、不写文件。CLI 只返回 content-free digest/count/replay facts。`reconciliation_reviewed` +仍不授予 import、rollback、restart、SQL、Secret 解密或外部副作用 authority;任何领域 adapter 必须由下一份独立 ADR 定义。 -`review.commit`、强认证 User/issuer keyring authorization、terminal seal、`reconciliation_reviewed` 推进和 `review.verify` 尚未实现,因此本 -ADR 继续保持 Proposed;当前 prepared review 不授予任何 import、rollback 或 restart authority。 +验证结果:聚焦套件 `32 total / 30 pass / 2 conditional Docker skip / 0 fail`;完整 Local Owner +`254 total / 247 pass / 7 conditional skip / 0 fail`;tracked backend `1541 total / 1539 pass / 2 conditional skip / 0 fail`; +18-package clean build/逐包测试、八项架构/部署/发布审计、十四档 artifact audit 与真实 Docker readonly `2/2` 均通过。PostgreSQL 18.6 +arm64 physical HA 以 146 gates、timeline `1 → 2` 通过,private report SHA-256 为 +`3d6623465913d43e6f1a8838896d6deb6664dafd0c26970bddb4d6165fb60c00`,独立 evidence audit 无 finding。workspace 仍为 18 packages, +`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner 为 `155 source / 154 nested / 1 root binary entry`,Local SQLite 为 +`203 source / 202 nested / 1 root public export`。新增 7 个生产源码全部进入既有领域目录,没有新增 package、dependency、binary 或常驻 +对象;基础 Edge/Standalone closure 精确保持 `2,611,978 / 2,612,056` bytes、319 files、58 modules。 ## 被拒绝的替代方案 diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts index 9e50d905..5133c793 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts @@ -117,6 +117,12 @@ import { writeLocalReconciliationReviewDiagnostics, writeLocalReconciliationReviewDiagnosticsCommandFile, } from './reconciliation/review/preparation'; +import { + commitLocalReconciliationReview, + commitLocalReconciliationReviewCommandFile, + verifyLocalReconciliationReview, + verifyLocalReconciliationReviewCommandFile, +} from './reconciliation/review/completion'; export { commitLocalReconciliationPlan, @@ -129,6 +135,10 @@ export { prepareLocalReconciliationReviewCommandFile, writeLocalReconciliationReviewDiagnostics, writeLocalReconciliationReviewDiagnosticsCommandFile, + commitLocalReconciliationReview, + commitLocalReconciliationReviewCommandFile, + verifyLocalReconciliationReview, + verifyLocalReconciliationReviewCommandFile, }; export { @@ -181,6 +191,38 @@ export { type LocalReconciliationReviewPrepareCommand, type LocalReconciliationReviewPrepareResult, } from './reconciliation/review/contract'; +export { + MAX_LOCAL_RECONCILIATION_REVIEW_AUTHORIZATION_LIFETIME_MS, + normalizeLocalReconciliationReviewCommitCommand, + normalizeLocalReconciliationReviewVerifyCommand, + type LocalReconciliationReviewCommitCommand, + type LocalReconciliationReviewCommitOptions, + type LocalReconciliationReviewTerminalResult, + type LocalReconciliationReviewVerifyCommand, +} from './reconciliation/review/completionContract'; +export { type LocalReconciliationReviewCompletionDependencies } from './reconciliation/review/completion'; +export { + LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS, + LOCAL_RECONCILIATION_REVIEW_REASONS, + MAX_EDGE_LOCAL_RECONCILIATION_REVIEW_DECISION_BYTES, + MAX_STANDALONE_LOCAL_RECONCILIATION_REVIEW_DECISION_BYTES, + type LocalReconciliationReviewDecision, + type LocalReconciliationReviewDecisionHeader, + type LocalReconciliationReviewDisposition, + type LocalReconciliationReviewReason, +} from './reconciliation/review/decisionFile'; +export { + MAX_LOCAL_RECONCILIATION_REVIEW_ISSUER_KEYS, + LocalReconciliationReviewIssuerKeyringFileProvider, + ensureLocalReconciliationReviewIssuerKeyring, + type LocalReconciliationReviewIssuerKeyringSummary, +} from './reconciliation/review/issuerKeyring'; +export { + normalizeLocalReconciliationReview, + normalizeLocalReconciliationReviewReceipt, + type LocalReconciliationReview, + type LocalReconciliationReviewReceipt, +} from './reconciliation/review/terminalEvidence'; export { localReconciliationReviewDirectory, normalizeLocalReconciliationReviewIntent, diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts index 0cb5ce3a..b00b6fa1 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts @@ -18,8 +18,10 @@ import { prepareLocalReconciliationReviewCommandFile, commitLocalReconciliationCaptureCommandFile, commitLocalReconciliationPlanCommandFile, + commitLocalReconciliationReviewCommandFile, verifyLocalReconciliationCaptureCommandFile, verifyLocalReconciliationPlanCommandFile, + verifyLocalReconciliationReviewCommandFile, writeLocalReconciliationReviewDiagnosticsCommandFile, prepareLocalDeploymentCommandFile, proveLocalDeploymentLegacyReadinessCommandFile, @@ -35,7 +37,7 @@ import { } from './localDeployment'; const USAGE = - 'Usage: ql3-local-deploy --command-file /absolute/private-command.json'; + 'Usage: ql3-local-deploy --command-file /absolute/private-command.json'; async function main(argv: readonly string[]): Promise { if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { @@ -72,6 +74,8 @@ async function main(argv: readonly string[]): Promise { argv[0] !== 'reconciliation-plan-verify' && argv[0] !== 'reconciliation-review-prepare' && argv[0] !== 'reconciliation-review-diagnostics' && + argv[0] !== 'reconciliation-review-commit' && + argv[0] !== 'reconciliation-review-verify' && argv[0] !== 'compose-revision' && argv[0] !== 'compose-preflight' && argv[0] !== 'compose-apply' && @@ -158,6 +162,10 @@ async function main(argv: readonly string[]): Promise { ? prepareLocalReconciliationReviewCommandFile(argv[2]!) : argv[0] === 'reconciliation-review-diagnostics' ? writeLocalReconciliationReviewDiagnosticsCommandFile(argv[2]!) + : argv[0] === 'reconciliation-review-commit' + ? commitLocalReconciliationReviewCommandFile(argv[2]!) + : argv[0] === 'reconciliation-review-verify' + ? verifyLocalReconciliationReviewCommandFile(argv[2]!) : argv[0] === 'compose-revision' ? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!) : argv[0] === 'compose-preflight' diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/authorization.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/authorization.ts new file mode 100644 index 00000000..67e5c91d --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/authorization.ts @@ -0,0 +1,752 @@ +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import type { LocalSecretKeyMaterial } from '@qinglong/runtime-core/local-secret'; +import { + normalizeSecurityPrincipal, + type SecurityPrincipal, +} from '@qinglong/runtime-core/security'; + +import { LocalDeploymentConfigurationError } from '../../foundation/error'; +import type { + LocalReconciliationReviewDecision, + LocalReconciliationReviewDisposition, + LocalReconciliationReviewReason, +} from './decisionFile'; +import { LocalReconciliationReviewIssuerKeyringFileProvider } from './issuerKeyring'; + +const HEADER_KIND = + 'qinglong3-local-reconciliation-review-authorization-header'; +const DECISION_KIND = + 'qinglong3-local-reconciliation-review-authorization-decision'; +const SIGNATURE_KIND = + 'qinglong3-local-reconciliation-review-authorization-signature'; +const MAX_LINE_BYTES = 64 * 1024; +const READ_CHUNK_BYTES = 64 * 1024; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; + +export interface LocalReconciliationReviewAuthorizationHeader { + readonly schemaVersion: 1; + readonly kind: typeof HEADER_KIND; + readonly reviewId: string; + readonly profile: 'edge' | 'standalone'; + readonly planDigest: string; + readonly preparationDigest: string; + readonly bundleDigest: string; + readonly bundleFingerprintDigest: string; + readonly preparedHeadDigest: string; + readonly reviewer: Readonly; + readonly issuedAtMs: number; + readonly expiresAtMs: number; +} + +export interface LocalReconciliationReviewAuthorizationEvidence { + readonly fileBytes: number; + readonly authorizationDigest: string; + readonly decisionCount: number; + readonly decisionSetDigest: string; + readonly decisionFileDigest: string; + readonly keyId: string; + readonly dispositionCounts: Readonly< + Record + >; + readonly reasonCounts: Readonly< + Record + >; + readonly header: Readonly; +} + +interface SignatureRecord { + readonly schemaVersion: 1; + readonly kind: typeof SIGNATURE_KIND; + readonly algorithm: 'hmac-sha256'; + readonly keyId: string; + readonly contentBytes: number; + readonly contentDigest: string; + readonly decisionCount: number; + readonly decisionSetDigest: string; + readonly decisionFileDigest: string; + readonly dispositionCounts: Readonly< + Record + >; + readonly reasonCounts: Readonly< + Record + >; + readonly authenticationTag: string; +} + +const DISPOSITIONS = [ + 'retain_target', + 'adopt_legacy', + 'retain_both', + 'exclude_legacy', + 'defer', + 'manual_external', +] as const; +const REASONS = [ + 'preserve_target', + 'prefer_legacy', + 'preserve_both', + 'legacy_excluded', + 'deferred_review', + 'external_recovery_required', +] as const; + +function zeroCounts(keys: readonly T[]): Record { + return Object.fromEntries(keys.map((key) => [key, 0])) as Record; +} + +function configurationError(message: string, cause?: unknown): never { + throw new LocalDeploymentConfigurationError( + `reconciliation review authorization ${message}`, + { cause }, + ); +} + +function exact( + value: unknown, + keys: readonly string[], + label: string, +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + configurationError(`${label} must be an object`); + } + const record = value as Record; + const actual = Object.keys(record).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + configurationError(`${label} shape is invalid`); + } + return record; +} + +function canonicalLine(value: unknown): Buffer { + const bytes = Buffer.from(`${JSON.stringify(value)}\n`, 'utf8'); + if (bytes.byteLength < 3 || bytes.byteLength > MAX_LINE_BYTES + 1) { + configurationError('record exceeds its line bound'); + } + return bytes; +} + +function writeAll(descriptor: number, bytes: Buffer): void { + let offset = 0; + while (offset < bytes.byteLength) { + const written = fs.writeSync( + descriptor, + bytes, + offset, + bytes.byteLength - offset, + ); + if (written < 1) configurationError('write stalled'); + offset += written; + } +} + +function signingMessage( + contentDigest: string, + contentBytes: number, + decisionFileDigest: string, +): Buffer { + return Buffer.from( + `qinglong3.local-reconciliation-review-authorization.v1\0${contentDigest}\0${contentBytes}\0${decisionFileDigest}`, + 'utf8', + ); +} + +function wipe(material: LocalSecretKeyMaterial | null | undefined): void { + material?.key.fill(0); +} + +function syncDirectory(directory: string): void { + const descriptor = fs.openSync(directory, fs.constants.O_RDONLY); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function normalizeHeader( + value: unknown, +): Readonly { + const record = exact( + value, + [ + 'bundleDigest', + 'bundleFingerprintDigest', + 'expiresAtMs', + 'issuedAtMs', + 'kind', + 'planDigest', + 'preparationDigest', + 'preparedHeadDigest', + 'profile', + 'reviewId', + 'reviewer', + 'schemaVersion', + ], + 'header', + ); + if ( + record.schemaVersion !== 1 || + record.kind !== HEADER_KIND || + (record.profile !== 'edge' && record.profile !== 'standalone') || + !Number.isSafeInteger(record.issuedAtMs) || + !Number.isSafeInteger(record.expiresAtMs) || + (record.expiresAtMs as number) <= (record.issuedAtMs as number) || + typeof record.reviewId !== 'string' || + [ + record.planDigest, + record.preparationDigest, + record.bundleDigest, + record.bundleFingerprintDigest, + record.preparedHeadDigest, + ].some((value) => typeof value !== 'string' || !DIGEST_PATTERN.test(value)) + ) { + configurationError('header is invalid'); + } + const principal = normalizeSecurityPrincipal( + record.reviewer as SecurityPrincipal, + record.issuedAtMs as number, + ); + if ( + principal.subject.type !== 'user' || + !['hardware', 'local_console', 'multi_factor'].includes( + principal.assurance, + ) || + (record.issuedAtMs as number) - principal.authenticatedAtMs > + 5 * 60 * 1_000 || + principal.authenticatedAtMs > (record.issuedAtMs as number) || + (record.expiresAtMs as number) - (record.issuedAtMs as number) > + 30 * 60 * 1_000 + ) { + configurationError('reviewer authority is invalid'); + } + return Object.freeze({ + ...(record as unknown as LocalReconciliationReviewAuthorizationHeader), + reviewer: principal, + }); +} + +function authorizationDecision( + value: unknown, +): Readonly { + const record = exact( + value, + [ + 'database', + 'disposition', + 'domain', + 'factDigest', + 'factKind', + 'kind', + 'ordinal', + 'reason', + 'schemaVersion', + ], + 'decision', + ); + if ( + record.schemaVersion !== 1 || + record.kind !== DECISION_KIND || + (record.database !== 'legacy' && record.database !== 'target') || + ![ + 'schema_lineage', + 'automation', + 'secret_and_config', + 'run_history', + 'plugin_package', + 'ai_and_tool', + 'identity_policy_audit', + 'unknown', + ].includes(record.domain as string) || + (record.factKind !== 'schema_object' && record.factKind !== 'table') || + !Number.isSafeInteger(record.ordinal) || + (record.ordinal as number) < 1 || + typeof record.factDigest !== 'string' || + !DIGEST_PATTERN.test(record.factDigest) || + !DISPOSITIONS.includes( + record.disposition as LocalReconciliationReviewDisposition, + ) || + !REASONS.includes(record.reason as LocalReconciliationReviewReason) + ) { + configurationError('decision record is invalid'); + } + const reasonByDisposition: Record< + LocalReconciliationReviewDisposition, + LocalReconciliationReviewReason + > = { + retain_target: 'preserve_target', + adopt_legacy: 'prefer_legacy', + retain_both: 'preserve_both', + exclude_legacy: 'legacy_excluded', + defer: 'deferred_review', + manual_external: 'external_recovery_required', + }; + if ( + reasonByDisposition[ + record.disposition as LocalReconciliationReviewDisposition + ] !== record.reason + ) { + configurationError('decision reason does not match disposition'); + } + return Object.freeze({ + ...(record as unknown as LocalReconciliationReviewDecision), + kind: 'qinglong3-local-reconciliation-review-decision', + }); +} + +function normalizeCounts( + value: unknown, + keys: readonly T[], + label: string, +): Readonly> { + const record = exact(value, keys, label); + if ( + keys.some( + (key) => + !Number.isSafeInteger(record[key]) || (record[key] as number) < 0, + ) + ) { + configurationError(`${label} is invalid`); + } + return Object.freeze(record) as Readonly>; +} + +function parseSignature(value: unknown): Readonly { + const record = exact( + value, + [ + 'algorithm', + 'authenticationTag', + 'contentBytes', + 'contentDigest', + 'decisionCount', + 'decisionFileDigest', + 'decisionSetDigest', + 'dispositionCounts', + 'keyId', + 'kind', + 'reasonCounts', + 'schemaVersion', + ], + 'signature', + ); + let tag: Buffer | undefined; + try { + tag = + typeof record.authenticationTag === 'string' + ? Buffer.from(record.authenticationTag, 'base64url') + : Buffer.alloc(0); + if ( + record.schemaVersion !== 1 || + record.kind !== SIGNATURE_KIND || + record.algorithm !== 'hmac-sha256' || + typeof record.keyId !== 'string' || + !Number.isSafeInteger(record.contentBytes) || + (record.contentBytes as number) < 1 || + typeof record.contentDigest !== 'string' || + !DIGEST_PATTERN.test(record.contentDigest) || + !Number.isSafeInteger(record.decisionCount) || + (record.decisionCount as number) < 0 || + typeof record.decisionSetDigest !== 'string' || + !DIGEST_PATTERN.test(record.decisionSetDigest) || + typeof record.decisionFileDigest !== 'string' || + !DIGEST_PATTERN.test(record.decisionFileDigest) || + tag.byteLength !== 32 || + tag.toString('base64url') !== record.authenticationTag + ) { + configurationError('signature record is invalid'); + } + return Object.freeze({ + ...(record as unknown as SignatureRecord), + dispositionCounts: normalizeCounts( + record.dispositionCounts, + DISPOSITIONS, + 'disposition counts', + ), + reasonCounts: normalizeCounts( + record.reasonCounts, + REASONS, + 'reason counts', + ), + }); + } finally { + tag?.fill(0); + } +} + +export async function publishLocalReconciliationReviewAuthorization( + options: Readonly<{ + targetPath: string; + stagePath: string; + maxBytes: number; + header: Readonly; + keyProvider: LocalReconciliationReviewIssuerKeyringFileProvider; + writeDecisions: ( + append: (decision: Readonly) => void, + ) => Readonly<{ + readonly decisionFileDigest: string; + readonly confirmDecisionFileAuthority: () => void; + }>; + confirmAuthority: () => void | Promise; + }>, +): Promise> { + await options.confirmAuthority(); + const keyringBefore = options.keyProvider.inspect(); + let material: LocalSecretKeyMaterial | undefined; + let descriptor: number | undefined; + let created = false; + try { + material = await options.keyProvider.active(); + if ( + material.keyId !== keyringBefore.activeKeyId || + material.key.byteLength !== 32 + ) { + configurationError('active issuer key drifted'); + } + descriptor = fs.openSync( + options.stagePath, + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_WRONLY | + (fs.constants.O_NOFOLLOW ?? 0), + 0o600, + ); + created = true; + fs.fchmodSync(descriptor, 0o600); + const fileHash = createHash('sha256'); + const contentHash = createHash('sha256'); + const decisionHash = createHash('sha256').update( + 'qinglong3.local-reconciliation-review-decision-set.v1\0', + ); + let fileBytes = 0; + let contentBytes = 0; + let decisionCount = 0; + const dispositionCounts = zeroCounts(DISPOSITIONS); + const reasonCounts = zeroCounts(REASONS); + const writeContent = (value: unknown, isDecision: boolean): void => { + const line = canonicalLine(value); + try { + if (fileBytes + line.byteLength > options.maxBytes) { + configurationError('exceeds profile byte budget'); + } + writeAll(descriptor!, line); + fileHash.update(line); + contentHash.update(line); + contentBytes += line.byteLength; + fileBytes += line.byteLength; + if (isDecision) decisionHash.update(line); + } finally { + line.fill(0); + } + }; + writeContent(options.header, false); + const decisionFile = options.writeDecisions((selected) => { + const record = Object.freeze({ ...selected, kind: DECISION_KIND }); + writeContent(record, true); + decisionCount += 1; + dispositionCounts[selected.disposition] += 1; + reasonCounts[selected.reason] += 1; + }); + if (!DIGEST_PATTERN.test(decisionFile.decisionFileDigest)) { + configurationError('decision file evidence is invalid'); + } + await options.confirmAuthority(); + decisionFile.confirmDecisionFileAuthority(); + const keyringAfter = options.keyProvider.inspect(); + if ( + keyringAfter.keyringDigest !== keyringBefore.keyringDigest || + keyringAfter.activeKeyId !== material.keyId + ) { + configurationError('issuer authority changed while signing'); + } + const contentDigest = contentHash.digest('hex'); + const decisionSetDigest = decisionHash.digest('hex'); + const message = signingMessage( + contentDigest, + contentBytes, + decisionFile.decisionFileDigest, + ); + const authenticationTag = createHmac('sha256', material.key) + .update(message) + .digest('base64url'); + message.fill(0); + await options.confirmAuthority(); + decisionFile.confirmDecisionFileAuthority(); + const keyringSigned = options.keyProvider.inspect(); + if ( + keyringSigned.keyringDigest !== keyringBefore.keyringDigest || + keyringSigned.activeKeyId !== material.keyId + ) { + configurationError('issuer authority changed after signing'); + } + const signature: SignatureRecord = Object.freeze({ + schemaVersion: 1, + kind: SIGNATURE_KIND, + algorithm: 'hmac-sha256', + keyId: material.keyId, + contentBytes, + contentDigest, + decisionCount, + decisionFileDigest: decisionFile.decisionFileDigest, + decisionSetDigest, + dispositionCounts: Object.freeze(dispositionCounts), + reasonCounts: Object.freeze(reasonCounts), + authenticationTag, + }); + const signatureLine = canonicalLine(signature); + try { + if (fileBytes + signatureLine.byteLength > options.maxBytes) { + configurationError('exceeds profile byte budget'); + } + writeAll(descriptor, signatureLine); + fileHash.update(signatureLine); + fileBytes += signatureLine.byteLength; + } finally { + signatureLine.fill(0); + } + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + try { + fs.linkSync(options.stagePath, options.targetPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + configurationError('target already exists'); + } + syncDirectory(path.dirname(options.targetPath)); + fs.unlinkSync(options.stagePath); + created = false; + syncDirectory(path.dirname(options.stagePath)); + return Object.freeze({ + fileBytes, + authorizationDigest: fileHash.digest('hex'), + decisionCount, + decisionFileDigest: decisionFile.decisionFileDigest, + decisionSetDigest, + keyId: material.keyId, + dispositionCounts: Object.freeze(dispositionCounts), + reasonCounts: Object.freeze(reasonCounts), + header: options.header, + }); + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + return configurationError('cannot be published', error); + } finally { + wipe(material); + if (descriptor !== undefined) fs.closeSync(descriptor); + if (created) { + try { + fs.unlinkSync(options.stagePath); + } catch { + // A complete signed stage may be recovered; partial stages fail closed. + } + } + } +} + +function parseLine(line: Buffer): unknown { + if (line.byteLength < 2 || line.byteLength > MAX_LINE_BYTES) { + configurationError('line bound is invalid'); + } + try { + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(line)); + } catch (error) { + configurationError('line is not valid UTF-8 JSON', error); + } +} + +export async function verifyLocalReconciliationReviewAuthorization( + filePath: string, + options: Readonly<{ + maxBytes: number; + allowedModes: readonly number[]; + keyProvider: LocalReconciliationReviewIssuerKeyringFileProvider; + expected: Readonly<{ + reviewId: string; + profile: 'edge' | 'standalone'; + planDigest: string; + preparationDigest: string; + bundleDigest: string; + bundleFingerprintDigest: string; + preparedHeadDigest: string; + }>; + }>, +): Promise> { + const uid = process.getuid?.(); + if (!Number.isSafeInteger(uid) || uid !== process.geteuid?.()) { + configurationError('requires stable POSIX identity'); + } + const before = fs.lstatSync(filePath, { bigint: true }); + if ( + !before.isFile() || + before.isSymbolicLink() || + Number(before.uid) !== uid || + !options.allowedModes.includes(Number(before.mode) & 0o777) || + before.nlink !== 1n || + before.size < 2n || + before.size > BigInt(options.maxBytes) + ) { + configurationError('file identity or size is invalid'); + } + let descriptor: number | undefined; + let material: LocalSecretKeyMaterial | null = null; + try { + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = fs.fstatSync(descriptor, { bigint: true }); + if ( + opened.dev !== before.dev || + opened.ino !== before.ino || + opened.size !== before.size + ) { + configurationError('file changed while opening'); + } + const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES); + const fileHash = createHash('sha256'); + const contentHash = createHash('sha256'); + const decisionHash = createHash('sha256').update( + 'qinglong3.local-reconciliation-review-decision-set.v1\0', + ); + let offset = 0; + let length = 0; + let fileBytes = 0; + let contentBytes = 0; + const lineBuffer = Buffer.allocUnsafe(MAX_LINE_BYTES); + let lineLength = 0; + const nextLine = (): Buffer | null => { + while (true) { + if (offset >= length) { + length = fs.readSync(descriptor!, chunk, 0, chunk.byteLength, null); + offset = 0; + if (length === 0) { + if (lineLength !== 0) configurationError('must end with newline'); + return null; + } + fileBytes += length; + fileHash.update(chunk.subarray(0, length)); + } + const byte = chunk[offset++]!; + if (byte === 0x0a) { + const line = Buffer.from(lineBuffer.subarray(0, lineLength)); + lineLength = 0; + return line; + } + if (lineLength >= MAX_LINE_BYTES) + configurationError('line is too large'); + lineBuffer[lineLength] = byte; + lineLength += 1; + } + }; + const updateContent = ( + line: Buffer, + includeInDecisionSet: boolean, + ): void => { + contentHash.update(line).update('\n'); + contentBytes += line.byteLength + 1; + if (includeInDecisionSet) decisionHash.update(line).update('\n'); + }; + const first = nextLine(); + if (first === null) configurationError('header is absent'); + const normalizedHeader = normalizeHeader(parseLine(first)); + updateContent(first, false); + for (const [key, expected] of Object.entries(options.expected)) { + if ( + normalizedHeader[ + key as keyof LocalReconciliationReviewAuthorizationHeader + ] !== expected + ) { + configurationError('header binding drifted'); + } + } + const dispositionCounts = zeroCounts(DISPOSITIONS); + const reasonCounts = zeroCounts(REASONS); + let decisionCount = 0; + let pending = nextLine(); + if (pending === null) configurationError('signature is absent'); + while (true) { + const following = nextLine(); + if (following === null) break; + const selected = authorizationDecision(parseLine(pending)); + updateContent(pending, true); + decisionCount += 1; + dispositionCounts[selected.disposition] += 1; + reasonCounts[selected.reason] += 1; + pending.fill(0); + pending = following; + } + const signature = parseSignature(parseLine(pending)); + pending.fill(0); + const contentDigest = contentHash.digest('hex'); + const decisionSetDigest = decisionHash.digest('hex'); + if ( + signature.contentBytes !== contentBytes || + signature.contentDigest !== contentDigest || + signature.decisionCount !== decisionCount || + signature.decisionSetDigest !== decisionSetDigest || + JSON.stringify(signature.dispositionCounts) !== + JSON.stringify(dispositionCounts) || + JSON.stringify(signature.reasonCounts) !== JSON.stringify(reasonCounts) + ) { + configurationError('signed summary drifted'); + } + material = await options.keyProvider.resolve(signature.keyId); + if (!material || material.key.byteLength !== 32) + configurationError('issuer key is unavailable'); + const message = signingMessage( + contentDigest, + contentBytes, + signature.decisionFileDigest, + ); + const expectedTag = createHmac('sha256', material.key) + .update(message) + .digest(); + message.fill(0); + const actualTag = Buffer.from(signature.authenticationTag, 'base64url'); + const valid = timingSafeEqual(expectedTag, actualTag); + expectedTag.fill(0); + actualTag.fill(0); + if (!valid) configurationError('signature is invalid'); + const after = fs.fstatSync(descriptor, { bigint: true }); + const pathAfter = fs.lstatSync(filePath, { bigint: true }); + if ( + fileBytes !== Number(before.size) || + after.dev !== before.dev || + after.ino !== before.ino || + after.size !== before.size || + pathAfter.dev !== before.dev || + pathAfter.ino !== before.ino || + pathAfter.size !== before.size || + pathAfter.mtimeNs !== before.mtimeNs || + pathAfter.ctimeNs !== before.ctimeNs || + pathAfter.mode !== before.mode || + pathAfter.nlink !== before.nlink + ) { + configurationError('file changed while reading'); + } + return Object.freeze({ + fileBytes, + authorizationDigest: fileHash.digest('hex'), + decisionCount, + decisionFileDigest: signature.decisionFileDigest, + decisionSetDigest, + keyId: signature.keyId, + dispositionCounts: Object.freeze(dispositionCounts), + reasonCounts: Object.freeze(reasonCounts), + header: normalizedHeader, + }); + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + return configurationError('cannot be verified', error); + } finally { + wipe(material); + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/completion.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/completion.ts new file mode 100644 index 00000000..bb62da62 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/completion.ts @@ -0,0 +1,976 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { readPrivateLocalCommandFile } from '@qinglong/local-command-file'; +import { + establishAuthenticatedLocalCommand, + type AuthenticatedLocalCommand, +} from '@qinglong/local-owner-console/authenticated-command'; +import { openLocalSqliteAuthenticationReadDatabase } from '@qinglong/local-sqlite/authentication-read'; + +import { currentIdentity } from '../../foundation/contract'; +import { LocalDeploymentConfigurationError } from '../../foundation/error'; +import { + publishExactFile, + validatePrivateDirectory, +} from '../../foundation/files'; +import { cutoverDigest } from '../../cutover/targetEvidence'; +import { + advanceLocalCutoverInstanceHead, + readLocalCutoverInstanceHead, + type LocalCutoverInstanceHead, +} from '../../cutover/instanceLineage'; +import { readLocalReconciliationCaptureIntent } from '../preparation'; +import { readLocalReconciliationPlanTerminal } from '../planning/preparation'; +import { withLocalReconciliationSealedDatabase } from '../sealed-bundle/reader'; +import { + publishLocalReconciliationReviewAuthorization, + verifyLocalReconciliationReviewAuthorization, + type LocalReconciliationReviewAuthorizationEvidence, + type LocalReconciliationReviewAuthorizationHeader, +} from './authorization'; +import { + normalizeLocalReconciliationReviewCommitCommand, + normalizeLocalReconciliationReviewVerifyCommand, + type LocalReconciliationReviewCommitCommand, + type LocalReconciliationReviewTerminalResult, + type LocalReconciliationReviewVerifyCommand, +} from './completionContract'; +import { + assertLocalReconciliationReviewDecisionMatchesFact, + MAX_EDGE_LOCAL_RECONCILIATION_REVIEW_DECISION_BYTES, + MAX_STANDALONE_LOCAL_RECONCILIATION_REVIEW_DECISION_BYTES, + withLocalReconciliationReviewDecisionFile, +} from './decisionFile'; +import { visitLocalReconciliationDiagnosticFacts } from './diagnostics'; +import { + ensureLocalReconciliationReviewIssuerKeyring, + LocalReconciliationReviewIssuerKeyringFileProvider, +} from './issuerKeyring'; +import { + normalizeLocalReconciliationReviewIntent, + type LocalReconciliationReviewDependencies, + type LocalReconciliationReviewIntent, +} from './preparation'; +import { + buildLocalReconciliationReview, + buildLocalReconciliationReviewReceipt, + normalizeLocalReconciliationReview, + normalizeLocalReconciliationReviewReceipt, + readLocalReconciliationReviewTerminalJson, + terminalEvidenceContents, + type LocalReconciliationReview, + type LocalReconciliationReviewReceipt, +} from './terminalEvidence'; + +const MAX_AUTHENTICATION_AGE_MS = 5 * 60 * 1_000; +const COMMIT_CLOCK_SKEW_MS = 60_000; + +interface ReviewPaths { + readonly root: string; + readonly staging: string; + readonly intent: string; + readonly authorization: string; + readonly authorizationStage: string; + readonly review: string; + readonly receipt: string; +} + +type AuthenticationDatabase = Awaited< + ReturnType +>; + +export interface LocalReconciliationReviewCompletionDependencies + extends LocalReconciliationReviewDependencies { + readonly openAuthenticationDatabase?: typeof openLocalSqliteAuthenticationReadDatabase; + readonly authenticate?: typeof establishAuthenticatedLocalCommand; + readonly now?: () => number; + readonly afterAuthorizationPublished?: () => void; + readonly afterReviewPublished?: () => void; + readonly afterReceiptPublished?: () => void; + readonly afterTerminalSealed?: () => void; + readonly afterHeadAdvanced?: () => void; +} + +interface TerminalReview { + readonly intent: Readonly; + readonly authorization: Readonly; + readonly review: Readonly; + readonly receipt: Readonly; + readonly head: Readonly; +} + +function configurationError(message: string, cause?: unknown): never { + throw new LocalDeploymentConfigurationError(message, { cause }); +} + +function paths(reviewRoot: string, reviewId: string): Readonly { + const root = path.join(reviewRoot, reviewId); + const staging = path.join(root, 'staging'); + return Object.freeze({ + root, + staging, + intent: path.join(root, 'intent.json'), + authorization: path.join(root, 'authorization.ndjson'), + authorizationStage: path.join(staging, 'authorization.ndjson.stage'), + review: path.join(root, 'review.json'), + receipt: path.join(root, 'receipt.json'), + }); +} + +function validateDirectory( + directory: string, + uid: number, + modes: readonly number[], + label: string, +): number { + let stat: fs.Stats; + try { + stat = fs.lstatSync(directory); + } catch (error) { + configurationError(`${label} is unavailable`, error); + } + const mode = stat.mode & 0o777; + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + stat.uid !== uid || + !modes.includes(mode) || + fs.realpathSync(directory) !== directory + ) { + configurationError(`${label} identity is invalid`); + } + return mode; +} + +function validateCatalog( + selected: Readonly, + terminal: boolean, +): void { + const allowed = new Set([ + 'authorization.ndjson', + 'intent.json', + 'receipt.json', + 'review.json', + 'staging', + ...(!terminal + ? [ + '.intent.json.ql3-deploy-stage', + '.receipt.json.ql3-deploy-stage', + '.review.json.ql3-deploy-stage', + ] + : []), + ]); + for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) { + if (!allowed.has(entry.name) || entry.isSymbolicLink()) { + configurationError( + 'reconciliation review root contains unknown material', + ); + } + } + const stagingEntries = fs.readdirSync(selected.staging); + if ( + terminal + ? stagingEntries.length !== 0 + : stagingEntries.some((entry) => entry !== 'authorization.ndjson.stage') + ) { + configurationError( + 'reconciliation review staging contains unknown material', + ); + } +} + +function readIntent( + selected: Readonly, + uid: number, +): Readonly { + return normalizeLocalReconciliationReviewIntent( + readLocalReconciliationReviewTerminalJson( + selected.intent, + uid, + [0o600, 0o400], + ), + ); +} + +function validateIntentBinding( + command: Readonly, + intent: Readonly, +): void { + if ( + intent.command.options.deploymentRoot !== command.options.deploymentRoot || + intent.command.options.captureRoot !== command.options.captureRoot || + intent.command.options.planRoot !== command.options.planRoot || + intent.command.options.reviewRoot !== command.options.reviewRoot || + intent.command.options.allowRootService !== + command.options.allowRootService || + intent.command.request.reviewId !== command.request.reviewId || + intent.preparationDigest !== command.request.expectedPreparationDigest + ) { + configurationError('review commit is detached from its preparation'); + } +} + +function validateHeadIdentity( + head: Readonly, + intent: Readonly, +): void { + if ( + head.profile !== intent.profile || + head.cutoverId !== intent.cutoverId || + head.activationDigest !== intent.activationDigest || + head.generation !== intent.generation + ) { + configurationError('reconciliation review instance head identity drifted'); + } +} + +function expectedAuthorization( + intent: Readonly, + preparedHeadDigest: string, +): Readonly<{ + reviewId: string; + profile: 'edge' | 'standalone'; + planDigest: string; + preparationDigest: string; + bundleDigest: string; + bundleFingerprintDigest: string; + preparedHeadDigest: string; +}> { + return Object.freeze({ + reviewId: intent.command.request.reviewId, + profile: intent.profile, + planDigest: intent.command.request.expectedPlanDigest, + preparationDigest: intent.preparationDigest, + bundleDigest: intent.bundleDigest, + bundleFingerprintDigest: intent.bundleFingerprintDigest, + preparedHeadDigest, + }); +} + +function maxAuthorizationBytes(profile: 'edge' | 'standalone'): number { + return profile === 'edge' + ? MAX_EDGE_LOCAL_RECONCILIATION_REVIEW_DECISION_BYTES + : MAX_STANDALONE_LOCAL_RECONCILIATION_REVIEW_DECISION_BYTES; +} + +function strongPrincipal( + authenticated: Readonly, + committedAtMs: number, +): Readonly { + const principal = authenticated.principal; + if ( + principal.subject.type !== 'user' || + !['hardware', 'local_console', 'multi_factor'].includes( + principal.assurance, + ) || + principal.authenticatedAtMs > committedAtMs || + committedAtMs - principal.authenticatedAtMs > MAX_AUTHENTICATION_AGE_MS || + principal.expiresAtMs <= committedAtMs + ) { + configurationError( + 'review commit requires a recent strongly authenticated User', + ); + } + return principal; +} + +function advanceReviewedHead( + intent: Readonly, + committedAtMs: number, + reviewDigest: string, + uid: number, +): Readonly { + return advanceLocalCutoverInstanceHead( + { + options: { deploymentRoot: intent.command.options.deploymentRoot }, + request: { + cutoverId: intent.cutoverId, + profile: intent.profile, + instanceId: intent.instanceId, + expectedActivationDigest: intent.activationDigest, + requestedAtMs: committedAtMs, + }, + }, + uid, + 'reconciliation_reviewed', + intent.generation, + reviewDigest, + ); +} + +function sealFile(filePath: string, uid: number): void { + let descriptor: number | undefined; + try { + const before = fs.lstatSync(filePath, { bigint: true }); + if ( + !before.isFile() || + before.isSymbolicLink() || + Number(before.uid) !== uid || + ![0o600, 0o400].includes(Number(before.mode) & 0o777) || + before.nlink !== 1n + ) { + configurationError('review terminal file cannot be sealed'); + } + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = fs.fstatSync(descriptor, { bigint: true }); + if ( + opened.dev !== before.dev || + opened.ino !== before.ino || + opened.size !== before.size + ) { + configurationError('review terminal file changed while sealing'); + } + if ((Number(opened.mode) & 0o777) !== 0o400) + fs.fchmodSync(descriptor, 0o400); + fs.fsyncSync(descriptor); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function sealDirectory(directory: string, uid: number): void { + const mode = validateDirectory( + directory, + uid, + [0o700, 0o500], + 'review terminal directory', + ); + const descriptor = fs.openSync(directory, fs.constants.O_RDONLY); + try { + if (mode !== 0o500) fs.fchmodSync(descriptor, 0o500); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function sealTerminal(selected: Readonly, uid: number): void { + if (fs.readdirSync(selected.staging).length !== 0) { + configurationError('review staging must be empty before terminal seal'); + } + for (const filePath of [ + selected.intent, + selected.authorization, + selected.review, + selected.receipt, + ]) { + sealFile(filePath, uid); + } + sealDirectory(selected.staging, uid); + sealDirectory(selected.root, uid); + validateCatalog(selected, true); +} + +function syncDirectory(directory: string): void { + const descriptor = fs.openSync(directory, fs.constants.O_RDONLY); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +async function recoverAuthorizationStage( + selected: Readonly, + intent: Readonly, + issuerKeyringPath: string, + preparedHeadDigest: string, +): Promise { + if (!fs.existsSync(selected.authorizationStage)) return; + if (fs.existsSync(selected.authorization)) { + const stage = fs.statSync(selected.authorizationStage, { bigint: true }); + const target = fs.statSync(selected.authorization, { bigint: true }); + if (stage.dev !== target.dev || stage.ino !== target.ino) { + configurationError('authorization stage conflicts with published target'); + } + fs.unlinkSync(selected.authorizationStage); + syncDirectory(selected.staging); + await verifyLocalReconciliationReviewAuthorization(selected.authorization, { + maxBytes: maxAuthorizationBytes(intent.profile), + allowedModes: [0o600], + keyProvider: new LocalReconciliationReviewIssuerKeyringFileProvider( + issuerKeyringPath, + ), + expected: expectedAuthorization(intent, preparedHeadDigest), + }); + return; + } + await verifyLocalReconciliationReviewAuthorization( + selected.authorizationStage, + { + maxBytes: maxAuthorizationBytes(intent.profile), + allowedModes: [0o600], + keyProvider: new LocalReconciliationReviewIssuerKeyringFileProvider( + issuerKeyringPath, + ), + expected: expectedAuthorization(intent, preparedHeadDigest), + }, + ); + fs.linkSync(selected.authorizationStage, selected.authorization); + syncDirectory(selected.root); + fs.unlinkSync(selected.authorizationStage); + syncDirectory(selected.staging); +} + +function validateReviewBinding( + intent: Readonly, + authorization: Readonly, + review: Readonly, + receipt: Readonly, +): void { + const reviewerDigest = cutoverDigest({ + subject: authorization.header.reviewer.subject, + authenticationId: authorization.header.reviewer.authenticationId, + authenticatedAtMs: authorization.header.reviewer.authenticatedAtMs, + assurance: authorization.header.reviewer.assurance, + }); + if ( + review.reviewId !== intent.command.request.reviewId || + review.profile !== intent.profile || + review.planDigest !== intent.command.request.expectedPlanDigest || + review.preparationDigest !== intent.preparationDigest || + review.bundleDigest !== intent.bundleDigest || + review.bundleFingerprintDigest !== intent.bundleFingerprintDigest || + review.authorizationDigest !== authorization.authorizationDigest || + review.decisionFileDigest !== authorization.decisionFileDigest || + review.decisionSetDigest !== authorization.decisionSetDigest || + review.decisionCount !== authorization.decisionCount || + review.preparedHeadDigest !== authorization.header.preparedHeadDigest || + review.issuedAtMs !== authorization.header.issuedAtMs || + review.expiresAtMs !== authorization.header.expiresAtMs || + review.committedAtMs !== authorization.header.issuedAtMs || + review.reviewerDigest !== reviewerDigest || + JSON.stringify(review.dispositionCounts) !== + JSON.stringify(authorization.dispositionCounts) || + JSON.stringify(review.reasonCounts) !== + JSON.stringify(authorization.reasonCounts) || + receipt.reviewId !== review.reviewId || + receipt.planDigest !== review.planDigest || + receipt.preparationDigest !== review.preparationDigest || + receipt.authorizationDigest !== review.authorizationDigest || + receipt.decisionSetDigest !== review.decisionSetDigest || + receipt.decisionCount !== review.decisionCount || + receipt.keyId !== authorization.keyId || + receipt.reviewDigest !== review.reviewDigest || + receipt.committedAtMs !== review.committedAtMs + ) { + configurationError('terminal reconciliation review binding drifted'); + } +} + +async function readTerminal( + selected: Readonly, + intent: Readonly, + issuerKeyringPath: string, + uid: number, + allowedModes: readonly number[], +): Promise< + Readonly<{ + authorization: Readonly; + review: Readonly; + receipt: Readonly; + }> +> { + const review = normalizeLocalReconciliationReview( + readLocalReconciliationReviewTerminalJson( + selected.review, + uid, + allowedModes, + ), + ); + const receipt = normalizeLocalReconciliationReviewReceipt( + readLocalReconciliationReviewTerminalJson( + selected.receipt, + uid, + allowedModes, + ), + ); + const authorization = await verifyLocalReconciliationReviewAuthorization( + selected.authorization, + { + maxBytes: maxAuthorizationBytes(intent.profile), + allowedModes, + keyProvider: new LocalReconciliationReviewIssuerKeyringFileProvider( + issuerKeyringPath, + ), + expected: expectedAuthorization(intent, review.preparedHeadDigest), + }, + ); + validateReviewBinding(intent, authorization, review, receipt); + return Object.freeze({ authorization, review, receipt }); +} + +function result( + operation: LocalReconciliationReviewTerminalResult['operation'], + status: LocalReconciliationReviewTerminalResult['status'], + terminal: Readonly<{ + authorization: Readonly; + review: Readonly; + }>, + head: Readonly, +): Readonly { + return Object.freeze({ + schemaVersion: 1, + operation, + status, + state: 'reconciliation_reviewed', + reviewId: terminal.review.reviewId, + reviewDigest: terminal.review.reviewDigest, + authorizationDigest: terminal.authorization.authorizationDigest, + decisionSetDigest: terminal.authorization.decisionSetDigest, + decisionCount: terminal.authorization.decisionCount, + instanceHeadDigest: head.headDigest, + }); +} + +async function confirmCurrentAuthority( + authenticated: Readonly, + command: Readonly, + intent: Readonly, + uid: number, +): Promise { + await authenticated.confirm(); + const terminal = readLocalReconciliationPlanTerminal( + command.options.planRoot, + intent.command.request.planId, + uid, + ); + if ( + terminal.plan.planDigest !== intent.command.request.expectedPlanDigest || + terminal.plan.bundleDigest !== intent.bundleDigest || + terminal.bundle.fingerprintDigest !== intent.bundleFingerprintDigest + ) { + configurationError('review authority lost exact plan or bundle binding'); + } + const currentHead = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + uid, + ); + validateHeadIdentity(currentHead, intent); + if ( + currentHead.state !== 'reconciliation_review_prepared' || + currentHead.headDigest !== command.request.expectedHeadDigest || + currentHead.sourceRecordDigest !== intent.preparationDigest + ) { + configurationError('review authority lost prepared head binding'); + } +} + +async function publishAuthorization( + command: Readonly, + intent: Readonly, + selected: Readonly, + head: Readonly, + terminal: ReturnType, + authenticated: Readonly, + dependencies: LocalReconciliationReviewCompletionDependencies, + uid: number, +): Promise> { + const reviewer = strongPrincipal( + authenticated, + command.request.committedAtMs, + ); + ensureLocalReconciliationReviewIssuerKeyring( + command.options.issuerKeyringPath, + ); + const keyProvider = new LocalReconciliationReviewIssuerKeyringFileProvider( + command.options.issuerKeyringPath, + ); + const header: Readonly = + Object.freeze({ + schemaVersion: 1, + kind: 'qinglong3-local-reconciliation-review-authorization-header', + reviewId: command.request.reviewId, + profile: intent.profile, + planDigest: terminal.plan.planDigest, + preparationDigest: intent.preparationDigest, + bundleDigest: terminal.bundle.receipt.bundleDigest, + bundleFingerprintDigest: terminal.bundle.fingerprintDigest, + preparedHeadDigest: head.headDigest, + reviewer, + issuedAtMs: command.request.committedAtMs, + expiresAtMs: + command.request.committedAtMs + command.request.authorizationLifetimeMs, + }); + return publishLocalReconciliationReviewAuthorization({ + targetPath: selected.authorization, + stagePath: selected.authorizationStage, + maxBytes: maxAuthorizationBytes(intent.profile), + header, + keyProvider, + confirmAuthority: () => + confirmCurrentAuthority(authenticated, command, intent, uid), + writeDecisions: (append) => { + const reviewed = withLocalReconciliationReviewDecisionFile( + command.request.decisionFilePath, + { + reviewId: command.request.reviewId, + profile: intent.profile, + planDigest: terminal.plan.planDigest, + preparationDigest: intent.preparationDigest, + }, + (cursor) => { + for (const database of ['legacy', 'target'] as const) { + const opened = withLocalReconciliationSealedDatabase( + terminal.bundle, + database, + uid, + dependencies, + (client) => + visitLocalReconciliationDiagnosticFacts( + client, + database, + (fact) => { + if (fact.decisionRequirement === 'informational') return; + const decision = cursor.next(); + if (decision === null) { + configurationError( + 'review decision file omitted a canonical fact', + ); + } + assertLocalReconciliationReviewDecisionMatchesFact( + decision, + fact, + ); + append(decision); + }, + ), + ); + if (opened === null) { + configurationError( + 'manual-required SQLite topology cannot be reviewed', + ); + } + } + }, + ); + return Object.freeze({ + decisionFileDigest: reviewed.evidence.fileDigest, + confirmDecisionFileAuthority: reviewed.confirmIdentity, + }); + }, + }); +} + +export async function commitLocalReconciliationReview( + input: unknown, + dependencies: LocalReconciliationReviewCompletionDependencies = {}, +): Promise> { + const command = normalizeLocalReconciliationReviewCommitCommand(input); + const uid = currentIdentity().uid; + validatePrivateDirectory( + command.options.deploymentRoot, + uid, + 'deploymentRoot', + ); + validatePrivateDirectory(command.options.captureRoot, uid, 'captureRoot'); + validatePrivateDirectory(command.options.planRoot, uid, 'planRoot'); + validatePrivateDirectory(command.options.reviewRoot, uid, 'reviewRoot'); + const selected = paths(command.options.reviewRoot, command.request.reviewId); + validateDirectory( + selected.root, + uid, + [0o700, 0o500], + 'reconciliationReviewDirectory', + ); + validateDirectory( + selected.staging, + uid, + [0o700, 0o500], + 'reconciliationReviewStaging', + ); + const intent = readIntent(selected, uid); + validateIntentBinding(command, intent); + const terminalPlan = readLocalReconciliationPlanTerminal( + command.options.planRoot, + intent.command.request.planId, + uid, + ); + if ( + terminalPlan.plan.planDigest !== + intent.command.request.expectedPlanDigest || + terminalPlan.plan.bundleDigest !== intent.bundleDigest || + terminalPlan.bundle.fingerprintDigest !== intent.bundleFingerprintDigest + ) { + configurationError('review commit lost terminal plan binding'); + } + const captureIntent = readLocalReconciliationCaptureIntent( + command.options.captureRoot, + terminalPlan.plan.captureId, + ); + if ( + captureIntent.command.request.targetDatabasePath !== + command.options.targetDatabasePath + ) { + configurationError( + 'authentication database is detached from sealed target', + ); + } + let head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + uid, + ); + validateHeadIdentity(head, intent); + if (fs.existsSync(selected.receipt)) { + const terminal = await readTerminal( + selected, + intent, + command.options.issuerKeyringPath, + uid, + [0o600, 0o400], + ); + if ( + terminal.review.committedAtMs !== command.request.committedAtMs || + terminal.authorization.header.expiresAtMs !== + command.request.committedAtMs + + command.request.authorizationLifetimeMs || + terminal.review.preparedHeadDigest !== + command.request.expectedHeadDigest || + (head.state !== 'reconciliation_review_prepared' && + head.state !== 'reconciliation_reviewed') || + (head.state === 'reconciliation_review_prepared' && + head.sourceRecordDigest !== intent.preparationDigest) || + (head.state === 'reconciliation_reviewed' && + head.sourceRecordDigest !== terminal.review.reviewDigest) + ) { + configurationError('terminal review lost instance head binding'); + } + sealTerminal(selected, uid); + const wasReviewed = head.state === 'reconciliation_reviewed'; + head = + head.state === 'reconciliation_reviewed' + ? head + : advanceReviewedHead( + intent, + terminal.review.committedAtMs, + terminal.review.reviewDigest, + uid, + ); + dependencies.afterHeadAdvanced?.(); + return result( + command.operation, + wasReviewed ? 'existing' : 'prepared', + terminal, + head, + ); + } + if ( + head.state !== 'reconciliation_review_prepared' || + head.headDigest !== command.request.expectedHeadDigest || + head.sourceRecordDigest !== intent.preparationDigest + ) { + configurationError( + 'review commit lost prepared instance head compare-and-swap', + ); + } + validateCatalog(selected, false); + await recoverAuthorizationStage( + selected, + intent, + command.options.issuerKeyringPath, + command.request.expectedHeadDigest, + ); + let authorization: Readonly; + if (fs.existsSync(selected.authorization)) { + authorization = await verifyLocalReconciliationReviewAuthorization( + selected.authorization, + { + maxBytes: maxAuthorizationBytes(intent.profile), + allowedModes: [0o600, 0o400], + keyProvider: new LocalReconciliationReviewIssuerKeyringFileProvider( + command.options.issuerKeyringPath, + ), + expected: expectedAuthorization( + intent, + command.request.expectedHeadDigest, + ), + }, + ); + } else { + const now = (dependencies.now ?? Date.now)(); + if ( + !Number.isSafeInteger(now) || + Math.abs(now - command.request.committedAtMs) > COMMIT_CLOCK_SKEW_MS + ) { + configurationError( + 'review commit timestamp is outside its bounded clock window', + ); + } + const openDatabase = + dependencies.openAuthenticationDatabase ?? + openLocalSqliteAuthenticationReadDatabase; + const database: AuthenticationDatabase = await openDatabase({ + databasePath: command.options.targetDatabasePath, + profile: intent.profile, + ...(command.options.busyTimeoutMs === undefined + ? {} + : { busyTimeoutMs: command.options.busyTimeoutMs }), + }); + try { + const authenticate = + dependencies.authenticate ?? establishAuthenticatedLocalCommand; + const authenticated = await authenticate(database, { + deploymentRoot: command.options.deploymentRoot, + databasePath: command.options.targetDatabasePath, + ownerPepperKeyringDirectory: + command.options.ownerPepperKeyringDirectory, + credentialFilePath: command.options.credentialFilePath, + authenticationNamespace: 'local_reconciliation_review', + now: () => command.request.committedAtMs, + }); + authorization = await publishAuthorization( + command, + intent, + selected, + head, + terminalPlan, + authenticated, + dependencies, + uid, + ); + dependencies.afterAuthorizationPublished?.(); + } finally { + await database.close(); + } + } + if ( + authorization.header.issuedAtMs !== command.request.committedAtMs || + authorization.header.expiresAtMs !== + command.request.committedAtMs + command.request.authorizationLifetimeMs + ) { + configurationError( + 'review authorization lifetime is detached from command', + ); + } + const review = buildLocalReconciliationReview({ + authorization, + decisionFileDigest: authorization.decisionFileDigest, + committedAtMs: command.request.committedAtMs, + }); + publishExactFile( + selected.review, + terminalEvidenceContents(review), + 0o600, + uid, + 'reconciliation review', + ); + dependencies.afterReviewPublished?.(); + const receipt = buildLocalReconciliationReviewReceipt( + review, + authorization.keyId, + ); + publishExactFile( + selected.receipt, + terminalEvidenceContents(receipt), + 0o600, + uid, + 'reconciliation review receipt', + ); + dependencies.afterReceiptPublished?.(); + validateReviewBinding(intent, authorization, review, receipt); + sealTerminal(selected, uid); + dependencies.afterTerminalSealed?.(); + head = advanceReviewedHead( + intent, + review.committedAtMs, + review.reviewDigest, + uid, + ); + dependencies.afterHeadAdvanced?.(); + return result(command.operation, 'prepared', { authorization, review }, head); +} + +export async function verifyLocalReconciliationReview( + input: unknown, +): Promise> { + const command = normalizeLocalReconciliationReviewVerifyCommand(input); + const uid = currentIdentity().uid; + validatePrivateDirectory( + command.options.deploymentRoot, + uid, + 'deploymentRoot', + ); + validatePrivateDirectory(command.options.captureRoot, uid, 'captureRoot'); + validatePrivateDirectory(command.options.planRoot, uid, 'planRoot'); + validatePrivateDirectory(command.options.reviewRoot, uid, 'reviewRoot'); + const selected = paths(command.options.reviewRoot, command.request.reviewId); + validateDirectory( + selected.root, + uid, + [0o500], + 'reconciliationReviewDirectory', + ); + validateDirectory( + selected.staging, + uid, + [0o500], + 'reconciliationReviewStaging', + ); + validateCatalog(selected, true); + const intent = readIntent(selected, uid); + if ( + intent.command.options.deploymentRoot !== command.options.deploymentRoot || + intent.command.options.captureRoot !== command.options.captureRoot || + intent.command.options.planRoot !== command.options.planRoot || + intent.command.options.reviewRoot !== command.options.reviewRoot || + intent.command.options.allowRootService !== + command.options.allowRootService || + intent.command.request.reviewId !== command.request.reviewId + ) { + configurationError('review verify is detached from preparation'); + } + const terminalPlan = readLocalReconciliationPlanTerminal( + command.options.planRoot, + intent.command.request.planId, + uid, + ); + if ( + terminalPlan.plan.planDigest !== + intent.command.request.expectedPlanDigest || + terminalPlan.plan.bundleDigest !== intent.bundleDigest || + terminalPlan.bundle.fingerprintDigest !== intent.bundleFingerprintDigest + ) { + configurationError('review verify lost terminal plan binding'); + } + const terminal = await readTerminal( + selected, + intent, + command.options.issuerKeyringPath, + uid, + [0o400], + ); + if (terminal.review.reviewDigest !== command.request.expectedReviewDigest) { + configurationError('review verify digest mismatch'); + } + const head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + uid, + ); + validateHeadIdentity(head, intent); + if ( + head.state !== 'reconciliation_reviewed' || + head.sourceRecordDigest !== terminal.review.reviewDigest + ) { + configurationError('review verify lost reviewed instance head'); + } + return result(command.operation, 'verified', terminal, head); +} + +export async function commitLocalReconciliationReviewCommandFile( + filePath: string, +): Promise> { + return commitLocalReconciliationReview(readPrivateLocalCommandFile(filePath)); +} + +export async function verifyLocalReconciliationReviewCommandFile( + filePath: string, +): Promise> { + return verifyLocalReconciliationReview(readPrivateLocalCommandFile(filePath)); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/completionContract.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/completionContract.ts new file mode 100644 index 00000000..57e24b05 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/completionContract.ts @@ -0,0 +1,361 @@ +import path from 'node:path'; + +import { currentIdentity } from '../../foundation/contract'; +import { LocalDeploymentConfigurationError } from '../../foundation/error'; +import type { LocalReconciliationReviewOptions } from './contract'; + +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; +const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/; +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 MAX_PATH_BYTES = 4_096; +export const MAX_LOCAL_RECONCILIATION_REVIEW_AUTHORIZATION_LIFETIME_MS = + 30 * 60 * 1_000; + +export interface LocalReconciliationReviewCommitOptions + extends LocalReconciliationReviewOptions { + readonly targetDatabasePath: string; + readonly ownerPepperKeyringDirectory: string; + readonly credentialFilePath: string; + readonly issuerKeyringPath: string; + readonly busyTimeoutMs?: number; +} + +export interface LocalReconciliationReviewCommitCommand { + readonly schemaVersion: 1; + readonly operation: 'local.deployment.reconciliation.review.commit'; + readonly options: Readonly; + readonly request: Readonly<{ + reviewId: string; + expectedPreparationDigest: string; + expectedHeadDigest: string; + decisionFilePath: string; + committedAtMs: number; + authorizationLifetimeMs: number; + }>; +} + +export interface LocalReconciliationReviewVerifyCommand { + readonly schemaVersion: 1; + readonly operation: 'local.deployment.reconciliation.review.verify'; + readonly options: Readonly< + LocalReconciliationReviewOptions & { readonly issuerKeyringPath: string } + >; + readonly request: Readonly<{ + reviewId: string; + expectedReviewDigest: string; + }>; +} + +export interface LocalReconciliationReviewTerminalResult { + readonly schemaVersion: 1; + readonly operation: + | 'local.deployment.reconciliation.review.commit' + | 'local.deployment.reconciliation.review.verify'; + readonly status: 'prepared' | 'existing' | 'verified'; + readonly state: 'reconciliation_reviewed'; + readonly reviewId: string; + readonly reviewDigest: string; + readonly authorizationDigest: string; + readonly decisionSetDigest: string; + readonly decisionCount: number; + readonly instanceHeadDigest: string; +} + +function configurationError(message: string): never { + throw new LocalDeploymentConfigurationError(message); +} + +function object(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + configurationError(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + 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]) + ) { + configurationError(`${label} shape is invalid`); + } +} + +function safePath(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + !path.isAbsolute(value) || + path.parse(value).root === value || + path.normalize(value) !== value || + value.includes('\0') || + value.includes('//') || + !SAFE_PATH_PATTERN.test(value) || + Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES + ) { + configurationError(`${label} must be a safe non-root absolute path`); + } + return value; +} + +function digest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + configurationError(`${label} must be a SHA-256 digest`); + } + return value; +} + +function reviewId(value: unknown): string { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { + configurationError('reviewId must be a UUID v4'); + } + return value; +} + +function descendant(root: string, candidate: string, label: string): void { + const relative = path.relative(root, candidate); + if ( + relative.length === 0 || + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + configurationError(`${label} must be a descendant of deploymentRoot`); + } +} + +function overlaps(left: string, right: string): boolean { + const relative = path.relative(left, right); + return ( + relative === '' || + (!relative.startsWith('..') && !path.isAbsolute(relative)) + ); +} + +function roots( + value: Record, +): Readonly { + const identity = currentIdentity(); + if ( + typeof value.allowRootService !== 'boolean' || + (identity.uid === 0) !== value.allowRootService + ) { + configurationError('reconciliation review command identity is invalid'); + } + const selected = [ + safePath(value.deploymentRoot, 'deploymentRoot'), + safePath(value.captureRoot, 'captureRoot'), + safePath(value.planRoot, 'planRoot'), + safePath(value.reviewRoot, 'reviewRoot'), + ]; + for (let left = 0; left < selected.length; left += 1) { + for (let right = left + 1; right < selected.length; right += 1) { + if ( + overlaps(selected[left]!, selected[right]!) || + overlaps(selected[right]!, selected[left]!) + ) { + configurationError( + 'deployment, capture, plan and review roots must not overlap', + ); + } + } + } + return Object.freeze({ + deploymentRoot: selected[0]!, + captureRoot: selected[1]!, + planRoot: selected[2]!, + reviewRoot: selected[3]!, + allowRootService: value.allowRootService, + }); +} + +export function normalizeLocalReconciliationReviewCommitCommand( + value: unknown, +): Readonly { + const command = object(value, 'reconciliation review commit command'); + exact( + command, + ['operation', 'options', 'request', 'schemaVersion'], + 'command', + ); + if ( + command.schemaVersion !== 1 || + command.operation !== 'local.deployment.reconciliation.review.commit' + ) { + configurationError('reconciliation review commit command is invalid'); + } + const options = object(command.options, 'options'); + const hasBusyTimeout = Object.hasOwn(options, 'busyTimeoutMs'); + exact( + options, + [ + 'allowRootService', + 'captureRoot', + 'credentialFilePath', + 'deploymentRoot', + 'issuerKeyringPath', + 'ownerPepperKeyringDirectory', + 'planRoot', + 'reviewRoot', + 'targetDatabasePath', + ...(hasBusyTimeout ? ['busyTimeoutMs'] : []), + ], + 'options', + ); + const normalizedRoots = roots(options); + const targetDatabasePath = safePath( + options.targetDatabasePath, + 'targetDatabasePath', + ); + const ownerPepperKeyringDirectory = safePath( + options.ownerPepperKeyringDirectory, + 'ownerPepperKeyringDirectory', + ); + const credentialFilePath = safePath( + options.credentialFilePath, + 'credentialFilePath', + ); + const issuerKeyringPath = safePath( + options.issuerKeyringPath, + 'issuerKeyringPath', + ); + for (const [label, selected] of [ + ['ownerPepperKeyringDirectory', ownerPepperKeyringDirectory], + ['credentialFilePath', credentialFilePath], + ['issuerKeyringPath', issuerKeyringPath], + ] as const) { + descendant(normalizedRoots.deploymentRoot, selected, label); + } + if ( + options.busyTimeoutMs !== undefined && + (!Number.isSafeInteger(options.busyTimeoutMs) || + (options.busyTimeoutMs as number) < 100 || + (options.busyTimeoutMs as number) > 30_000) + ) { + configurationError('busyTimeoutMs is invalid'); + } + const request = object(command.request, 'request'); + exact( + request, + [ + 'authorizationLifetimeMs', + 'committedAtMs', + 'decisionFilePath', + 'expectedHeadDigest', + 'expectedPreparationDigest', + 'reviewId', + ], + 'request', + ); + const decisionFilePath = safePath( + request.decisionFilePath, + 'decisionFilePath', + ); + if ( + [ + normalizedRoots.deploymentRoot, + normalizedRoots.captureRoot, + normalizedRoots.planRoot, + normalizedRoots.reviewRoot, + ].some( + (root) => + overlaps(root, decisionFilePath) || overlaps(decisionFilePath, root), + ) || + !Number.isSafeInteger(request.committedAtMs) || + (request.committedAtMs as number) < 0 || + !Number.isSafeInteger(request.authorizationLifetimeMs) || + (request.authorizationLifetimeMs as number) < 1 || + (request.authorizationLifetimeMs as number) > + MAX_LOCAL_RECONCILIATION_REVIEW_AUTHORIZATION_LIFETIME_MS + ) { + configurationError('reconciliation review commit request is invalid'); + } + return Object.freeze({ + schemaVersion: 1 as const, + operation: 'local.deployment.reconciliation.review.commit' as const, + options: Object.freeze({ + ...normalizedRoots, + targetDatabasePath, + ownerPepperKeyringDirectory, + credentialFilePath, + issuerKeyringPath, + ...(options.busyTimeoutMs === undefined + ? {} + : { busyTimeoutMs: options.busyTimeoutMs as number }), + }), + request: Object.freeze({ + reviewId: reviewId(request.reviewId), + expectedPreparationDigest: digest( + request.expectedPreparationDigest, + 'expectedPreparationDigest', + ), + expectedHeadDigest: digest( + request.expectedHeadDigest, + 'expectedHeadDigest', + ), + decisionFilePath, + committedAtMs: request.committedAtMs as number, + authorizationLifetimeMs: request.authorizationLifetimeMs as number, + }), + }); +} + +export function normalizeLocalReconciliationReviewVerifyCommand( + value: unknown, +): Readonly { + const command = object(value, 'reconciliation review verify command'); + exact( + command, + ['operation', 'options', 'request', 'schemaVersion'], + 'command', + ); + if ( + command.schemaVersion !== 1 || + command.operation !== 'local.deployment.reconciliation.review.verify' + ) { + configurationError('reconciliation review verify command is invalid'); + } + const options = object(command.options, 'options'); + exact( + options, + [ + 'allowRootService', + 'captureRoot', + 'deploymentRoot', + 'issuerKeyringPath', + 'planRoot', + 'reviewRoot', + ], + 'options', + ); + const normalizedRoots = roots(options); + const issuerKeyringPath = safePath( + options.issuerKeyringPath, + 'issuerKeyringPath', + ); + descendant( + normalizedRoots.deploymentRoot, + issuerKeyringPath, + 'issuerKeyringPath', + ); + const request = object(command.request, 'request'); + exact(request, ['expectedReviewDigest', 'reviewId'], 'request'); + return Object.freeze({ + schemaVersion: 1 as const, + operation: 'local.deployment.reconciliation.review.verify' as const, + options: Object.freeze({ ...normalizedRoots, issuerKeyringPath }), + request: Object.freeze({ + reviewId: reviewId(request.reviewId), + expectedReviewDigest: digest( + request.expectedReviewDigest, + 'expectedReviewDigest', + ), + }), + }); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/decisionFile.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/decisionFile.ts new file mode 100644 index 00000000..27db5171 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/decisionFile.ts @@ -0,0 +1,439 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { LocalDeploymentConfigurationError } from '../../foundation/error'; +import type { LocalReconciliationDiagnosticFact } from './diagnostics'; + +const HEADER_KIND = 'qinglong3-local-reconciliation-review-decision-header'; +const DECISION_KIND = 'qinglong3-local-reconciliation-review-decision'; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; +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 MAX_LINE_BYTES = 64 * 1024; +const READ_CHUNK_BYTES = 64 * 1024; +export const MAX_EDGE_LOCAL_RECONCILIATION_REVIEW_DECISION_BYTES = + 8 * 1024 * 1024; +export const MAX_STANDALONE_LOCAL_RECONCILIATION_REVIEW_DECISION_BYTES = + 32 * 1024 * 1024; + +export const LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS = Object.freeze([ + 'retain_target', + 'adopt_legacy', + 'retain_both', + 'exclude_legacy', + 'defer', + 'manual_external', +] as const); + +export type LocalReconciliationReviewDisposition = + (typeof LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS)[number]; + +export const LOCAL_RECONCILIATION_REVIEW_REASONS = Object.freeze([ + 'preserve_target', + 'prefer_legacy', + 'preserve_both', + 'legacy_excluded', + 'deferred_review', + 'external_recovery_required', +] as const); + +export type LocalReconciliationReviewReason = + (typeof LOCAL_RECONCILIATION_REVIEW_REASONS)[number]; + +export interface LocalReconciliationReviewDecisionHeader { + readonly schemaVersion: 1; + readonly kind: typeof HEADER_KIND; + readonly diagnosticsContractVersion: 1; + readonly reviewId: string; + readonly profile: 'edge' | 'standalone'; + readonly planDigest: string; + readonly preparationDigest: string; +} + +export interface LocalReconciliationReviewDecision { + readonly schemaVersion: 1; + readonly kind: typeof DECISION_KIND; + readonly database: 'legacy' | 'target'; + readonly domain: LocalReconciliationDiagnosticFact['domain']; + readonly factKind: LocalReconciliationDiagnosticFact['factKind']; + readonly ordinal: number; + readonly factDigest: string; + readonly disposition: LocalReconciliationReviewDisposition; + readonly reason: LocalReconciliationReviewReason; +} + +export interface LocalReconciliationReviewDecisionFileEvidence { + readonly fileBytes: number; + readonly fileDigest: string; + readonly decisionCount: number; +} + +export interface LocalReconciliationReviewDecisionCursor { + readonly header: Readonly; + next(): Readonly | null; +} + +interface FileIdentity { + readonly device: bigint; + readonly inode: bigint; + readonly size: bigint; + readonly modifiedAtNs: bigint; + readonly changedAtNs: bigint; + readonly uid: number; + readonly mode: number; + readonly links: bigint; +} + +function configurationError(message: string, cause?: unknown): never { + throw new LocalDeploymentConfigurationError( + `reconciliation review decision file ${message}`, + { cause }, + ); +} + +function exact( + value: unknown, + keys: readonly string[], + label: string, +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + configurationError(`${label} must be an object`); + } + const record = value as Record; + const actual = Object.keys(record).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + configurationError(`${label} shape is invalid`); + } + return record; +} + +function parse(line: Buffer, label: string): unknown { + if (line.byteLength < 2 || line.byteLength > MAX_LINE_BYTES) { + configurationError(`${label} line bound is invalid`); + } + try { + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(line)); + } catch (error) { + configurationError(`${label} is not valid UTF-8 JSON`, error); + } +} + +function header( + value: unknown, + expected: Readonly<{ + reviewId: string; + profile: 'edge' | 'standalone'; + planDigest: string; + preparationDigest: string; + }>, +): Readonly { + const record = exact( + value, + [ + 'diagnosticsContractVersion', + 'kind', + 'planDigest', + 'preparationDigest', + 'profile', + 'reviewId', + 'schemaVersion', + ], + 'header', + ); + if ( + record.schemaVersion !== 1 || + record.kind !== HEADER_KIND || + record.diagnosticsContractVersion !== 1 || + typeof record.reviewId !== 'string' || + !UUID_V4_PATTERN.test(record.reviewId) || + (record.profile !== 'edge' && record.profile !== 'standalone') || + typeof record.planDigest !== 'string' || + !DIGEST_PATTERN.test(record.planDigest) || + typeof record.preparationDigest !== 'string' || + !DIGEST_PATTERN.test(record.preparationDigest) || + record.reviewId !== expected.reviewId || + record.profile !== expected.profile || + record.planDigest !== expected.planDigest || + record.preparationDigest !== expected.preparationDigest + ) { + configurationError('header binding is invalid'); + } + return Object.freeze( + record, + ) as unknown as Readonly; +} + +function decision(value: unknown): Readonly { + const record = exact( + value, + [ + 'database', + 'disposition', + 'domain', + 'factDigest', + 'factKind', + 'kind', + 'ordinal', + 'reason', + 'schemaVersion', + ], + 'decision', + ); + if ( + record.schemaVersion !== 1 || + record.kind !== DECISION_KIND || + (record.database !== 'legacy' && record.database !== 'target') || + ![ + 'schema_lineage', + 'automation', + 'secret_and_config', + 'run_history', + 'plugin_package', + 'ai_and_tool', + 'identity_policy_audit', + 'unknown', + ].includes(record.domain as string) || + (record.factKind !== 'schema_object' && record.factKind !== 'table') || + !Number.isSafeInteger(record.ordinal) || + (record.ordinal as number) < 1 || + (record.ordinal as number) > 4_096 || + typeof record.factDigest !== 'string' || + !DIGEST_PATTERN.test(record.factDigest) || + !LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS.includes( + record.disposition as LocalReconciliationReviewDisposition, + ) || + !LOCAL_RECONCILIATION_REVIEW_REASONS.includes( + record.reason as LocalReconciliationReviewReason, + ) + ) { + configurationError('decision is invalid'); + } + const reasonByDisposition: Record< + LocalReconciliationReviewDisposition, + LocalReconciliationReviewReason + > = { + retain_target: 'preserve_target', + adopt_legacy: 'prefer_legacy', + retain_both: 'preserve_both', + exclude_legacy: 'legacy_excluded', + defer: 'deferred_review', + manual_external: 'external_recovery_required', + }; + if ( + reasonByDisposition[ + record.disposition as LocalReconciliationReviewDisposition + ] !== record.reason + ) { + configurationError('decision reason does not match disposition'); + } + return Object.freeze( + record, + ) as unknown as Readonly; +} + +function identity(stat: fs.BigIntStats): FileIdentity { + return Object.freeze({ + device: stat.dev, + inode: stat.ino, + size: stat.size, + modifiedAtNs: stat.mtimeNs, + changedAtNs: stat.ctimeNs, + uid: Number(stat.uid), + mode: Number(stat.mode) & 0o777, + links: stat.nlink, + }); +} + +function same(left: FileIdentity, right: FileIdentity): boolean { + return Object.keys(left).every( + (key) => + left[key as keyof FileIdentity] === right[key as keyof FileIdentity], + ); +} + +function assertParent(filePath: string, uid: number): FileIdentity { + const stat = fs.lstatSync(path.dirname(filePath), { bigint: true }); + const selected = identity(stat); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + selected.uid !== uid || + selected.mode !== 0o700 || + fs.realpathSync(path.dirname(filePath)) !== path.dirname(filePath) + ) { + configurationError('parent must be a canonical current-UID 0700 directory'); + } + return selected; +} + +export function assertLocalReconciliationReviewDecisionMatchesFact( + selected: Readonly, + fact: Readonly, +): void { + if ( + selected.database !== fact.database || + selected.domain !== fact.domain || + selected.factKind !== fact.factKind || + selected.ordinal !== fact.ordinal || + selected.factDigest !== fact.factDigest + ) { + configurationError('decision sequence does not match canonical facts'); + } + if ( + fact.decisionRequirement === 'informational' || + (fact.decisionRequirement === 'blocked' && + selected.disposition !== 'defer' && + selected.disposition !== 'manual_external') || + (fact.domain === 'run_history' && + !['retain_target', 'retain_both', 'defer', 'manual_external'].includes( + selected.disposition, + )) || + ((fact.domain === 'secret_and_config' || + fact.domain === 'identity_policy_audit' || + fact.domain === 'unknown') && + !['defer', 'manual_external'].includes(selected.disposition)) + ) { + configurationError( + 'decision disposition is not allowed for canonical fact', + ); + } +} + +export function withLocalReconciliationReviewDecisionFile( + filePath: string, + expected: Readonly<{ + reviewId: string; + profile: 'edge' | 'standalone'; + planDigest: string; + preparationDigest: string; + }>, + consume: (cursor: LocalReconciliationReviewDecisionCursor) => T, +): Readonly<{ + result: T; + evidence: LocalReconciliationReviewDecisionFileEvidence; + confirmIdentity(): void; +}> { + const uid = process.getuid?.(); + if (!Number.isSafeInteger(uid) || uid !== process.geteuid?.()) { + configurationError('requires stable POSIX identity'); + } + const parent = assertParent(filePath, uid!); + const beforeStat = fs.lstatSync(filePath, { bigint: true }); + const before = identity(beforeStat); + const maxBytes = + expected.profile === 'edge' + ? MAX_EDGE_LOCAL_RECONCILIATION_REVIEW_DECISION_BYTES + : MAX_STANDALONE_LOCAL_RECONCILIATION_REVIEW_DECISION_BYTES; + if ( + !beforeStat.isFile() || + beforeStat.isSymbolicLink() || + before.uid !== uid || + before.mode !== 0o600 || + before.links !== 1n || + before.size < 2n || + before.size > BigInt(maxBytes) + ) { + configurationError('identity or size is invalid'); + } + let descriptor: number | undefined; + try { + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = identity(fs.fstatSync(descriptor, { bigint: true })); + if (!same(before, opened)) configurationError('changed while opening'); + const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES); + let chunkOffset = 0; + let chunkLength = 0; + let bytesRead = 0; + const lineBuffer = Buffer.allocUnsafe(MAX_LINE_BYTES); + let lineLength = 0; + const fileHash = createHash('sha256'); + const nextLine = (): Buffer | null => { + while (true) { + if (chunkOffset >= chunkLength) { + chunkLength = fs.readSync( + descriptor!, + chunk, + 0, + chunk.byteLength, + null, + ); + chunkOffset = 0; + if (chunkLength === 0) { + if (lineLength !== 0) configurationError('must end with newline'); + return null; + } + bytesRead += chunkLength; + fileHash.update(chunk.subarray(0, chunkLength)); + } + const byte = chunk[chunkOffset++]!; + if (byte === 0x0a) { + const line = Buffer.from(lineBuffer.subarray(0, lineLength)); + lineLength = 0; + return line; + } + if (lineLength >= MAX_LINE_BYTES) + configurationError('line is too large'); + lineBuffer[lineLength] = byte; + lineLength += 1; + } + }; + const first = nextLine(); + if (!first) configurationError('header is absent'); + const normalizedHeader = header(parse(first, 'header'), expected); + let decisionCount = 0; + const cursor: LocalReconciliationReviewDecisionCursor = Object.freeze({ + header: normalizedHeader, + next() { + const line = nextLine(); + if (line === null) return null; + decisionCount += 1; + return decision(parse(line, 'decision')); + }, + }); + const result = consume(cursor); + if (cursor.next() !== null) configurationError('contains extra decisions'); + const after = identity(fs.fstatSync(descriptor, { bigint: true })); + const currentParent = assertParent(filePath, uid!); + if ( + bytesRead !== Number(before.size) || + !same(before, after) || + parent.device !== currentParent.device || + parent.inode !== currentParent.inode || + !same(before, identity(fs.lstatSync(filePath, { bigint: true }))) + ) { + configurationError('identity changed while reading'); + } + const confirmIdentity = (): void => { + const latestParent = assertParent(filePath, uid!); + if ( + parent.device !== latestParent.device || + parent.inode !== latestParent.inode || + !same(before, identity(fs.lstatSync(filePath, { bigint: true }))) + ) { + configurationError('identity changed after reading'); + } + }; + return Object.freeze({ + result, + evidence: Object.freeze({ + fileBytes: bytesRead, + fileDigest: fileHash.digest('hex'), + decisionCount, + }), + confirmIdentity, + }); + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + return configurationError('cannot be read', error); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/diagnostics.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/diagnostics.ts index 3373f40f..2aaab0aa 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/diagnostics.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/diagnostics.ts @@ -89,9 +89,7 @@ function quotedIdentifier(name: string): string { return `"${name.replaceAll('"', '""')}"`; } -function requirement( - domain: LocalReconciliationPlanDomain, -): Readonly<{ +function requirement(domain: LocalReconciliationPlanDomain): Readonly<{ decisionRequirement: LocalReconciliationDiagnosticDecisionRequirement; reason: LocalReconciliationDiagnosticReason; }> { @@ -188,7 +186,10 @@ function schemaFacts( domain: LocalReconciliationPlanDomain, offset: number, limit: number, -): Readonly<{ records: readonly Readonly[]; complete: boolean }> { +): Readonly<{ + records: readonly Readonly[]; + complete: boolean; +}> { const rows = client .prepare( `SELECT type, name, tbl_name AS tableName @@ -208,7 +209,8 @@ function schemaFacts( let complete = true; for (const row of rows) { catalog += 1; - if (catalog > 4_096) configurationError('diagnostic schema budget is exceeded'); + if (catalog > 4_096) + configurationError('diagnostic schema budget is exceeded'); if ( typeof row.type !== 'string' || typeof row.name !== 'string' || @@ -250,7 +252,10 @@ function tableFacts( domain: LocalReconciliationPlanDomain, offset: number, limit: number, -): Readonly<{ records: readonly Readonly[]; complete: boolean }> { +): Readonly<{ + records: readonly Readonly[]; + complete: boolean; +}> { const rows = client .prepare( `SELECT name, type @@ -269,7 +274,8 @@ function tableFacts( let complete = true; for (const row of rows) { catalog += 1; - if (catalog > 512) configurationError('diagnostic table budget is exceeded'); + if (catalog > 512) + configurationError('diagnostic table budget is exceeded'); if ( typeof row.name !== 'string' || typeof row.type !== 'string' || @@ -277,7 +283,8 @@ function tableFacts( ) { configurationError('diagnostic table catalog drifted'); } - if (classifyLocalReconciliationFact(database, row.name) !== domain) continue; + if (classifyLocalReconciliationFact(database, row.name) !== domain) + continue; const ordinal = seen + 1; seen += 1; if (seen <= offset) continue; @@ -345,13 +352,57 @@ export function buildLocalReconciliationDiagnosticPage( nextOffset, records: selected.records, }); - const page = Object.freeze({ ...payload, pageDigest: cutoverDigest(payload) }); - if (Buffer.byteLength(`${JSON.stringify(page, null, 2)}\n`, 'utf8') > MAX_PAGE_BYTES) { + const page = Object.freeze({ + ...payload, + pageDigest: cutoverDigest(payload), + }); + if ( + Buffer.byteLength(`${JSON.stringify(page, null, 2)}\n`, 'utf8') > + MAX_PAGE_BYTES + ) { configurationError('diagnostic page exceeds its 256 KiB budget'); } return page; } +/** + * Re-derives the complete bounded fact sequence without trusting diagnostic + * page files. One database descriptor is held by the caller and each query is + * bounded by the same catalog ceilings as diagnostics. + */ +export function visitLocalReconciliationDiagnosticFacts( + client: DatabaseSync, + database: LocalReconciliationSealedDatabaseKind, + visitor: (fact: Readonly) => void, +): void { + for (const domain of [ + 'schema_lineage', + 'automation', + 'secret_and_config', + 'run_history', + 'plugin_package', + 'ai_and_tool', + 'identity_policy_audit', + 'unknown', + ] as const satisfies readonly LocalReconciliationPlanDomain[]) { + for (const factKind of ['schema_object', 'table'] as const) { + let offset = 0; + while (true) { + const selected = + factKind === 'schema_object' + ? schemaFacts(client, database, domain, offset, 64) + : tableFacts(client, database, domain, offset, 64); + for (const record of selected.records) visitor(record); + if (selected.complete) break; + if (selected.records.length < 1) { + configurationError('diagnostic canonical sequence did not advance'); + } + offset += selected.records.length; + } + } + } +} + function pageBytes(page: Readonly): Buffer { const bytes = Buffer.from(`${JSON.stringify(page, null, 2)}\n`, 'utf8'); if (bytes.byteLength < 2 || bytes.byteLength > MAX_PAGE_BYTES) { @@ -396,11 +447,7 @@ function syncDirectory(directory: string): void { } } -function writeStage( - stagePath: string, - bytes: Buffer, - uid: number, -): void { +function writeStage(stagePath: string, bytes: Buffer, uid: number): void { let descriptor: number | undefined; let created = false; try { diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/issuerKeyring.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/issuerKeyring.ts new file mode 100644 index 00000000..2c56644e --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/issuerKeyring.ts @@ -0,0 +1,404 @@ +import { createHash, randomBytes } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + assertLocalSecretKeyId, + type LocalSecretKeyMaterial, + type LocalSecretKeyProvider, +} from '@qinglong/runtime-core/local-secret'; + +import { LocalDeploymentConfigurationError } from '../../foundation/error'; + +const KEYRING_KIND = 'qinglong3-local-reconciliation-review-issuer-keyring'; +const KEY_ID_PREFIX = 'qlrrk-'; +const KEY_BYTES = 32; +const MAX_KEYRING_BYTES = 16 * 1024; +const MAX_PATH_BYTES = 4_096; +export const MAX_LOCAL_RECONCILIATION_REVIEW_ISSUER_KEYS = 8; + +interface IssuerKeyRecord { + readonly generation: number; + readonly keyId: string; + readonly material: string; +} + +interface IssuerKeyringManifest { + readonly schemaVersion: 1; + readonly kind: typeof KEYRING_KIND; + readonly activeGeneration: number; + readonly keys: readonly Readonly[]; +} + +export interface LocalReconciliationReviewIssuerKeyringSummary { + readonly schemaVersion: 1; + readonly kind: 'qinglong3-local-reconciliation-review-issuer-keyring-summary'; + readonly activeGeneration: number; + readonly activeKeyId: string; + readonly keyCount: number; + readonly keyringDigest: string; +} + +interface FileIdentity { + readonly device: bigint; + readonly inode: bigint; + readonly size: bigint; + readonly modifiedAtNs: bigint; + readonly changedAtNs: bigint; +} + +function configurationError(message: string, cause?: unknown): never { + throw new LocalDeploymentConfigurationError( + `reconciliation review issuer keyring ${message}`, + { cause }, + ); +} + +function currentUid(): number { + if ( + typeof process.getuid !== 'function' || + typeof process.geteuid !== 'function' + ) { + configurationError('requires POSIX identity'); + } + const uid = process.getuid(); + if (uid !== process.geteuid() || !Number.isSafeInteger(uid) || uid < 0) { + configurationError('requires stable POSIX identity'); + } + return uid; +} + +function boundedPath(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 + ) { + configurationError('path is invalid'); + } + return value; +} + +function assertParent(filePath: string, uid: number): fs.BigIntStats { + let stat: fs.BigIntStats; + try { + stat = fs.lstatSync(path.dirname(filePath), { bigint: true }); + } catch (error) { + configurationError('parent is unavailable', error); + } + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + Number(stat.uid) !== uid || + (Number(stat.mode) & 0o777) !== 0o700 || + fs.realpathSync(path.dirname(filePath)) !== path.dirname(filePath) + ) { + configurationError('parent must be a canonical current-UID 0700 directory'); + } + return stat; +} + +function exact(value: object, keys: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return ( + actual.length === expected.length && + actual.every((key, index) => key === expected[index]) + ); +} + +function parseManifest(bytes: Buffer): Readonly { + let value: unknown; + try { + value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch (error) { + configurationError('content is invalid', error); + } + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + !exact(value, ['activeGeneration', 'keys', 'kind', 'schemaVersion']) + ) { + configurationError('shape is invalid'); + } + const candidate = value as Record; + if ( + candidate.schemaVersion !== 1 || + candidate.kind !== KEYRING_KIND || + !Number.isSafeInteger(candidate.activeGeneration) || + (candidate.activeGeneration as number) < 1 || + !Array.isArray(candidate.keys) || + candidate.keys.length < 1 || + candidate.keys.length > MAX_LOCAL_RECONCILIATION_REVIEW_ISSUER_KEYS + ) { + configurationError('manifest is invalid'); + } + const keys: IssuerKeyRecord[] = []; + let previousGeneration = 0; + for (const raw of candidate.keys) { + if ( + !raw || + typeof raw !== 'object' || + Array.isArray(raw) || + !exact(raw, ['generation', 'keyId', 'material']) + ) { + configurationError('key record shape is invalid'); + } + const record = raw as Record; + let decoded: Buffer | undefined; + try { + assertLocalSecretKeyId(record.keyId as string); + decoded = + typeof record.material === 'string' + ? Buffer.from(record.material, 'base64url') + : Buffer.alloc(0); + if ( + !Number.isSafeInteger(record.generation) || + (record.generation as number) !== previousGeneration + 1 || + typeof record.keyId !== 'string' || + !record.keyId.startsWith(KEY_ID_PREFIX) || + typeof record.material !== 'string' || + decoded.byteLength !== KEY_BYTES || + decoded.toString('base64url') !== record.material + ) { + configurationError('key record is invalid'); + } + previousGeneration = record.generation as number; + keys.push( + Object.freeze({ + generation: previousGeneration, + keyId: record.keyId, + material: record.material, + }), + ); + } finally { + decoded?.fill(0); + } + } + if (candidate.activeGeneration !== previousGeneration) { + configurationError('active generation is not the immutable tail'); + } + return Object.freeze({ + schemaVersion: 1, + kind: KEYRING_KIND, + activeGeneration: previousGeneration, + keys: Object.freeze(keys), + }); +} + +function canonical(manifest: Readonly): Buffer { + return Buffer.from(`${JSON.stringify(manifest)}\n`, 'utf8'); +} + +function identity(stat: fs.BigIntStats): FileIdentity { + return Object.freeze({ + device: stat.dev, + inode: stat.ino, + size: stat.size, + modifiedAtNs: stat.mtimeNs, + changedAtNs: stat.ctimeNs, + }); +} + +function sameIdentity(left: FileIdentity, right: FileIdentity): boolean { + return ( + left.device === right.device && + left.inode === right.inode && + left.size === right.size && + left.modifiedAtNs === right.modifiedAtNs && + left.changedAtNs === right.changedAtNs + ); +} + +function load(filePath: string): Readonly<{ + manifest: Readonly; + identity: FileIdentity; +}> { + const uid = currentUid(); + const parent = assertParent(filePath, uid); + let descriptor: number | undefined; + let bytes: Buffer | undefined; + try { + const before = fs.lstatSync(filePath, { bigint: true }); + if ( + !before.isFile() || + before.isSymbolicLink() || + Number(before.uid) !== uid || + (Number(before.mode) & 0o777) !== 0o600 || + before.nlink !== 1n || + before.size < 2n || + before.size > BigInt(MAX_KEYRING_BYTES) + ) { + configurationError('file identity is invalid'); + } + const expected = identity(before); + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = fs.fstatSync(descriptor, { bigint: true }); + if (!opened.isFile() || !sameIdentity(expected, identity(opened))) { + configurationError('file changed while opening'); + } + bytes = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor, { bigint: true }); + const currentParent = assertParent(filePath, uid); + if ( + !sameIdentity(expected, identity(after)) || + parent.dev !== currentParent.dev || + parent.ino !== currentParent.ino + ) { + configurationError('authority changed while reading'); + } + return Object.freeze({ + manifest: parseManifest(bytes), + identity: expected, + }); + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + return configurationError('cannot be read', error); + } finally { + bytes?.fill(0); + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function summary( + manifest: Readonly, +): Readonly { + const bytes = canonical(manifest); + try { + const active = manifest.keys[manifest.keys.length - 1]!; + return Object.freeze({ + schemaVersion: 1, + kind: 'qinglong3-local-reconciliation-review-issuer-keyring-summary', + activeGeneration: manifest.activeGeneration, + activeKeyId: active.keyId, + keyCount: manifest.keys.length, + keyringDigest: createHash('sha256') + .update('qinglong3.local-reconciliation-review-issuer-keyring.v1\0') + .update(bytes) + .digest('hex'), + }); + } finally { + bytes.fill(0); + } +} + +function material(record: Readonly): LocalSecretKeyMaterial { + return Object.freeze({ + keyId: record.keyId, + key: Uint8Array.from(Buffer.from(record.material, 'base64url')), + }); +} + +export class LocalReconciliationReviewIssuerKeyringFileProvider + implements LocalSecretKeyProvider +{ + private readonly filePath: string; + + constructor(candidatePath: string) { + this.filePath = boundedPath(candidatePath); + } + + async active(): Promise { + const manifest = load(this.filePath).manifest; + return material(manifest.keys[manifest.keys.length - 1]!); + } + + async resolve(keyId: string): Promise { + try { + assertLocalSecretKeyId(keyId); + } catch (error) { + configurationError('key id is invalid', error); + } + const record = load(this.filePath).manifest.keys.find( + (candidate) => candidate.keyId === keyId, + ); + return record ? material(record) : null; + } + + inspect(): Readonly { + return summary(load(this.filePath).manifest); + } +} + +function syncDirectory(directory: string): void { + const descriptor = fs.openSync(directory, fs.constants.O_RDONLY); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +export function ensureLocalReconciliationReviewIssuerKeyring( + candidatePath: string, +): Readonly { + const filePath = boundedPath(candidatePath); + if (fs.existsSync(filePath)) return summary(load(filePath).manifest); + const uid = currentUid(); + assertParent(filePath, uid); + const key = randomBytes(KEY_BYTES); + const keyId = `${KEY_ID_PREFIX}${randomBytes(12).toString('base64url')}`; + const manifest: Readonly = Object.freeze({ + schemaVersion: 1, + kind: KEYRING_KIND, + activeGeneration: 1, + keys: Object.freeze([ + Object.freeze({ + generation: 1, + keyId, + material: key.toString('base64url'), + }), + ]), + }); + const bytes = canonical(manifest); + const stage = `${filePath}.ql3-reconciliation-review-stage`; + let descriptor: number | undefined; + let created = false; + try { + descriptor = fs.openSync( + stage, + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_WRONLY | + (fs.constants.O_NOFOLLOW ?? 0), + 0o600, + ); + created = true; + fs.fchmodSync(descriptor, 0o600); + fs.writeFileSync(descriptor, bytes); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + try { + fs.linkSync(stage, filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + syncDirectory(path.dirname(filePath)); + fs.unlinkSync(stage); + created = false; + syncDirectory(path.dirname(filePath)); + return summary(load(filePath).manifest); + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + return configurationError('cannot be provisioned', error); + } finally { + key.fill(0); + bytes.fill(0); + if (descriptor !== undefined) fs.closeSync(descriptor); + if (created) { + try { + fs.unlinkSync(stage); + } catch { + // An unpublished owner-only stage is fail-closed and recoverable. + } + } + } +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/preparation.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/preparation.ts index 425db941..89ee25dc 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/preparation.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/preparation.ts @@ -107,7 +107,10 @@ function contents(value: unknown): string { function overlaps(left: string, right: string): boolean { const relative = path.relative(left, right); - return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); + return ( + relative === '' || + (!relative.startsWith('..') && !path.isAbsolute(relative)) + ); } export function localReconciliationReviewDirectory( @@ -149,19 +152,34 @@ function ensureReviewDirectory( return paths; } -function validateCatalog(paths: Readonly): void { +function validateCatalog( + paths: Readonly, +): void { const allowed = new Set([ + 'authorization.ndjson', 'intent.json', + 'receipt.json', + 'review.json', 'staging', + '.receipt.json.ql3-deploy-stage', + '.review.json.ql3-deploy-stage', '.intent.json.ql3-deploy-stage', ]); for (const entry of fs.readdirSync(paths.root, { withFileTypes: true })) { if (!allowed.has(entry.name) || entry.isSymbolicLink()) { - configurationError('reconciliation review root contains unknown material'); + configurationError( + 'reconciliation review root contains unknown material', + ); } } - if (fs.readdirSync(paths.staging).length !== 0) { - configurationError('reconciliation review staging contains unknown material'); + if ( + fs + .readdirSync(paths.staging) + .some((entry) => entry !== 'authorization.ndjson.stage') + ) { + configurationError( + 'reconciliation review staging contains unknown material', + ); } } @@ -172,7 +190,8 @@ function buildIntent( if ( terminal.intent.command.options.deploymentRoot !== command.options.deploymentRoot || - terminal.intent.command.options.captureRoot !== command.options.captureRoot || + terminal.intent.command.options.captureRoot !== + command.options.captureRoot || terminal.intent.command.options.planRoot !== command.options.planRoot || terminal.intent.command.options.allowRootService !== command.options.allowRootService || @@ -197,7 +216,10 @@ function buildIntent( planReceiptDigest: terminal.receipt.receiptDigest, plannedHeadDigest: command.request.expectedHeadDigest, }); - return Object.freeze({ ...payload, preparationDigest: cutoverDigest(payload) }); + return Object.freeze({ + ...payload, + preparationDigest: cutoverDigest(payload), + }); } export function normalizeLocalReconciliationReviewIntent( @@ -224,7 +246,9 @@ export function normalizeLocalReconciliationReviewIntent( ], 'reconciliation review intent', ); - const command = normalizeLocalReconciliationReviewPrepareCommand(intent.command); + const command = normalizeLocalReconciliationReviewPrepareCommand( + intent.command, + ); const { preparationDigest, ...payload } = intent; if ( intent.schema !== INTENT_SCHEMA || @@ -311,10 +335,22 @@ export function prepareLocalReconciliationReview( ): Readonly { const command = normalizeLocalReconciliationReviewPrepareCommand(input); const identity = currentIdentity(); - validatePrivateDirectory(command.options.deploymentRoot, identity.uid, 'deploymentRoot'); - validatePrivateDirectory(command.options.captureRoot, identity.uid, 'captureRoot'); + validatePrivateDirectory( + command.options.deploymentRoot, + identity.uid, + 'deploymentRoot', + ); + validatePrivateDirectory( + command.options.captureRoot, + identity.uid, + 'captureRoot', + ); validatePrivateDirectory(command.options.planRoot, identity.uid, 'planRoot'); - validatePrivateDirectory(command.options.reviewRoot, identity.uid, 'reviewRoot'); + validatePrivateDirectory( + command.options.reviewRoot, + identity.uid, + 'reviewRoot', + ); const terminal = readLocalReconciliationPlanTerminal( command.options.planRoot, command.request.planId, @@ -336,7 +372,9 @@ export function prepareLocalReconciliationReview( (head.state !== 'reconciliation_planned' && head.state !== 'reconciliation_review_prepared') ) { - configurationError('review prepare lost the planned instance head compare-and-swap'); + configurationError( + 'review prepare lost the planned instance head compare-and-swap', + ); } const paths = ensureReviewDirectory( command.options.reviewRoot, @@ -384,7 +422,8 @@ function validateDiagnosticsBinding( intent.command.options.captureRoot !== command.options.captureRoot || intent.command.options.planRoot !== command.options.planRoot || intent.command.options.reviewRoot !== command.options.reviewRoot || - intent.command.options.allowRootService !== command.options.allowRootService || + intent.command.options.allowRootService !== + command.options.allowRootService || intent.command.request.reviewId !== command.request.reviewId || intent.preparationDigest !== command.request.expectedPreparationDigest ) { @@ -413,13 +452,36 @@ export function writeLocalReconciliationReviewDiagnostics( ): Readonly { const command = normalizeLocalReconciliationReviewDiagnosticsCommand(input); const identity = currentIdentity(); - validatePrivateDirectory(command.options.deploymentRoot, identity.uid, 'deploymentRoot'); - validatePrivateDirectory(command.options.captureRoot, identity.uid, 'captureRoot'); + validatePrivateDirectory( + command.options.deploymentRoot, + identity.uid, + 'deploymentRoot', + ); + validatePrivateDirectory( + command.options.captureRoot, + identity.uid, + 'captureRoot', + ); validatePrivateDirectory(command.options.planRoot, identity.uid, 'planRoot'); - validatePrivateDirectory(command.options.reviewRoot, identity.uid, 'reviewRoot'); - const paths = reviewPaths(command.options.reviewRoot, command.request.reviewId); - validatePrivateDirectory(paths.root, identity.uid, 'reconciliationReviewDirectory'); - validatePrivateDirectory(paths.staging, identity.uid, 'reconciliationReviewStaging'); + validatePrivateDirectory( + command.options.reviewRoot, + identity.uid, + 'reviewRoot', + ); + const paths = reviewPaths( + command.options.reviewRoot, + command.request.reviewId, + ); + validatePrivateDirectory( + paths.root, + identity.uid, + 'reconciliationReviewDirectory', + ); + validatePrivateDirectory( + paths.staging, + identity.uid, + 'reconciliationReviewStaging', + ); const intent = readLocalReconciliationReviewIntent( command.options.reviewRoot, command.request.reviewId, @@ -466,7 +528,9 @@ export function writeLocalReconciliationReviewDiagnostics( }), ); if (page === null) { - configurationError('manual-required SQLite topology has no diagnostic page'); + configurationError( + 'manual-required SQLite topology has no diagnostic page', + ); } const currentHead = readLocalCutoverInstanceHead( command.options.deploymentRoot, @@ -478,7 +542,9 @@ export function writeLocalReconciliationReviewDiagnostics( currentHead.state !== 'reconciliation_review_prepared' || currentHead.sourceRecordDigest !== intent.preparationDigest ) { - configurationError('review diagnostics instance head changed while reading'); + configurationError( + 'review diagnostics instance head changed while reading', + ); } dependencies.beforeDiagnosticPublish?.(); const status = publishLocalReconciliationDiagnosticPage( @@ -505,7 +571,9 @@ export function writeLocalReconciliationReviewDiagnostics( export function prepareLocalReconciliationReviewCommandFile( filePath: string, ): Readonly { - return prepareLocalReconciliationReview(readPrivateLocalCommandFile(filePath)); + return prepareLocalReconciliationReview( + readPrivateLocalCommandFile(filePath), + ); } export function writeLocalReconciliationReviewDiagnosticsCommandFile( diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/terminalEvidence.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/terminalEvidence.ts new file mode 100644 index 00000000..eaca09b8 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/terminalEvidence.ts @@ -0,0 +1,396 @@ +import fs from 'node:fs'; + +import { LocalDeploymentConfigurationError } from '../../foundation/error'; +import { cutoverDigest } from '../../cutover/targetEvidence'; +import type { LocalReconciliationReviewAuthorizationEvidence } from './authorization'; +import type { + LocalReconciliationReviewDisposition, + LocalReconciliationReviewReason, +} from './decisionFile'; + +const REVIEW_SCHEMA = 'qinglong3-local-reconciliation-review'; +const RECEIPT_SCHEMA = 'qinglong3-local-reconciliation-review-receipt'; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; +const MAX_TERMINAL_BYTES = 64 * 1024; + +export interface LocalReconciliationReview { + readonly schema: typeof REVIEW_SCHEMA; + readonly schemaVersion: 1; + readonly state: 'reconciliation_reviewed'; + readonly reviewId: string; + readonly profile: 'edge' | 'standalone'; + readonly planDigest: string; + readonly preparationDigest: string; + readonly bundleDigest: string; + readonly bundleFingerprintDigest: string; + readonly preparedHeadDigest: string; + readonly authorizationDigest: string; + readonly decisionFileDigest: string; + readonly decisionSetDigest: string; + readonly decisionCount: number; + readonly dispositionCounts: Readonly< + Record + >; + readonly reasonCounts: Readonly< + Record + >; + readonly reviewerDigest: string; + readonly issuedAtMs: number; + readonly expiresAtMs: number; + readonly committedAtMs: number; + readonly reviewDigest: string; +} + +export interface LocalReconciliationReviewReceipt { + readonly schema: typeof RECEIPT_SCHEMA; + readonly schemaVersion: 1; + readonly state: 'reconciliation_reviewed'; + readonly reviewId: string; + readonly planDigest: string; + readonly preparationDigest: string; + readonly authorizationDigest: string; + readonly decisionSetDigest: string; + readonly decisionCount: number; + readonly keyId: string; + readonly reviewDigest: string; + readonly committedAtMs: number; + readonly receiptDigest: string; +} + +function configurationError(message: string, cause?: unknown): never { + throw new LocalDeploymentConfigurationError( + `terminal reconciliation review ${message}`, + { cause }, + ); +} + +function object(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + configurationError(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + 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]) + ) { + configurationError(`${label} shape is invalid`); + } +} + +function digest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + configurationError(`${label} is invalid`); + } + return value; +} + +function counts( + value: unknown, + keys: readonly string[], + label: string, +): Readonly> { + const record = object(value, label); + exact(record, keys, label); + if ( + keys.some( + (key) => + !Number.isSafeInteger(record[key]) || (record[key] as number) < 0, + ) + ) { + configurationError(`${label} is invalid`); + } + return Object.freeze(record as Record); +} + +export function buildLocalReconciliationReview( + input: Readonly<{ + authorization: Readonly; + decisionFileDigest: string; + committedAtMs: number; + }>, +): Readonly { + const header = input.authorization.header; + const reviewerDigest = cutoverDigest({ + subject: header.reviewer.subject, + authenticationId: header.reviewer.authenticationId, + authenticatedAtMs: header.reviewer.authenticatedAtMs, + assurance: header.reviewer.assurance, + }); + const payload = Object.freeze({ + schema: REVIEW_SCHEMA, + schemaVersion: 1 as const, + state: 'reconciliation_reviewed' as const, + reviewId: header.reviewId, + profile: header.profile, + planDigest: header.planDigest, + preparationDigest: header.preparationDigest, + bundleDigest: header.bundleDigest, + bundleFingerprintDigest: header.bundleFingerprintDigest, + preparedHeadDigest: header.preparedHeadDigest, + authorizationDigest: input.authorization.authorizationDigest, + decisionFileDigest: digest( + input.decisionFileDigest, + 'decision file digest', + ), + decisionSetDigest: input.authorization.decisionSetDigest, + decisionCount: input.authorization.decisionCount, + dispositionCounts: input.authorization.dispositionCounts, + reasonCounts: input.authorization.reasonCounts, + reviewerDigest, + issuedAtMs: header.issuedAtMs, + expiresAtMs: header.expiresAtMs, + committedAtMs: input.committedAtMs, + }); + return Object.freeze({ ...payload, reviewDigest: cutoverDigest(payload) }); +} + +export function buildLocalReconciliationReviewReceipt( + review: Readonly, + keyId: string, +): Readonly { + const payload = Object.freeze({ + schema: RECEIPT_SCHEMA, + schemaVersion: 1 as const, + state: 'reconciliation_reviewed' as const, + reviewId: review.reviewId, + planDigest: review.planDigest, + preparationDigest: review.preparationDigest, + authorizationDigest: review.authorizationDigest, + decisionSetDigest: review.decisionSetDigest, + decisionCount: review.decisionCount, + keyId, + reviewDigest: review.reviewDigest, + committedAtMs: review.committedAtMs, + }); + return Object.freeze({ ...payload, receiptDigest: cutoverDigest(payload) }); +} + +export function normalizeLocalReconciliationReview( + value: unknown, +): Readonly { + const review = object(value, 'review'); + exact( + review, + [ + 'authorizationDigest', + 'bundleDigest', + 'bundleFingerprintDigest', + 'committedAtMs', + 'decisionCount', + 'decisionFileDigest', + 'decisionSetDigest', + 'dispositionCounts', + 'expiresAtMs', + 'issuedAtMs', + 'planDigest', + 'preparationDigest', + 'preparedHeadDigest', + 'profile', + 'reasonCounts', + 'reviewDigest', + 'reviewId', + 'reviewerDigest', + 'schema', + 'schemaVersion', + 'state', + ], + 'review', + ); + const dispositionCounts = counts( + review.dispositionCounts, + [ + 'adopt_legacy', + 'defer', + 'exclude_legacy', + 'manual_external', + 'retain_both', + 'retain_target', + ], + 'disposition counts', + ); + const reasonCounts = counts( + review.reasonCounts, + [ + 'deferred_review', + 'external_recovery_required', + 'legacy_excluded', + 'prefer_legacy', + 'preserve_both', + 'preserve_target', + ], + 'reason counts', + ); + const { reviewDigest, ...rawPayload } = review; + const payload = Object.freeze({ + ...rawPayload, + dispositionCounts, + reasonCounts, + }); + if ( + review.schema !== REVIEW_SCHEMA || + review.schemaVersion !== 1 || + review.state !== 'reconciliation_reviewed' || + (review.profile !== 'edge' && review.profile !== 'standalone') || + !Number.isSafeInteger(review.decisionCount) || + (review.decisionCount as number) < 0 || + !Number.isSafeInteger(review.issuedAtMs) || + !Number.isSafeInteger(review.expiresAtMs) || + !Number.isSafeInteger(review.committedAtMs) || + (review.expiresAtMs as number) <= (review.issuedAtMs as number) || + [ + review.planDigest, + review.preparationDigest, + review.bundleDigest, + review.bundleFingerprintDigest, + review.preparedHeadDigest, + review.authorizationDigest, + review.decisionFileDigest, + review.decisionSetDigest, + review.reviewerDigest, + reviewDigest, + ].some( + (selected) => + typeof selected !== 'string' || !DIGEST_PATTERN.test(selected), + ) || + cutoverDigest(payload) !== reviewDigest || + Object.values(dispositionCounts).reduce( + (total, count) => total + count, + 0, + ) !== review.decisionCount || + Object.values(reasonCounts).reduce((total, count) => total + count, 0) !== + review.decisionCount + ) { + configurationError('review drifted'); + } + return Object.freeze({ + ...(review as unknown as LocalReconciliationReview), + dispositionCounts: + dispositionCounts as LocalReconciliationReview['dispositionCounts'], + reasonCounts: reasonCounts as LocalReconciliationReview['reasonCounts'], + }); +} + +export function normalizeLocalReconciliationReviewReceipt( + value: unknown, +): Readonly { + const receipt = object(value, 'receipt'); + exact( + receipt, + [ + 'authorizationDigest', + 'committedAtMs', + 'decisionCount', + 'decisionSetDigest', + 'keyId', + 'planDigest', + 'preparationDigest', + 'receiptDigest', + 'reviewDigest', + 'reviewId', + 'schema', + 'schemaVersion', + 'state', + ], + 'receipt', + ); + const { receiptDigest, ...payload } = receipt; + if ( + receipt.schema !== RECEIPT_SCHEMA || + receipt.schemaVersion !== 1 || + receipt.state !== 'reconciliation_reviewed' || + !Number.isSafeInteger(receipt.decisionCount) || + (receipt.decisionCount as number) < 0 || + !Number.isSafeInteger(receipt.committedAtMs) || + typeof receipt.keyId !== 'string' || + receipt.keyId.length < 1 || + receipt.keyId.length > 128 || + [ + receipt.planDigest, + receipt.preparationDigest, + receipt.authorizationDigest, + receipt.decisionSetDigest, + receipt.reviewDigest, + receiptDigest, + ].some( + (selected) => + typeof selected !== 'string' || !DIGEST_PATTERN.test(selected), + ) || + cutoverDigest(payload) !== receiptDigest + ) { + configurationError('receipt drifted'); + } + return Object.freeze( + receipt, + ) as unknown as Readonly; +} + +export function terminalEvidenceContents(value: unknown): string { + const contents = `${JSON.stringify(value, null, 2)}\n`; + if ( + Buffer.byteLength(contents, 'utf8') < 2 || + Buffer.byteLength(contents, 'utf8') > MAX_TERMINAL_BYTES + ) { + configurationError('evidence exceeds its byte bound'); + } + return contents; +} + +export function readLocalReconciliationReviewTerminalJson( + filePath: string, + uid: number, + allowedModes: readonly number[], +): unknown { + let descriptor: number | undefined; + let bytes: Buffer | undefined; + try { + const before = fs.lstatSync(filePath, { bigint: true }); + if ( + !before.isFile() || + before.isSymbolicLink() || + Number(before.uid) !== uid || + !allowedModes.includes(Number(before.mode) & 0o777) || + before.nlink !== 1n || + before.size < 2n || + before.size > BigInt(MAX_TERMINAL_BYTES) + ) { + configurationError('evidence identity is invalid'); + } + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = fs.fstatSync(descriptor, { bigint: true }); + if ( + opened.dev !== before.dev || + opened.ino !== before.ino || + opened.size !== before.size + ) { + configurationError('evidence changed while opening'); + } + bytes = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor, { bigint: true }); + if ( + after.dev !== before.dev || + after.ino !== before.ino || + after.size !== before.size + ) { + configurationError('evidence changed while reading'); + } + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + return configurationError('evidence cannot be read', error); + } finally { + bytes?.fill(0); + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} diff --git a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs index a564cd7b..86aaa613 100644 --- a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs +++ b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs @@ -10,11 +10,13 @@ const { test } = require('node:test'); const { commitLocalReconciliationCapture, commitLocalReconciliationPlan, + commitLocalReconciliationReview, prepareLocalReconciliationCapture, prepareLocalReconciliationPlan, prepareLocalReconciliationReview, verifyLocalReconciliationCapture, verifyLocalReconciliationPlan, + verifyLocalReconciliationReview, writeLocalReconciliationReviewDiagnostics, } = require('../dist/deployment/localDeployment.js'); const { @@ -193,9 +195,13 @@ function fixture( fs.writeFileSync(`${targetDatabasePath}-wal`, 'target-wal-facts\n', { mode: 0o600, }); - fs.writeFileSync(`${legacySourcePath}-journal`, 'legacy-journal-state\n', { - mode: 0o600, - }); + fs.writeFileSync( + `${legacySourcePath}-journal`, + 'legacy-journal-state\n', + { + mode: 0o600, + }, + ); } } const commitmentPayload = { @@ -682,6 +688,7 @@ function preparedPlan(t, options = {}) { }; return { ...state, + captureCommand: state.command, captured, planRoot, prepareCommand, @@ -694,7 +701,10 @@ function preparedReview(t, options = {}) { const state = preparedPlan(t, options); const planned = commitLocalReconciliationPlan(state.planCommitCommand); const root = path.dirname(state.captureRoot); - const reviewRoot = path.join(root, `review-root-${options.reviewSuffix ?? '1'}`); + const reviewRoot = path.join( + root, + `review-root-${options.reviewSuffix ?? '1'}`, + ); const diagnosticRoot = path.join( root, `diagnostic-root-${options.reviewSuffix ?? '1'}`, @@ -712,8 +722,7 @@ function preparedReview(t, options = {}) { allowRootService: rootAcknowledgement(), }, request: { - reviewId: - options.reviewId ?? '00000000-0000-4000-8000-000000000301', + reviewId: options.reviewId ?? '00000000-0000-4000-8000-000000000301', planId: state.prepareCommand.request.planId, expectedPlanDigest: planned.planDigest, expectedHeadDigest: planned.instanceHeadDigest, @@ -758,6 +767,163 @@ function diagnosticCommand( }; } +function writeReviewDecisionFile(state, prepared, fileName = 'review.ndjson') { + const decisions = []; + const domains = [ + 'schema_lineage', + 'automation', + 'secret_and_config', + 'run_history', + 'plugin_package', + 'ai_and_tool', + 'identity_policy_audit', + 'unknown', + ]; + for (const database of ['legacy', 'target']) { + for (const domain of domains) { + for (const factKind of ['schema_object', 'table']) { + let offset = 0; + let pageNumber = 0; + while (true) { + const command = diagnosticCommand(state, prepared, { + database, + domain, + factKind, + offset, + outputName: `decision-${database}-${domain}-${factKind}-${pageNumber}.json`, + }); + const result = writeLocalReconciliationReviewDiagnostics(command); + const page = JSON.parse( + fs.readFileSync(command.request.outputPath, 'utf8'), + ); + for (const fact of page.records) { + if (fact.decisionRequirement === 'informational') continue; + const blocked = fact.decisionRequirement === 'blocked'; + decisions.push({ + schemaVersion: 1, + kind: 'qinglong3-local-reconciliation-review-decision', + database: fact.database, + domain: fact.domain, + factKind: fact.factKind, + ordinal: fact.ordinal, + factDigest: fact.factDigest, + disposition: blocked ? 'manual_external' : 'retain_target', + reason: blocked + ? 'external_recovery_required' + : 'preserve_target', + }); + } + if (result.complete) break; + offset = result.nextOffset; + pageNumber += 1; + } + } + } + } + const records = [ + { + schemaVersion: 1, + kind: 'qinglong3-local-reconciliation-review-decision-header', + diagnosticsContractVersion: 1, + reviewId: state.reviewCommand.request.reviewId, + profile: state.command.request.profile, + planDigest: state.planned.planDigest, + preparationDigest: prepared.preparationDigest, + }, + ...decisions, + ]; + const filePath = path.join(state.diagnosticRoot, fileName); + fs.writeFileSync( + filePath, + `${records.map((record) => JSON.stringify(record)).join('\n')}\n`, + { mode: 0o600 }, + ); + return { filePath, records, decisions }; +} + +function reviewCommitFixture(t, options = {}) { + const state = preparedReview(t, options); + const prepared = prepareLocalReconciliationReview(state.reviewCommand); + const reviewFile = writeReviewDecisionFile(state, prepared); + const ownerPepperKeyringDirectory = path.join( + state.deploymentRoot, + 'review-owner-peppers', + ); + fs.mkdirSync(ownerPepperKeyringDirectory, { mode: 0o700 }); + const credentialFilePath = path.join( + state.deploymentRoot, + 'review-credential.json', + ); + fs.writeFileSync(credentialFilePath, '{}\n', { mode: 0o600 }); + const issuerKeyringPath = path.join( + state.deploymentRoot, + 'review-issuer.keyring', + ); + const committedAtMs = Date.now(); + const command = { + schemaVersion: 1, + operation: 'local.deployment.reconciliation.review.commit', + options: { + ...state.reviewCommand.options, + targetDatabasePath: state.targetDatabasePath, + ownerPepperKeyringDirectory, + credentialFilePath, + issuerKeyringPath, + }, + request: { + reviewId: state.reviewCommand.request.reviewId, + expectedPreparationDigest: prepared.preparationDigest, + expectedHeadDigest: prepared.instanceHeadDigest, + decisionFilePath: reviewFile.filePath, + committedAtMs, + authorizationLifetimeMs: 30 * 60 * 1_000, + }, + }; + let authentications = 0; + let confirmations = 0; + const dependencies = { + now: () => committedAtMs, + async openAuthenticationDatabase() { + return { async close() {} }; + }, + async authenticate(_database, authenticateOptions) { + authentications += 1; + assert.equal( + authenticateOptions.authenticationNamespace, + 'local_reconciliation_review', + ); + return { + principal: { + subject: { type: 'user', id: 'review-owner' }, + authenticationId: 'local_reconciliation_review:test', + authenticatedAtMs: committedAtMs, + expiresAtMs: committedAtMs + 60_000, + assurance: 'local_console', + }, + databaseFence: { + credentialId: 'review-owner', + credentialVersion: 1, + pepperKeyId: 'review-owner-v1', + pepperVersion: 1, + }, + async confirm() { + confirmations += 1; + }, + }; + }, + }; + return { + ...state, + prepared, + reviewFile, + command, + dependencies, + issuerKeyringPath, + authenticationCount: () => authentications, + confirmationCount: () => confirmations, + }; +} + function dockerReadSealedSqlite(assetsDirectory, mode) { const source = mode === 'main_only_immutable' @@ -770,7 +936,9 @@ function dockerReadSealedSqlite(assetsDirectory, mode) { const files = fs.readdirSync('/bundle').sort(); const snapshot = () => Object.fromEntries(files.map((name) => [name, crypto.createHash('sha256').update(fs.readFileSync('/bundle/' + name)).digest('hex')])); const before = snapshot(); - const client = new DatabaseSync(${JSON.stringify(source)}, { allowExtension: false, defensive: true, readOnly: true, timeout: 0 }); + const client = new DatabaseSync(${JSON.stringify( + source, + )}, { allowExtension: false, defensive: true, readOnly: true, timeout: 0 }); client.enableDefensive(true); client.exec('PRAGMA trusted_schema = OFF; PRAGMA query_only = ON; PRAGMA temp_store = MEMORY; PRAGMA mmap_size = 0; PRAGMA cache_size = -2048'); const row = client.prepare('SELECT COUNT(*) AS count FROM "QingLong3TaskDefinitions"').get(); @@ -851,10 +1019,7 @@ test('commit captures main, sidecars and recovery then verifies without sources' fs.readFileSync(captureAssetPath(state, 'target-wal'), 'utf8'), 'target-wal-facts\n', ); - assert.equal( - fs.statSync(capturePath(state, 'assets')).mode & 0o777, - 0o500, - ); + assert.equal(fs.statSync(capturePath(state, 'assets')).mode & 0o777, 0o500); for (const asset of manifest.assets) { assert.equal( fs.statSync(captureAssetPath(state, asset.logicalName)).mode & 0o777, @@ -1002,19 +1167,13 @@ test('commit converges a partially sealed terminal bundle without sources', (t) fs.statSync(captureAssetPath(state, 'target-wal')).mode & 0o777, 0o600, ); - assert.equal( - fs.statSync(capturePath(state, 'assets')).mode & 0o777, - 0o700, - ); + assert.equal(fs.statSync(capturePath(state, 'assets')).mode & 0o777, 0o700); fs.unlinkSync(state.targetDatabasePath); fs.unlinkSync(state.legacySourcePath); fs.unlinkSync(state.recoveryPath); const resumed = commitLocalReconciliationCapture(state.commitCommand); assert.equal(resumed.state, 'reconciliation_captured'); - assert.equal( - fs.statSync(capturePath(state, 'assets')).mode & 0o777, - 0o500, - ); + assert.equal(fs.statSync(capturePath(state, 'assets')).mode & 0o777, 0o500); const manifest = JSON.parse( fs.readFileSync(capturePath(state, 'manifest.json'), 'utf8'), ); @@ -1206,13 +1365,13 @@ test('capture manifest schema v1 is rejected instead of silently upgraded', (t) test('plan reads sealed main-only SQLite with fixed budgets and verifies without opening', (t) => { const state = preparedPlan(t); - const beforeAssets = fs.readdirSync( - capturePath(state, 'assets'), - ).map((name) => ({ - name, - bytes: fs.readFileSync(capturePath(state, `assets/${name}`)), - stat: fs.statSync(capturePath(state, `assets/${name}`), { bigint: true }), - })); + const beforeAssets = fs + .readdirSync(capturePath(state, 'assets')) + .map((name) => ({ + name, + bytes: fs.readFileSync(capturePath(state, `assets/${name}`)), + stat: fs.statSync(capturePath(state, `assets/${name}`), { bigint: true }), + })); const opens = []; const committed = commitLocalReconciliationPlan(state.planCommitCommand, { beforeDatabaseOpen(kind, mode, cacheKiB) { @@ -1467,9 +1626,7 @@ test('hot journal and unpaired sidecars become manual without SQLite open', (t) ], ); assert.equal( - plan.domains.every( - (domain) => domain.disposition === 'manual_required', - ), + plan.domains.every((domain) => domain.disposition === 'manual_required'), true, ); }); @@ -1481,11 +1638,7 @@ test('unknown target schema is summarized only by digest and requires manual rev }); commitLocalReconciliationPlan(state.planCommitCommand); const planText = fs.readFileSync( - path.join( - state.planRoot, - state.prepareCommand.request.planId, - 'plan.json', - ), + path.join(state.planRoot, state.prepareCommand.request.planId, 'plan.json'), 'utf8', ); const plan = JSON.parse(planText); @@ -1706,7 +1859,10 @@ test('review diagnostics publish one private exact page without changing assets' assert.equal(result.recordCount, 1); assert.equal(result.complete, true); assert.equal(result.nextOffset, null); - assert.equal(JSON.stringify(result).includes(command.request.outputPath), false); + assert.equal( + JSON.stringify(result).includes(command.request.outputPath), + false, + ); assert.equal(JSON.stringify(result).includes('Crontabs'), false); const pageText = fs.readFileSync(command.request.outputPath, 'utf8'); const page = JSON.parse(pageText); @@ -1845,6 +2001,334 @@ test('review diagnostics page at sixty-four and CLI output stays content-free', assert.equal(cli.stderr, ''); }); +test('review commit signs the exact decision stream, seals terminal evidence and verifies read-only', async (t) => { + const state = reviewCommitFixture(t, { + planId: '00000000-0000-4000-8000-000000000351', + reviewId: '00000000-0000-4000-8000-000000000352', + }); + const targetBefore = fs.statSync(state.targetDatabasePath, { bigint: true }); + const targetBytes = fs.readFileSync(state.targetDatabasePath); + const committed = await commitLocalReconciliationReview( + state.command, + state.dependencies, + ); + assert.equal(committed.status, 'prepared'); + assert.equal(committed.state, 'reconciliation_reviewed'); + assert.equal(committed.decisionCount, state.reviewFile.decisions.length); + assert.equal(state.authenticationCount(), 1); + assert.equal(state.confirmationCount(), 3); + const reviewDirectory = path.join( + state.reviewRoot, + state.reviewCommand.request.reviewId, + ); + assert.deepEqual(fs.readdirSync(reviewDirectory).sort(), [ + 'authorization.ndjson', + 'intent.json', + 'receipt.json', + 'review.json', + 'staging', + ]); + assert.equal(fs.statSync(reviewDirectory).mode & 0o777, 0o500); + assert.equal( + fs.statSync(path.join(reviewDirectory, 'staging')).mode & 0o777, + 0o500, + ); + for (const fileName of [ + 'authorization.ndjson', + 'intent.json', + 'receipt.json', + 'review.json', + ]) { + assert.equal( + fs.statSync(path.join(reviewDirectory, fileName)).mode & 0o777, + 0o400, + ); + } + const authorizationText = fs.readFileSync( + path.join(reviewDirectory, 'authorization.ndjson'), + 'utf8', + ); + assert.equal(authorizationText.includes('Crontabs'), false); + assert.equal(authorizationText.includes('private-value'), false); + assert.equal(authorizationText.includes('0 0 * * *'), false); + const head = readLocalCutoverInstanceHead( + state.deploymentRoot, + state.captureCommand.request.instanceId, + state.uid, + ); + assert.equal(head.state, 'reconciliation_reviewed'); + assert.equal(head.sourceRecordDigest, committed.reviewDigest); + const verifyCommand = { + schemaVersion: 1, + operation: 'local.deployment.reconciliation.review.verify', + options: { + ...state.reviewCommand.options, + issuerKeyringPath: state.issuerKeyringPath, + }, + request: { + reviewId: state.reviewCommand.request.reviewId, + expectedReviewDigest: committed.reviewDigest, + }, + }; + const verified = await verifyLocalReconciliationReview(verifyCommand); + assert.equal(verified.status, 'verified'); + assert.equal(verified.reviewDigest, committed.reviewDigest); + const targetAfter = fs.statSync(state.targetDatabasePath, { bigint: true }); + assert.equal( + fs.readFileSync(state.targetDatabasePath).equals(targetBytes), + true, + ); + assert.equal(targetAfter.mtimeNs, targetBefore.mtimeNs); + assert.equal(targetAfter.ctimeNs, targetBefore.ctimeNs); + + const commandPath = path.join(state.deploymentRoot, 'review-verify.json'); + fs.writeFileSync(commandPath, `${JSON.stringify(verifyCommand)}\n`, { + mode: 0o600, + }); + const cli = spawnSync( + process.execPath, + [ + path.join(__dirname, '../dist/deployment/localDeploymentCli.js'), + 'reconciliation-review-verify', + '--command-file', + commandPath, + ], + { encoding: 'utf8' }, + ); + assert.equal(cli.status, 0, cli.stderr); + assert.equal(JSON.parse(cli.stdout).status, 'verified'); + assert.equal(cli.stdout.includes(state.reviewRoot), false); + assert.equal(cli.stdout.includes('review-owner'), false); + assert.equal(cli.stdout.includes('Crontabs'), false); +}); + +test('review commit rejects missing and policy-invalid decisions before terminal publication', async (t) => { + const missing = reviewCommitFixture(t, { + planId: '00000000-0000-4000-8000-000000000361', + reviewId: '00000000-0000-4000-8000-000000000362', + reviewSuffix: 'missing', + }); + const missingRecords = missing.reviewFile.records.slice(0, -1); + fs.writeFileSync( + missing.reviewFile.filePath, + `${missingRecords.map((record) => JSON.stringify(record)).join('\n')}\n`, + { mode: 0o600 }, + ); + await assert.rejects( + commitLocalReconciliationReview(missing.command, missing.dependencies), + /omitted a canonical fact/, + ); + assert.equal( + fs.existsSync( + path.join( + missing.reviewRoot, + missing.reviewCommand.request.reviewId, + 'authorization.ndjson', + ), + ), + false, + ); + + const blocked = reviewCommitFixture(t, { + planId: '00000000-0000-4000-8000-000000000363', + reviewId: '00000000-0000-4000-8000-000000000364', + reviewSuffix: 'blocked', + }); + const blockedRecord = blocked.reviewFile.records.find( + (record) => + record.kind === 'qinglong3-local-reconciliation-review-decision' && + [ + 'secret_and_config', + 'run_history', + 'identity_policy_audit', + 'unknown', + ].includes(record.domain), + ); + blockedRecord.disposition = 'adopt_legacy'; + blockedRecord.reason = 'prefer_legacy'; + fs.writeFileSync( + blocked.reviewFile.filePath, + `${blocked.reviewFile.records + .map((record) => JSON.stringify(record)) + .join('\n')}\n`, + { mode: 0o600 }, + ); + await assert.rejects( + commitLocalReconciliationReview(blocked.command, blocked.dependencies), + /not allowed for canonical fact/, + ); +}); + +test('review commit rejects weak principals, oversized Edge streams and decision-file drift around signing', async (t) => { + const weak = reviewCommitFixture(t, { + planId: '00000000-0000-4000-8000-000000000365', + reviewId: '00000000-0000-4000-8000-000000000366', + reviewSuffix: 'weak', + }); + await assert.rejects( + commitLocalReconciliationReview(weak.command, { + ...weak.dependencies, + async authenticate() { + return { + principal: { + subject: { type: 'user', id: 'weak-user' }, + authenticationId: 'local_reconciliation_review:weak', + authenticatedAtMs: weak.command.request.committedAtMs, + expiresAtMs: weak.command.request.committedAtMs + 60_000, + assurance: 'single_factor', + }, + databaseFence: {}, + async confirm() {}, + }; + }, + }), + /recent strongly authenticated User/, + ); + + const oversized = reviewCommitFixture(t, { + planId: '00000000-0000-4000-8000-000000000367', + reviewId: '00000000-0000-4000-8000-000000000368', + reviewSuffix: 'oversized', + }); + fs.truncateSync(oversized.reviewFile.filePath, 8 * 1024 * 1024 + 1); + await assert.rejects( + commitLocalReconciliationReview(oversized.command, oversized.dependencies), + /identity or size is invalid/, + ); + + const drift = reviewCommitFixture(t, { + planId: '00000000-0000-4000-8000-000000000369', + reviewId: '00000000-0000-4000-8000-00000000036a', + reviewSuffix: 'drift', + }); + let confirmations = 0; + await assert.rejects( + commitLocalReconciliationReview(drift.command, { + ...drift.dependencies, + async authenticate() { + return { + principal: { + subject: { type: 'user', id: 'review-owner' }, + authenticationId: 'local_reconciliation_review:drift', + authenticatedAtMs: drift.command.request.committedAtMs, + expiresAtMs: drift.command.request.committedAtMs + 60_000, + assurance: 'hardware', + }, + databaseFence: {}, + async confirm() { + confirmations += 1; + if (confirmations === 3) { + fs.appendFileSync(drift.reviewFile.filePath, '{}\n'); + } + }, + }; + }, + }), + /identity changed after reading/, + ); +}); + +test('review commit resumes authorization, receipt, seal and head response-loss windows without re-authentication', async (t) => { + const authorizationState = reviewCommitFixture(t, { + planId: '00000000-0000-4000-8000-000000000371', + reviewId: '00000000-0000-4000-8000-000000000372', + reviewSuffix: 'authorization-crash', + }); + await assert.rejects( + commitLocalReconciliationReview(authorizationState.command, { + ...authorizationState.dependencies, + afterAuthorizationPublished() { + throw new Error('authorization crash'); + }, + }), + /authorization crash/, + ); + const authorizationDirectory = path.join( + authorizationState.reviewRoot, + authorizationState.reviewCommand.request.reviewId, + ); + fs.linkSync( + path.join(authorizationDirectory, 'authorization.ndjson'), + path.join(authorizationDirectory, 'staging', 'authorization.ndjson.stage'), + ); + const authorizationReplay = await commitLocalReconciliationReview( + authorizationState.command, + { + ...authorizationState.dependencies, + async authenticate() { + throw new Error('must not re-authenticate signed response-loss replay'); + }, + }, + ); + assert.equal(authorizationReplay.state, 'reconciliation_reviewed'); + + const receiptState = reviewCommitFixture(t, { + planId: '00000000-0000-4000-8000-000000000373', + reviewId: '00000000-0000-4000-8000-000000000374', + reviewSuffix: 'receipt-crash', + }); + await assert.rejects( + commitLocalReconciliationReview(receiptState.command, { + ...receiptState.dependencies, + afterReceiptPublished() { + throw new Error('receipt crash'); + }, + }), + /receipt crash/, + ); + const receiptReplay = await commitLocalReconciliationReview( + receiptState.command, + receiptState.dependencies, + ); + assert.equal(receiptReplay.state, 'reconciliation_reviewed'); + + const sealState = reviewCommitFixture(t, { + planId: '00000000-0000-4000-8000-000000000375', + reviewId: '00000000-0000-4000-8000-000000000376', + reviewSuffix: 'seal-crash', + }); + await assert.rejects( + commitLocalReconciliationReview(sealState.command, { + ...sealState.dependencies, + afterTerminalSealed() { + throw new Error('seal crash'); + }, + }), + /seal crash/, + ); + assert.equal( + fs.statSync( + path.join(sealState.reviewRoot, sealState.reviewCommand.request.reviewId), + ).mode & 0o777, + 0o500, + ); + const sealReplay = await commitLocalReconciliationReview( + sealState.command, + sealState.dependencies, + ); + assert.equal(sealReplay.state, 'reconciliation_reviewed'); + + const headState = reviewCommitFixture(t, { + planId: '00000000-0000-4000-8000-000000000377', + reviewId: '00000000-0000-4000-8000-000000000378', + reviewSuffix: 'head-crash', + }); + await assert.rejects( + commitLocalReconciliationReview(headState.command, { + ...headState.dependencies, + afterHeadAdvanced() { + throw new Error('head response loss'); + }, + }), + /head response loss/, + ); + const headReplay = await commitLocalReconciliationReview( + headState.command, + headState.dependencies, + ); + assert.equal(headReplay.status, 'existing'); +}); + test( 'real stopped Docker target produces an independently verified bundle', { skip: process.env.QL3_RECONCILIATION_DOCKER_GATE !== '1' }, diff --git a/packages/ql3-local-sqlite/package.json b/packages/ql3-local-sqlite/package.json index fc4680bf..2691a0c2 100644 --- a/packages/ql3-local-sqlite/package.json +++ b/packages/ql3-local-sqlite/package.json @@ -315,6 +315,11 @@ "require": "./dist/security/securityAuditQuery.js", "default": "./dist/security/securityAuditQuery.js" }, + "./authentication-read": { + "types": "./dist/security/authenticationRead.d.ts", + "require": "./dist/security/authenticationRead.js", + "default": "./dist/security/authenticationRead.js" + }, "./security-audit-retention": { "types": "./dist/security/securityAuditRetention.d.ts", "require": "./dist/security/securityAuditRetention.js", diff --git a/packages/ql3-local-sqlite/src/security/authenticationRead.ts b/packages/ql3-local-sqlite/src/security/authenticationRead.ts new file mode 100644 index 00000000..3ecf44c1 --- /dev/null +++ b/packages/ql3-local-sqlite/src/security/authenticationRead.ts @@ -0,0 +1,58 @@ +// Security owns the read-only credential projection used by stopped-state commands. +import type { ApiCredentialRepository } from '@qinglong/runtime-core/api-credential'; +import type { LocalOwnerPepperRepository } from '@qinglong/runtime-core/local-owner-pepper'; + +import { LocalSqliteOperationAuthority } from '../authority/operationAuthority'; +import { LocalSqliteOwnerPepperRepository } from '../local-owner/ownerPepperRepository'; +import { + assertLocalSqliteOptions, + assertLocalSqlitePathBoundary, + openLocalSqliteClient, + type LocalSqliteDatabaseOptions, + type LocalSqliteProfile, +} from '../storage/config'; +import { LocalSqliteApiCredentialRepository } from './apiCredentialRepository'; +import { + auditLocalSqliteReadiness, + type LocalSqliteReadinessEvidence, +} from '../readiness/readiness'; + +export interface LocalSqliteAuthenticationReadDatabase { + readonly profile: LocalSqliteProfile; + readonly readiness: LocalSqliteReadinessEvidence; + readonly apiCredentials: ApiCredentialRepository; + readonly ownerPepper: Pick; + close(): Promise; +} + +/** + * Opens only the repositories needed to authenticate a local User. The SQLite + * descriptor is read-only, so stopped-state review cannot change journals, + * schema, credential rows, or any other target state. + */ +export async function openLocalSqliteAuthenticationReadDatabase( + options: LocalSqliteDatabaseOptions, +): Promise { + assertLocalSqliteOptions(options); + assertLocalSqlitePathBoundary(options.databasePath, false); + const client = openLocalSqliteClient(options, true); + try { + const readiness = await auditLocalSqliteReadiness(client); + const authority = new LocalSqliteOperationAuthority(client); + let closePromise: Promise | undefined; + return Object.freeze({ + profile: options.profile, + readiness, + apiCredentials: new LocalSqliteApiCredentialRepository(authority), + ownerPepper: new LocalSqliteOwnerPepperRepository(authority), + close() { + if (closePromise) return closePromise; + closePromise = authority.close(); + return closePromise; + }, + }); + } catch (error) { + if (client.isOpen) client.close(); + throw error; + } +} diff --git a/packages/ql3-local-sqlite/test/authenticationRead.test.cjs b/packages/ql3-local-sqlite/test/authenticationRead.test.cjs new file mode 100644 index 00000000..457969db --- /dev/null +++ b/packages/ql3-local-sqlite/test/authenticationRead.test.cjs @@ -0,0 +1,40 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { + openLocalSqliteAuthenticationReadDatabase, +} = require('../dist/security/authenticationRead.js'); +const { migrateLocalSqlitePath } = require('../dist/migration/migration.js'); + +test('authentication projection opens the target read-only without journal or file drift', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-auth-read-')); + fs.chmodSync(root, 0o700); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const databasePath = path.join(root, 'qinglong3.sqlite'); + await migrateLocalSqlitePath({ databasePath, profile: 'edge' }); + fs.chmodSync(databasePath, 0o600); + const bytes = fs.readFileSync(databasePath); + const before = fs.statSync(databasePath, { bigint: true }); + const database = await openLocalSqliteAuthenticationReadDatabase({ + databasePath, + profile: 'edge', + }); + try { + assert.equal(database.profile, 'edge'); + assert.equal(database.readiness.contractName, 'local-control-core'); + assert.equal(database.readiness.contractVersion, 50); + assert.equal(await database.apiCredentials.resolve('absent'), null); + assert.equal(await database.ownerPepper.resolveKey('absent'), null); + } finally { + await database.close(); + } + const after = fs.statSync(databasePath, { bigint: true }); + assert.equal(fs.readFileSync(databasePath).equals(bytes), true); + assert.equal(after.size, before.size); + assert.equal(after.mtimeNs, before.mtimeNs); + assert.equal(after.ctimeNs, before.ctimeNs); + assert.deepEqual(fs.readdirSync(root).sort(), ['qinglong3.sqlite']); +}); diff --git a/scripts/ql3-cluster-dependency-audit.cjs b/scripts/ql3-cluster-dependency-audit.cjs index 77130da4..1cc8ef68 100644 --- a/scripts/ql3-cluster-dependency-audit.cjs +++ b/scripts/ql3-cluster-dependency-audit.cjs @@ -2010,6 +2010,14 @@ function auditSourceImports(root, packagePath, findings) { 'src/lifecycle/localReadiness.ts' && specifier === '@qinglong/local-sqlite/readiness-inspection' ) && + !( + path.relative(packageDirectory, filePath) === + 'src/deployment/reconciliation/review/completion.ts' && + [ + '@qinglong/local-owner-console/authenticated-command', + '@qinglong/local-sqlite/authentication-read', + ].includes(specifier) + ) && !( [ 'src/deployment/compose/composeApply.ts', @@ -2643,6 +2651,28 @@ function auditSourceImports(root, packagePath, findings) { ) { continue; } + if ( + packagePath === 'packages/ql3-local-owner-cli' && + [ + { + file: 'src/deployment/reconciliation/review/authorization.ts', + specifiers: [ + '@qinglong/runtime-core/local-secret', + '@qinglong/runtime-core/security', + ], + }, + { + file: 'src/deployment/reconciliation/review/issuerKeyring.ts', + specifiers: ['@qinglong/runtime-core/local-secret'], + }, + ].some( + ({ file, specifiers }) => + path.relative(packageDirectory, filePath) === file && + specifiers.includes(specifier), + ) + ) { + continue; + } if ( packagePath === 'packages/ql3-local-owner-cli' && path.relative(packageDirectory, filePath) === diff --git a/test/back/ql3ClusterDependencyAudit.test.cjs b/test/back/ql3ClusterDependencyAudit.test.cjs index 80d8c90e..69ad6907 100644 --- a/test/back/ql3ClusterDependencyAudit.test.cjs +++ b/test/back/ql3ClusterDependencyAudit.test.cjs @@ -2083,6 +2083,73 @@ test('confines fresh setup authority to the reviewed owner CLI subpath', (t) => ); }); +test('confines reconciliation review authentication to exact read-only owners', (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'ql3-reconciliation-review-boundary-'), + ); + const reviewDirectory = path.join( + root, + 'packages/ql3-local-owner-cli/src/deployment/reconciliation/review', + ); + fs.mkdirSync(reviewDirectory, { recursive: true }); + fs.writeFileSync( + path.join(reviewDirectory, 'authorization.ts'), + [ + "import type { Key } from '@qinglong/runtime-core/local-secret';", + "import type { Principal } from '@qinglong/runtime-core/security';", + ].join('\n'), + ); + fs.writeFileSync( + path.join(reviewDirectory, 'completion.ts'), + [ + "import { authenticate } from '@qinglong/local-owner-console/authenticated-command';", + "import { database } from '@qinglong/local-sqlite/authentication-read';", + ].join('\n'), + ); + fs.writeFileSync( + path.join(reviewDirectory, 'issuerKeyring.ts'), + "import type { Key } from '@qinglong/runtime-core/local-secret';", + ); + fs.writeFileSync( + path.join(reviewDirectory, 'neighbor.ts'), + [ + "import { authenticate } from '@qinglong/local-owner-console/authenticated-command';", + "import { database } from '@qinglong/local-sqlite/authentication-read';", + "import type { Key } from '@qinglong/runtime-core/local-secret';", + "import type { Principal } from '@qinglong/runtime-core/security';", + ].join('\n'), + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const findings = []; + auditSourceImports(root, 'packages/ql3-local-owner-cli', findings); + assert.deepEqual( + findings.map(({ code, file, specifier }) => ({ code, file, specifier })), + [ + { + code: 'FORBIDDEN_LOCAL_ADOPTION_CLI_AUTHORITY_IMPORT', + file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/review/neighbor.ts', + specifier: '@qinglong/local-owner-console/authenticated-command', + }, + { + code: 'FORBIDDEN_LOCAL_ADOPTION_CLI_AUTHORITY_IMPORT', + file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/review/neighbor.ts', + specifier: '@qinglong/local-sqlite/authentication-read', + }, + { + code: 'FORBIDDEN_PACKAGE_SOURCE_IMPORT', + file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/review/neighbor.ts', + specifier: '@qinglong/runtime-core/local-secret', + }, + { + code: 'FORBIDDEN_PACKAGE_SOURCE_IMPORT', + file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/review/neighbor.ts', + specifier: '@qinglong/runtime-core/security', + }, + ], + ); +}); + test('deleted Owner ceremony package names remain dependency tombstones', (t) => { const root = fixture( t, diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index c62b41f9..ce63c90b 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localOwnerCli.rootSourceFileRoles, }, { - sourceFiles: 149, + sourceFiles: 155, rootSourceFiles: 1, rootSourceLines: 50, - nestedSourceFiles: 148, + nestedSourceFiles: 154, rootSourceFileRoles: { 'cli.ts': 'binary_entry' }, }, ); @@ -540,10 +540,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localSqlite.rootSourceFileRoles, }, { - sourceFiles: 202, + sourceFiles: 203, rootSourceFiles: 1, rootSourceLines: 31, - nestedSourceFiles: 201, + nestedSourceFiles: 202, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, );