mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): authorize reconciliation reviews
This commit is contained in:
@@ -11,21 +11,25 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-21):
|
最新增量证据(2026-08-21):
|
||||||
|
|
||||||
- D-391/ADR-0484(第一切片已实现,commit/verify 待实现):`reconciliation_planned` 仍是内容无关 summary,不能为人工裁决泄漏明细或
|
- D-391/ADR-0484(已接受):既有 Local Owner 已完整实现
|
||||||
获得 import authority。既有 Local Owner 已在 `deployment/reconciliation/review/` 增加
|
`reconciliation.review.prepare|diagnostics|commit|verify`,以
|
||||||
`reconciliation.review.prepare|diagnostics`,并为后续 commit/verify 冻结
|
`reconciliation_planned → reconciliation_review_prepared → reconciliation_reviewed` CAS 建立唯一 review fence。diagnostics 每次只把一个
|
||||||
`reconciliation_planned → reconciliation_review_prepared → reconciliation_reviewed` CAS 建立唯一 review fence。diagnostics 每次只把
|
database/domain/fact-kind 的最多 64 条私有记录 no-replace 写入 caller 指定的 owner-only 文件;stdout 不含路径、名称或 fact digest。
|
||||||
一个 database/domain/fact-kind 的最多 64 条私有记录 no-replace 写入 caller 指定的 owner-only 文件;stdout 只返回 page digest、计数和
|
commit 不信任 page,而是从 exact sealed bundle 重新流式派生 canonical facts,与 Edge ≤8 MiB、Standalone ≤32 MiB 的稳定 descriptor
|
||||||
offset,不含路径、名称或 fact digest。commit 不信任 page,而是从 exact sealed bundle 重新流式派生 canonical facts,与 Edge ≤8 MiB、
|
NDJSON decision file 逐条匹配。unknown、Secret/credential/pepper、active Run 与 custody 缺口固定 blocked,人工选择不能提升为自动可导入。
|
||||||
Standalone ≤32 MiB 的 private NDJSON decision file 逐条匹配。unknown、Secret/credential/pepper、active Run 与 custody 缺口固定 blocked,
|
生产 composition 只用新的 Local SQLite authentication-read projection 加载 credential/pepper,通过既有 Owner authentication 建立最多
|
||||||
人工选择也不能提升为自动可导入。authorization 必须来自 target Owner credential 建立的近期强认证 User,并以独立 issuer keyring、
|
5 分钟的强 User principal;独立最多八代 issuer keyring 签发最长 30 分钟的 domain-separated authorization,并在签名前后重验 decision
|
||||||
exact review file/plan/bundle/head fence 签名;terminal review 只保存签名 authorization、compact counts/digest 和 content-free receipt。
|
file、credential、keyring、plan、bundle fingerprint 与 prepared head。authorization/review/receipt/seal/head 的 crash/response-loss 窗口均
|
||||||
verify 不打开 SQLite。实现不新增 package/dependency/binary/daemon,不把文件平铺回 `src/` 根,也不调用 DML、Secret 解密、Docker/init/
|
exact replay,terminal evidence 为 `0400/0500`;verify 只验证签名和 exact binding,不打开 SQLite、不写文件。实现不新增 package、dependency、
|
||||||
network。后续领域 adapter 必须消费 exact `reviewDigest` 后重新认证和授权,分别定义 backup、prepare/commit、幂等与 rollback,不能把
|
binary 或 daemon,不平铺 `src/`,也不调用 DML、Secret 解密、Docker/init/network。后续领域 adapter 必须以独立 ADR 消费 exact
|
||||||
review completion 当作 reconciliation completion。当前聚焦套件 `28 total / 26 pass / 2 conditional Docker skip / 0 fail`,Local Owner
|
`reviewDigest` 后重新认证授权,不能把 review completion 当作 reconciliation completion。聚焦套件
|
||||||
`250 total / 243 pass / 7 conditional skip / 0 fail`,tracked backend `1540 total / 1538 pass / 2 conditional skip / 0 fail`,
|
`32 total / 30 pass / 2 conditional Docker skip / 0 fail`,Local Owner `254 total / 247 pass / 7 conditional skip / 0 fail`,tracked
|
||||||
18-package clean build/逐包测试、八项架构/发布审计、十四档 artifact 和真实 Docker readonly `2/2` 全通过。workspace 仍为 18
|
backend `1541 total / 1539 pass / 2 conditional skip / 0 fail`;18-package clean build/逐包测试、八项架构/部署/发布审计、十四档 artifact 与
|
||||||
packages,Local Owner `149 source / 148 nested / 1 root binary entry`;新增 3 个源码全部位于 review 子目录,基础常驻 closure 不增长。
|
真实 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 的严格只读消费与独立
|
- D-390/ADR-0483(已接受):既有 Local Owner 已实现密封 capture 的严格只读消费与独立
|
||||||
`reconciliation.plan.prepare|commit|verify`。capture v2 使用 SQLite 可识别的固定 `target.sqlite* / legacy.sqlite* /
|
`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
|
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`,
|
`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
|
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。
|
`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
|
D-391/ADR-0484 已完成私有逐对象诊断、受认证人工裁决、签名 terminal review 与只读 verify;它仍不能借 plan 或 review 获得自动 import、
|
||||||
authority,完成实现与完整门禁前保持 Proposed。
|
rollback 或 restart authority,下一阶段领域 adapter 继续使用独立 ADR 和门禁。
|
||||||
- D-389/ADR-0482(已接受):target stopped 后的 `reconciliation_required` 不能直接逆迁移或覆盖 2.x source;既有 Local Owner
|
- D-389/ADR-0482(已接受):target stopped 后的 `reconciliation_required` 不能直接逆迁移或覆盖 2.x source;既有 Local Owner
|
||||||
已实现独立 `reconciliation.capture.prepare|commit|verify`,只允许 exact stopped reconciliation head,以 instance CAS 建立唯一
|
已实现独立 `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
|
capture fence,并把 target main/sidecars、Legacy source main/sidecars、activation recovery 与内容无关 lineage 以固定 64 KiB
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ADR-0484:私有 Reconciliation 诊断与受认证人工裁决
|
# ADR-0484:私有 Reconciliation 诊断与受认证人工裁决
|
||||||
|
|
||||||
- 状态:Proposed(D-391 契约冻结)
|
- 状态:Accepted(D-391 已实现并完成门禁)
|
||||||
- 日期:2026-08-21
|
- 日期: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
|
- 关联 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
|
- 关联 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 建立
|
D-391 已完整实现 `review.prepare`、`review.diagnostics`、`review.commit` 与 `review.verify`。prepare/diagnostics 保持第一切片的 exact
|
||||||
`reconciliation_review_prepared` 唯一 fence,覆盖 head response loss、第二 review、rollback 和 restart 拒绝。diagnostics 在每次
|
plan/bundle/head fence、64 条私有分页、blocked fact 与 byte-exact replay;commit 新增 Edge 8 MiB、Standalone 32 MiB 的稳定 descriptor
|
||||||
SQLite open 前后重验密封资产,只为一个 database/domain/fact-kind 发布最多 64 条 owner-only page;Secret、identity、history 和 unknown
|
NDJSON 流,逐条重新派生密封 bundle facts,不读取 diagnostics page。生产 composition 只通过新的 Local SQLite authentication-read
|
||||||
facts 固定 blocked,未知表不读取 row,terminal result 不返回路径、名称或 fact digest。page 使用 256 KiB 固定上限、deterministic
|
projection 读取 credential/pepper,并使用既有 `establishAuthenticatedLocalCommand` 建立最多 5 分钟的强 User principal。独立 issuer
|
||||||
stage、hard-link no-replace 与 fsync,重复请求只能得到 byte-exact existing page。
|
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
|
authorization、review、receipt、seal 与 head 的每个 crash/response-loss 窗口均已覆盖;terminal evidence 收敛为 `0400/0500`,verify
|
||||||
`250 total / 243 pass / 7 conditional skip / 0 fail`;tracked backend `1540 total / 1538 pass / 2 conditional skip / 0 fail`;
|
复验签名与全部 exact binding,不打开 SQLite、不写文件。CLI 只返回 content-free digest/count/replay facts。`reconciliation_reviewed`
|
||||||
18-package clean build/逐包测试、八项架构/发布审计、十四档 artifact audit 与真实 Docker readonly `2/2` 均通过。workspace 仍为 18
|
仍不授予 import、rollback、restart、SQL、Secret 解密或外部副作用 authority;任何领域 adapter 必须由下一份独立 ADR 定义。
|
||||||
packages,`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner 为 `149 source / 148 nested / 1 root binary entry`,新增
|
|
||||||
3 个源码全部位于 `deployment/reconciliation/review/`,没有新增 dependency 或常驻对象,基础 Edge/Standalone closure 未增长。
|
|
||||||
|
|
||||||
`review.commit`、强认证 User/issuer keyring authorization、terminal seal、`reconciliation_reviewed` 推进和 `review.verify` 尚未实现,因此本
|
验证结果:聚焦套件 `32 total / 30 pass / 2 conditional Docker skip / 0 fail`;完整 Local Owner
|
||||||
ADR 继续保持 Proposed;当前 prepared review 不授予任何 import、rollback 或 restart authority。
|
`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。
|
||||||
|
|
||||||
## 被拒绝的替代方案
|
## 被拒绝的替代方案
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ import {
|
|||||||
writeLocalReconciliationReviewDiagnostics,
|
writeLocalReconciliationReviewDiagnostics,
|
||||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||||
} from './reconciliation/review/preparation';
|
} from './reconciliation/review/preparation';
|
||||||
|
import {
|
||||||
|
commitLocalReconciliationReview,
|
||||||
|
commitLocalReconciliationReviewCommandFile,
|
||||||
|
verifyLocalReconciliationReview,
|
||||||
|
verifyLocalReconciliationReviewCommandFile,
|
||||||
|
} from './reconciliation/review/completion';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
commitLocalReconciliationPlan,
|
commitLocalReconciliationPlan,
|
||||||
@@ -129,6 +135,10 @@ export {
|
|||||||
prepareLocalReconciliationReviewCommandFile,
|
prepareLocalReconciliationReviewCommandFile,
|
||||||
writeLocalReconciliationReviewDiagnostics,
|
writeLocalReconciliationReviewDiagnostics,
|
||||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||||
|
commitLocalReconciliationReview,
|
||||||
|
commitLocalReconciliationReviewCommandFile,
|
||||||
|
verifyLocalReconciliationReview,
|
||||||
|
verifyLocalReconciliationReviewCommandFile,
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -181,6 +191,38 @@ export {
|
|||||||
type LocalReconciliationReviewPrepareCommand,
|
type LocalReconciliationReviewPrepareCommand,
|
||||||
type LocalReconciliationReviewPrepareResult,
|
type LocalReconciliationReviewPrepareResult,
|
||||||
} from './reconciliation/review/contract';
|
} 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 {
|
export {
|
||||||
localReconciliationReviewDirectory,
|
localReconciliationReviewDirectory,
|
||||||
normalizeLocalReconciliationReviewIntent,
|
normalizeLocalReconciliationReviewIntent,
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ import {
|
|||||||
prepareLocalReconciliationReviewCommandFile,
|
prepareLocalReconciliationReviewCommandFile,
|
||||||
commitLocalReconciliationCaptureCommandFile,
|
commitLocalReconciliationCaptureCommandFile,
|
||||||
commitLocalReconciliationPlanCommandFile,
|
commitLocalReconciliationPlanCommandFile,
|
||||||
|
commitLocalReconciliationReviewCommandFile,
|
||||||
verifyLocalReconciliationCaptureCommandFile,
|
verifyLocalReconciliationCaptureCommandFile,
|
||||||
verifyLocalReconciliationPlanCommandFile,
|
verifyLocalReconciliationPlanCommandFile,
|
||||||
|
verifyLocalReconciliationReviewCommandFile,
|
||||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||||
prepareLocalDeploymentCommandFile,
|
prepareLocalDeploymentCommandFile,
|
||||||
proveLocalDeploymentLegacyReadinessCommandFile,
|
proveLocalDeploymentLegacyReadinessCommandFile,
|
||||||
@@ -35,7 +37,7 @@ import {
|
|||||||
} from './localDeployment';
|
} from './localDeployment';
|
||||||
|
|
||||||
const USAGE =
|
const USAGE =
|
||||||
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||||
|
|
||||||
async function main(argv: readonly string[]): Promise<void> {
|
async function main(argv: readonly string[]): Promise<void> {
|
||||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||||
@@ -72,6 +74,8 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
argv[0] !== 'reconciliation-plan-verify' &&
|
argv[0] !== 'reconciliation-plan-verify' &&
|
||||||
argv[0] !== 'reconciliation-review-prepare' &&
|
argv[0] !== 'reconciliation-review-prepare' &&
|
||||||
argv[0] !== 'reconciliation-review-diagnostics' &&
|
argv[0] !== 'reconciliation-review-diagnostics' &&
|
||||||
|
argv[0] !== 'reconciliation-review-commit' &&
|
||||||
|
argv[0] !== 'reconciliation-review-verify' &&
|
||||||
argv[0] !== 'compose-revision' &&
|
argv[0] !== 'compose-revision' &&
|
||||||
argv[0] !== 'compose-preflight' &&
|
argv[0] !== 'compose-preflight' &&
|
||||||
argv[0] !== 'compose-apply' &&
|
argv[0] !== 'compose-apply' &&
|
||||||
@@ -158,6 +162,10 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
? prepareLocalReconciliationReviewCommandFile(argv[2]!)
|
? prepareLocalReconciliationReviewCommandFile(argv[2]!)
|
||||||
: argv[0] === 'reconciliation-review-diagnostics'
|
: argv[0] === 'reconciliation-review-diagnostics'
|
||||||
? writeLocalReconciliationReviewDiagnosticsCommandFile(argv[2]!)
|
? writeLocalReconciliationReviewDiagnosticsCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-review-commit'
|
||||||
|
? commitLocalReconciliationReviewCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-review-verify'
|
||||||
|
? verifyLocalReconciliationReviewCommandFile(argv[2]!)
|
||||||
: argv[0] === 'compose-revision'
|
: argv[0] === 'compose-revision'
|
||||||
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
||||||
: argv[0] === 'compose-preflight'
|
: argv[0] === 'compose-preflight'
|
||||||
|
|||||||
@@ -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<SecurityPrincipal>;
|
||||||
|
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<LocalReconciliationReviewDisposition, number>
|
||||||
|
>;
|
||||||
|
readonly reasonCounts: Readonly<
|
||||||
|
Record<LocalReconciliationReviewReason, number>
|
||||||
|
>;
|
||||||
|
readonly header: Readonly<LocalReconciliationReviewAuthorizationHeader>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<LocalReconciliationReviewDisposition, number>
|
||||||
|
>;
|
||||||
|
readonly reasonCounts: Readonly<
|
||||||
|
Record<LocalReconciliationReviewReason, number>
|
||||||
|
>;
|
||||||
|
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<T extends string>(keys: readonly T[]): Record<T, number> {
|
||||||
|
return Object.fromEntries(keys.map((key) => [key, 0])) as Record<T, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
configurationError(`${label} must be an object`);
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
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<LocalReconciliationReviewAuthorizationHeader> {
|
||||||
|
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<LocalReconciliationReviewDecision> {
|
||||||
|
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<T extends string>(
|
||||||
|
value: unknown,
|
||||||
|
keys: readonly T[],
|
||||||
|
label: string,
|
||||||
|
): Readonly<Record<T, number>> {
|
||||||
|
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<Record<T, number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSignature(value: unknown): Readonly<SignatureRecord> {
|
||||||
|
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<LocalReconciliationReviewAuthorizationHeader>;
|
||||||
|
keyProvider: LocalReconciliationReviewIssuerKeyringFileProvider;
|
||||||
|
writeDecisions: (
|
||||||
|
append: (decision: Readonly<LocalReconciliationReviewDecision>) => void,
|
||||||
|
) => Readonly<{
|
||||||
|
readonly decisionFileDigest: string;
|
||||||
|
readonly confirmDecisionFileAuthority: () => void;
|
||||||
|
}>;
|
||||||
|
confirmAuthority: () => void | Promise<void>;
|
||||||
|
}>,
|
||||||
|
): Promise<Readonly<LocalReconciliationReviewAuthorizationEvidence>> {
|
||||||
|
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<Readonly<LocalReconciliationReviewAuthorizationEvidence>> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof openLocalSqliteAuthenticationReadDatabase>
|
||||||
|
>;
|
||||||
|
|
||||||
|
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<LocalReconciliationReviewIntent>;
|
||||||
|
readonly authorization: Readonly<LocalReconciliationReviewAuthorizationEvidence>;
|
||||||
|
readonly review: Readonly<LocalReconciliationReview>;
|
||||||
|
readonly receipt: Readonly<LocalReconciliationReviewReceipt>;
|
||||||
|
readonly head: Readonly<LocalCutoverInstanceHead>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configurationError(message: string, cause?: unknown): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||||
|
}
|
||||||
|
|
||||||
|
function paths(reviewRoot: string, reviewId: string): Readonly<ReviewPaths> {
|
||||||
|
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<ReviewPaths>,
|
||||||
|
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<ReviewPaths>,
|
||||||
|
uid: number,
|
||||||
|
): Readonly<LocalReconciliationReviewIntent> {
|
||||||
|
return normalizeLocalReconciliationReviewIntent(
|
||||||
|
readLocalReconciliationReviewTerminalJson(
|
||||||
|
selected.intent,
|
||||||
|
uid,
|
||||||
|
[0o600, 0o400],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateIntentBinding(
|
||||||
|
command: Readonly<LocalReconciliationReviewCommitCommand>,
|
||||||
|
intent: Readonly<LocalReconciliationReviewIntent>,
|
||||||
|
): 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<LocalCutoverInstanceHead>,
|
||||||
|
intent: Readonly<LocalReconciliationReviewIntent>,
|
||||||
|
): 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<LocalReconciliationReviewIntent>,
|
||||||
|
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<AuthenticatedLocalCommand>,
|
||||||
|
committedAtMs: number,
|
||||||
|
): Readonly<LocalReconciliationReviewAuthorizationHeader['reviewer']> {
|
||||||
|
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<LocalReconciliationReviewIntent>,
|
||||||
|
committedAtMs: number,
|
||||||
|
reviewDigest: string,
|
||||||
|
uid: number,
|
||||||
|
): Readonly<LocalCutoverInstanceHead> {
|
||||||
|
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<ReviewPaths>, 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<ReviewPaths>,
|
||||||
|
intent: Readonly<LocalReconciliationReviewIntent>,
|
||||||
|
issuerKeyringPath: string,
|
||||||
|
preparedHeadDigest: string,
|
||||||
|
): Promise<void> {
|
||||||
|
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<LocalReconciliationReviewIntent>,
|
||||||
|
authorization: Readonly<LocalReconciliationReviewAuthorizationEvidence>,
|
||||||
|
review: Readonly<LocalReconciliationReview>,
|
||||||
|
receipt: Readonly<LocalReconciliationReviewReceipt>,
|
||||||
|
): 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<ReviewPaths>,
|
||||||
|
intent: Readonly<LocalReconciliationReviewIntent>,
|
||||||
|
issuerKeyringPath: string,
|
||||||
|
uid: number,
|
||||||
|
allowedModes: readonly number[],
|
||||||
|
): Promise<
|
||||||
|
Readonly<{
|
||||||
|
authorization: Readonly<LocalReconciliationReviewAuthorizationEvidence>;
|
||||||
|
review: Readonly<LocalReconciliationReview>;
|
||||||
|
receipt: Readonly<LocalReconciliationReviewReceipt>;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
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<LocalReconciliationReviewAuthorizationEvidence>;
|
||||||
|
review: Readonly<LocalReconciliationReview>;
|
||||||
|
}>,
|
||||||
|
head: Readonly<LocalCutoverInstanceHead>,
|
||||||
|
): Readonly<LocalReconciliationReviewTerminalResult> {
|
||||||
|
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<AuthenticatedLocalCommand>,
|
||||||
|
command: Readonly<LocalReconciliationReviewCommitCommand>,
|
||||||
|
intent: Readonly<LocalReconciliationReviewIntent>,
|
||||||
|
uid: number,
|
||||||
|
): Promise<void> {
|
||||||
|
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<LocalReconciliationReviewCommitCommand>,
|
||||||
|
intent: Readonly<LocalReconciliationReviewIntent>,
|
||||||
|
selected: Readonly<ReviewPaths>,
|
||||||
|
head: Readonly<LocalCutoverInstanceHead>,
|
||||||
|
terminal: ReturnType<typeof readLocalReconciliationPlanTerminal>,
|
||||||
|
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||||
|
dependencies: LocalReconciliationReviewCompletionDependencies,
|
||||||
|
uid: number,
|
||||||
|
): Promise<Readonly<LocalReconciliationReviewAuthorizationEvidence>> {
|
||||||
|
const reviewer = strongPrincipal(
|
||||||
|
authenticated,
|
||||||
|
command.request.committedAtMs,
|
||||||
|
);
|
||||||
|
ensureLocalReconciliationReviewIssuerKeyring(
|
||||||
|
command.options.issuerKeyringPath,
|
||||||
|
);
|
||||||
|
const keyProvider = new LocalReconciliationReviewIssuerKeyringFileProvider(
|
||||||
|
command.options.issuerKeyringPath,
|
||||||
|
);
|
||||||
|
const header: Readonly<LocalReconciliationReviewAuthorizationHeader> =
|
||||||
|
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<Readonly<LocalReconciliationReviewTerminalResult>> {
|
||||||
|
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<LocalReconciliationReviewAuthorizationEvidence>;
|
||||||
|
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<Readonly<LocalReconciliationReviewTerminalResult>> {
|
||||||
|
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<Readonly<LocalReconciliationReviewTerminalResult>> {
|
||||||
|
return commitLocalReconciliationReview(readPrivateLocalCommandFile(filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyLocalReconciliationReviewCommandFile(
|
||||||
|
filePath: string,
|
||||||
|
): Promise<Readonly<LocalReconciliationReviewTerminalResult>> {
|
||||||
|
return verifyLocalReconciliationReview(readPrivateLocalCommandFile(filePath));
|
||||||
|
}
|
||||||
+361
@@ -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<LocalReconciliationReviewCommitOptions>;
|
||||||
|
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<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
configurationError(`${label} must be an object`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
keys: readonly string[],
|
||||||
|
label: string,
|
||||||
|
): void {
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const expected = [...keys].sort();
|
||||||
|
if (
|
||||||
|
actual.length !== expected.length ||
|
||||||
|
actual.some((key, index) => key !== expected[index])
|
||||||
|
) {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Readonly<LocalReconciliationReviewOptions> {
|
||||||
|
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<LocalReconciliationReviewCommitCommand> {
|
||||||
|
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<LocalReconciliationReviewVerifyCommand> {
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<LocalReconciliationReviewDecisionHeader>;
|
||||||
|
next(): Readonly<LocalReconciliationReviewDecision> | 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<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
configurationError(`${label} must be an object`);
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
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<LocalReconciliationReviewDecisionHeader> {
|
||||||
|
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<LocalReconciliationReviewDecisionHeader>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decision(value: unknown): Readonly<LocalReconciliationReviewDecision> {
|
||||||
|
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<LocalReconciliationReviewDecision>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<LocalReconciliationReviewDecision>,
|
||||||
|
fact: Readonly<LocalReconciliationDiagnosticFact>,
|
||||||
|
): 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<T>(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,9 +89,7 @@ function quotedIdentifier(name: string): string {
|
|||||||
return `"${name.replaceAll('"', '""')}"`;
|
return `"${name.replaceAll('"', '""')}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requirement(
|
function requirement(domain: LocalReconciliationPlanDomain): Readonly<{
|
||||||
domain: LocalReconciliationPlanDomain,
|
|
||||||
): Readonly<{
|
|
||||||
decisionRequirement: LocalReconciliationDiagnosticDecisionRequirement;
|
decisionRequirement: LocalReconciliationDiagnosticDecisionRequirement;
|
||||||
reason: LocalReconciliationDiagnosticReason;
|
reason: LocalReconciliationDiagnosticReason;
|
||||||
}> {
|
}> {
|
||||||
@@ -188,7 +186,10 @@ function schemaFacts(
|
|||||||
domain: LocalReconciliationPlanDomain,
|
domain: LocalReconciliationPlanDomain,
|
||||||
offset: number,
|
offset: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Readonly<{ records: readonly Readonly<LocalReconciliationDiagnosticFact>[]; complete: boolean }> {
|
): Readonly<{
|
||||||
|
records: readonly Readonly<LocalReconciliationDiagnosticFact>[];
|
||||||
|
complete: boolean;
|
||||||
|
}> {
|
||||||
const rows = client
|
const rows = client
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT type, name, tbl_name AS tableName
|
`SELECT type, name, tbl_name AS tableName
|
||||||
@@ -208,7 +209,8 @@ function schemaFacts(
|
|||||||
let complete = true;
|
let complete = true;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
catalog += 1;
|
catalog += 1;
|
||||||
if (catalog > 4_096) configurationError('diagnostic schema budget is exceeded');
|
if (catalog > 4_096)
|
||||||
|
configurationError('diagnostic schema budget is exceeded');
|
||||||
if (
|
if (
|
||||||
typeof row.type !== 'string' ||
|
typeof row.type !== 'string' ||
|
||||||
typeof row.name !== 'string' ||
|
typeof row.name !== 'string' ||
|
||||||
@@ -250,7 +252,10 @@ function tableFacts(
|
|||||||
domain: LocalReconciliationPlanDomain,
|
domain: LocalReconciliationPlanDomain,
|
||||||
offset: number,
|
offset: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Readonly<{ records: readonly Readonly<LocalReconciliationDiagnosticFact>[]; complete: boolean }> {
|
): Readonly<{
|
||||||
|
records: readonly Readonly<LocalReconciliationDiagnosticFact>[];
|
||||||
|
complete: boolean;
|
||||||
|
}> {
|
||||||
const rows = client
|
const rows = client
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT name, type
|
`SELECT name, type
|
||||||
@@ -269,7 +274,8 @@ function tableFacts(
|
|||||||
let complete = true;
|
let complete = true;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
catalog += 1;
|
catalog += 1;
|
||||||
if (catalog > 512) configurationError('diagnostic table budget is exceeded');
|
if (catalog > 512)
|
||||||
|
configurationError('diagnostic table budget is exceeded');
|
||||||
if (
|
if (
|
||||||
typeof row.name !== 'string' ||
|
typeof row.name !== 'string' ||
|
||||||
typeof row.type !== 'string' ||
|
typeof row.type !== 'string' ||
|
||||||
@@ -277,7 +283,8 @@ function tableFacts(
|
|||||||
) {
|
) {
|
||||||
configurationError('diagnostic table catalog drifted');
|
configurationError('diagnostic table catalog drifted');
|
||||||
}
|
}
|
||||||
if (classifyLocalReconciliationFact(database, row.name) !== domain) continue;
|
if (classifyLocalReconciliationFact(database, row.name) !== domain)
|
||||||
|
continue;
|
||||||
const ordinal = seen + 1;
|
const ordinal = seen + 1;
|
||||||
seen += 1;
|
seen += 1;
|
||||||
if (seen <= offset) continue;
|
if (seen <= offset) continue;
|
||||||
@@ -345,13 +352,57 @@ export function buildLocalReconciliationDiagnosticPage(
|
|||||||
nextOffset,
|
nextOffset,
|
||||||
records: selected.records,
|
records: selected.records,
|
||||||
});
|
});
|
||||||
const page = Object.freeze({ ...payload, pageDigest: cutoverDigest(payload) });
|
const page = Object.freeze({
|
||||||
if (Buffer.byteLength(`${JSON.stringify(page, null, 2)}\n`, 'utf8') > MAX_PAGE_BYTES) {
|
...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');
|
configurationError('diagnostic page exceeds its 256 KiB budget');
|
||||||
}
|
}
|
||||||
return page;
|
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<LocalReconciliationDiagnosticFact>) => 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<LocalReconciliationDiagnosticPage>): Buffer {
|
function pageBytes(page: Readonly<LocalReconciliationDiagnosticPage>): Buffer {
|
||||||
const bytes = Buffer.from(`${JSON.stringify(page, null, 2)}\n`, 'utf8');
|
const bytes = Buffer.from(`${JSON.stringify(page, null, 2)}\n`, 'utf8');
|
||||||
if (bytes.byteLength < 2 || bytes.byteLength > MAX_PAGE_BYTES) {
|
if (bytes.byteLength < 2 || bytes.byteLength > MAX_PAGE_BYTES) {
|
||||||
@@ -396,11 +447,7 @@ function syncDirectory(directory: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeStage(
|
function writeStage(stagePath: string, bytes: Buffer, uid: number): void {
|
||||||
stagePath: string,
|
|
||||||
bytes: Buffer,
|
|
||||||
uid: number,
|
|
||||||
): void {
|
|
||||||
let descriptor: number | undefined;
|
let descriptor: number | undefined;
|
||||||
let created = false;
|
let created = false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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<IssuerKeyRecord>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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<IssuerKeyringManifest> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<IssuerKeyringManifest>): 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<IssuerKeyringManifest>;
|
||||||
|
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<IssuerKeyringManifest>,
|
||||||
|
): Readonly<LocalReconciliationReviewIssuerKeyringSummary> {
|
||||||
|
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<IssuerKeyRecord>): 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<LocalSecretKeyMaterial> {
|
||||||
|
const manifest = load(this.filePath).manifest;
|
||||||
|
return material(manifest.keys[manifest.keys.length - 1]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolve(keyId: string): Promise<LocalSecretKeyMaterial | null> {
|
||||||
|
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<LocalReconciliationReviewIssuerKeyringSummary> {
|
||||||
|
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<LocalReconciliationReviewIssuerKeyringSummary> {
|
||||||
|
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<IssuerKeyringManifest> = 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,7 +107,10 @@ function contents(value: unknown): string {
|
|||||||
|
|
||||||
function overlaps(left: string, right: string): boolean {
|
function overlaps(left: string, right: string): boolean {
|
||||||
const relative = path.relative(left, right);
|
const relative = path.relative(left, right);
|
||||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
return (
|
||||||
|
relative === '' ||
|
||||||
|
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function localReconciliationReviewDirectory(
|
export function localReconciliationReviewDirectory(
|
||||||
@@ -149,19 +152,34 @@ function ensureReviewDirectory(
|
|||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateCatalog(paths: Readonly<LocalReconciliationReviewPaths>): void {
|
function validateCatalog(
|
||||||
|
paths: Readonly<LocalReconciliationReviewPaths>,
|
||||||
|
): void {
|
||||||
const allowed = new Set([
|
const allowed = new Set([
|
||||||
|
'authorization.ndjson',
|
||||||
'intent.json',
|
'intent.json',
|
||||||
|
'receipt.json',
|
||||||
|
'review.json',
|
||||||
'staging',
|
'staging',
|
||||||
|
'.receipt.json.ql3-deploy-stage',
|
||||||
|
'.review.json.ql3-deploy-stage',
|
||||||
'.intent.json.ql3-deploy-stage',
|
'.intent.json.ql3-deploy-stage',
|
||||||
]);
|
]);
|
||||||
for (const entry of fs.readdirSync(paths.root, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(paths.root, { withFileTypes: true })) {
|
||||||
if (!allowed.has(entry.name) || entry.isSymbolicLink()) {
|
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) {
|
if (
|
||||||
configurationError('reconciliation review staging contains unknown material');
|
fs
|
||||||
|
.readdirSync(paths.staging)
|
||||||
|
.some((entry) => entry !== 'authorization.ndjson.stage')
|
||||||
|
) {
|
||||||
|
configurationError(
|
||||||
|
'reconciliation review staging contains unknown material',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +190,8 @@ function buildIntent(
|
|||||||
if (
|
if (
|
||||||
terminal.intent.command.options.deploymentRoot !==
|
terminal.intent.command.options.deploymentRoot !==
|
||||||
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.planRoot !== command.options.planRoot ||
|
||||||
terminal.intent.command.options.allowRootService !==
|
terminal.intent.command.options.allowRootService !==
|
||||||
command.options.allowRootService ||
|
command.options.allowRootService ||
|
||||||
@@ -197,7 +216,10 @@ function buildIntent(
|
|||||||
planReceiptDigest: terminal.receipt.receiptDigest,
|
planReceiptDigest: terminal.receipt.receiptDigest,
|
||||||
plannedHeadDigest: command.request.expectedHeadDigest,
|
plannedHeadDigest: command.request.expectedHeadDigest,
|
||||||
});
|
});
|
||||||
return Object.freeze({ ...payload, preparationDigest: cutoverDigest(payload) });
|
return Object.freeze({
|
||||||
|
...payload,
|
||||||
|
preparationDigest: cutoverDigest(payload),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeLocalReconciliationReviewIntent(
|
export function normalizeLocalReconciliationReviewIntent(
|
||||||
@@ -224,7 +246,9 @@ export function normalizeLocalReconciliationReviewIntent(
|
|||||||
],
|
],
|
||||||
'reconciliation review intent',
|
'reconciliation review intent',
|
||||||
);
|
);
|
||||||
const command = normalizeLocalReconciliationReviewPrepareCommand(intent.command);
|
const command = normalizeLocalReconciliationReviewPrepareCommand(
|
||||||
|
intent.command,
|
||||||
|
);
|
||||||
const { preparationDigest, ...payload } = intent;
|
const { preparationDigest, ...payload } = intent;
|
||||||
if (
|
if (
|
||||||
intent.schema !== INTENT_SCHEMA ||
|
intent.schema !== INTENT_SCHEMA ||
|
||||||
@@ -311,10 +335,22 @@ export function prepareLocalReconciliationReview(
|
|||||||
): Readonly<LocalReconciliationReviewPrepareResult> {
|
): Readonly<LocalReconciliationReviewPrepareResult> {
|
||||||
const command = normalizeLocalReconciliationReviewPrepareCommand(input);
|
const command = normalizeLocalReconciliationReviewPrepareCommand(input);
|
||||||
const identity = currentIdentity();
|
const identity = currentIdentity();
|
||||||
validatePrivateDirectory(command.options.deploymentRoot, identity.uid, 'deploymentRoot');
|
validatePrivateDirectory(
|
||||||
validatePrivateDirectory(command.options.captureRoot, identity.uid, 'captureRoot');
|
command.options.deploymentRoot,
|
||||||
|
identity.uid,
|
||||||
|
'deploymentRoot',
|
||||||
|
);
|
||||||
|
validatePrivateDirectory(
|
||||||
|
command.options.captureRoot,
|
||||||
|
identity.uid,
|
||||||
|
'captureRoot',
|
||||||
|
);
|
||||||
validatePrivateDirectory(command.options.planRoot, identity.uid, 'planRoot');
|
validatePrivateDirectory(command.options.planRoot, identity.uid, 'planRoot');
|
||||||
validatePrivateDirectory(command.options.reviewRoot, identity.uid, 'reviewRoot');
|
validatePrivateDirectory(
|
||||||
|
command.options.reviewRoot,
|
||||||
|
identity.uid,
|
||||||
|
'reviewRoot',
|
||||||
|
);
|
||||||
const terminal = readLocalReconciliationPlanTerminal(
|
const terminal = readLocalReconciliationPlanTerminal(
|
||||||
command.options.planRoot,
|
command.options.planRoot,
|
||||||
command.request.planId,
|
command.request.planId,
|
||||||
@@ -336,7 +372,9 @@ export function prepareLocalReconciliationReview(
|
|||||||
(head.state !== 'reconciliation_planned' &&
|
(head.state !== 'reconciliation_planned' &&
|
||||||
head.state !== 'reconciliation_review_prepared')
|
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(
|
const paths = ensureReviewDirectory(
|
||||||
command.options.reviewRoot,
|
command.options.reviewRoot,
|
||||||
@@ -384,7 +422,8 @@ function validateDiagnosticsBinding(
|
|||||||
intent.command.options.captureRoot !== command.options.captureRoot ||
|
intent.command.options.captureRoot !== command.options.captureRoot ||
|
||||||
intent.command.options.planRoot !== command.options.planRoot ||
|
intent.command.options.planRoot !== command.options.planRoot ||
|
||||||
intent.command.options.reviewRoot !== command.options.reviewRoot ||
|
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.command.request.reviewId !== command.request.reviewId ||
|
||||||
intent.preparationDigest !== command.request.expectedPreparationDigest
|
intent.preparationDigest !== command.request.expectedPreparationDigest
|
||||||
) {
|
) {
|
||||||
@@ -413,13 +452,36 @@ export function writeLocalReconciliationReviewDiagnostics(
|
|||||||
): Readonly<LocalReconciliationReviewDiagnosticsResult> {
|
): Readonly<LocalReconciliationReviewDiagnosticsResult> {
|
||||||
const command = normalizeLocalReconciliationReviewDiagnosticsCommand(input);
|
const command = normalizeLocalReconciliationReviewDiagnosticsCommand(input);
|
||||||
const identity = currentIdentity();
|
const identity = currentIdentity();
|
||||||
validatePrivateDirectory(command.options.deploymentRoot, identity.uid, 'deploymentRoot');
|
validatePrivateDirectory(
|
||||||
validatePrivateDirectory(command.options.captureRoot, identity.uid, 'captureRoot');
|
command.options.deploymentRoot,
|
||||||
|
identity.uid,
|
||||||
|
'deploymentRoot',
|
||||||
|
);
|
||||||
|
validatePrivateDirectory(
|
||||||
|
command.options.captureRoot,
|
||||||
|
identity.uid,
|
||||||
|
'captureRoot',
|
||||||
|
);
|
||||||
validatePrivateDirectory(command.options.planRoot, identity.uid, 'planRoot');
|
validatePrivateDirectory(command.options.planRoot, identity.uid, 'planRoot');
|
||||||
validatePrivateDirectory(command.options.reviewRoot, identity.uid, 'reviewRoot');
|
validatePrivateDirectory(
|
||||||
const paths = reviewPaths(command.options.reviewRoot, command.request.reviewId);
|
command.options.reviewRoot,
|
||||||
validatePrivateDirectory(paths.root, identity.uid, 'reconciliationReviewDirectory');
|
identity.uid,
|
||||||
validatePrivateDirectory(paths.staging, identity.uid, 'reconciliationReviewStaging');
|
'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(
|
const intent = readLocalReconciliationReviewIntent(
|
||||||
command.options.reviewRoot,
|
command.options.reviewRoot,
|
||||||
command.request.reviewId,
|
command.request.reviewId,
|
||||||
@@ -466,7 +528,9 @@ export function writeLocalReconciliationReviewDiagnostics(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (page === null) {
|
if (page === null) {
|
||||||
configurationError('manual-required SQLite topology has no diagnostic page');
|
configurationError(
|
||||||
|
'manual-required SQLite topology has no diagnostic page',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const currentHead = readLocalCutoverInstanceHead(
|
const currentHead = readLocalCutoverInstanceHead(
|
||||||
command.options.deploymentRoot,
|
command.options.deploymentRoot,
|
||||||
@@ -478,7 +542,9 @@ export function writeLocalReconciliationReviewDiagnostics(
|
|||||||
currentHead.state !== 'reconciliation_review_prepared' ||
|
currentHead.state !== 'reconciliation_review_prepared' ||
|
||||||
currentHead.sourceRecordDigest !== intent.preparationDigest
|
currentHead.sourceRecordDigest !== intent.preparationDigest
|
||||||
) {
|
) {
|
||||||
configurationError('review diagnostics instance head changed while reading');
|
configurationError(
|
||||||
|
'review diagnostics instance head changed while reading',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
dependencies.beforeDiagnosticPublish?.();
|
dependencies.beforeDiagnosticPublish?.();
|
||||||
const status = publishLocalReconciliationDiagnosticPage(
|
const status = publishLocalReconciliationDiagnosticPage(
|
||||||
@@ -505,7 +571,9 @@ export function writeLocalReconciliationReviewDiagnostics(
|
|||||||
export function prepareLocalReconciliationReviewCommandFile(
|
export function prepareLocalReconciliationReviewCommandFile(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
): Readonly<LocalReconciliationReviewPrepareResult> {
|
): Readonly<LocalReconciliationReviewPrepareResult> {
|
||||||
return prepareLocalReconciliationReview(readPrivateLocalCommandFile(filePath));
|
return prepareLocalReconciliationReview(
|
||||||
|
readPrivateLocalCommandFile(filePath),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeLocalReconciliationReviewDiagnosticsCommandFile(
|
export function writeLocalReconciliationReviewDiagnosticsCommandFile(
|
||||||
|
|||||||
+396
@@ -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<LocalReconciliationReviewDisposition, number>
|
||||||
|
>;
|
||||||
|
readonly reasonCounts: Readonly<
|
||||||
|
Record<LocalReconciliationReviewReason, number>
|
||||||
|
>;
|
||||||
|
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<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
configurationError(`${label} must be an object`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
keys: readonly string[],
|
||||||
|
label: string,
|
||||||
|
): void {
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const expected = [...keys].sort();
|
||||||
|
if (
|
||||||
|
actual.length !== expected.length ||
|
||||||
|
actual.some((key, index) => key !== expected[index])
|
||||||
|
) {
|
||||||
|
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<Record<string, number>> {
|
||||||
|
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<string, number>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalReconciliationReview(
|
||||||
|
input: Readonly<{
|
||||||
|
authorization: Readonly<LocalReconciliationReviewAuthorizationEvidence>;
|
||||||
|
decisionFileDigest: string;
|
||||||
|
committedAtMs: number;
|
||||||
|
}>,
|
||||||
|
): Readonly<LocalReconciliationReview> {
|
||||||
|
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<LocalReconciliationReview>,
|
||||||
|
keyId: string,
|
||||||
|
): Readonly<LocalReconciliationReviewReceipt> {
|
||||||
|
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<LocalReconciliationReview> {
|
||||||
|
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<LocalReconciliationReviewReceipt> {
|
||||||
|
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<LocalReconciliationReviewReceipt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,13 @@ const { test } = require('node:test');
|
|||||||
const {
|
const {
|
||||||
commitLocalReconciliationCapture,
|
commitLocalReconciliationCapture,
|
||||||
commitLocalReconciliationPlan,
|
commitLocalReconciliationPlan,
|
||||||
|
commitLocalReconciliationReview,
|
||||||
prepareLocalReconciliationCapture,
|
prepareLocalReconciliationCapture,
|
||||||
prepareLocalReconciliationPlan,
|
prepareLocalReconciliationPlan,
|
||||||
prepareLocalReconciliationReview,
|
prepareLocalReconciliationReview,
|
||||||
verifyLocalReconciliationCapture,
|
verifyLocalReconciliationCapture,
|
||||||
verifyLocalReconciliationPlan,
|
verifyLocalReconciliationPlan,
|
||||||
|
verifyLocalReconciliationReview,
|
||||||
writeLocalReconciliationReviewDiagnostics,
|
writeLocalReconciliationReviewDiagnostics,
|
||||||
} = require('../dist/deployment/localDeployment.js');
|
} = require('../dist/deployment/localDeployment.js');
|
||||||
const {
|
const {
|
||||||
@@ -193,9 +195,13 @@ function fixture(
|
|||||||
fs.writeFileSync(`${targetDatabasePath}-wal`, 'target-wal-facts\n', {
|
fs.writeFileSync(`${targetDatabasePath}-wal`, 'target-wal-facts\n', {
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
});
|
});
|
||||||
fs.writeFileSync(`${legacySourcePath}-journal`, 'legacy-journal-state\n', {
|
fs.writeFileSync(
|
||||||
mode: 0o600,
|
`${legacySourcePath}-journal`,
|
||||||
});
|
'legacy-journal-state\n',
|
||||||
|
{
|
||||||
|
mode: 0o600,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const commitmentPayload = {
|
const commitmentPayload = {
|
||||||
@@ -682,6 +688,7 @@ function preparedPlan(t, options = {}) {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
captureCommand: state.command,
|
||||||
captured,
|
captured,
|
||||||
planRoot,
|
planRoot,
|
||||||
prepareCommand,
|
prepareCommand,
|
||||||
@@ -694,7 +701,10 @@ function preparedReview(t, options = {}) {
|
|||||||
const state = preparedPlan(t, options);
|
const state = preparedPlan(t, options);
|
||||||
const planned = commitLocalReconciliationPlan(state.planCommitCommand);
|
const planned = commitLocalReconciliationPlan(state.planCommitCommand);
|
||||||
const root = path.dirname(state.captureRoot);
|
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(
|
const diagnosticRoot = path.join(
|
||||||
root,
|
root,
|
||||||
`diagnostic-root-${options.reviewSuffix ?? '1'}`,
|
`diagnostic-root-${options.reviewSuffix ?? '1'}`,
|
||||||
@@ -712,8 +722,7 @@ function preparedReview(t, options = {}) {
|
|||||||
allowRootService: rootAcknowledgement(),
|
allowRootService: rootAcknowledgement(),
|
||||||
},
|
},
|
||||||
request: {
|
request: {
|
||||||
reviewId:
|
reviewId: options.reviewId ?? '00000000-0000-4000-8000-000000000301',
|
||||||
options.reviewId ?? '00000000-0000-4000-8000-000000000301',
|
|
||||||
planId: state.prepareCommand.request.planId,
|
planId: state.prepareCommand.request.planId,
|
||||||
expectedPlanDigest: planned.planDigest,
|
expectedPlanDigest: planned.planDigest,
|
||||||
expectedHeadDigest: planned.instanceHeadDigest,
|
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) {
|
function dockerReadSealedSqlite(assetsDirectory, mode) {
|
||||||
const source =
|
const source =
|
||||||
mode === 'main_only_immutable'
|
mode === 'main_only_immutable'
|
||||||
@@ -770,7 +936,9 @@ function dockerReadSealedSqlite(assetsDirectory, mode) {
|
|||||||
const files = fs.readdirSync('/bundle').sort();
|
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 snapshot = () => Object.fromEntries(files.map((name) => [name, crypto.createHash('sha256').update(fs.readFileSync('/bundle/' + name)).digest('hex')]));
|
||||||
const before = snapshot();
|
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.enableDefensive(true);
|
||||||
client.exec('PRAGMA trusted_schema = OFF; PRAGMA query_only = ON; PRAGMA temp_store = MEMORY; PRAGMA mmap_size = 0; PRAGMA cache_size = -2048');
|
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();
|
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'),
|
fs.readFileSync(captureAssetPath(state, 'target-wal'), 'utf8'),
|
||||||
'target-wal-facts\n',
|
'target-wal-facts\n',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(fs.statSync(capturePath(state, 'assets')).mode & 0o777, 0o500);
|
||||||
fs.statSync(capturePath(state, 'assets')).mode & 0o777,
|
|
||||||
0o500,
|
|
||||||
);
|
|
||||||
for (const asset of manifest.assets) {
|
for (const asset of manifest.assets) {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
fs.statSync(captureAssetPath(state, asset.logicalName)).mode & 0o777,
|
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,
|
fs.statSync(captureAssetPath(state, 'target-wal')).mode & 0o777,
|
||||||
0o600,
|
0o600,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(fs.statSync(capturePath(state, 'assets')).mode & 0o777, 0o700);
|
||||||
fs.statSync(capturePath(state, 'assets')).mode & 0o777,
|
|
||||||
0o700,
|
|
||||||
);
|
|
||||||
fs.unlinkSync(state.targetDatabasePath);
|
fs.unlinkSync(state.targetDatabasePath);
|
||||||
fs.unlinkSync(state.legacySourcePath);
|
fs.unlinkSync(state.legacySourcePath);
|
||||||
fs.unlinkSync(state.recoveryPath);
|
fs.unlinkSync(state.recoveryPath);
|
||||||
const resumed = commitLocalReconciliationCapture(state.commitCommand);
|
const resumed = commitLocalReconciliationCapture(state.commitCommand);
|
||||||
assert.equal(resumed.state, 'reconciliation_captured');
|
assert.equal(resumed.state, 'reconciliation_captured');
|
||||||
assert.equal(
|
assert.equal(fs.statSync(capturePath(state, 'assets')).mode & 0o777, 0o500);
|
||||||
fs.statSync(capturePath(state, 'assets')).mode & 0o777,
|
|
||||||
0o500,
|
|
||||||
);
|
|
||||||
const manifest = JSON.parse(
|
const manifest = JSON.parse(
|
||||||
fs.readFileSync(capturePath(state, 'manifest.json'), 'utf8'),
|
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) => {
|
test('plan reads sealed main-only SQLite with fixed budgets and verifies without opening', (t) => {
|
||||||
const state = preparedPlan(t);
|
const state = preparedPlan(t);
|
||||||
const beforeAssets = fs.readdirSync(
|
const beforeAssets = fs
|
||||||
capturePath(state, 'assets'),
|
.readdirSync(capturePath(state, 'assets'))
|
||||||
).map((name) => ({
|
.map((name) => ({
|
||||||
name,
|
name,
|
||||||
bytes: fs.readFileSync(capturePath(state, `assets/${name}`)),
|
bytes: fs.readFileSync(capturePath(state, `assets/${name}`)),
|
||||||
stat: fs.statSync(capturePath(state, `assets/${name}`), { bigint: true }),
|
stat: fs.statSync(capturePath(state, `assets/${name}`), { bigint: true }),
|
||||||
}));
|
}));
|
||||||
const opens = [];
|
const opens = [];
|
||||||
const committed = commitLocalReconciliationPlan(state.planCommitCommand, {
|
const committed = commitLocalReconciliationPlan(state.planCommitCommand, {
|
||||||
beforeDatabaseOpen(kind, mode, cacheKiB) {
|
beforeDatabaseOpen(kind, mode, cacheKiB) {
|
||||||
@@ -1467,9 +1626,7 @@ test('hot journal and unpaired sidecars become manual without SQLite open', (t)
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
plan.domains.every(
|
plan.domains.every((domain) => domain.disposition === 'manual_required'),
|
||||||
(domain) => domain.disposition === 'manual_required',
|
|
||||||
),
|
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1481,11 +1638,7 @@ test('unknown target schema is summarized only by digest and requires manual rev
|
|||||||
});
|
});
|
||||||
commitLocalReconciliationPlan(state.planCommitCommand);
|
commitLocalReconciliationPlan(state.planCommitCommand);
|
||||||
const planText = fs.readFileSync(
|
const planText = fs.readFileSync(
|
||||||
path.join(
|
path.join(state.planRoot, state.prepareCommand.request.planId, 'plan.json'),
|
||||||
state.planRoot,
|
|
||||||
state.prepareCommand.request.planId,
|
|
||||||
'plan.json',
|
|
||||||
),
|
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
const plan = JSON.parse(planText);
|
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.recordCount, 1);
|
||||||
assert.equal(result.complete, true);
|
assert.equal(result.complete, true);
|
||||||
assert.equal(result.nextOffset, null);
|
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);
|
assert.equal(JSON.stringify(result).includes('Crontabs'), false);
|
||||||
const pageText = fs.readFileSync(command.request.outputPath, 'utf8');
|
const pageText = fs.readFileSync(command.request.outputPath, 'utf8');
|
||||||
const page = JSON.parse(pageText);
|
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, '');
|
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(
|
test(
|
||||||
'real stopped Docker target produces an independently verified bundle',
|
'real stopped Docker target produces an independently verified bundle',
|
||||||
{ skip: process.env.QL3_RECONCILIATION_DOCKER_GATE !== '1' },
|
{ skip: process.env.QL3_RECONCILIATION_DOCKER_GATE !== '1' },
|
||||||
|
|||||||
@@ -315,6 +315,11 @@
|
|||||||
"require": "./dist/security/securityAuditQuery.js",
|
"require": "./dist/security/securityAuditQuery.js",
|
||||||
"default": "./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": {
|
"./security-audit-retention": {
|
||||||
"types": "./dist/security/securityAuditRetention.d.ts",
|
"types": "./dist/security/securityAuditRetention.d.ts",
|
||||||
"require": "./dist/security/securityAuditRetention.js",
|
"require": "./dist/security/securityAuditRetention.js",
|
||||||
|
|||||||
@@ -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<LocalOwnerPepperRepository, 'resolveKey'>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<LocalSqliteAuthenticationReadDatabase> {
|
||||||
|
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<void> | 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -2010,6 +2010,14 @@ function auditSourceImports(root, packagePath, findings) {
|
|||||||
'src/lifecycle/localReadiness.ts' &&
|
'src/lifecycle/localReadiness.ts' &&
|
||||||
specifier === '@qinglong/local-sqlite/readiness-inspection'
|
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',
|
'src/deployment/compose/composeApply.ts',
|
||||||
@@ -2643,6 +2651,28 @@ function auditSourceImports(root, packagePath, findings) {
|
|||||||
) {
|
) {
|
||||||
continue;
|
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 (
|
if (
|
||||||
packagePath === 'packages/ql3-local-owner-cli' &&
|
packagePath === 'packages/ql3-local-owner-cli' &&
|
||||||
path.relative(packageDirectory, filePath) ===
|
path.relative(packageDirectory, filePath) ===
|
||||||
|
|||||||
@@ -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) => {
|
test('deleted Owner ceremony package names remain dependency tombstones', (t) => {
|
||||||
const root = fixture(
|
const root = fixture(
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 149,
|
sourceFiles: 155,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 50,
|
rootSourceLines: 50,
|
||||||
nestedSourceFiles: 148,
|
nestedSourceFiles: 154,
|
||||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -540,10 +540,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localSqlite.rootSourceFileRoles,
|
rootSourceFileRoles: localSqlite.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 202,
|
sourceFiles: 203,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 31,
|
rootSourceLines: 31,
|
||||||
nestedSourceFiles: 201,
|
nestedSourceFiles: 202,
|
||||||
rootSourceFileRoles: { 'index.ts': 'public_export' },
|
rootSourceFileRoles: { 'index.ts': 'public_export' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user