mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): authorize reconciliation reviews
This commit is contained in:
@@ -11,21 +11,25 @@
|
||||
|
||||
最新增量证据(2026-08-21):
|
||||
|
||||
- D-391/ADR-0484(第一切片已实现,commit/verify 待实现):`reconciliation_planned` 仍是内容无关 summary,不能为人工裁决泄漏明细或
|
||||
获得 import authority。既有 Local Owner 已在 `deployment/reconciliation/review/` 增加
|
||||
`reconciliation.review.prepare|diagnostics`,并为后续 commit/verify 冻结
|
||||
`reconciliation_planned → reconciliation_review_prepared → reconciliation_reviewed` CAS 建立唯一 review fence。diagnostics 每次只把
|
||||
一个 database/domain/fact-kind 的最多 64 条私有记录 no-replace 写入 caller 指定的 owner-only 文件;stdout 只返回 page digest、计数和
|
||||
offset,不含路径、名称或 fact digest。commit 不信任 page,而是从 exact sealed bundle 重新流式派生 canonical facts,与 Edge ≤8 MiB、
|
||||
Standalone ≤32 MiB 的 private NDJSON decision file 逐条匹配。unknown、Secret/credential/pepper、active Run 与 custody 缺口固定 blocked,
|
||||
人工选择也不能提升为自动可导入。authorization 必须来自 target Owner credential 建立的近期强认证 User,并以独立 issuer keyring、
|
||||
exact review file/plan/bundle/head fence 签名;terminal review 只保存签名 authorization、compact counts/digest 和 content-free receipt。
|
||||
verify 不打开 SQLite。实现不新增 package/dependency/binary/daemon,不把文件平铺回 `src/` 根,也不调用 DML、Secret 解密、Docker/init/
|
||||
network。后续领域 adapter 必须消费 exact `reviewDigest` 后重新认证和授权,分别定义 backup、prepare/commit、幂等与 rollback,不能把
|
||||
review completion 当作 reconciliation completion。当前聚焦套件 `28 total / 26 pass / 2 conditional Docker skip / 0 fail`,Local Owner
|
||||
`250 total / 243 pass / 7 conditional skip / 0 fail`,tracked backend `1540 total / 1538 pass / 2 conditional skip / 0 fail`,
|
||||
18-package clean build/逐包测试、八项架构/发布审计、十四档 artifact 和真实 Docker readonly `2/2` 全通过。workspace 仍为 18
|
||||
packages,Local Owner `149 source / 148 nested / 1 root binary entry`;新增 3 个源码全部位于 review 子目录,基础常驻 closure 不增长。
|
||||
- D-391/ADR-0484(已接受):既有 Local Owner 已完整实现
|
||||
`reconciliation.review.prepare|diagnostics|commit|verify`,以
|
||||
`reconciliation_planned → reconciliation_review_prepared → reconciliation_reviewed` CAS 建立唯一 review fence。diagnostics 每次只把一个
|
||||
database/domain/fact-kind 的最多 64 条私有记录 no-replace 写入 caller 指定的 owner-only 文件;stdout 不含路径、名称或 fact digest。
|
||||
commit 不信任 page,而是从 exact sealed bundle 重新流式派生 canonical facts,与 Edge ≤8 MiB、Standalone ≤32 MiB 的稳定 descriptor
|
||||
NDJSON decision file 逐条匹配。unknown、Secret/credential/pepper、active Run 与 custody 缺口固定 blocked,人工选择不能提升为自动可导入。
|
||||
生产 composition 只用新的 Local SQLite authentication-read projection 加载 credential/pepper,通过既有 Owner authentication 建立最多
|
||||
5 分钟的强 User principal;独立最多八代 issuer keyring 签发最长 30 分钟的 domain-separated authorization,并在签名前后重验 decision
|
||||
file、credential、keyring、plan、bundle fingerprint 与 prepared head。authorization/review/receipt/seal/head 的 crash/response-loss 窗口均
|
||||
exact replay,terminal evidence 为 `0400/0500`;verify 只验证签名和 exact binding,不打开 SQLite、不写文件。实现不新增 package、dependency、
|
||||
binary 或 daemon,不平铺 `src/`,也不调用 DML、Secret 解密、Docker/init/network。后续领域 adapter 必须以独立 ADR 消费 exact
|
||||
`reviewDigest` 后重新认证授权,不能把 review completion 当作 reconciliation completion。聚焦套件
|
||||
`32 total / 30 pass / 2 conditional Docker skip / 0 fail`,Local Owner `254 total / 247 pass / 7 conditional skip / 0 fail`,tracked
|
||||
backend `1541 total / 1539 pass / 2 conditional skip / 0 fail`;18-package clean build/逐包测试、八项架构/部署/发布审计、十四档 artifact 与
|
||||
真实 Docker readonly `2/2` 全通过。workspace 仍为 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner
|
||||
`155 source / 154 nested / 1 root binary entry`,Local SQLite `203 source / 202 nested / 1 root public export`,新增 7 个生产源码全部位于既有
|
||||
领域目录。基础 Edge/Standalone closure 精确保持 `2,611,978 / 2,612,056` bytes、319 files、58 modules。PostgreSQL 18.6 arm64 physical HA
|
||||
以 146 gates、timeline `1 → 2` 通过,private evidence SHA-256 为
|
||||
`3d6623465913d43e6f1a8838896d6deb6664dafd0c26970bddb4d6165fb60c00`,离线审计无 finding。
|
||||
- D-390/ADR-0483(已接受):既有 Local Owner 已实现密封 capture 的严格只读消费与独立
|
||||
`reconciliation.plan.prepare|commit|verify`。capture v2 使用 SQLite 可识别的固定 `target.sqlite* / legacy.sqlite* /
|
||||
recovery.sqlite` 物理名和 `0400/0500` terminal seal;main-only 走 immutable readonly,WAL+SHM 完整配对走普通 readonly,hot
|
||||
@@ -38,8 +42,8 @@
|
||||
`246 total / 239 pass / 7 conditional skip / 0 fail`,tracked backend `1540 total / 1538 pass / 2 conditional skip / 0 fail`,
|
||||
18-package clean build/逐包测试、八项架构/发布审计和十四档 artifact audit 全通过。workspace 仍为 18 packages,Local Owner
|
||||
`146 source / 145 nested / 1 root binary entry`;基础 Edge/Standalone closure 仍为 319 files、58 modules,不含一次性 plan authority。
|
||||
D-391/ADR-0484 已冻结消费 exact plan digest 的私有逐对象诊断、人工裁决与审批协议;它仍不能借 plan 获得自动 import 或 rollback
|
||||
authority,完成实现与完整门禁前保持 Proposed。
|
||||
D-391/ADR-0484 已完成私有逐对象诊断、受认证人工裁决、签名 terminal review 与只读 verify;它仍不能借 plan 或 review 获得自动 import、
|
||||
rollback 或 restart authority,下一阶段领域 adapter 继续使用独立 ADR 和门禁。
|
||||
- D-389/ADR-0482(已接受):target stopped 后的 `reconciliation_required` 不能直接逆迁移或覆盖 2.x source;既有 Local Owner
|
||||
已实现独立 `reconciliation.capture.prepare|commit|verify`,只允许 exact stopped reconciliation head,以 instance CAS 建立唯一
|
||||
capture fence,并把 target main/sidecars、Legacy source main/sidecars、activation recovery 与内容无关 lineage 以固定 64 KiB
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-0484:私有 Reconciliation 诊断与受认证人工裁决
|
||||
|
||||
- 状态:Proposed(D-391 契约冻结)
|
||||
- 状态:Accepted(D-391 已实现并完成门禁)
|
||||
- 日期:2026-08-21
|
||||
- 关联 RFC:QL-RFC-0001 D-05、D-06、D-17、D-64、D-87、D-184、D-259、D-383、D-389、D-390、D-391
|
||||
- 关联 ADR:ADR-0064、ADR-0094、ADR-0095、ADR-0194、ADR-0201、ADR-0314、ADR-0315、ADR-0482、ADR-0483
|
||||
@@ -121,20 +121,25 @@ output 不进入基础 Edge/Standalone artifact;Cluster/PostgreSQL/Kubernetes
|
||||
|
||||
## 当前实现进度
|
||||
|
||||
D-391 第一切片已实现 `review.prepare` 与 `review.diagnostics`。prepare 读取并重验 exact terminal plan/bundle,以 instance CAS 建立
|
||||
`reconciliation_review_prepared` 唯一 fence,覆盖 head response loss、第二 review、rollback 和 restart 拒绝。diagnostics 在每次
|
||||
SQLite open 前后重验密封资产,只为一个 database/domain/fact-kind 发布最多 64 条 owner-only page;Secret、identity、history 和 unknown
|
||||
facts 固定 blocked,未知表不读取 row,terminal result 不返回路径、名称或 fact digest。page 使用 256 KiB 固定上限、deterministic
|
||||
stage、hard-link no-replace 与 fsync,重复请求只能得到 byte-exact existing page。
|
||||
D-391 已完整实现 `review.prepare`、`review.diagnostics`、`review.commit` 与 `review.verify`。prepare/diagnostics 保持第一切片的 exact
|
||||
plan/bundle/head fence、64 条私有分页、blocked fact 与 byte-exact replay;commit 新增 Edge 8 MiB、Standalone 32 MiB 的稳定 descriptor
|
||||
NDJSON 流,逐条重新派生密封 bundle facts,不读取 diagnostics page。生产 composition 只通过新的 Local SQLite authentication-read
|
||||
projection 读取 credential/pepper,并使用既有 `establishAuthenticatedLocalCommand` 建立最多 5 分钟的强 User principal。独立 issuer
|
||||
keyring 最多八代 key,authorization 生命周期最多 30 分钟,签名前后重验 decision file、credential、keyring、plan、bundle fingerprint 和
|
||||
prepared head。
|
||||
|
||||
验证结果:聚焦套件 `28 total / 26 pass / 2 conditional Docker skip / 0 fail`;完整 Local Owner
|
||||
`250 total / 243 pass / 7 conditional skip / 0 fail`;tracked backend `1540 total / 1538 pass / 2 conditional skip / 0 fail`;
|
||||
18-package clean build/逐包测试、八项架构/发布审计、十四档 artifact audit 与真实 Docker readonly `2/2` 均通过。workspace 仍为 18
|
||||
packages,`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner 为 `149 source / 148 nested / 1 root binary entry`,新增
|
||||
3 个源码全部位于 `deployment/reconciliation/review/`,没有新增 dependency 或常驻对象,基础 Edge/Standalone closure 未增长。
|
||||
authorization、review、receipt、seal 与 head 的每个 crash/response-loss 窗口均已覆盖;terminal evidence 收敛为 `0400/0500`,verify
|
||||
复验签名与全部 exact binding,不打开 SQLite、不写文件。CLI 只返回 content-free digest/count/replay facts。`reconciliation_reviewed`
|
||||
仍不授予 import、rollback、restart、SQL、Secret 解密或外部副作用 authority;任何领域 adapter 必须由下一份独立 ADR 定义。
|
||||
|
||||
`review.commit`、强认证 User/issuer keyring authorization、terminal seal、`reconciliation_reviewed` 推进和 `review.verify` 尚未实现,因此本
|
||||
ADR 继续保持 Proposed;当前 prepared review 不授予任何 import、rollback 或 restart authority。
|
||||
验证结果:聚焦套件 `32 total / 30 pass / 2 conditional Docker skip / 0 fail`;完整 Local Owner
|
||||
`254 total / 247 pass / 7 conditional skip / 0 fail`;tracked backend `1541 total / 1539 pass / 2 conditional skip / 0 fail`;
|
||||
18-package clean build/逐包测试、八项架构/部署/发布审计、十四档 artifact audit 与真实 Docker readonly `2/2` 均通过。PostgreSQL 18.6
|
||||
arm64 physical HA 以 146 gates、timeline `1 → 2` 通过,private report SHA-256 为
|
||||
`3d6623465913d43e6f1a8838896d6deb6664dafd0c26970bddb4d6165fb60c00`,独立 evidence audit 无 finding。workspace 仍为 18 packages,
|
||||
`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner 为 `155 source / 154 nested / 1 root binary entry`,Local SQLite 为
|
||||
`203 source / 202 nested / 1 root public export`。新增 7 个生产源码全部进入既有领域目录,没有新增 package、dependency、binary 或常驻
|
||||
对象;基础 Edge/Standalone closure 精确保持 `2,611,978 / 2,612,056` bytes、319 files、58 modules。
|
||||
|
||||
## 被拒绝的替代方案
|
||||
|
||||
|
||||
@@ -117,6 +117,12 @@ import {
|
||||
writeLocalReconciliationReviewDiagnostics,
|
||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||
} from './reconciliation/review/preparation';
|
||||
import {
|
||||
commitLocalReconciliationReview,
|
||||
commitLocalReconciliationReviewCommandFile,
|
||||
verifyLocalReconciliationReview,
|
||||
verifyLocalReconciliationReviewCommandFile,
|
||||
} from './reconciliation/review/completion';
|
||||
|
||||
export {
|
||||
commitLocalReconciliationPlan,
|
||||
@@ -129,6 +135,10 @@ export {
|
||||
prepareLocalReconciliationReviewCommandFile,
|
||||
writeLocalReconciliationReviewDiagnostics,
|
||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||
commitLocalReconciliationReview,
|
||||
commitLocalReconciliationReviewCommandFile,
|
||||
verifyLocalReconciliationReview,
|
||||
verifyLocalReconciliationReviewCommandFile,
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -181,6 +191,38 @@ export {
|
||||
type LocalReconciliationReviewPrepareCommand,
|
||||
type LocalReconciliationReviewPrepareResult,
|
||||
} from './reconciliation/review/contract';
|
||||
export {
|
||||
MAX_LOCAL_RECONCILIATION_REVIEW_AUTHORIZATION_LIFETIME_MS,
|
||||
normalizeLocalReconciliationReviewCommitCommand,
|
||||
normalizeLocalReconciliationReviewVerifyCommand,
|
||||
type LocalReconciliationReviewCommitCommand,
|
||||
type LocalReconciliationReviewCommitOptions,
|
||||
type LocalReconciliationReviewTerminalResult,
|
||||
type LocalReconciliationReviewVerifyCommand,
|
||||
} from './reconciliation/review/completionContract';
|
||||
export { type LocalReconciliationReviewCompletionDependencies } from './reconciliation/review/completion';
|
||||
export {
|
||||
LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS,
|
||||
LOCAL_RECONCILIATION_REVIEW_REASONS,
|
||||
MAX_EDGE_LOCAL_RECONCILIATION_REVIEW_DECISION_BYTES,
|
||||
MAX_STANDALONE_LOCAL_RECONCILIATION_REVIEW_DECISION_BYTES,
|
||||
type LocalReconciliationReviewDecision,
|
||||
type LocalReconciliationReviewDecisionHeader,
|
||||
type LocalReconciliationReviewDisposition,
|
||||
type LocalReconciliationReviewReason,
|
||||
} from './reconciliation/review/decisionFile';
|
||||
export {
|
||||
MAX_LOCAL_RECONCILIATION_REVIEW_ISSUER_KEYS,
|
||||
LocalReconciliationReviewIssuerKeyringFileProvider,
|
||||
ensureLocalReconciliationReviewIssuerKeyring,
|
||||
type LocalReconciliationReviewIssuerKeyringSummary,
|
||||
} from './reconciliation/review/issuerKeyring';
|
||||
export {
|
||||
normalizeLocalReconciliationReview,
|
||||
normalizeLocalReconciliationReviewReceipt,
|
||||
type LocalReconciliationReview,
|
||||
type LocalReconciliationReviewReceipt,
|
||||
} from './reconciliation/review/terminalEvidence';
|
||||
export {
|
||||
localReconciliationReviewDirectory,
|
||||
normalizeLocalReconciliationReviewIntent,
|
||||
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
prepareLocalReconciliationReviewCommandFile,
|
||||
commitLocalReconciliationCaptureCommandFile,
|
||||
commitLocalReconciliationPlanCommandFile,
|
||||
commitLocalReconciliationReviewCommandFile,
|
||||
verifyLocalReconciliationCaptureCommandFile,
|
||||
verifyLocalReconciliationPlanCommandFile,
|
||||
verifyLocalReconciliationReviewCommandFile,
|
||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||
prepareLocalDeploymentCommandFile,
|
||||
proveLocalDeploymentLegacyReadinessCommandFile,
|
||||
@@ -35,7 +37,7 @@ import {
|
||||
} from './localDeployment';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-local-deploy <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> {
|
||||
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-review-prepare' &&
|
||||
argv[0] !== 'reconciliation-review-diagnostics' &&
|
||||
argv[0] !== 'reconciliation-review-commit' &&
|
||||
argv[0] !== 'reconciliation-review-verify' &&
|
||||
argv[0] !== 'compose-revision' &&
|
||||
argv[0] !== 'compose-preflight' &&
|
||||
argv[0] !== 'compose-apply' &&
|
||||
@@ -158,6 +162,10 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
? prepareLocalReconciliationReviewCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-review-diagnostics'
|
||||
? writeLocalReconciliationReviewDiagnosticsCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-review-commit'
|
||||
? commitLocalReconciliationReviewCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-review-verify'
|
||||
? verifyLocalReconciliationReviewCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-revision'
|
||||
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-preflight'
|
||||
|
||||
@@ -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('"', '""')}"`;
|
||||
}
|
||||
|
||||
function requirement(
|
||||
domain: LocalReconciliationPlanDomain,
|
||||
): Readonly<{
|
||||
function requirement(domain: LocalReconciliationPlanDomain): Readonly<{
|
||||
decisionRequirement: LocalReconciliationDiagnosticDecisionRequirement;
|
||||
reason: LocalReconciliationDiagnosticReason;
|
||||
}> {
|
||||
@@ -188,7 +186,10 @@ function schemaFacts(
|
||||
domain: LocalReconciliationPlanDomain,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): Readonly<{ records: readonly Readonly<LocalReconciliationDiagnosticFact>[]; complete: boolean }> {
|
||||
): Readonly<{
|
||||
records: readonly Readonly<LocalReconciliationDiagnosticFact>[];
|
||||
complete: boolean;
|
||||
}> {
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT type, name, tbl_name AS tableName
|
||||
@@ -208,7 +209,8 @@ function schemaFacts(
|
||||
let complete = true;
|
||||
for (const row of rows) {
|
||||
catalog += 1;
|
||||
if (catalog > 4_096) configurationError('diagnostic schema budget is exceeded');
|
||||
if (catalog > 4_096)
|
||||
configurationError('diagnostic schema budget is exceeded');
|
||||
if (
|
||||
typeof row.type !== 'string' ||
|
||||
typeof row.name !== 'string' ||
|
||||
@@ -250,7 +252,10 @@ function tableFacts(
|
||||
domain: LocalReconciliationPlanDomain,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): Readonly<{ records: readonly Readonly<LocalReconciliationDiagnosticFact>[]; complete: boolean }> {
|
||||
): Readonly<{
|
||||
records: readonly Readonly<LocalReconciliationDiagnosticFact>[];
|
||||
complete: boolean;
|
||||
}> {
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT name, type
|
||||
@@ -269,7 +274,8 @@ function tableFacts(
|
||||
let complete = true;
|
||||
for (const row of rows) {
|
||||
catalog += 1;
|
||||
if (catalog > 512) configurationError('diagnostic table budget is exceeded');
|
||||
if (catalog > 512)
|
||||
configurationError('diagnostic table budget is exceeded');
|
||||
if (
|
||||
typeof row.name !== 'string' ||
|
||||
typeof row.type !== 'string' ||
|
||||
@@ -277,7 +283,8 @@ function tableFacts(
|
||||
) {
|
||||
configurationError('diagnostic table catalog drifted');
|
||||
}
|
||||
if (classifyLocalReconciliationFact(database, row.name) !== domain) continue;
|
||||
if (classifyLocalReconciliationFact(database, row.name) !== domain)
|
||||
continue;
|
||||
const ordinal = seen + 1;
|
||||
seen += 1;
|
||||
if (seen <= offset) continue;
|
||||
@@ -345,13 +352,57 @@ export function buildLocalReconciliationDiagnosticPage(
|
||||
nextOffset,
|
||||
records: selected.records,
|
||||
});
|
||||
const page = Object.freeze({ ...payload, pageDigest: cutoverDigest(payload) });
|
||||
if (Buffer.byteLength(`${JSON.stringify(page, null, 2)}\n`, 'utf8') > MAX_PAGE_BYTES) {
|
||||
const page = Object.freeze({
|
||||
...payload,
|
||||
pageDigest: cutoverDigest(payload),
|
||||
});
|
||||
if (
|
||||
Buffer.byteLength(`${JSON.stringify(page, null, 2)}\n`, 'utf8') >
|
||||
MAX_PAGE_BYTES
|
||||
) {
|
||||
configurationError('diagnostic page exceeds its 256 KiB budget');
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derives the complete bounded fact sequence without trusting diagnostic
|
||||
* page files. One database descriptor is held by the caller and each query is
|
||||
* bounded by the same catalog ceilings as diagnostics.
|
||||
*/
|
||||
export function visitLocalReconciliationDiagnosticFacts(
|
||||
client: DatabaseSync,
|
||||
database: LocalReconciliationSealedDatabaseKind,
|
||||
visitor: (fact: Readonly<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 {
|
||||
const bytes = Buffer.from(`${JSON.stringify(page, null, 2)}\n`, 'utf8');
|
||||
if (bytes.byteLength < 2 || bytes.byteLength > MAX_PAGE_BYTES) {
|
||||
@@ -396,11 +447,7 @@ function syncDirectory(directory: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function writeStage(
|
||||
stagePath: string,
|
||||
bytes: Buffer,
|
||||
uid: number,
|
||||
): void {
|
||||
function writeStage(stagePath: string, bytes: Buffer, uid: number): void {
|
||||
let descriptor: number | undefined;
|
||||
let created = false;
|
||||
try {
|
||||
|
||||
@@ -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 {
|
||||
const relative = path.relative(left, right);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
return (
|
||||
relative === '' ||
|
||||
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
export function localReconciliationReviewDirectory(
|
||||
@@ -149,19 +152,34 @@ function ensureReviewDirectory(
|
||||
return paths;
|
||||
}
|
||||
|
||||
function validateCatalog(paths: Readonly<LocalReconciliationReviewPaths>): void {
|
||||
function validateCatalog(
|
||||
paths: Readonly<LocalReconciliationReviewPaths>,
|
||||
): void {
|
||||
const allowed = new Set([
|
||||
'authorization.ndjson',
|
||||
'intent.json',
|
||||
'receipt.json',
|
||||
'review.json',
|
||||
'staging',
|
||||
'.receipt.json.ql3-deploy-stage',
|
||||
'.review.json.ql3-deploy-stage',
|
||||
'.intent.json.ql3-deploy-stage',
|
||||
]);
|
||||
for (const entry of fs.readdirSync(paths.root, { withFileTypes: true })) {
|
||||
if (!allowed.has(entry.name) || entry.isSymbolicLink()) {
|
||||
configurationError('reconciliation review root contains unknown material');
|
||||
configurationError(
|
||||
'reconciliation review root contains unknown material',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (fs.readdirSync(paths.staging).length !== 0) {
|
||||
configurationError('reconciliation review staging contains unknown material');
|
||||
if (
|
||||
fs
|
||||
.readdirSync(paths.staging)
|
||||
.some((entry) => entry !== 'authorization.ndjson.stage')
|
||||
) {
|
||||
configurationError(
|
||||
'reconciliation review staging contains unknown material',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +190,8 @@ function buildIntent(
|
||||
if (
|
||||
terminal.intent.command.options.deploymentRoot !==
|
||||
command.options.deploymentRoot ||
|
||||
terminal.intent.command.options.captureRoot !== command.options.captureRoot ||
|
||||
terminal.intent.command.options.captureRoot !==
|
||||
command.options.captureRoot ||
|
||||
terminal.intent.command.options.planRoot !== command.options.planRoot ||
|
||||
terminal.intent.command.options.allowRootService !==
|
||||
command.options.allowRootService ||
|
||||
@@ -197,7 +216,10 @@ function buildIntent(
|
||||
planReceiptDigest: terminal.receipt.receiptDigest,
|
||||
plannedHeadDigest: command.request.expectedHeadDigest,
|
||||
});
|
||||
return Object.freeze({ ...payload, preparationDigest: cutoverDigest(payload) });
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
preparationDigest: cutoverDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationReviewIntent(
|
||||
@@ -224,7 +246,9 @@ export function normalizeLocalReconciliationReviewIntent(
|
||||
],
|
||||
'reconciliation review intent',
|
||||
);
|
||||
const command = normalizeLocalReconciliationReviewPrepareCommand(intent.command);
|
||||
const command = normalizeLocalReconciliationReviewPrepareCommand(
|
||||
intent.command,
|
||||
);
|
||||
const { preparationDigest, ...payload } = intent;
|
||||
if (
|
||||
intent.schema !== INTENT_SCHEMA ||
|
||||
@@ -311,10 +335,22 @@ export function prepareLocalReconciliationReview(
|
||||
): Readonly<LocalReconciliationReviewPrepareResult> {
|
||||
const command = normalizeLocalReconciliationReviewPrepareCommand(input);
|
||||
const identity = currentIdentity();
|
||||
validatePrivateDirectory(command.options.deploymentRoot, identity.uid, 'deploymentRoot');
|
||||
validatePrivateDirectory(command.options.captureRoot, identity.uid, 'captureRoot');
|
||||
validatePrivateDirectory(
|
||||
command.options.deploymentRoot,
|
||||
identity.uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validatePrivateDirectory(
|
||||
command.options.captureRoot,
|
||||
identity.uid,
|
||||
'captureRoot',
|
||||
);
|
||||
validatePrivateDirectory(command.options.planRoot, identity.uid, 'planRoot');
|
||||
validatePrivateDirectory(command.options.reviewRoot, identity.uid, 'reviewRoot');
|
||||
validatePrivateDirectory(
|
||||
command.options.reviewRoot,
|
||||
identity.uid,
|
||||
'reviewRoot',
|
||||
);
|
||||
const terminal = readLocalReconciliationPlanTerminal(
|
||||
command.options.planRoot,
|
||||
command.request.planId,
|
||||
@@ -336,7 +372,9 @@ export function prepareLocalReconciliationReview(
|
||||
(head.state !== 'reconciliation_planned' &&
|
||||
head.state !== 'reconciliation_review_prepared')
|
||||
) {
|
||||
configurationError('review prepare lost the planned instance head compare-and-swap');
|
||||
configurationError(
|
||||
'review prepare lost the planned instance head compare-and-swap',
|
||||
);
|
||||
}
|
||||
const paths = ensureReviewDirectory(
|
||||
command.options.reviewRoot,
|
||||
@@ -384,7 +422,8 @@ function validateDiagnosticsBinding(
|
||||
intent.command.options.captureRoot !== command.options.captureRoot ||
|
||||
intent.command.options.planRoot !== command.options.planRoot ||
|
||||
intent.command.options.reviewRoot !== command.options.reviewRoot ||
|
||||
intent.command.options.allowRootService !== command.options.allowRootService ||
|
||||
intent.command.options.allowRootService !==
|
||||
command.options.allowRootService ||
|
||||
intent.command.request.reviewId !== command.request.reviewId ||
|
||||
intent.preparationDigest !== command.request.expectedPreparationDigest
|
||||
) {
|
||||
@@ -413,13 +452,36 @@ export function writeLocalReconciliationReviewDiagnostics(
|
||||
): Readonly<LocalReconciliationReviewDiagnosticsResult> {
|
||||
const command = normalizeLocalReconciliationReviewDiagnosticsCommand(input);
|
||||
const identity = currentIdentity();
|
||||
validatePrivateDirectory(command.options.deploymentRoot, identity.uid, 'deploymentRoot');
|
||||
validatePrivateDirectory(command.options.captureRoot, identity.uid, 'captureRoot');
|
||||
validatePrivateDirectory(
|
||||
command.options.deploymentRoot,
|
||||
identity.uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validatePrivateDirectory(
|
||||
command.options.captureRoot,
|
||||
identity.uid,
|
||||
'captureRoot',
|
||||
);
|
||||
validatePrivateDirectory(command.options.planRoot, identity.uid, 'planRoot');
|
||||
validatePrivateDirectory(command.options.reviewRoot, identity.uid, 'reviewRoot');
|
||||
const paths = reviewPaths(command.options.reviewRoot, command.request.reviewId);
|
||||
validatePrivateDirectory(paths.root, identity.uid, 'reconciliationReviewDirectory');
|
||||
validatePrivateDirectory(paths.staging, identity.uid, 'reconciliationReviewStaging');
|
||||
validatePrivateDirectory(
|
||||
command.options.reviewRoot,
|
||||
identity.uid,
|
||||
'reviewRoot',
|
||||
);
|
||||
const paths = reviewPaths(
|
||||
command.options.reviewRoot,
|
||||
command.request.reviewId,
|
||||
);
|
||||
validatePrivateDirectory(
|
||||
paths.root,
|
||||
identity.uid,
|
||||
'reconciliationReviewDirectory',
|
||||
);
|
||||
validatePrivateDirectory(
|
||||
paths.staging,
|
||||
identity.uid,
|
||||
'reconciliationReviewStaging',
|
||||
);
|
||||
const intent = readLocalReconciliationReviewIntent(
|
||||
command.options.reviewRoot,
|
||||
command.request.reviewId,
|
||||
@@ -466,7 +528,9 @@ export function writeLocalReconciliationReviewDiagnostics(
|
||||
}),
|
||||
);
|
||||
if (page === null) {
|
||||
configurationError('manual-required SQLite topology has no diagnostic page');
|
||||
configurationError(
|
||||
'manual-required SQLite topology has no diagnostic page',
|
||||
);
|
||||
}
|
||||
const currentHead = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
@@ -478,7 +542,9 @@ export function writeLocalReconciliationReviewDiagnostics(
|
||||
currentHead.state !== 'reconciliation_review_prepared' ||
|
||||
currentHead.sourceRecordDigest !== intent.preparationDigest
|
||||
) {
|
||||
configurationError('review diagnostics instance head changed while reading');
|
||||
configurationError(
|
||||
'review diagnostics instance head changed while reading',
|
||||
);
|
||||
}
|
||||
dependencies.beforeDiagnosticPublish?.();
|
||||
const status = publishLocalReconciliationDiagnosticPage(
|
||||
@@ -505,7 +571,9 @@ export function writeLocalReconciliationReviewDiagnostics(
|
||||
export function prepareLocalReconciliationReviewCommandFile(
|
||||
filePath: string,
|
||||
): Readonly<LocalReconciliationReviewPrepareResult> {
|
||||
return prepareLocalReconciliationReview(readPrivateLocalCommandFile(filePath));
|
||||
return prepareLocalReconciliationReview(
|
||||
readPrivateLocalCommandFile(filePath),
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
commitLocalReconciliationCapture,
|
||||
commitLocalReconciliationPlan,
|
||||
commitLocalReconciliationReview,
|
||||
prepareLocalReconciliationCapture,
|
||||
prepareLocalReconciliationPlan,
|
||||
prepareLocalReconciliationReview,
|
||||
verifyLocalReconciliationCapture,
|
||||
verifyLocalReconciliationPlan,
|
||||
verifyLocalReconciliationReview,
|
||||
writeLocalReconciliationReviewDiagnostics,
|
||||
} = require('../dist/deployment/localDeployment.js');
|
||||
const {
|
||||
@@ -193,9 +195,13 @@ function fixture(
|
||||
fs.writeFileSync(`${targetDatabasePath}-wal`, 'target-wal-facts\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.writeFileSync(`${legacySourcePath}-journal`, 'legacy-journal-state\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
`${legacySourcePath}-journal`,
|
||||
'legacy-journal-state\n',
|
||||
{
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
const commitmentPayload = {
|
||||
@@ -682,6 +688,7 @@ function preparedPlan(t, options = {}) {
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
captureCommand: state.command,
|
||||
captured,
|
||||
planRoot,
|
||||
prepareCommand,
|
||||
@@ -694,7 +701,10 @@ function preparedReview(t, options = {}) {
|
||||
const state = preparedPlan(t, options);
|
||||
const planned = commitLocalReconciliationPlan(state.planCommitCommand);
|
||||
const root = path.dirname(state.captureRoot);
|
||||
const reviewRoot = path.join(root, `review-root-${options.reviewSuffix ?? '1'}`);
|
||||
const reviewRoot = path.join(
|
||||
root,
|
||||
`review-root-${options.reviewSuffix ?? '1'}`,
|
||||
);
|
||||
const diagnosticRoot = path.join(
|
||||
root,
|
||||
`diagnostic-root-${options.reviewSuffix ?? '1'}`,
|
||||
@@ -712,8 +722,7 @@ function preparedReview(t, options = {}) {
|
||||
allowRootService: rootAcknowledgement(),
|
||||
},
|
||||
request: {
|
||||
reviewId:
|
||||
options.reviewId ?? '00000000-0000-4000-8000-000000000301',
|
||||
reviewId: options.reviewId ?? '00000000-0000-4000-8000-000000000301',
|
||||
planId: state.prepareCommand.request.planId,
|
||||
expectedPlanDigest: planned.planDigest,
|
||||
expectedHeadDigest: planned.instanceHeadDigest,
|
||||
@@ -758,6 +767,163 @@ function diagnosticCommand(
|
||||
};
|
||||
}
|
||||
|
||||
function writeReviewDecisionFile(state, prepared, fileName = 'review.ndjson') {
|
||||
const decisions = [];
|
||||
const domains = [
|
||||
'schema_lineage',
|
||||
'automation',
|
||||
'secret_and_config',
|
||||
'run_history',
|
||||
'plugin_package',
|
||||
'ai_and_tool',
|
||||
'identity_policy_audit',
|
||||
'unknown',
|
||||
];
|
||||
for (const database of ['legacy', 'target']) {
|
||||
for (const domain of domains) {
|
||||
for (const factKind of ['schema_object', 'table']) {
|
||||
let offset = 0;
|
||||
let pageNumber = 0;
|
||||
while (true) {
|
||||
const command = diagnosticCommand(state, prepared, {
|
||||
database,
|
||||
domain,
|
||||
factKind,
|
||||
offset,
|
||||
outputName: `decision-${database}-${domain}-${factKind}-${pageNumber}.json`,
|
||||
});
|
||||
const result = writeLocalReconciliationReviewDiagnostics(command);
|
||||
const page = JSON.parse(
|
||||
fs.readFileSync(command.request.outputPath, 'utf8'),
|
||||
);
|
||||
for (const fact of page.records) {
|
||||
if (fact.decisionRequirement === 'informational') continue;
|
||||
const blocked = fact.decisionRequirement === 'blocked';
|
||||
decisions.push({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-reconciliation-review-decision',
|
||||
database: fact.database,
|
||||
domain: fact.domain,
|
||||
factKind: fact.factKind,
|
||||
ordinal: fact.ordinal,
|
||||
factDigest: fact.factDigest,
|
||||
disposition: blocked ? 'manual_external' : 'retain_target',
|
||||
reason: blocked
|
||||
? 'external_recovery_required'
|
||||
: 'preserve_target',
|
||||
});
|
||||
}
|
||||
if (result.complete) break;
|
||||
offset = result.nextOffset;
|
||||
pageNumber += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const records = [
|
||||
{
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-reconciliation-review-decision-header',
|
||||
diagnosticsContractVersion: 1,
|
||||
reviewId: state.reviewCommand.request.reviewId,
|
||||
profile: state.command.request.profile,
|
||||
planDigest: state.planned.planDigest,
|
||||
preparationDigest: prepared.preparationDigest,
|
||||
},
|
||||
...decisions,
|
||||
];
|
||||
const filePath = path.join(state.diagnosticRoot, fileName);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
`${records.map((record) => JSON.stringify(record)).join('\n')}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return { filePath, records, decisions };
|
||||
}
|
||||
|
||||
function reviewCommitFixture(t, options = {}) {
|
||||
const state = preparedReview(t, options);
|
||||
const prepared = prepareLocalReconciliationReview(state.reviewCommand);
|
||||
const reviewFile = writeReviewDecisionFile(state, prepared);
|
||||
const ownerPepperKeyringDirectory = path.join(
|
||||
state.deploymentRoot,
|
||||
'review-owner-peppers',
|
||||
);
|
||||
fs.mkdirSync(ownerPepperKeyringDirectory, { mode: 0o700 });
|
||||
const credentialFilePath = path.join(
|
||||
state.deploymentRoot,
|
||||
'review-credential.json',
|
||||
);
|
||||
fs.writeFileSync(credentialFilePath, '{}\n', { mode: 0o600 });
|
||||
const issuerKeyringPath = path.join(
|
||||
state.deploymentRoot,
|
||||
'review-issuer.keyring',
|
||||
);
|
||||
const committedAtMs = Date.now();
|
||||
const command = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.review.commit',
|
||||
options: {
|
||||
...state.reviewCommand.options,
|
||||
targetDatabasePath: state.targetDatabasePath,
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
issuerKeyringPath,
|
||||
},
|
||||
request: {
|
||||
reviewId: state.reviewCommand.request.reviewId,
|
||||
expectedPreparationDigest: prepared.preparationDigest,
|
||||
expectedHeadDigest: prepared.instanceHeadDigest,
|
||||
decisionFilePath: reviewFile.filePath,
|
||||
committedAtMs,
|
||||
authorizationLifetimeMs: 30 * 60 * 1_000,
|
||||
},
|
||||
};
|
||||
let authentications = 0;
|
||||
let confirmations = 0;
|
||||
const dependencies = {
|
||||
now: () => committedAtMs,
|
||||
async openAuthenticationDatabase() {
|
||||
return { async close() {} };
|
||||
},
|
||||
async authenticate(_database, authenticateOptions) {
|
||||
authentications += 1;
|
||||
assert.equal(
|
||||
authenticateOptions.authenticationNamespace,
|
||||
'local_reconciliation_review',
|
||||
);
|
||||
return {
|
||||
principal: {
|
||||
subject: { type: 'user', id: 'review-owner' },
|
||||
authenticationId: 'local_reconciliation_review:test',
|
||||
authenticatedAtMs: committedAtMs,
|
||||
expiresAtMs: committedAtMs + 60_000,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
databaseFence: {
|
||||
credentialId: 'review-owner',
|
||||
credentialVersion: 1,
|
||||
pepperKeyId: 'review-owner-v1',
|
||||
pepperVersion: 1,
|
||||
},
|
||||
async confirm() {
|
||||
confirmations += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
prepared,
|
||||
reviewFile,
|
||||
command,
|
||||
dependencies,
|
||||
issuerKeyringPath,
|
||||
authenticationCount: () => authentications,
|
||||
confirmationCount: () => confirmations,
|
||||
};
|
||||
}
|
||||
|
||||
function dockerReadSealedSqlite(assetsDirectory, mode) {
|
||||
const source =
|
||||
mode === 'main_only_immutable'
|
||||
@@ -770,7 +936,9 @@ function dockerReadSealedSqlite(assetsDirectory, mode) {
|
||||
const files = fs.readdirSync('/bundle').sort();
|
||||
const snapshot = () => Object.fromEntries(files.map((name) => [name, crypto.createHash('sha256').update(fs.readFileSync('/bundle/' + name)).digest('hex')]));
|
||||
const before = snapshot();
|
||||
const client = new DatabaseSync(${JSON.stringify(source)}, { allowExtension: false, defensive: true, readOnly: true, timeout: 0 });
|
||||
const client = new DatabaseSync(${JSON.stringify(
|
||||
source,
|
||||
)}, { allowExtension: false, defensive: true, readOnly: true, timeout: 0 });
|
||||
client.enableDefensive(true);
|
||||
client.exec('PRAGMA trusted_schema = OFF; PRAGMA query_only = ON; PRAGMA temp_store = MEMORY; PRAGMA mmap_size = 0; PRAGMA cache_size = -2048');
|
||||
const row = client.prepare('SELECT COUNT(*) AS count FROM "QingLong3TaskDefinitions"').get();
|
||||
@@ -851,10 +1019,7 @@ test('commit captures main, sidecars and recovery then verifies without sources'
|
||||
fs.readFileSync(captureAssetPath(state, 'target-wal'), 'utf8'),
|
||||
'target-wal-facts\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.statSync(capturePath(state, 'assets')).mode & 0o777,
|
||||
0o500,
|
||||
);
|
||||
assert.equal(fs.statSync(capturePath(state, 'assets')).mode & 0o777, 0o500);
|
||||
for (const asset of manifest.assets) {
|
||||
assert.equal(
|
||||
fs.statSync(captureAssetPath(state, asset.logicalName)).mode & 0o777,
|
||||
@@ -1002,19 +1167,13 @@ test('commit converges a partially sealed terminal bundle without sources', (t)
|
||||
fs.statSync(captureAssetPath(state, 'target-wal')).mode & 0o777,
|
||||
0o600,
|
||||
);
|
||||
assert.equal(
|
||||
fs.statSync(capturePath(state, 'assets')).mode & 0o777,
|
||||
0o700,
|
||||
);
|
||||
assert.equal(fs.statSync(capturePath(state, 'assets')).mode & 0o777, 0o700);
|
||||
fs.unlinkSync(state.targetDatabasePath);
|
||||
fs.unlinkSync(state.legacySourcePath);
|
||||
fs.unlinkSync(state.recoveryPath);
|
||||
const resumed = commitLocalReconciliationCapture(state.commitCommand);
|
||||
assert.equal(resumed.state, 'reconciliation_captured');
|
||||
assert.equal(
|
||||
fs.statSync(capturePath(state, 'assets')).mode & 0o777,
|
||||
0o500,
|
||||
);
|
||||
assert.equal(fs.statSync(capturePath(state, 'assets')).mode & 0o777, 0o500);
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(capturePath(state, 'manifest.json'), 'utf8'),
|
||||
);
|
||||
@@ -1206,13 +1365,13 @@ test('capture manifest schema v1 is rejected instead of silently upgraded', (t)
|
||||
|
||||
test('plan reads sealed main-only SQLite with fixed budgets and verifies without opening', (t) => {
|
||||
const state = preparedPlan(t);
|
||||
const beforeAssets = fs.readdirSync(
|
||||
capturePath(state, 'assets'),
|
||||
).map((name) => ({
|
||||
name,
|
||||
bytes: fs.readFileSync(capturePath(state, `assets/${name}`)),
|
||||
stat: fs.statSync(capturePath(state, `assets/${name}`), { bigint: true }),
|
||||
}));
|
||||
const beforeAssets = fs
|
||||
.readdirSync(capturePath(state, 'assets'))
|
||||
.map((name) => ({
|
||||
name,
|
||||
bytes: fs.readFileSync(capturePath(state, `assets/${name}`)),
|
||||
stat: fs.statSync(capturePath(state, `assets/${name}`), { bigint: true }),
|
||||
}));
|
||||
const opens = [];
|
||||
const committed = commitLocalReconciliationPlan(state.planCommitCommand, {
|
||||
beforeDatabaseOpen(kind, mode, cacheKiB) {
|
||||
@@ -1467,9 +1626,7 @@ test('hot journal and unpaired sidecars become manual without SQLite open', (t)
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
plan.domains.every(
|
||||
(domain) => domain.disposition === 'manual_required',
|
||||
),
|
||||
plan.domains.every((domain) => domain.disposition === 'manual_required'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -1481,11 +1638,7 @@ test('unknown target schema is summarized only by digest and requires manual rev
|
||||
});
|
||||
commitLocalReconciliationPlan(state.planCommitCommand);
|
||||
const planText = fs.readFileSync(
|
||||
path.join(
|
||||
state.planRoot,
|
||||
state.prepareCommand.request.planId,
|
||||
'plan.json',
|
||||
),
|
||||
path.join(state.planRoot, state.prepareCommand.request.planId, 'plan.json'),
|
||||
'utf8',
|
||||
);
|
||||
const plan = JSON.parse(planText);
|
||||
@@ -1706,7 +1859,10 @@ test('review diagnostics publish one private exact page without changing assets'
|
||||
assert.equal(result.recordCount, 1);
|
||||
assert.equal(result.complete, true);
|
||||
assert.equal(result.nextOffset, null);
|
||||
assert.equal(JSON.stringify(result).includes(command.request.outputPath), false);
|
||||
assert.equal(
|
||||
JSON.stringify(result).includes(command.request.outputPath),
|
||||
false,
|
||||
);
|
||||
assert.equal(JSON.stringify(result).includes('Crontabs'), false);
|
||||
const pageText = fs.readFileSync(command.request.outputPath, 'utf8');
|
||||
const page = JSON.parse(pageText);
|
||||
@@ -1845,6 +2001,334 @@ test('review diagnostics page at sixty-four and CLI output stays content-free',
|
||||
assert.equal(cli.stderr, '');
|
||||
});
|
||||
|
||||
test('review commit signs the exact decision stream, seals terminal evidence and verifies read-only', async (t) => {
|
||||
const state = reviewCommitFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000351',
|
||||
reviewId: '00000000-0000-4000-8000-000000000352',
|
||||
});
|
||||
const targetBefore = fs.statSync(state.targetDatabasePath, { bigint: true });
|
||||
const targetBytes = fs.readFileSync(state.targetDatabasePath);
|
||||
const committed = await commitLocalReconciliationReview(
|
||||
state.command,
|
||||
state.dependencies,
|
||||
);
|
||||
assert.equal(committed.status, 'prepared');
|
||||
assert.equal(committed.state, 'reconciliation_reviewed');
|
||||
assert.equal(committed.decisionCount, state.reviewFile.decisions.length);
|
||||
assert.equal(state.authenticationCount(), 1);
|
||||
assert.equal(state.confirmationCount(), 3);
|
||||
const reviewDirectory = path.join(
|
||||
state.reviewRoot,
|
||||
state.reviewCommand.request.reviewId,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(reviewDirectory).sort(), [
|
||||
'authorization.ndjson',
|
||||
'intent.json',
|
||||
'receipt.json',
|
||||
'review.json',
|
||||
'staging',
|
||||
]);
|
||||
assert.equal(fs.statSync(reviewDirectory).mode & 0o777, 0o500);
|
||||
assert.equal(
|
||||
fs.statSync(path.join(reviewDirectory, 'staging')).mode & 0o777,
|
||||
0o500,
|
||||
);
|
||||
for (const fileName of [
|
||||
'authorization.ndjson',
|
||||
'intent.json',
|
||||
'receipt.json',
|
||||
'review.json',
|
||||
]) {
|
||||
assert.equal(
|
||||
fs.statSync(path.join(reviewDirectory, fileName)).mode & 0o777,
|
||||
0o400,
|
||||
);
|
||||
}
|
||||
const authorizationText = fs.readFileSync(
|
||||
path.join(reviewDirectory, 'authorization.ndjson'),
|
||||
'utf8',
|
||||
);
|
||||
assert.equal(authorizationText.includes('Crontabs'), false);
|
||||
assert.equal(authorizationText.includes('private-value'), false);
|
||||
assert.equal(authorizationText.includes('0 0 * * *'), false);
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
state.deploymentRoot,
|
||||
state.captureCommand.request.instanceId,
|
||||
state.uid,
|
||||
);
|
||||
assert.equal(head.state, 'reconciliation_reviewed');
|
||||
assert.equal(head.sourceRecordDigest, committed.reviewDigest);
|
||||
const verifyCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.review.verify',
|
||||
options: {
|
||||
...state.reviewCommand.options,
|
||||
issuerKeyringPath: state.issuerKeyringPath,
|
||||
},
|
||||
request: {
|
||||
reviewId: state.reviewCommand.request.reviewId,
|
||||
expectedReviewDigest: committed.reviewDigest,
|
||||
},
|
||||
};
|
||||
const verified = await verifyLocalReconciliationReview(verifyCommand);
|
||||
assert.equal(verified.status, 'verified');
|
||||
assert.equal(verified.reviewDigest, committed.reviewDigest);
|
||||
const targetAfter = fs.statSync(state.targetDatabasePath, { bigint: true });
|
||||
assert.equal(
|
||||
fs.readFileSync(state.targetDatabasePath).equals(targetBytes),
|
||||
true,
|
||||
);
|
||||
assert.equal(targetAfter.mtimeNs, targetBefore.mtimeNs);
|
||||
assert.equal(targetAfter.ctimeNs, targetBefore.ctimeNs);
|
||||
|
||||
const commandPath = path.join(state.deploymentRoot, 'review-verify.json');
|
||||
fs.writeFileSync(commandPath, `${JSON.stringify(verifyCommand)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
const cli = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(__dirname, '../dist/deployment/localDeploymentCli.js'),
|
||||
'reconciliation-review-verify',
|
||||
'--command-file',
|
||||
commandPath,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(cli.status, 0, cli.stderr);
|
||||
assert.equal(JSON.parse(cli.stdout).status, 'verified');
|
||||
assert.equal(cli.stdout.includes(state.reviewRoot), false);
|
||||
assert.equal(cli.stdout.includes('review-owner'), false);
|
||||
assert.equal(cli.stdout.includes('Crontabs'), false);
|
||||
});
|
||||
|
||||
test('review commit rejects missing and policy-invalid decisions before terminal publication', async (t) => {
|
||||
const missing = reviewCommitFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000361',
|
||||
reviewId: '00000000-0000-4000-8000-000000000362',
|
||||
reviewSuffix: 'missing',
|
||||
});
|
||||
const missingRecords = missing.reviewFile.records.slice(0, -1);
|
||||
fs.writeFileSync(
|
||||
missing.reviewFile.filePath,
|
||||
`${missingRecords.map((record) => JSON.stringify(record)).join('\n')}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await assert.rejects(
|
||||
commitLocalReconciliationReview(missing.command, missing.dependencies),
|
||||
/omitted a canonical fact/,
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(
|
||||
path.join(
|
||||
missing.reviewRoot,
|
||||
missing.reviewCommand.request.reviewId,
|
||||
'authorization.ndjson',
|
||||
),
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
const blocked = reviewCommitFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000363',
|
||||
reviewId: '00000000-0000-4000-8000-000000000364',
|
||||
reviewSuffix: 'blocked',
|
||||
});
|
||||
const blockedRecord = blocked.reviewFile.records.find(
|
||||
(record) =>
|
||||
record.kind === 'qinglong3-local-reconciliation-review-decision' &&
|
||||
[
|
||||
'secret_and_config',
|
||||
'run_history',
|
||||
'identity_policy_audit',
|
||||
'unknown',
|
||||
].includes(record.domain),
|
||||
);
|
||||
blockedRecord.disposition = 'adopt_legacy';
|
||||
blockedRecord.reason = 'prefer_legacy';
|
||||
fs.writeFileSync(
|
||||
blocked.reviewFile.filePath,
|
||||
`${blocked.reviewFile.records
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join('\n')}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await assert.rejects(
|
||||
commitLocalReconciliationReview(blocked.command, blocked.dependencies),
|
||||
/not allowed for canonical fact/,
|
||||
);
|
||||
});
|
||||
|
||||
test('review commit rejects weak principals, oversized Edge streams and decision-file drift around signing', async (t) => {
|
||||
const weak = reviewCommitFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000365',
|
||||
reviewId: '00000000-0000-4000-8000-000000000366',
|
||||
reviewSuffix: 'weak',
|
||||
});
|
||||
await assert.rejects(
|
||||
commitLocalReconciliationReview(weak.command, {
|
||||
...weak.dependencies,
|
||||
async authenticate() {
|
||||
return {
|
||||
principal: {
|
||||
subject: { type: 'user', id: 'weak-user' },
|
||||
authenticationId: 'local_reconciliation_review:weak',
|
||||
authenticatedAtMs: weak.command.request.committedAtMs,
|
||||
expiresAtMs: weak.command.request.committedAtMs + 60_000,
|
||||
assurance: 'single_factor',
|
||||
},
|
||||
databaseFence: {},
|
||||
async confirm() {},
|
||||
};
|
||||
},
|
||||
}),
|
||||
/recent strongly authenticated User/,
|
||||
);
|
||||
|
||||
const oversized = reviewCommitFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000367',
|
||||
reviewId: '00000000-0000-4000-8000-000000000368',
|
||||
reviewSuffix: 'oversized',
|
||||
});
|
||||
fs.truncateSync(oversized.reviewFile.filePath, 8 * 1024 * 1024 + 1);
|
||||
await assert.rejects(
|
||||
commitLocalReconciliationReview(oversized.command, oversized.dependencies),
|
||||
/identity or size is invalid/,
|
||||
);
|
||||
|
||||
const drift = reviewCommitFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000369',
|
||||
reviewId: '00000000-0000-4000-8000-00000000036a',
|
||||
reviewSuffix: 'drift',
|
||||
});
|
||||
let confirmations = 0;
|
||||
await assert.rejects(
|
||||
commitLocalReconciliationReview(drift.command, {
|
||||
...drift.dependencies,
|
||||
async authenticate() {
|
||||
return {
|
||||
principal: {
|
||||
subject: { type: 'user', id: 'review-owner' },
|
||||
authenticationId: 'local_reconciliation_review:drift',
|
||||
authenticatedAtMs: drift.command.request.committedAtMs,
|
||||
expiresAtMs: drift.command.request.committedAtMs + 60_000,
|
||||
assurance: 'hardware',
|
||||
},
|
||||
databaseFence: {},
|
||||
async confirm() {
|
||||
confirmations += 1;
|
||||
if (confirmations === 3) {
|
||||
fs.appendFileSync(drift.reviewFile.filePath, '{}\n');
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
/identity changed after reading/,
|
||||
);
|
||||
});
|
||||
|
||||
test('review commit resumes authorization, receipt, seal and head response-loss windows without re-authentication', async (t) => {
|
||||
const authorizationState = reviewCommitFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000371',
|
||||
reviewId: '00000000-0000-4000-8000-000000000372',
|
||||
reviewSuffix: 'authorization-crash',
|
||||
});
|
||||
await assert.rejects(
|
||||
commitLocalReconciliationReview(authorizationState.command, {
|
||||
...authorizationState.dependencies,
|
||||
afterAuthorizationPublished() {
|
||||
throw new Error('authorization crash');
|
||||
},
|
||||
}),
|
||||
/authorization crash/,
|
||||
);
|
||||
const authorizationDirectory = path.join(
|
||||
authorizationState.reviewRoot,
|
||||
authorizationState.reviewCommand.request.reviewId,
|
||||
);
|
||||
fs.linkSync(
|
||||
path.join(authorizationDirectory, 'authorization.ndjson'),
|
||||
path.join(authorizationDirectory, 'staging', 'authorization.ndjson.stage'),
|
||||
);
|
||||
const authorizationReplay = await commitLocalReconciliationReview(
|
||||
authorizationState.command,
|
||||
{
|
||||
...authorizationState.dependencies,
|
||||
async authenticate() {
|
||||
throw new Error('must not re-authenticate signed response-loss replay');
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(authorizationReplay.state, 'reconciliation_reviewed');
|
||||
|
||||
const receiptState = reviewCommitFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000373',
|
||||
reviewId: '00000000-0000-4000-8000-000000000374',
|
||||
reviewSuffix: 'receipt-crash',
|
||||
});
|
||||
await assert.rejects(
|
||||
commitLocalReconciliationReview(receiptState.command, {
|
||||
...receiptState.dependencies,
|
||||
afterReceiptPublished() {
|
||||
throw new Error('receipt crash');
|
||||
},
|
||||
}),
|
||||
/receipt crash/,
|
||||
);
|
||||
const receiptReplay = await commitLocalReconciliationReview(
|
||||
receiptState.command,
|
||||
receiptState.dependencies,
|
||||
);
|
||||
assert.equal(receiptReplay.state, 'reconciliation_reviewed');
|
||||
|
||||
const sealState = reviewCommitFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000375',
|
||||
reviewId: '00000000-0000-4000-8000-000000000376',
|
||||
reviewSuffix: 'seal-crash',
|
||||
});
|
||||
await assert.rejects(
|
||||
commitLocalReconciliationReview(sealState.command, {
|
||||
...sealState.dependencies,
|
||||
afterTerminalSealed() {
|
||||
throw new Error('seal crash');
|
||||
},
|
||||
}),
|
||||
/seal crash/,
|
||||
);
|
||||
assert.equal(
|
||||
fs.statSync(
|
||||
path.join(sealState.reviewRoot, sealState.reviewCommand.request.reviewId),
|
||||
).mode & 0o777,
|
||||
0o500,
|
||||
);
|
||||
const sealReplay = await commitLocalReconciliationReview(
|
||||
sealState.command,
|
||||
sealState.dependencies,
|
||||
);
|
||||
assert.equal(sealReplay.state, 'reconciliation_reviewed');
|
||||
|
||||
const headState = reviewCommitFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000377',
|
||||
reviewId: '00000000-0000-4000-8000-000000000378',
|
||||
reviewSuffix: 'head-crash',
|
||||
});
|
||||
await assert.rejects(
|
||||
commitLocalReconciliationReview(headState.command, {
|
||||
...headState.dependencies,
|
||||
afterHeadAdvanced() {
|
||||
throw new Error('head response loss');
|
||||
},
|
||||
}),
|
||||
/head response loss/,
|
||||
);
|
||||
const headReplay = await commitLocalReconciliationReview(
|
||||
headState.command,
|
||||
headState.dependencies,
|
||||
);
|
||||
assert.equal(headReplay.status, 'existing');
|
||||
});
|
||||
|
||||
test(
|
||||
'real stopped Docker target produces an independently verified bundle',
|
||||
{ skip: process.env.QL3_RECONCILIATION_DOCKER_GATE !== '1' },
|
||||
|
||||
@@ -315,6 +315,11 @@
|
||||
"require": "./dist/security/securityAuditQuery.js",
|
||||
"default": "./dist/security/securityAuditQuery.js"
|
||||
},
|
||||
"./authentication-read": {
|
||||
"types": "./dist/security/authenticationRead.d.ts",
|
||||
"require": "./dist/security/authenticationRead.js",
|
||||
"default": "./dist/security/authenticationRead.js"
|
||||
},
|
||||
"./security-audit-retention": {
|
||||
"types": "./dist/security/securityAuditRetention.d.ts",
|
||||
"require": "./dist/security/securityAuditRetention.js",
|
||||
|
||||
@@ -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' &&
|
||||
specifier === '@qinglong/local-sqlite/readiness-inspection'
|
||||
) &&
|
||||
!(
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
'src/deployment/reconciliation/review/completion.ts' &&
|
||||
[
|
||||
'@qinglong/local-owner-console/authenticated-command',
|
||||
'@qinglong/local-sqlite/authentication-read',
|
||||
].includes(specifier)
|
||||
) &&
|
||||
!(
|
||||
[
|
||||
'src/deployment/compose/composeApply.ts',
|
||||
@@ -2643,6 +2651,28 @@ function auditSourceImports(root, packagePath, findings) {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
packagePath === 'packages/ql3-local-owner-cli' &&
|
||||
[
|
||||
{
|
||||
file: 'src/deployment/reconciliation/review/authorization.ts',
|
||||
specifiers: [
|
||||
'@qinglong/runtime-core/local-secret',
|
||||
'@qinglong/runtime-core/security',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'src/deployment/reconciliation/review/issuerKeyring.ts',
|
||||
specifiers: ['@qinglong/runtime-core/local-secret'],
|
||||
},
|
||||
].some(
|
||||
({ file, specifiers }) =>
|
||||
path.relative(packageDirectory, filePath) === file &&
|
||||
specifiers.includes(specifier),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
packagePath === 'packages/ql3-local-owner-cli' &&
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
|
||||
@@ -2083,6 +2083,73 @@ test('confines fresh setup authority to the reviewed owner CLI subpath', (t) =>
|
||||
);
|
||||
});
|
||||
|
||||
test('confines reconciliation review authentication to exact read-only owners', (t) => {
|
||||
const root = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-reconciliation-review-boundary-'),
|
||||
);
|
||||
const reviewDirectory = path.join(
|
||||
root,
|
||||
'packages/ql3-local-owner-cli/src/deployment/reconciliation/review',
|
||||
);
|
||||
fs.mkdirSync(reviewDirectory, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(reviewDirectory, 'authorization.ts'),
|
||||
[
|
||||
"import type { Key } from '@qinglong/runtime-core/local-secret';",
|
||||
"import type { Principal } from '@qinglong/runtime-core/security';",
|
||||
].join('\n'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(reviewDirectory, 'completion.ts'),
|
||||
[
|
||||
"import { authenticate } from '@qinglong/local-owner-console/authenticated-command';",
|
||||
"import { database } from '@qinglong/local-sqlite/authentication-read';",
|
||||
].join('\n'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(reviewDirectory, 'issuerKeyring.ts'),
|
||||
"import type { Key } from '@qinglong/runtime-core/local-secret';",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(reviewDirectory, 'neighbor.ts'),
|
||||
[
|
||||
"import { authenticate } from '@qinglong/local-owner-console/authenticated-command';",
|
||||
"import { database } from '@qinglong/local-sqlite/authentication-read';",
|
||||
"import type { Key } from '@qinglong/runtime-core/local-secret';",
|
||||
"import type { Principal } from '@qinglong/runtime-core/security';",
|
||||
].join('\n'),
|
||||
);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const findings = [];
|
||||
auditSourceImports(root, 'packages/ql3-local-owner-cli', findings);
|
||||
assert.deepEqual(
|
||||
findings.map(({ code, file, specifier }) => ({ code, file, specifier })),
|
||||
[
|
||||
{
|
||||
code: 'FORBIDDEN_LOCAL_ADOPTION_CLI_AUTHORITY_IMPORT',
|
||||
file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/review/neighbor.ts',
|
||||
specifier: '@qinglong/local-owner-console/authenticated-command',
|
||||
},
|
||||
{
|
||||
code: 'FORBIDDEN_LOCAL_ADOPTION_CLI_AUTHORITY_IMPORT',
|
||||
file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/review/neighbor.ts',
|
||||
specifier: '@qinglong/local-sqlite/authentication-read',
|
||||
},
|
||||
{
|
||||
code: 'FORBIDDEN_PACKAGE_SOURCE_IMPORT',
|
||||
file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/review/neighbor.ts',
|
||||
specifier: '@qinglong/runtime-core/local-secret',
|
||||
},
|
||||
{
|
||||
code: 'FORBIDDEN_PACKAGE_SOURCE_IMPORT',
|
||||
file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/review/neighbor.ts',
|
||||
specifier: '@qinglong/runtime-core/security',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('deleted Owner ceremony package names remain dependency tombstones', (t) => {
|
||||
const root = fixture(
|
||||
t,
|
||||
|
||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 149,
|
||||
sourceFiles: 155,
|
||||
rootSourceFiles: 1,
|
||||
rootSourceLines: 50,
|
||||
nestedSourceFiles: 148,
|
||||
nestedSourceFiles: 154,
|
||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||
},
|
||||
);
|
||||
@@ -540,10 +540,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: localSqlite.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 202,
|
||||
sourceFiles: 203,
|
||||
rootSourceFiles: 1,
|
||||
rootSourceLines: 31,
|
||||
nestedSourceFiles: 201,
|
||||
nestedSourceFiles: 202,
|
||||
rootSourceFileRoles: { 'index.ts': 'public_export' },
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user