diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 60feb16f..f896571f 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -304,6 +304,7 @@ jobs: test/back/ql3ReleaseSetContract.test.cjs test/back/ql3ReleaseCatalogContract.test.cjs test/back/ql3ReleaseDeploymentReadinessContract.test.cjs + test/back/ql3ReleaseTagFinalizer.test.cjs test/back/ql3ReleasePublicationClosureContract.test.cjs test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs test/back/ql3DeploymentLockContract.test.cjs diff --git a/.github/workflows/ql3-image-release.yml b/.github/workflows/ql3-image-release.yml index 4ede86df..2c8c84e3 100644 --- a/.github/workflows/ql3-image-release.yml +++ b/.github/workflows/ql3-image-release.yml @@ -1579,100 +1579,17 @@ jobs: run: | set -euo pipefail umask 077 - node <<'NODE' - const fs = require('node:fs'); - const { spawnSync } = require('node:child_process'); - const { - createPublicationTagObservation, - } = require('./scripts/ql3-release-publication-closure-contract.cjs'); - const plan = JSON.parse(fs.readFileSync(process.env.PUBLICATION_PLAN, 'utf8')); - const regctl = process.env.REGCTL; - const maxInventoryBytes = 1024 * 1024; - const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/u; - const run = (args) => { - const result = spawnSync(regctl, args, { - encoding: 'utf8', - maxBuffer: maxInventoryBytes, - }); - if (result.error) throw result.error; - if (result.status !== 0) { - throw new Error(`regctl ${args.join(' ')} failed`); - } - return result.stdout; - }; - const states = []; - for (const image of plan.images) { - const source = run(['image', 'digest', image.immutableReference]).trim(); - if (source !== image.digest) { - throw new Error('source digest drifted before promotion'); - } - const inventoryContents = run([ - 'tag', - 'ls', - image.registryRepository, - '--format', - '{{ range .Tags }}{{ println . }}{{ end }}', - ]); - if ( - Buffer.byteLength(inventoryContents) > maxInventoryBytes || - (inventoryContents.length > 0 && !inventoryContents.endsWith('\n')) - ) { - throw new Error('release tag inventory is invalid or unbounded'); - } - const inventory = inventoryContents.length === 0 - ? [] - : inventoryContents.slice(0, -1).split('\n'); - if ( - inventory.some((tag) => !tagPattern.test(tag)) || - new Set(inventory).size !== inventory.length - ) { - throw new Error('release tag inventory is malformed'); - } - const inventorySet = new Set(inventory); - for (const tag of image.tags) { - const tagName = tag.reference.slice(image.registryRepository.length + 1); - const present = inventorySet.has(tagName); - if (present) { - const current = run(['image', 'digest', tag.reference]).trim(); - if (current !== image.digest) { - throw new Error('release tag already points at another digest'); - } - } - states.push({ image, tag, present }); - } - } - for (const state of states) { - if (!state.present) { - run([ - 'image', - 'copy', - state.image.immutableReference, - state.tag.reference, - ]); - } - } - const observedTags = []; - for (const state of states) { - const promoted = run(['image', 'digest', state.tag.reference]).trim(); - if (promoted !== state.image.digest) { - throw new Error('promoted tag does not resolve to the release-set digest'); - } - observedTags.push({ - image: state.image.name, - kind: state.tag.kind, - reference: state.tag.reference, - digest: promoted, - }); - } - const observation = createPublicationTagObservation(plan, observedTags); - const descriptor = fs.openSync(process.env.TAG_OBSERVATIONS, 'wx', 0o600); - try { - fs.writeFileSync(descriptor, `${JSON.stringify(observation)}\n`); - fs.fsyncSync(descriptor); - } finally { - fs.closeSync(descriptor); - } - NODE + node scripts/ql3-release-tag-finalizer.cjs \ + --mode=finalize \ + --plan="${PUBLICATION_PLAN}" \ + --regctl="${REGCTL}" \ + --output="${TAG_OBSERVATIONS}" > /dev/null + node scripts/ql3-release-tag-finalizer.cjs \ + --mode=audit \ + --plan="${PUBLICATION_PLAN}" \ + --regctl="${REGCTL}" \ + --observation="${TAG_OBSERVATIONS}" > \ + "${RUNNER_TEMP}/release-tag-finalization-audit.json" - name: Close and audit the deployment-ready public tag set env: diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 192da22d..c692e4fa 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,24 @@ 最新增量证据(2026-08-18): +- D-352/ADR-0444(已接受;首份真实 GHCR response-loss 重放待实际 release tag):修复 D-351 终态 finalizer 中仍残留的 + mutation-before-validation 风险。原 workflow 内联 Node publisher 直接解析 plan 并执行 registry copy,直到生成 observation 时才间接验证 + plan self-digest;篡改 plan 可能先产生错误副作用。promotion 现唯一进入 `scripts/ql3-release-tag-finalizer.cjs`:只读取 current-user + `0700/0600` canonical plan,在第一条 registry command 前完整验证 publication plan v2、deployment readiness、repository、immutable + source 和 exact tags;固定 `regctl` dev/inode/size/uid/mode/time identity 并在每条命令前后复验。所有 repository 的 source digest、最大 + 1 MiB canonical inventory 与既有目标 tag 必须在首次 copy 前全量通过,最后一个冲突同样保持零 mutation;copy-after-write response loss + 不猜测结果,同 source 重跑复用 exact tag 并只补 absent。最终 observation 以 `0600` no-replace 发布,closure 前再执行一次 + `registryMutation=false` 的 live terminal audit。hermetic registry 已覆盖 plan 篡改零调用、全局 conflict 零写、response-loss 重放、畸形/ + 超限 inventory、错误终态 digest、no-replace CLI 与 executable drift;workflow 不再保留高权限 Node heredoc。publication/observation/ + closure schema 和无 CAS/跨仓库事务声明不变,不新增 package、生产依赖、数据库、Kubernetes object 或设备常驻资源。阶段门已重跑:finalizer/ + publication/workflow/Console distribution 聚焦测试 116/116,完整 backend 1,403 pass + 2 条条件 skip/0 fail,18-package clean + build/test 退出 0,14/14 静态审计与 14/14 artifact 档位全部 compatible。artifact 保持 D-351 字节基线:基础 Edge/Standalone + `2589890/2589968`、adopted `2809185/2809308`、application `3632769/3632889`、application-api + `3800322/3800466`、AI `3069143/3069233`、application+AI `4493043/4493175`、MCP `7315930/7316038`。D-352 不修改 + package/runtime、数据库/schema、镜像内容或 Kubernetes/Compose 部署面,因而没有制造新的 PostgreSQL HA/K3s 物理环境证据;本阶段复用紧邻 + D-351 已通过的 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 与三节点 K3s v1.34.3/arm64 synthetic fixture 作为未变部署面的基线, + 不把它们冒充 D-352 新运行的证据。真实 GHCR response-loss 仍只能由受保护 release tag 或受控 release repository 演练产生。 + - D-351/ADR-0443(已接受;首份真实 GHCR deployment-ready finalization 待实际 release tag):D-350 的 catalog-ready publication 继续收紧为 deployment-ready publication。`release-set` job 只发布、验签并 attested immutable catalog,不再登录 registry 或修改最终 image tag;`local|all` 必须先完成 Edge 与 Standalone 的 catalog-bound 正式 Compose rollout,`cluster|all` 必须先完成三节点 diff --git a/docs/adr/ADR-0443-deployment-ready-terminal-release-finalization.md b/docs/adr/ADR-0443-deployment-ready-terminal-release-finalization.md index d02793bd..78a1d38c 100644 --- a/docs/adr/ADR-0443-deployment-ready-terminal-release-finalization.md +++ b/docs/adr/ADR-0443-deployment-ready-terminal-release-finalization.md @@ -5,6 +5,7 @@ - 关联 RFC:QL-RFC-0001 D-03、D-14、D-336、D-344、D-345、D-350、D-351 - 关联 ADR:ADR-0436、ADR-0437、ADR-0441、ADR-0442 - Supersedes:ADR-0442 中“catalog receipt attested 后即可 mutation 最终 image tag”的发布顺序 +- Amended by:ADR-0444 将最终 tag mutation 从 YAML heredoc 收敛为 pre-registry validated、可重放演练的独立 finalizer ## 上下文 diff --git a/docs/adr/ADR-0444-fail-closed-release-tag-finalizer-and-replay-rehearsal.md b/docs/adr/ADR-0444-fail-closed-release-tag-finalizer-and-replay-rehearsal.md new file mode 100644 index 00000000..20d1b3e2 --- /dev/null +++ b/docs/adr/ADR-0444-fail-closed-release-tag-finalizer-and-replay-rehearsal.md @@ -0,0 +1,78 @@ +# ADR-0444:Fail-closed Release Tag Finalizer 与重放演练 + +- 状态:Accepted +- 日期:2026-08-18 +- 关联 RFC:QL-RFC-0001 D-03、D-14、D-350、D-351、D-352 +- 关联 ADR:ADR-0441、ADR-0442、ADR-0443 +- Amends:ADR-0443 的最终 tag mutation 实现,不改变 deployment-ready 发布语义 + +## 上下文 + +ADR-0443 已把最终 tag mutation 移到 scope-exact deployment readiness attestation 之后,但真正执行 promotion 的代码仍是一段嵌在 +GitHub Actions YAML 中的 Node heredoc。它直接 `JSON.parse` publication plan,读取 source/inventory 并执行 `regctl image copy`;只有全部 +copy 和最终回读完成后,`createPublicationTagObservation` 才间接调用 plan validator。 + +因此,若 runner 上的 plan bytes 在生成后被替换、截断或重写为另一组 repository/tag,旧实现可能先尝试 registry mutation,再因 plan +self-digest 或 exact tag 结构不合法而失败。workflow 正则审计只能检查源码片段存在,不能直接执行“最后一个 repository 冲突时零写”或“copy +已落地但客户端丢失响应后精确重放”的状态机。 + +## 决策 + +1. 最终 tag promotion 只通过 `scripts/ql3-release-tag-finalizer.cjs` 执行;release workflow 不再包含拥有 registry mutation authority 的 + inline Node heredoc。 +2. finalizer 只接受 current-user `0700` canonical parent 中的单链接 `0600` canonical publication plan。它在创建 registry adapter、读取 + immutable source、列 tag 或 copy 之前执行完整 `validatePublicationPlan`,包括 v2 schema、self-digest、release/source/scope、readiness、 + repository、immutable reference 和两个 exact target tag。 +3. `regctl` 必须是 canonical absolute、current-user、单链接、不可被 group/other 写入的 executable。finalizer 固定其 dev/inode/size/ + uid/mode/mtime/ctime,并在每个 registry command 前后复验;漂移立即失败。 +4. mutation 前必须完成所有 image repository 的全量 preflight:逐一回读 immutable source digest;取得最大 1 MiB、换行闭合、合法且无重复的 + tag inventory;解析每个已存在目标 tag 并要求 exact digest。任一 source、inventory 或目标冲突时,全局 copy 次数必须为零。 +5. 全量 preflight 成功后,只对 absent target 执行 `regctl image copy`。exact target 不重写。copy 返回失败时不猜测远端是否已提交,也不删除 + 已正确写入的 tag;同一 protected source tag 重跑会重新 inventory,复用 exact tag 并只补 absent tag。 +6. copy 阶段结束后按 publication plan 固定顺序回读所有 tag,创建 canonical + `qinglong/release-publication-tag-observation@v1`。输出只允许在 `0700` parent 下以 `0600` no-replace 创建。 +7. workflow 在 closure 前再次以 `--mode=audit` 读取 plan 与 observation,重新执行 source、inventory、全部 tag digest 和 observation + self-digest 检查;audit 明确 `registryMutation=false`。只有 live terminal audit 成功才创建 closure receipt。 +8. publication plan、tag observation 与 closure schema 不升级;本 ADR 修复执行顺序和可测试性,不改变 release identity。OCI registry 仍没有 tag + CAS 或跨 repository transaction,closure 继续诚实声明 `registryTagCas=false` 与 `crossRepositoryAtomicity=false`。 + +## 故障与恢复 + +- plan 非 canonical、权限过宽、self-digest 错误或 tag/repository 漂移:任何 registry command 前失败。 +- immutable source、inventory 或任一既有 tag 不确定:所有 repository 保持零 mutation。 +- copy 在远端提交后丢失响应:本轮失败且不生成 observation;重跑复用已经 exact 的 tag,只补 absent tag。 +- copy 成功但 tag 回读不是计划 digest:不生成 observation/closure,不声称发布完成。 +- observation 已存在:finalize 不覆盖;运维者应先执行 read-only audit,不能删除证据后伪造另一份终态。 +- `regctl` 在运行中被替换或改写:命令前后 identity 复验失败,后续 mutation 停止。 + +## 部署与资源影响 + +- 不新增 workspace package、生产依赖、数据库、schema、migration、Kubernetes object、RBAC 或部署服务。 +- Edge、Standalone、低配路由器和 Cluster 节点不执行 finalizer,不增加产物体积、RSS、磁盘写、listener、timer、watcher 或常驻进程。 +- 增量工作只发生在短生命周期 release runner;每个 repository 一次最大 1 MiB inventory,以及 promotion 后一次小型只读 audit。 + +## 被拒绝的替代方案 + +### 继续依赖 YAML 正则审计 inline publisher + +拒绝。正则能证明片段存在,不能证明 plan validation 发生在第一条 registry command 之前,也不能执行 response-loss 状态机。 + +### mutation 后再验证 plan + +拒绝。失败关闭必须保护副作用边界;终态 validator 不能撤销已经写错的 tag。 + +### copy 失败时立即覆盖或删除目标 tag + +拒绝。客户端错误不能区分远端未提交和响应丢失;删除或覆盖会扩大不确定性,并与 no-CAS 事实冲突。 + +### 把 hermetic rehearsal 当作真实 GHCR 证据 + +拒绝。fake registry 证明状态机与零写/重放性质,不证明 GHCR 权限、网络、Cosign、GitHub attestation 或组织级并发控制。 + +## 验证 + +- finalizer 单测覆盖正常 promotion/read-only audit、pre-registry plan rejection、全局 conflict 零写、copy-after-write response loss 重放、 + malformed/unbounded inventory、错误终态 digest、canonical private no-replace CLI 与 executable identity 漂移; +- workflow audit 强制 CI 执行 finalizer tests、唯一脚本入口、`finalize → audit → closure` 顺序,并拒绝 inline heredoc 回归; +- 完整仓库、产物档位和部署 Gate 的阶段结果记录在 QL-RFC-0001 D-352;真实 GHCR response-loss 证据仍须由受保护 release tag 或受控 + release repository 演练产生。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 19a95d27..c3781c9a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -447,6 +447,7 @@ | [ADR-0441](./ADR-0441-no-overwrite-release-catalog-discovery-publication.md) | Release Catalog Discovery Tag 无覆盖发布 | Accepted(首份真实 GHCR conflict/reuse 证据待实际 release tag) | | [ADR-0442](./ADR-0442-catalog-ready-terminal-release-tag-publication.md) | Catalog-ready 的终态 Release Tag 发布与闭合收据 | Superseded by ADR-0443(bounded promotion/closure 机制保留) | | [ADR-0443](./ADR-0443-deployment-ready-terminal-release-finalization.md) | Deployment-ready 的终态 Release Finalization | Accepted(首份真实 GHCR deployment-ready finalization 待实际 release tag) | +| [ADR-0444](./ADR-0444-fail-closed-release-tag-finalizer-and-replay-rehearsal.md) | Fail-closed Release Tag Finalizer 与重放演练 | Accepted(首份真实 GHCR response-loss 重放待实际 release tag) | ## 规则 diff --git a/docs/operations/ql3-release-set-deployment.md b/docs/operations/ql3-release-set-deployment.md index a2c01c7b..d681cd26 100644 --- a/docs/operations/ql3-release-set-deployment.md +++ b/docs/operations/ql3-release-set-deployment.md @@ -81,6 +81,21 @@ readiness 是本次 release workflow 的操作证据,不是新的镜像内容 readiness digest 不同;release-set 与 immutable catalog digest 仍必须相同,既有 exact tag 只能复用,不同 digest 继续失败。OCI registry 没有跨 repository 事务或 tag CAS,因此 closure 证明的是观测到的终态,不是原子提交。 +### Fail-closed tag finalizer + +最终 tag writer 不再由 workflow 内联脚本实现。`ql3-release-tag-finalizer.cjs --mode=finalize` 必须先从 owner-private `0700` 目录稳定读取 +`0600` canonical publication plan,完整验证 plan self-digest、deployment readiness 和 exact repository/tag,再固定 checksum-verified +`regctl` 的文件 identity。任何 registry mutation 前,它会遍历所有 image repository,验证 immutable source、最大 1 MiB 的合法无重复 tag +inventory,以及每个已存在目标 tag 的 exact digest;任一冲突时 copy 次数为零。 + +promotion 只填补 absent tag,不重写 exact tag。若 `regctl image copy` 在远端提交后丢失响应,本轮不会生成 observation;同一 protected +source tag 重跑会重新 inventory,复用已存在 exact tag并只补缺失项。不得手工删除“可能已成功”的 tag,也不得把任意 registry error 降级为 +absent。全部 tag 最终回读后,observation 以 `0600` no-replace 生成;紧接着的 `--mode=audit` 再次只读验证 source、inventory、live tag 与 +observation,成功后才允许 closure。 + +hermetic response-loss rehearsal 只证明状态机和零写/精确重放性质,不是 GHCR 在线证据。真实组织权限、网络响应丢失、并发 writer 与 GitHub +attestation 仍必须通过受保护 release tag 或受控 release repository 演练取得。 + ## 选择 scope | 部署类型 | release scope | 必须出现的镜像 | diff --git a/scripts/ql3-cluster-copilot-console-distribution-audit.cjs b/scripts/ql3-cluster-copilot-console-distribution-audit.cjs index abb7ffec..1b6a0fa6 100644 --- a/scripts/ql3-cluster-copilot-console-distribution-audit.cjs +++ b/scripts/ql3-cluster-copilot-console-distribution-audit.cjs @@ -258,6 +258,7 @@ function auditClusterCopilotConsoleDistribution(options = {}) { 'Verify the durable catalog and create its immutable receipt', 'Attest the immutable release-catalog receipt', 'Attest deployment readiness before any final tag mutation', + 'scripts/ql3-release-tag-finalizer.cjs', 'Promote final tags only after every required deployment gate', 'Close and audit the deployment-ready public tag set', 'Attest the deployment-ready release publication closure receipt', diff --git a/scripts/ql3-cluster-image-release-audit.cjs b/scripts/ql3-cluster-image-release-audit.cjs index 72e8af52..22397e44 100644 --- a/scripts/ql3-cluster-image-release-audit.cjs +++ b/scripts/ql3-cluster-image-release-audit.cjs @@ -274,8 +274,8 @@ function auditClusterImageCiWorkflow( ); requirePattern( source, - /node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3PrivateReleaseEvidenceReceiptContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseDeploymentReadinessContract\.test\.cjs[\s\S]*test\/back\/ql3ReleasePublicationClosureContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/, - 'cluster image CI must run SBOM, candidate, private evidence receipt, release-set, durable catalog, deployment-lock and workflow negative tests; catalog consumption ceremony is mandatory', + /node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3PrivateReleaseEvidenceReceiptContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseDeploymentReadinessContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseTagFinalizer\.test\.cjs[\s\S]*test\/back\/ql3ReleasePublicationClosureContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/, + 'cluster image CI must run SBOM, candidate, private evidence receipt, release-set, durable catalog, deployment-lock, tag finalizer and workflow negative tests; catalog consumption ceremony is mandatory', ); requirePattern( source, @@ -1024,9 +1024,10 @@ function auditReleaseWorkflow(source) { }) || finalizationSteps[11]?.uses !== 'docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c' || - !/createPublicationTagObservation[\s\S]*maxInventoryBytes = 1024 \* 1024[\s\S]*tagPattern[\s\S]*for \(const image of plan\.images\)[\s\S]*image\.immutableReference[\s\S]*'tag',[\s\S]*'ls',[\s\S]*image\.registryRepository[\s\S]*inventoryContents\.endsWith\('\\n'\)[\s\S]*new Set\(inventory\)\.size !== inventory\.length[\s\S]*release tag already points at another digest[\s\S]*for \(const state of states\)[\s\S]*'image',[\s\S]*'copy',[\s\S]*state\.image\.immutableReference[\s\S]*promoted tag does not resolve to the release-set digest[\s\S]*createPublicationTagObservation\(plan, observedTags\)[\s\S]*fs\.openSync\(process\.env\.TAG_OBSERVATIONS, 'wx', 0o600\)/u.test( + !/ql3-release-tag-finalizer\.cjs[\s\S]*--mode=finalize[\s\S]*--plan="\$\{PUBLICATION_PLAN\}"[\s\S]*--regctl="\$\{REGCTL\}"[\s\S]*--output="\$\{TAG_OBSERVATIONS\}"[\s\S]*ql3-release-tag-finalizer\.cjs[\s\S]*--mode=audit[\s\S]*--plan="\$\{PUBLICATION_PLAN\}"[\s\S]*--regctl="\$\{REGCTL\}"[\s\S]*--observation="\$\{TAG_OBSERVATIONS\}"[\s\S]*release-tag-finalization-audit\.json/u.test( finalizationSteps[12]?.run ?? '', ) || + /node\s+<<['"]?NODE/u.test(finalizationSteps[12]?.run ?? '') || !/ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=close[\s\S]*--plan="\$\{PUBLICATION_PLAN\}"[\s\S]*--observations="\$\{TAG_OBSERVATIONS\}"[\s\S]*--output="\$\{CLOSURE_RECEIPT\}"[\s\S]*--mode=audit[\s\S]*--receipt="\$\{CLOSURE_RECEIPT\}"/u.test( finalizationSteps[13]?.run ?? '', ) || @@ -1454,9 +1455,13 @@ function auditReleaseWorkflow(source) { receiptAttested: true, }, finalPublicationClosure: { + finalizer: 'scripts/ql3-release-tag-finalizer.cjs', planSchema: 'qinglong/release-publication-plan@v2', tagObservationSchema: 'qinglong/release-publication-tag-observation@v1', receiptSchema: 'qinglong/release-publication-closure-receipt@v2', + planValidatedBeforeRegistryAccess: true, + hermeticResponseLossRehearsal: true, + liveTerminalAuditBeforeClosure: true, catalogReadyBeforeTagMutation: true, deploymentReadyBeforeTagMutation: true, allTagsExactDigest: true, diff --git a/scripts/ql3-release-publication-closure-contract.cjs b/scripts/ql3-release-publication-closure-contract.cjs index 4df195a5..30c827df 100644 --- a/scripts/ql3-release-publication-closure-contract.cjs +++ b/scripts/ql3-release-publication-closure-contract.cjs @@ -606,4 +606,6 @@ module.exports = Object.freeze({ createPublicationTagObservation, parseArguments, runCli, + validatePublicationPlan, + validateTagObservation, }); diff --git a/scripts/ql3-release-tag-finalizer.cjs b/scripts/ql3-release-tag-finalizer.cjs new file mode 100644 index 00000000..2524c90f --- /dev/null +++ b/scripts/ql3-release-tag-finalizer.cjs @@ -0,0 +1,406 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { + createPublicationTagObservation, + validatePublicationPlan, + validateTagObservation, +} = require('./ql3-release-publication-closure-contract.cjs'); + +const MAX_JSON_BYTES = 1024 * 1024; +const MAX_INVENTORY_BYTES = 1024 * 1024; +const TAG_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/u; + +class QingLong3ReleaseTagFinalizerError extends Error { + constructor(message) { + super(`QingLong 3 release tag finalizer failed: ${message}`); + this.name = 'QingLong3ReleaseTagFinalizerError'; + } +} + +function fail(message) { + throw new QingLong3ReleaseTagFinalizerError(message); +} + +function canonicalJson(value) { + return `${JSON.stringify(value)}\n`; +} + +function currentUid() { + if (typeof process.getuid !== 'function') { + fail('a POSIX current user is required'); + } + return process.getuid(); +} + +function resolveCanonicalAbsolute(input, label) { + if ( + typeof input !== 'string' || + !path.isAbsolute(input) || + path.resolve(input) !== input + ) { + fail(`${label} path must be canonical and absolute`); + } + return input; +} + +function validatePrivateParent(parent, label) { + const uid = currentUid(); + const stat = fs.lstatSync(parent); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + stat.uid !== uid || + (stat.mode & 0o777) !== 0o700 || + fs.realpathSync(parent) !== parent + ) { + fail(`${label} parent must be a canonical current-user 0700 directory`); + } +} + +function readPrivateCanonicalJson(filePath, label) { + const resolved = resolveCanonicalAbsolute(filePath, label); + validatePrivateParent(path.dirname(resolved), label); + let descriptor; + let before; + let after; + let bytes; + try { + descriptor = fs.openSync( + resolved, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW, + ); + before = fs.fstatSync(descriptor); + if ( + !before.isFile() || + before.uid !== currentUid() || + (before.mode & 0o777) !== 0o600 || + before.nlink !== 1 || + before.size < 2 || + before.size > MAX_JSON_BYTES || + fs.realpathSync(resolved) !== resolved + ) { + fail(`${label} must be a bounded current-user 0600 regular file`); + } + bytes = fs.readFileSync(descriptor); + after = fs.fstatSync(descriptor); + } catch (error) { + if (error instanceof QingLong3ReleaseTagFinalizerError) throw error; + fail(`${label} cannot be read through a stable descriptor`); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeMs !== after.mtimeMs || + before.ctimeMs !== after.ctimeMs || + bytes.byteLength !== before.size + ) { + fail(`${label} changed while being read`); + } + const contents = bytes.toString('utf8'); + if (!Buffer.from(contents, 'utf8').equals(bytes)) { + fail(`${label} must contain valid UTF-8`); + } + let value; + try { + value = JSON.parse(contents); + } catch { + fail(`${label} must contain valid JSON`); + } + if (canonicalJson(value) !== contents) { + fail(`${label} must use canonical JSON encoding`); + } + return value; +} + +function writePrivateNoReplace(filePath, value) { + const resolved = resolveCanonicalAbsolute(filePath, 'output'); + validatePrivateParent(path.dirname(resolved), 'output'); + let descriptor; + try { + descriptor = fs.openSync(resolved, 'wx', 0o600); + fs.writeFileSync(descriptor, canonicalJson(value)); + fs.fsyncSync(descriptor); + } catch { + fail('output must be published once as a private regular file'); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function validateRegistryAdapter(registry) { + if ( + registry === null || + typeof registry !== 'object' || + typeof registry.resolveDigest !== 'function' || + typeof registry.listTags !== 'function' || + typeof registry.copyImage !== 'function' + ) { + fail('registry adapter is incomplete'); + } + return registry; +} + +function parseTagInventory(contents) { + if ( + typeof contents !== 'string' || + Buffer.byteLength(contents, 'utf8') > MAX_INVENTORY_BYTES || + (contents.length > 0 && !contents.endsWith('\n')) + ) { + fail('release tag inventory is invalid or unbounded'); + } + const tags = contents.length === 0 ? [] : contents.slice(0, -1).split('\n'); + if ( + tags.some((tag) => !TAG_PATTERN.test(tag)) || + new Set(tags).size !== tags.length + ) { + fail('release tag inventory is malformed'); + } + return Object.freeze([...tags]); +} + +function tagName(image, tag) { + return tag.reference.slice(image.registryRepository.length + 1); +} + +function preflightReleaseTags(plan, registryInput) { + validatePublicationPlan(plan); + const registry = validateRegistryAdapter(registryInput); + const states = []; + for (const image of plan.images) { + const sourceDigest = registry.resolveDigest(image.immutableReference); + if (sourceDigest !== image.digest) { + fail('source digest drifted before promotion'); + } + const inventory = new Set( + parseTagInventory(registry.listTags(image.registryRepository)), + ); + for (const tag of image.tags) { + const present = inventory.has(tagName(image, tag)); + if (present && registry.resolveDigest(tag.reference) !== image.digest) { + fail('release tag already points at another digest'); + } + states.push(Object.freeze({ image, tag, present })); + } + } + return Object.freeze(states); +} + +function observeExactTags(plan, states, registry) { + const observedTags = []; + for (const state of states) { + const digest = registry.resolveDigest(state.tag.reference); + if (digest !== state.image.digest) { + fail('promoted tag does not resolve to the release-set digest'); + } + observedTags.push({ + image: state.image.name, + kind: state.tag.kind, + reference: state.tag.reference, + digest, + }); + } + return createPublicationTagObservation(plan, observedTags); +} + +function finalizeReleaseTags(plan, registryInput) { + validatePublicationPlan(plan); + const registry = validateRegistryAdapter(registryInput); + const states = preflightReleaseTags(plan, registry); + for (const state of states) { + if (!state.present) { + registry.copyImage(state.image.immutableReference, state.tag.reference); + } + } + return observeExactTags(plan, states, registry); +} + +function auditReleaseTags(plan, observation, registryInput) { + validatePublicationPlan(plan); + validateTagObservation(plan, observation); + const registry = validateRegistryAdapter(registryInput); + const states = preflightReleaseTags(plan, registry); + if (states.some((state) => !state.present)) { + fail('publication audit found an absent final tag'); + } + const observed = observeExactTags(plan, states, registry); + if (JSON.stringify(observed) !== JSON.stringify(observation)) { + fail('publication observation differs from live registry state'); + } + return Object.freeze({ + schemaVersion: 1, + planDigest: plan.planDigest, + observationDigest: observation.observationDigest, + tagCount: observation.tags.length, + allTagsExactDigest: true, + registryMutation: false, + compatible: true, + }); +} + +function resolveRegctlExecutable(input) { + const resolved = resolveCanonicalAbsolute(input, 'regctl'); + const stat = fs.lstatSync(resolved); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.uid !== currentUid() || + stat.nlink !== 1 || + (stat.mode & 0o111) === 0 || + (stat.mode & 0o022) !== 0 || + fs.realpathSync(resolved) !== resolved + ) { + fail('regctl must be a canonical current-user non-writable executable'); + } + return Object.freeze({ + path: resolved, + dev: stat.dev, + ino: stat.ino, + size: stat.size, + uid: stat.uid, + mode: stat.mode, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + }); +} + +function revalidateRegctlExecutable(identity) { + const stat = fs.lstatSync(identity.path); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + fs.realpathSync(identity.path) !== identity.path || + stat.dev !== identity.dev || + stat.ino !== identity.ino || + stat.size !== identity.size || + stat.uid !== identity.uid || + stat.mode !== identity.mode || + stat.mtimeMs !== identity.mtimeMs || + stat.ctimeMs !== identity.ctimeMs + ) { + fail('regctl executable identity changed during finalization'); + } +} + +function createRegctlAdapter(regctlInput) { + const identity = resolveRegctlExecutable(regctlInput); + const run = (operation, args, timeout = 30_000) => { + revalidateRegctlExecutable(identity); + const result = spawnSync(identity.path, args, { + encoding: 'utf8', + maxBuffer: MAX_INVENTORY_BYTES, + timeout, + killSignal: 'SIGKILL', + }); + revalidateRegctlExecutable(identity); + if (result.error || result.status !== 0) { + fail(`registry command failed during ${operation}`); + } + return result.stdout; + }; + return Object.freeze({ + resolveDigest(reference) { + return run('digest resolution', ['image', 'digest', reference]).trim(); + }, + listTags(repository) { + return run('bounded tag inventory', [ + 'tag', + 'ls', + repository, + '--format', + '{{ range .Tags }}{{ println . }}{{ end }}', + ]); + }, + copyImage(source, target) { + run('tag promotion', ['image', 'copy', source, target], 120_000); + }, + }); +} + +function parseArguments(argv) { + const values = {}; + for (const argument of argv) { + const separator = argument.indexOf('='); + if ( + !argument.startsWith('--') || + separator < 3 || + separator === argument.length - 1 + ) { + fail('arguments must use --name=value'); + } + const key = argument.slice(2, separator); + if (Object.hasOwn(values, key)) fail('arguments must be unique'); + values[key] = argument.slice(separator + 1); + } + const expected = + values.mode === 'finalize' + ? ['mode', 'output', 'plan', 'regctl'] + : values.mode === 'audit' + ? ['mode', 'observation', 'plan', 'regctl'] + : []; + if ( + expected.length === 0 || + JSON.stringify(Object.keys(values).sort()) !== + JSON.stringify([...expected].sort()) + ) { + fail( + 'usage: --mode=finalize --plan=/absolute/plan.json --regctl=/absolute/regctl --output=/absolute/observation.json or --mode=audit --plan=/absolute/plan.json --regctl=/absolute/regctl --observation=/absolute/observation.json', + ); + } + return Object.freeze(values); +} + +function runCli(argv, output = process.stdout, dependencies = {}) { + const options = parseArguments(argv); + const plan = readPrivateCanonicalJson(options.plan, 'publication plan'); + validatePublicationPlan(plan); + const registry = dependencies.registry ?? createRegctlAdapter(options.regctl); + if (options.mode === 'finalize') { + const observation = finalizeReleaseTags(plan, registry); + writePrivateNoReplace(options.output, observation); + output.write(canonicalJson(observation)); + return observation; + } + const observation = readPrivateCanonicalJson( + options.observation, + 'tag observation', + ); + const audit = auditReleaseTags(plan, observation, registry); + output.write(canonicalJson(audit)); + return audit; +} + +if (require.main === module) { + try { + runCli(process.argv.slice(2)); + } catch (error) { + process.stderr.write( + `${ + error instanceof Error + ? error.message + : 'release tag finalization failed' + }\n`, + ); + process.exitCode = 1; + } +} + +module.exports = Object.freeze({ + MAX_INVENTORY_BYTES, + QingLong3ReleaseTagFinalizerError, + auditReleaseTags, + createRegctlAdapter, + finalizeReleaseTags, + parseArguments, + parseTagInventory, + preflightReleaseTags, + revalidateRegctlExecutable, + runCli, +}); diff --git a/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs b/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs index ef04cc6f..88996205 100644 --- a/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs +++ b/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs @@ -78,6 +78,15 @@ test('rejects verifier, embedded artifact and release workflow drift', () => { ), 'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT', ], + [ + '.github/workflows/ql3-image-release.yml', + (source) => + source.replaceAll( + 'scripts/ql3-release-tag-finalizer.cjs', + 'scripts/unvalidated-inline-publisher.cjs', + ), + 'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT', + ], ]; for (const [target, transform, code] of fixtures) { const report = auditClusterCopilotConsoleDistribution({ diff --git a/test/back/ql3ClusterImageReleaseAudit.test.cjs b/test/back/ql3ClusterImageReleaseAudit.test.cjs index 585cb79e..3140ddd5 100644 --- a/test/back/ql3ClusterImageReleaseAudit.test.cjs +++ b/test/back/ql3ClusterImageReleaseAudit.test.cjs @@ -136,9 +136,13 @@ test('accepts the reviewed native CI and digest release contracts', () => { receiptAttested: true, }, finalPublicationClosure: { + finalizer: 'scripts/ql3-release-tag-finalizer.cjs', planSchema: 'qinglong/release-publication-plan@v2', tagObservationSchema: 'qinglong/release-publication-tag-observation@v1', receiptSchema: 'qinglong/release-publication-closure-receipt@v2', + planValidatedBeforeRegistryAccess: true, + hermeticResponseLossRehearsal: true, + liveTerminalAuditBeforeClosure: true, catalogReadyBeforeTagMutation: true, deploymentReadyBeforeTagMutation: true, allTagsExactDigest: true, @@ -219,7 +223,7 @@ test('rejects removal of the durable release-catalog contract tests', () => { ); assert.throws( () => auditClusterImageCiWorkflow(mutated), - /durable catalog, deployment-lock and workflow negative tests/, + /durable catalog, deployment-lock.*workflow negative tests/, ); }); @@ -230,7 +234,7 @@ test('rejects removal of the final publication closure contract tests', () => { ); assert.throws( () => auditClusterImageCiWorkflow(mutated), - /durable catalog, deployment-lock and workflow negative tests/, + /durable catalog, deployment-lock.*workflow negative tests/, ); }); @@ -241,7 +245,18 @@ test('rejects removal of the deployment readiness contract tests', () => { ); assert.throws( () => auditClusterImageCiWorkflow(mutated), - /durable catalog, deployment-lock and workflow negative tests/, + /durable catalog, deployment-lock.*workflow negative tests/, + ); +}); + +test('rejects removal of the fail-closed tag finalizer tests', () => { + const mutated = ciSource.replace( + 'test/back/ql3ReleaseTagFinalizer.test.cjs', + 'test/back/tag-finalizer-tests-removed.test.cjs', + ); + assert.throws( + () => auditClusterImageCiWorkflow(mutated), + /tag finalizer.*workflow negative tests/, ); }); @@ -263,7 +278,7 @@ test('rejects removal of the deployment-lock materialization contract tests', () ); assert.throws( () => auditClusterImageCiWorkflow(mutated), - /deployment-lock and workflow negative tests/, + /deployment-lock.*workflow negative tests/, ); }); @@ -1170,14 +1185,29 @@ test('rejects deployment readiness without its own pre-promotion attestation', ( assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/); }); -test('rejects final tag promotion without bounded repository inventory', () => { +test('rejects final tag promotion that bypasses the validated finalizer', () => { const mutated = releaseSource.replace( - " 'tag',\n 'ls',\n image.registryRepository,", - " 'image',\n 'digest',\n image.registryRepository,", + ' --mode=finalize \\', + ' --mode=audit \\', ); assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/); }); +test('rejects final tag promotion without a live read-only terminal audit', () => { + const audited = [ + ' --mode=audit \\', + ' --plan="${PUBLICATION_PLAN}" \\', + ' --regctl="${REGCTL}"', + ].join('\n'); + const unaudited = [ + ' --mode=finalize \\', + ' --plan="${PUBLICATION_PLAN}" \\', + ' --regctl="${REGCTL}"', + ].join('\n'); + const mutated = releaseSource.replace(audited, unaudited); + assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/); +}); + test('rejects omission of the final publication closure audit', () => { const mutated = releaseSource.replace( ' --mode=close \\', diff --git a/test/back/ql3ReleaseTagFinalizer.test.cjs b/test/back/ql3ReleaseTagFinalizer.test.cjs new file mode 100644 index 00000000..50a44a4a --- /dev/null +++ b/test/back/ql3ReleaseTagFinalizer.test.cjs @@ -0,0 +1,362 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { + PUBLICATION_PLAN_SCHEMA, +} = require('../../scripts/ql3-release-publication-closure-contract.cjs'); +const { + SCHEMA: DEPLOYMENT_READINESS_SCHEMA, +} = require('../../scripts/ql3-release-deployment-readiness-contract.cjs'); +const { + RELEASE_SET_SCHEMA, +} = require('../../scripts/ql3-release-set-contract.cjs'); +const { + MAX_INVENTORY_BYTES, + auditReleaseTags, + createRegctlAdapter, + finalizeReleaseTags, + parseArguments, + runCli, +} = require('../../scripts/ql3-release-tag-finalizer.cjs'); + +function sha256(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function publicationPlan() { + const version = '3.0.0-alpha.0'; + const sourceRevision = 'a'.repeat(40); + const repository = 'ghcr.io/qinglong-release/qinglong3-local-application'; + const imageDigest = `sha256:${'1'.repeat(64)}`; + const manifestDigest = `sha256:${'2'.repeat(64)}`; + const unsigned = { + schemaVersion: 1, + schema: PUBLICATION_PLAN_SCHEMA, + release: { + version, + sourceRevision, + sourceRef: `refs/tags/v${version}`, + scope: 'local', + }, + releaseSet: { + schema: RELEASE_SET_SCHEMA, + releaseSetDigest: `sha256:${'3'.repeat(64)}`, + contentDigest: `sha256:${'4'.repeat(64)}`, + }, + catalog: { + planDigest: `sha256:${'5'.repeat(64)}`, + receiptDigest: `sha256:${'6'.repeat(64)}`, + manifestDigest, + immutableReference: `ghcr.io/qinglong-release/qinglong3-release-catalog@${manifestDigest}`, + }, + deploymentReadiness: { + schema: DEPLOYMENT_READINESS_SCHEMA, + receiptDigest: `sha256:${'7'.repeat(64)}`, + finalizerConsumptionDigest: `sha256:${'8'.repeat(64)}`, + requiredDeploymentFamilies: ['local'], + }, + requiredPrerequisites: { + releaseSetProvenance: 'attested_before_catalog_publication', + catalogSignature: 'verified_exact_workflow_identity', + catalogProvenance: 'verified_source_tag_and_revision', + catalogReceipt: 'attested_before_deployment_gates', + deploymentReadiness: 'scope_exact_receipt_attested_before_tag_promotion', + }, + promotionPolicy: { + authority: 'verified_catalog_bound_deployments', + inventory: 'bounded_exact_repository_tags', + conflict: 'fail_closed_before_any_tag_mutation', + recovery: 'reuse_exact_digest_only', + finalVerification: 'all_tags_exact_digest', + crossRepositoryAtomicity: false, + registryTagCas: false, + }, + images: [ + { + name: 'local', + registryRepository: repository, + immutableReference: `${repository}@${imageDigest}`, + digest: imageDigest, + tags: [ + { kind: 'version', reference: `${repository}:${version}` }, + { + kind: 'source', + reference: `${repository}:sha-${sourceRevision}`, + }, + ], + }, + ], + }; + return Object.freeze({ + ...unsigned, + planDigest: sha256(JSON.stringify(unsigned)), + }); +} + +function splitTag(reference) { + const separator = reference.lastIndexOf(':'); + return [reference.slice(0, separator), reference.slice(separator + 1)]; +} + +class FakeRegistry { + constructor(plan) { + this.sources = new Map( + plan.images.map((image) => [image.immutableReference, image.digest]), + ); + this.repositories = new Map( + plan.images.map((image) => [image.registryRepository, new Map()]), + ); + this.calls = []; + this.inventoryOverride = undefined; + this.copyFailureAfterWrite = false; + this.copyDrift = false; + } + + resolveDigest(reference) { + this.calls.push(['resolveDigest', reference]); + if (reference.includes('@')) return this.sources.get(reference); + const [repository, tag] = splitTag(reference); + return this.repositories.get(repository)?.get(tag); + } + + listTags(repository) { + this.calls.push(['listTags', repository]); + if (this.inventoryOverride !== undefined) return this.inventoryOverride; + const tags = [...(this.repositories.get(repository)?.keys() ?? [])].sort(); + return tags.length === 0 ? '' : `${tags.join('\n')}\n`; + } + + copyImage(source, target) { + this.calls.push(['copyImage', source, target]); + const [repository, tag] = splitTag(target); + const digest = this.copyDrift + ? `sha256:${'f'.repeat(64)}` + : this.sources.get(source); + this.repositories.get(repository).set(tag, digest); + if (this.copyFailureAfterWrite) { + this.copyFailureAfterWrite = false; + throw new Error('simulated response loss'); + } + } + + seed(reference, digest) { + const [repository, tag] = splitTag(reference); + this.repositories.get(repository).set(tag, digest); + } + + copyCalls() { + return this.calls.filter(([operation]) => operation === 'copyImage'); + } +} + +function temporaryDirectory(t) { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-release-tag-finalizer-')), + ); + fs.chmodSync(directory, 0o700); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return directory; +} + +function writeCanonical(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { + mode: 0o600, + flag: 'wx', + }); +} + +test('finalizes every exact tag and audits the live terminal state', () => { + const plan = publicationPlan(); + const registry = new FakeRegistry(plan); + const observation = finalizeReleaseTags(plan, registry); + assert.equal(registry.copyCalls().length, 2); + assert.equal(observation.tags.length, 2); + assert.equal( + observation.tags.every((tag) => tag.digest === plan.images[0].digest), + true, + ); + const copiesBeforeAudit = registry.copyCalls().length; + assert.deepEqual(auditReleaseTags(plan, observation, registry), { + schemaVersion: 1, + planDigest: plan.planDigest, + observationDigest: observation.observationDigest, + tagCount: 2, + allTagsExactDigest: true, + registryMutation: false, + compatible: true, + }); + assert.equal(registry.copyCalls().length, copiesBeforeAudit); +}); + +test('validates the complete plan before any registry observation or mutation', () => { + const plan = structuredClone(publicationPlan()); + plan.images[0].tags[0].reference = + 'ghcr.io/attacker/other-repository:3.0.0-alpha.0'; + const { planDigest: _discarded, ...unsigned } = plan; + plan.planDigest = sha256(JSON.stringify(unsigned)); + const registry = new FakeRegistry(publicationPlan()); + assert.throws( + () => finalizeReleaseTags(plan, registry), + /publication plan tag is invalid/, + ); + assert.deepEqual(registry.calls, []); +}); + +test('preflights every conflict before the first tag mutation', () => { + const plan = publicationPlan(); + const registry = new FakeRegistry(plan); + registry.seed(plan.images[0].tags[1].reference, `sha256:${'e'.repeat(64)}`); + assert.throws( + () => finalizeReleaseTags(plan, registry), + /already points at another digest/, + ); + assert.equal(registry.copyCalls().length, 0); +}); + +test('recovers copy response loss by reusing exact tags and only filling absence', () => { + const plan = publicationPlan(); + const registry = new FakeRegistry(plan); + registry.copyFailureAfterWrite = true; + assert.throws( + () => finalizeReleaseTags(plan, registry), + /simulated response loss/, + ); + assert.equal(registry.copyCalls().length, 1); + const observation = finalizeReleaseTags(plan, registry); + assert.equal(registry.copyCalls().length, 2); + assert.equal(observation.tags.length, 2); + assert.equal( + registry.resolveDigest(plan.images[0].tags[0].reference), + plan.images[0].digest, + ); +}); + +test('rejects malformed or unbounded inventory before mutation', () => { + for (const inventory of [ + 'duplicate\nduplicate\n', + 'not canonical', + `${'x'.repeat(MAX_INVENTORY_BYTES + 1)}\n`, + ]) { + const plan = publicationPlan(); + const registry = new FakeRegistry(plan); + registry.inventoryOverride = inventory; + assert.throws( + () => finalizeReleaseTags(plan, registry), + /inventory is malformed|inventory is invalid or unbounded/, + ); + assert.equal(registry.copyCalls().length, 0); + } +}); + +test('fails terminal observation when a copy resolves to the wrong digest', () => { + const plan = publicationPlan(); + const registry = new FakeRegistry(plan); + registry.copyDrift = true; + assert.throws( + () => finalizeReleaseTags(plan, registry), + /promoted tag does not resolve/, + ); +}); + +test('runs canonical private no-replace finalize and read-only audit stages', (t) => { + const directory = temporaryDirectory(t); + const plan = publicationPlan(); + const registry = new FakeRegistry(plan); + const planPath = path.join(directory, 'plan.json'); + const observationPath = path.join(directory, 'observation.json'); + writeCanonical(planPath, plan); + const output = { write() {} }; + const observation = runCli( + [ + '--mode=finalize', + `--plan=${planPath}`, + '--regctl=/unused/injected-regctl', + `--output=${observationPath}`, + ], + output, + { registry }, + ); + assert.equal(fs.statSync(observationPath).mode & 0o777, 0o600); + assert.equal( + fs.readFileSync(observationPath, 'utf8'), + `${JSON.stringify(observation)}\n`, + ); + const audit = runCli( + [ + '--mode=audit', + `--plan=${planPath}`, + '--regctl=/unused/injected-regctl', + `--observation=${observationPath}`, + ], + output, + { registry }, + ); + assert.equal(audit.compatible, true); + assert.equal(audit.registryMutation, false); + assert.throws( + () => + runCli( + [ + '--mode=finalize', + `--plan=${planPath}`, + '--regctl=/unused/injected-regctl', + `--output=${observationPath}`, + ], + output, + { registry }, + ), + /output must be published once/, + ); +}); + +test('accepts only closed CLI modes and a hardened regctl executable', (t) => { + assert.deepEqual( + parseArguments([ + '--mode=finalize', + '--plan=/private/plan.json', + '--regctl=/private/regctl', + '--output=/private/observation.json', + ]), + { + mode: 'finalize', + plan: '/private/plan.json', + regctl: '/private/regctl', + output: '/private/observation.json', + }, + ); + for (const argv of [ + ['--mode=finalize', '--plan=/p', '--regctl=/r'], + [ + '--mode=audit', + '--plan=/p', + '--regctl=/r', + '--observation=/o', + '--force=true', + ], + ['--mode=unknown', '--plan=/p', '--regctl=/r', '--output=/o'], + ]) { + assert.throws(() => parseArguments(argv), /usage/); + } + + const directory = temporaryDirectory(t); + const executable = path.join(directory, 'regctl'); + fs.writeFileSync(executable, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + const pinnedAdapter = createRegctlAdapter(executable); + assert.equal(typeof pinnedAdapter.resolveDigest, 'function'); + fs.chmodSync(executable, 0o700); + assert.throws( + () => pinnedAdapter.resolveDigest('ghcr.io/example/image@sha256:digest'), + /identity changed/, + ); + fs.chmodSync(executable, 0o775); + assert.throws(() => createRegctlAdapter(executable), /non-writable/); + fs.chmodSync(executable, 0o755); + const alias = path.join(directory, 'regctl-alias'); + fs.symlinkSync(executable, alias); + assert.throws(() => createRegctlAdapter(alias), /non-writable/); +});