From f3e148d30b702cf3712a878058558ef2ed162624 Mon Sep 17 00:00:00 2001 From: Jintao Date: Sun, 26 Jul 2026 11:32:02 +0800 Subject: [PATCH 1/2] Add RootFS notice candidate review results --- .github/workflows/ci.yml | 4 + CHANGELOG.en.md | 5 + CHANGELOG.md | 4 + Compliance/RootFS/v0.3.3/EVIDENCE.json | 6 +- .../RootFS/v0.3.3/LICENSE-INVENTORY.json | 2 + .../v0.3.3/LICENSE-NOTICE-REVIEW-RESULTS.json | 174 +++++++ Compliance/RootFS/v0.3.3/NOTICE.md | 11 +- Compliance/RootFS/v0.3.3/README.md | 30 +- Compliance/RootFS/v0.3.3/SHA256SUMS | 7 +- Docs/ReleaseCompliance.md | 8 +- Docs/Roadmap.md | 2 +- Docs/RootFS.md | 11 +- Docs/en/ReleaseCompliance.md | 14 +- Docs/en/Roadmap.md | 2 +- Docs/en/RootFS.md | 14 +- README.md | 2 +- Scripts/generate-rootfs-compliance.rb | 95 +++- .../rootfs-license-notice-review-results.rb | 441 ++++++++++++++++++ .../RootFSLicenseNoticeReviewResultsTests.rb | 282 +++++++++++ 19 files changed, 1070 insertions(+), 44 deletions(-) create mode 100644 Compliance/RootFS/v0.3.3/LICENSE-NOTICE-REVIEW-RESULTS.json create mode 100644 Scripts/rootfs-license-notice-review-results.rb create mode 100644 Tests/Scripts/RootFSLicenseNoticeReviewResultsTests.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb6981c..d8b647f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: ruby -c Scripts/rootfs-license-review.rb ruby -c Scripts/rootfs-license-review-results.rb ruby -c Scripts/rootfs-license-notice-candidates.rb + ruby -c Scripts/rootfs-license-notice-review-results.rb ruby -c Scripts/prepare-rootfs-license-review.rb ruby -c Scripts/prepare-rootfs-license-notice-bundle.rb ruby -c Scripts/rootfs-source-acquisition.rb @@ -50,6 +51,7 @@ jobs: ruby -c Tests/Scripts/RootFSLicenseReviewResultsTests.rb ruby -c Tests/Scripts/RootFSLicenseNoticeCandidatesTests.rb ruby -c Tests/Scripts/RootFSLicenseNoticeBundleTests.rb + ruby -c Tests/Scripts/RootFSLicenseNoticeReviewResultsTests.rb ruby -c Tests/Scripts/RootFSSourceBundleTests.rb bash Tests/Scripts/RuntimeSmokeScriptTests.sh bash Tests/Scripts/RootFSCandidateSmokeManifestTests.sh @@ -57,10 +59,12 @@ jobs: ruby Tests/Scripts/RootFSLicenseReviewResultsTests.rb ruby Tests/Scripts/RootFSLicenseNoticeCandidatesTests.rb ruby Tests/Scripts/RootFSLicenseNoticeBundleTests.rb + ruby Tests/Scripts/RootFSLicenseNoticeReviewResultsTests.rb ruby Tests/Scripts/RootFSSourceBundleTests.rb ruby Scripts/prepare-rootfs-license-review.rb --validate-only ruby Scripts/rootfs-license-review-results.rb ruby Scripts/rootfs-license-notice-candidates.rb + ruby Scripts/rootfs-license-notice-review-results.rb ruby Scripts/prepare-rootfs-license-notice-bundle.rb --validate-only ruby Scripts/prepare-rootfs-source-bundle.rb --validate-only diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index cbae006..e8ab488 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -41,6 +41,11 @@ All notable PocketRoot changes are recorded here. Semantic Versioning begins wit origins. It indexes 8 remote license/attribution payloads, 46 aports files, and the 21 existing evidence files for complete re-verification without committing payloads or opening engineering, legal, or redistribution gates. +- `LICENSE-NOTICE-REVIEW-RESULTS.json` and a strict external payload-tree + verifier. All 75 candidate payloads now have checksum-bound engineering + review; `apk-tools` and `pax-utils` have no remaining candidate-material + engineering items, six origins still need package-specific material, and + legal and redistribution gates remain closed. - arm64 Simulator and unsigned-device final-link gates for the full Experimental graph. - Repository iOS 18 native smoke covering 17 preparation, boot, guest, 8 MiB sustained binary-output, stdout/stderr overflow, command, cancellation, recovery, shutdown, and 256 MiB Simulator lifecycle peak-memory checks. - A repository-external, unapproved RootFS double-build candidate path for the diff --git a/CHANGELOG.md b/CHANGELOG.md index dcb2d84..dc3b34f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,10 @@ PocketRoot 的重要变化记录在这里。首个公开版本发布后遵循 Se `LICENSE-NOTICE-CANDIDATES.json`、严格验证器和仓库外原子 materializer;索引 8 份远端许可证/attribution 材料、46 份 aports 文件与 21 份既有证据,支持完整 复验,但不提交 payload,也不解除工程、法律或再分发门禁。 +- 加入 `LICENSE-NOTICE-REVIEW-RESULTS.json` 和严格外置 payload-tree 复验器; + 75/75 个候选 payload 已完成 checksum-bound 工程复核,`apk-tools`、 + `pax-utils` 的候选材料工程项关闭,另外 6 个 origin 仍需补逐包材料,法律和 + 再分发门禁保持关闭。 - 加入完整 Experimental graph 的 arm64 Simulator 与 unsigned device final-link gate。 - 加入 repository-owned iOS 18 native smoke App 和 runner,覆盖 17 项 prepare、boot、guest、8 MiB 持续二进制输出、stdout/stderr 超限、command、取消、recovery、shutdown 与 256 MiB Simulator 生命周期峰值内存门禁。 - native smoke 新增仓库外、未授权 RootFS 双构建候选入口:校验候选 provenance、 diff --git a/Compliance/RootFS/v0.3.3/EVIDENCE.json b/Compliance/RootFS/v0.3.3/EVIDENCE.json index 35c639a..c8ff941 100644 --- a/Compliance/RootFS/v0.3.3/EVIDENCE.json +++ b/Compliance/RootFS/v0.3.3/EVIDENCE.json @@ -44,7 +44,9 @@ "sourceOriginsWithRemainingLicenseReviewItems": 8, "indexedLicenseNoticeCandidateOrigins": 8, "pinnedRemoteLicenseNoticePayloads": 8, - "supplementalAportsCandidateFiles": 46 + "supplementalAportsCandidateFiles": 46, + "engineeringReviewedLicenseNoticeCandidatePayloads": 75, + "sourceOriginsWithRemainingCandidatePayloadReviewItems": 6 }, "engineeringStatus": { "completeInstalledPackageInventory": true, @@ -55,7 +57,7 @@ "licenseCandidateEngineeringReviewCompleted": true, "completeLicenseNoticeCandidateBundleIndex": true, "licenseNoticeCandidatePayloadCommitted": false, - "licenseNoticeCandidateEngineeringReviewApproved": false, + "licenseNoticeCandidateEngineeringReviewCompleted": true, "completeLicenseAndNoticeBundle": false, "correspondingSourceBundleCollected": false, "redistributionApproved": false diff --git a/Compliance/RootFS/v0.3.3/LICENSE-INVENTORY.json b/Compliance/RootFS/v0.3.3/LICENSE-INVENTORY.json index 41ece52..a2d59be 100644 --- a/Compliance/RootFS/v0.3.3/LICENSE-INVENTORY.json +++ b/Compliance/RootFS/v0.3.3/LICENSE-INVENTORY.json @@ -33,6 +33,8 @@ "indexedOpenSourceOrigins": 8, "pinnedRemoteReferencePayloads": 8, "supplementalAportsFiles": 46, + "engineeringReviewedCandidatePayloads": 75, + "sourceOriginsWithRemainingCandidatePayloadReviewItems": 6, "candidateBundleIndexComplete": true, "candidatePayloadCommitted": false, "engineeringReviewCompleted": true, diff --git a/Compliance/RootFS/v0.3.3/LICENSE-NOTICE-REVIEW-RESULTS.json b/Compliance/RootFS/v0.3.3/LICENSE-NOTICE-REVIEW-RESULTS.json new file mode 100644 index 0000000..4ecbc0a --- /dev/null +++ b/Compliance/RootFS/v0.3.3/LICENSE-NOTICE-REVIEW-RESULTS.json @@ -0,0 +1,174 @@ +{ + "schemaVersion": 1, + "archive": { + "version": "v0.3.3", + "sha256": "be0f3c133f78f28b023288459b33dc28fa253a6ef29f7123bc5f3892edf90ad4" + }, + "candidateManifestSha256": "55e13487c9c0b0ad0160e91c440120b6faa1ef75b0cf467676c67834ca1083cb", + "status": "candidate-payloads-engineering-reviewed-open-release-gates", + "engineeringReviewCompleted": true, + "allIndexedCandidatePayloadsReviewed": true, + "referenceLicenseTextsReviewed": true, + "completePackageLicenseNoticeSetPresent": false, + "legalReviewApproved": false, + "redistributionApproved": false, + "candidatePayloadTreeFormat": "sha256-path-lines-v1", + "candidatePayloadTreeSha256": "a54935ff29aa4f9784a74f26733bd915f8e6750e8487138af3f17a9389b93d30", + "reviewedPayloadFileCount": 75, + "reviewedClosedOriginEvidenceCount": 4, + "sourceOriginsWithRemainingReviewItems": 6, + "sources": [ + { + "sourceOrigin": "alpine-baselayout", + "declaredLicenseExpressions": [ + "GPL-2.0-only" + ], + "reviewState": "candidate-payloads-engineering-reviewed-legal-review-open", + "licenseTextCoverage": "complete", + "attributionCoverage": "partial", + "reviewedExistingEvidenceCount": 1, + "reviewedReferenceLicenseCount": 1, + "reviewedSupplementalAportsCount": 0, + "reviewedRemoteEvidenceCount": 1, + "resolvedReviewItems": [ + "collect-complete-gpl-2.0-license-text" + ], + "remainingReviewItems": [ + "identify-package-specific-copyright-and-notice" + ], + "engineeringConclusion": "additional-package-material-required" + }, + { + "sourceOrigin": "alpine-keys", + "declaredLicenseExpressions": [ + "MIT" + ], + "reviewState": "candidate-payloads-engineering-reviewed-legal-review-open", + "licenseTextCoverage": "reference-only", + "attributionCoverage": "partial", + "reviewedExistingEvidenceCount": 1, + "reviewedReferenceLicenseCount": 1, + "reviewedSupplementalAportsCount": 0, + "reviewedRemoteEvidenceCount": 0, + "resolvedReviewItems": [], + "remainingReviewItems": [ + "collect-mit-license-grant-and-copyright-notice" + ], + "engineeringConclusion": "additional-package-material-required" + }, + { + "sourceOrigin": "apk-tools", + "declaredLicenseExpressions": [ + "GPL-2.0-only" + ], + "reviewState": "candidate-payloads-engineering-reviewed-legal-review-open", + "licenseTextCoverage": "complete", + "attributionCoverage": "complete", + "reviewedExistingEvidenceCount": 2, + "reviewedReferenceLicenseCount": 1, + "reviewedSupplementalAportsCount": 2, + "reviewedRemoteEvidenceCount": 0, + "resolvedReviewItems": [ + "confirm-package-specific-attribution-and-aports-patch-notices" + ], + "remainingReviewItems": [], + "engineeringConclusion": "candidate-material-complete-engineering-only" + }, + { + "sourceOrigin": "busybox", + "declaredLicenseExpressions": [ + "GPL-2.0-only" + ], + "reviewState": "candidate-payloads-engineering-reviewed-legal-review-open", + "licenseTextCoverage": "partial", + "attributionCoverage": "partial", + "reviewedExistingEvidenceCount": 3, + "reviewedReferenceLicenseCount": 1, + "reviewedSupplementalAportsCount": 36, + "reviewedRemoteEvidenceCount": 0, + "resolvedReviewItems": [ + "confirm-aports-patch-notices" + ], + "remainingReviewItems": [ + "review-bundled-third-party-license-and-attribution-coverage" + ], + "engineeringConclusion": "additional-package-material-required" + }, + { + "sourceOrigin": "ca-certificates", + "declaredLicenseExpressions": [ + "MPL-2.0 AND MIT" + ], + "reviewState": "candidate-payloads-engineering-reviewed-legal-review-open", + "licenseTextCoverage": "complete", + "attributionCoverage": "partial", + "reviewedExistingEvidenceCount": 2, + "reviewedReferenceLicenseCount": 2, + "reviewedSupplementalAportsCount": 0, + "reviewedRemoteEvidenceCount": 0, + "resolvedReviewItems": [ + "collect-complete-mpl-2.0-license-text", + "confirm-mit-script-notices-relevant-to-shipped-bundle" + ], + "remainingReviewItems": [ + "confirm-certificate-attribution-and-trust-store-requirements" + ], + "engineeringConclusion": "additional-package-material-required" + }, + { + "sourceOrigin": "musl", + "declaredLicenseExpressions": [ + "MIT", + "MIT AND BSD-2-Clause AND GPL-2.0-or-later" + ], + "reviewState": "candidate-payloads-engineering-reviewed-legal-review-open", + "licenseTextCoverage": "complete", + "attributionCoverage": "partial", + "reviewedExistingEvidenceCount": 5, + "reviewedReferenceLicenseCount": 3, + "reviewedSupplementalAportsCount": 3, + "reviewedRemoteEvidenceCount": 0, + "resolvedReviewItems": [], + "remainingReviewItems": [ + "confirm-third-party-musl-files-and-aports-helper-notice-coverage" + ], + "engineeringConclusion": "additional-package-material-required" + }, + { + "sourceOrigin": "openssl", + "declaredLicenseExpressions": [ + "Apache-2.0" + ], + "reviewState": "candidate-payloads-engineering-reviewed-legal-review-open", + "licenseTextCoverage": "complete", + "attributionCoverage": "partial", + "reviewedExistingEvidenceCount": 2, + "reviewedReferenceLicenseCount": 1, + "reviewedSupplementalAportsCount": 5, + "reviewedRemoteEvidenceCount": 1, + "resolvedReviewItems": [], + "remainingReviewItems": [ + "confirm-required-apache-notice-and-attribution-material" + ], + "engineeringConclusion": "additional-package-material-required" + }, + { + "sourceOrigin": "pax-utils", + "declaredLicenseExpressions": [ + "GPL-2.0-only" + ], + "reviewState": "candidate-payloads-engineering-reviewed-legal-review-open", + "licenseTextCoverage": "complete", + "attributionCoverage": "complete", + "reviewedExistingEvidenceCount": 1, + "reviewedReferenceLicenseCount": 1, + "reviewedSupplementalAportsCount": 0, + "reviewedRemoteEvidenceCount": 1, + "resolvedReviewItems": [ + "confirm-package-specific-copyright-and-aports-patch-notices" + ], + "remainingReviewItems": [], + "engineeringConclusion": "candidate-material-complete-engineering-only" + } + ] +} diff --git a/Compliance/RootFS/v0.3.3/NOTICE.md b/Compliance/RootFS/v0.3.3/NOTICE.md index 10d0c40..5f48f41 100644 --- a/Compliance/RootFS/v0.3.3/NOTICE.md +++ b/Compliance/RootFS/v0.3.3/NOTICE.md @@ -43,12 +43,17 @@ for `libc-dev`, `zlib`. 8 source origins still have package-specific open items, so this is not a complete or legally approved license/NOTICE bundle. `LICENSE-NOTICE-CANDIDATES.json` now pins an external candidate bundle for -those open origins: 8 -remote reference/attribution payloads and +those open origins: 8 remote +reference/attribution payloads and 46 supplemental aports files, together with all checksum-bound reviewed evidence. The repository tool can materialize and re-verify that bundle outside the repository. -These collected candidates still require engineering and legal review. +`LICENSE-NOTICE-REVIEW-RESULTS.json` records checksum-bound engineering +review of all 75 indexed +payload files. 2 origins have no remaining +candidate-material engineering items; 6 origins +still require package-specific material. Legal review and redistribution +approval remain open. ## Corresponding-source status diff --git a/Compliance/RootFS/v0.3.3/README.md b/Compliance/RootFS/v0.3.3/README.md index 4bb7e70..ec966da 100644 --- a/Compliance/RootFS/v0.3.3/README.md +++ b/Compliance/RootFS/v0.3.3/README.md @@ -23,6 +23,9 @@ pinned RootFS archive. It does not store the RootFS payload. - `LICENSE-NOTICE-CANDIDATES.json`:为剩余 8 个 source origin 固定 8 份远端 许可证/attribution 材料、46 份 aports 补充文件及现有 21 份复核证据的外置候选包; payload 不提交,工程、法律和再分发门禁保持关闭; +- `LICENSE-NOTICE-REVIEW-RESULTS.json`:绑定候选清单与 75 个 payload 文件树的 + 工程复核结果;2 个 origin 的候选材料工程项关闭,6 个仍需补逐包材料,法律和 + 再分发门禁保持关闭; - `RUNTIME-CONFIGURATION.json`:guest、`apk`、repository、world 和 DNS 默认配置; - `NOTICE.md`:可复现 attribution inventory 与尚未完成事项; - `EVIDENCE.json`:输入成员摘要、数量和明确的工程/发行状态; @@ -38,8 +41,10 @@ candidates. Two source origins have no remaining indexed review items; eight still have package-specific open items. `LICENSE-NOTICE-CANDIDATES.json` indexes an external candidate bundle for those eight origins: 8 pinned remote license/attribution payloads, 46 supplemental aports files, and the existing -21 reviewed evidence files. It neither commits those payloads nor approves -engineering, legal, or redistribution gates. +21 reviewed evidence files. `LICENSE-NOTICE-REVIEW-RESULTS.json` binds the +engineering review to the exact 75-file payload tree. Two origins have no +remaining candidate-material engineering items; six still require +package-specific material. Legal and redistribution approval remain open. ## 重新生成 / Regenerate @@ -146,6 +151,7 @@ path outside the repository: ```bash ruby Scripts/rootfs-license-notice-candidates.rb +ruby Scripts/rootfs-license-notice-review-results.rb ruby Scripts/prepare-rootfs-license-notice-bundle.rb --validate-only ruby Scripts/prepare-rootfs-license-notice-bundle.rb \ @@ -157,6 +163,9 @@ ruby Scripts/prepare-rootfs-license-notice-bundle.rb \ --source-bundle /absolute/rootfs-v0.3.3-source-review \ --license-review /absolute/rootfs-v0.3.3-license-review \ --verify /absolute/rootfs-v0.3.3-license-notice-candidates + +ruby Scripts/rootfs-license-notice-review-results.rb \ + --bundle /absolute/rootfs-v0.3.3-license-notice-candidates ``` 远端材料由固定 URL、字节数和 SHA-256 约束;可选 `--download-cache` 只读取以 @@ -174,15 +183,18 @@ advice, or redistribution approval. 这些文件不构成完整第三方 LICENSE/NOTICE bundle、经审查的 copyleft corresponding-source 交付、法律意见或再分发授权。源码获取清单已完整覆盖固定 inventory,21 个候选也都有工程复核结果;`libc-dev`、`zlib` 已关闭索引项,另外 -8 个 source origin 的新候选材料仍需工程/法律复核和包级版权/notice 确认。修改说明、构建完整性、 -源码提供方式、App Store 2.5.2 产品策略和负责人批准仍是发行阻塞项。 +8 个 source origin 的 75 个新候选 payload 已完成 checksum-bound 工程复核; +`apk-tools`、`pax-utils` 的候选材料工程项已关闭,另外 6 个 origin 仍需补逐包 +版权/notice 材料。修改说明、构建完整性、源码提供方式、法律审查、App Store +2.5.2 产品策略和负责人批准仍是发行阻塞项。 These files are not a complete third-party LICENSE/NOTICE bundle, reviewed copyleft corresponding-source delivery, legal advice, or redistribution approval. The acquisition manifest completely covers the pinned inventory and all 21 indexed candidates have engineering review results. `libc-dev` and -`zlib` have no remaining indexed items; eight source origins still need -engineering/legal review of the newly indexed candidates and package-specific -notice follow-up. Modification, build -completeness, source-offer mechanics, legal review, App Store 2.5.2 product -policy, and authorized approval remain distribution blockers. +`zlib` have no remaining indexed items. All 75 newly indexed payloads have a +checksum-bound engineering review; `apk-tools` and `pax-utils` have no +remaining candidate-material engineering items, while six origins still need +package-specific copyright/notice material. Modification, build completeness, +source-offer mechanics, legal review, App Store 2.5.2 product policy, and +authorized approval remain distribution blockers. diff --git a/Compliance/RootFS/v0.3.3/SHA256SUMS b/Compliance/RootFS/v0.3.3/SHA256SUMS index bec2a05..20fad9f 100644 --- a/Compliance/RootFS/v0.3.3/SHA256SUMS +++ b/Compliance/RootFS/v0.3.3/SHA256SUMS @@ -1,9 +1,10 @@ -cae8a2d6049ce8f948fe2e9bb852de2eb3601d287993b885e7e83eb0463f09eb EVIDENCE.json -2cfbeb2bf48698cc1f9e2ca22acc90041333cddee31f8dc67c98748949d24544 LICENSE-INVENTORY.json +69fc3496bb4832d109942f72e81009a4d1774d459f372422263903802c5d3019 EVIDENCE.json +c12f1bf7d6a83b8e4e549dee0cafe28162d9b7f0ab210af5370503fe72f195ec LICENSE-INVENTORY.json 55e13487c9c0b0ad0160e91c440120b6faa1ef75b0cf467676c67834ca1083cb LICENSE-NOTICE-CANDIDATES.json +faee5c5c91c7be648ff7772c69167be3056bbb3501e52a7057d208d8fbfa621c LICENSE-NOTICE-REVIEW-RESULTS.json 3c7f786d9551d716c2a8b374d8cc63d11a46d67827be2fa0cea73b51b6b92eda LICENSE-REVIEW-RESULTS.json 3d483714a09cb2194e1b4af9aef5dfec2fbcfd44c70d521899398a3893f1908a LICENSE-REVIEW.json -793f7e9b04896b261593b85878fdaeaabcde4bfacdd5c2340edd7e613c3fd531 NOTICE.md +39dff7ed584e32bb46559d29ffcff7cef4dc88278de1957383616fd412732a64 NOTICE.md 4f7f7626f3d0891a29717e4b7932c36004ecc9aac25a4aa104c300aae979e3e1 PACKAGE-INVENTORY.tsv dbca9b285015a0d8b1d339a894b4594c9e335cbc2d7f9d5212a41095ae4bd1e1 RUNTIME-CONFIGURATION.json 8e021cb8c4160c934a0202609691d7a94526cd016f4394a47da6e7a5ab41d0ea SBOM.spdx.json diff --git a/Docs/ReleaseCompliance.md b/Docs/ReleaseCompliance.md index c9a5d3e..b3713b0 100644 --- a/Docs/ReleaseCompliance.md +++ b/Docs/ReleaseCompliance.md @@ -77,8 +77,10 @@ aports snapshot/upstream distfile 获取清单和仓库外 materializer,并固 21/21 个候选均已工程复核,`libc-dev`、`zlib` 的索引项已关闭,另外 8 个 source origin 仍有逐包未决项。仓库现已为这 8 个 origin 固定外置候选包:8 份远端 许可证/attribution 材料、46 份 aports 补充文件和全部既有复核证据;工具可在 -仓库外原子生成并复验,但材料尚未通过工程/法律批准。修改与构建完整性、源码 -提供方式和法律审查仍未完成,不能视为完整 NOTICE 或已批准的对应源码交付。 +仓库外原子生成并复验。固定结果清单把工程复核绑定到精确的 75 文件 payload +tree;`apk-tools`、`pax-utils` 的候选材料工程项已关闭,另外 6 个 origin 仍需 +补逐包材料。修改与构建完整性、源码提供方式和法律审查仍未完成,不能视为完整 +NOTICE 或已批准的对应源码交付。 ## 当前仓库保护 @@ -177,6 +179,8 @@ Alpine `apk` 可以下载、安装和执行新增代码。即使初始 RootFS - [x] 对固定的 21 个 license/NOTICE 候选完成 checksum-bound 工程复核。 - [x] 为剩余 8 个 source origin 建立 checksum-bound 外置候选包索引与可复验 materializer;payload 不提交且批准门禁保持关闭。 +- [x] 对外置候选包的 75 个 payload 完成 checksum-bound 工程复核并固定结果; + 2 个 origin 的候选材料工程项关闭,6 个仍需补逐包材料。 - [ ] 收集 license text 和 NOTICE。 - [ ] 建立 copyleft corresponding source bundle。 - [x] 固定 DNS、repository 和 package-manager 默认配置事实。 diff --git a/Docs/Roadmap.md b/Docs/Roadmap.md index 31166b2..72a3d43 100644 --- a/Docs/Roadmap.md +++ b/Docs/Roadmap.md @@ -103,7 +103,7 @@ | 最低 Xcode 16 原生兼容 | 已通过 | Xcode 16.0 / iOS 18.0 SDK 完成 RootFS install、Simulator/device final-link 和 17 项 native smoke | | App lifecycle 与内存 | 进行中 | Simulator 与 Jack iPhone 均有 256 MiB `ru_maxrss` 门禁;真机 process suspend/resume、UIKit foreground/background、强制终止后数据恢复和有界 App delegate memory-warning 回调恢复已通过;补真实 memory pressure/jetsam | | RootFS ENOSPC/掉电 | 进行中 | 峰值空间预检、全 ENOSPC、七点持久化屏障、确定性掉电切点和 Jack iPhone 受限容量/ENOSPC 清理恢复已覆盖;补真实 storage pressure/强制断电 | -| License-reviewed RootFS | 阻塞 | 15 包 inventory、10 source origin、SPDX SBOM、默认配置证据、外置源码获取流程和 21/21 候选工程复核已完成;剩余 8 个 origin 的外置候选包已有 checksum-bound 索引与 materializer,下一步完成候选材料工程/法律复核、对应源码交付审查与负责人批准 | +| License-reviewed RootFS | 阻塞 | 15 包 inventory、10 source origin、SPDX SBOM、默认配置证据、外置源码获取流程、21/21 初始候选和 75/75 外置 payload 工程复核已完成;2 个 origin 的候选材料工程项关闭,6 个仍需补逐包材料,之后完成法律复核、对应源码交付审查与负责人批准 | | App Store 2.5.2 | 阻塞 | guest download/execute policy 有书面结论 | ### 后续 runtime 执行顺序 diff --git a/Docs/RootFS.md b/Docs/RootFS.md index f466f12..ac874e2 100644 --- a/Docs/RootFS.md +++ b/Docs/RootFS.md @@ -5,7 +5,7 @@ RootFS 是 PocketRoot 的外部供应链输入,不是普通测试 fixture。仓库提交的是不可变清单、校验和安全安装代码,不提交、镜像或默认打包 RootFS 二进制。 > [!WARNING] -> 固定 v0.3.3 归档已有可复现 package inventory、SPDX SBOM、默认配置证据、完整覆盖 inventory 的源码获取清单,以及 21 个 license/NOTICE 候选的工程复核结果;剩余 8 个 source origin 的外置候选包也已建立可复现索引,但候选材料的工程/法律复核、完整 NOTICE、对应源码交付审查与发行批准尚未闭环。以下 URL 与命令用于审计和本地开发,不构成公开再分发授权。应用必须先完成自己的法律与发行审查。 +> 固定 v0.3.3 归档已有可复现 package inventory、SPDX SBOM、默认配置证据、完整覆盖 inventory 的源码获取清单,以及 21 个初始候选和 75 个外置 LICENSE/NOTICE payload 的 checksum-bound 工程复核结果;2 个 origin 的候选材料工程项已关闭,6 个仍需补逐包材料。完整 NOTICE、法律复核、对应源码交付审查与发行批准尚未闭环。以下 URL 与命令用于审计和本地开发,不构成公开再分发授权。应用必须先完成自己的法律与发行审查。 ## 1. 固定清单 @@ -132,6 +132,7 @@ attribution 材料与 46 份 aports 补充文件。清单可独立校验;实 ```bash ruby Scripts/rootfs-license-notice-candidates.rb +ruby Scripts/rootfs-license-notice-review-results.rb ruby Scripts/prepare-rootfs-license-notice-bundle.rb --validate-only ruby Scripts/prepare-rootfs-license-notice-bundle.rb \ @@ -143,10 +144,16 @@ ruby Scripts/prepare-rootfs-license-notice-bundle.rb \ --source-bundle /absolute/new/path/outside-the-repository/rootfs-v0.3.3-source-review \ --license-review /absolute/new/path/outside-the-repository/rootfs-v0.3.3-license-review \ --verify /absolute/new/path/outside-the-repository/rootfs-v0.3.3-license-notice-candidates + +ruby Scripts/rootfs-license-notice-review-results.rb \ + --bundle /absolute/new/path/outside-the-repository/rootfs-v0.3.3-license-notice-candidates ``` 工具对远端材料强制 HTTPS、重定向次数、响应大小、固定字节数与 SHA-256,并原子 -创建输出;输出中的候选 NOTICE 和 receipt 仍只是工程审查输入,不代表已批准发行。 +创建输出。结果清单把工程复核绑定到精确的 75 文件 payload tree;复验器拒绝路径 +漂移、符号链接、特殊节点、已知摘要漂移和 tree digest 漂移。`apk-tools` 与 +`pax-utils` 的候选材料工程项已关闭,另外 6 个 origin 仍需补逐包材料;候选 NOTICE +和 receipt 不代表法律审查或发行批准。 不要把归档放入 `Sources/PocketRootResources/Resources`、Demo resources 或 Git LFS。合规完成前,`PocketRootBundledRootFSProvider` 的资源查找预期返回 `nil`。 diff --git a/Docs/en/ReleaseCompliance.md b/Docs/en/ReleaseCompliance.md index 80594eb..6b9da2c 100644 --- a/Docs/en/ReleaseCompliance.md +++ b/Docs/en/ReleaseCompliance.md @@ -43,10 +43,13 @@ A pinned result manifest records engineering review of all 21 candidates. still have package-level follow-up. The repository now pins an external candidate bundle for those origins: 8 remote license/attribution payloads, 46 supplemental aports files, and all existing reviewed evidence. The tool can -atomically materialize and re-verify it outside the repository, but engineering -and legal approval remain open. Modification, build-completeness, source-offer, -and legal reviews remain unresolved, so the output is neither a complete -NOTICE set nor approved corresponding-source delivery. +atomically materialize and re-verify it outside the repository. A pinned +results manifest binds engineering review to the exact 75-file payload tree. +`apk-tools` and `pax-utils` have no remaining candidate-material engineering +items; six origins still need package-specific material. Modification, +build-completeness, source-offer, and legal reviews remain unresolved, so the +output is neither a complete NOTICE set nor approved corresponding-source +delivery. ## Repository safeguards @@ -119,6 +122,9 @@ The current code does not provide a complete product-level privacy policy. - [x] Index a checksum-bound external candidate bundle and reproducible materializer for the eight remaining origins; payloads stay uncommitted and approval gates stay closed. +- [x] Complete checksum-bound engineering review of all 75 external candidate + payloads; two origins have no remaining candidate-material engineering items + and six still need package-specific material. - [ ] Collect license texts and NOTICE files. - [ ] Establish a corresponding-source bundle for copyleft components. - [x] Record default DNS, repository, and package-manager facts. diff --git a/Docs/en/Roadmap.md b/Docs/en/Roadmap.md index b4264d5..6b7da72 100644 --- a/Docs/en/Roadmap.md +++ b/Docs/en/Roadmap.md @@ -98,7 +98,7 @@ This establishes the current Simulator, minimum-Xcode 16, and single-iPhone one- | Minimum Xcode 16 native | Passed | Xcode 16.0 / iOS 18.0 SDK completed RootFS install, Simulator/device final links, and the 17-check native smoke | | App lifecycle and memory | In progress | Simulator and Jack iPhone have 256 MiB `ru_maxrss` gates; physical process suspend/resume, UIKit foreground/background, post-termination data recovery, and bounded App-delegate memory-warning recovery passed; add real memory-pressure/jetsam evidence | | RootFS ENOSPC/power faults | In progress | Peak-space preflight, full ENOSPC, seven persistence barriers, deterministic power-loss cuts, and bounded capacity/ENOSPC cleanup recovery on Jack iPhone are covered; add real storage-pressure/power-cut evidence | -| License-reviewed RootFS | Blocked | The 15-package/10-origin evidence and engineering review of all 21 candidates are complete; the eight remaining origins now have a checksum-bound external candidate index and materializer, so next complete engineering/legal review of those payloads, corresponding-source delivery review, and authorized approval | +| License-reviewed RootFS | Blocked | The 15-package/10-origin evidence, all 21 initial candidates, and all 75 external payloads have checksum-bound engineering review; two origins have no remaining candidate-material engineering items, six still need package-specific material, followed by legal review, corresponding-source delivery review, and authorized approval | | App Store 2.5.2 | Blocked | Written guest download/execute policy decision | ### Next runtime sequence diff --git a/Docs/en/RootFS.md b/Docs/en/RootFS.md index 65a1911..99a88ef 100644 --- a/Docs/en/RootFS.md +++ b/Docs/en/RootFS.md @@ -5,7 +5,7 @@ A RootFS is an external supply-chain input, not a normal fixture. PocketRoot commits immutable metadata and secure install code, not the payload. > [!WARNING] -> The pinned v0.3.3 archive now has a reproducible package inventory, SPDX SBOM, default-configuration evidence, a source-acquisition manifest covering the complete inventory, and engineering-review results for all 21 license/NOTICE candidates. A reproducible external candidate-bundle index now covers the eight source origins with remaining items, but engineering/legal review of those candidate payloads, the complete NOTICE set, corresponding-source delivery review, and distribution approval remain open. The URL and commands below support audit and local development; they do not grant redistribution rights. +> The pinned v0.3.3 archive now has a reproducible package inventory, SPDX SBOM, default-configuration evidence, a source-acquisition manifest covering the complete inventory, and checksum-bound engineering review of all 21 initial candidates and all 75 external LICENSE/NOTICE payloads. Two origins have no remaining candidate-material engineering items; six still need package-specific material. The complete NOTICE set, legal review, corresponding-source delivery review, and distribution approval remain open. The URL and commands below support audit and local development; they do not grant redistribution rights. ## Pinned manifest @@ -111,6 +111,7 @@ directories verified above: ```bash ruby Scripts/rootfs-license-notice-candidates.rb +ruby Scripts/rootfs-license-notice-review-results.rb ruby Scripts/prepare-rootfs-license-notice-bundle.rb --validate-only ruby Scripts/prepare-rootfs-license-notice-bundle.rb \ @@ -122,11 +123,18 @@ ruby Scripts/prepare-rootfs-license-notice-bundle.rb \ --source-bundle /absolute/new/path/outside-the-repository/rootfs-v0.3.3-source-review \ --license-review /absolute/new/path/outside-the-repository/rootfs-v0.3.3-license-review \ --verify /absolute/new/path/outside-the-repository/rootfs-v0.3.3-license-notice-candidates + +ruby Scripts/rootfs-license-notice-review-results.rb \ + --bundle /absolute/new/path/outside-the-repository/rootfs-v0.3.3-license-notice-candidates ``` The tool enforces HTTPS, redirect and response-size bounds, pinned byte counts -and SHA-256 digests, and atomic output creation. Its candidate NOTICE and -receipt remain engineering-review inputs, not distribution approval. +and SHA-256 digests, and atomic output creation. The results bind engineering +review to the exact 75-file payload tree; the verifier rejects path drift, +links, special nodes, known-digest drift, and tree-digest drift. `apk-tools` +and `pax-utils` have no remaining candidate-material engineering items; six +origins still need package-specific material. The candidate NOTICE and receipt +do not represent legal review or distribution approval. Do not put it in package resources, Demo resources, Git, or Git LFS. diff --git a/README.md b/README.md index c3658e0..e6e7e79 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ print("stderr:", result.stderr) - 展开大小:`18,838,016` 字节 - SHA-256:`be0f3c133f78f28b023288459b33dc28fa253a6ef29f7123bc5f3892edf90ad4` -固定 URL 只是清单元数据,不代表库会自动下载。仓库已从固定归档生成 RootFS 包清单与 SPDX SBOM;许可证、NOTICE、对应源码和完整发行物 SBOM 未完成前,不得把该 RootFS 加入 Package、App bundle 或公开发行物。 +固定 URL 只是清单元数据,不代表库会自动下载。仓库已从固定归档生成 RootFS 包清单与 SPDX SBOM,并完成 21 个初始候选及 75 个外置 LICENSE/NOTICE payload 的 checksum-bound 工程复核;6 个 source origin 仍需补逐包材料。许可证/NOTICE 法律复核、对应源码和完整发行物 SBOM 未完成前,不得把该 RootFS 加入 Package、App bundle 或公开发行物。 ## 验证命令 diff --git a/Scripts/generate-rootfs-compliance.rb b/Scripts/generate-rootfs-compliance.rb index 3135c1e..94d1109 100755 --- a/Scripts/generate-rootfs-compliance.rb +++ b/Scripts/generate-rootfs-compliance.rb @@ -9,6 +9,7 @@ require "uri" require "zlib" require_relative "rootfs-license-notice-candidates" +require_relative "rootfs-license-notice-review-results" require_relative "rootfs-license-review" require_relative "rootfs-license-review-results" require_relative "rootfs-source-acquisition" @@ -46,6 +47,8 @@ def parse_options "Compliance/RootFS/v0.3.3/LICENSE-REVIEW-RESULTS.json", license_notice_candidates: "Compliance/RootFS/v0.3.3/LICENSE-NOTICE-CANDIDATES.json", + license_notice_review_results: + "Compliance/RootFS/v0.3.3/LICENSE-NOTICE-REVIEW-RESULTS.json", check: false } @@ -82,6 +85,12 @@ def parse_options ) do |path| options[:license_notice_candidates] = path end + commands.on( + "--license-notice-review-results PATH", + "Pinned license/NOTICE candidate engineering review results" + ) do |path| + options[:license_notice_review_results] = path + end commands.on("--check", "Compare generated evidence without writing files") do options[:check] = true end @@ -448,7 +457,8 @@ def license_inventory( license_notice_paths, license_review_entries, license_review_result_entries, - license_notice_candidates + license_notice_candidates, + license_notice_review_results ) expressions = Hash.new(0) packages.each { |package| expressions[package[:license]] += 1 } @@ -487,6 +497,12 @@ def license_inventory( license_notice_candidates.fetch(:remote_payloads).length, "supplementalAportsFiles" => license_notice_candidates.fetch(:aports_paths).length, + "engineeringReviewedCandidatePayloads" => + license_notice_review_results.fetch("reviewedPayloadFileCount"), + "sourceOriginsWithRemainingCandidatePayloadReviewItems" => + license_notice_review_results.fetch( + "sourceOriginsWithRemainingReviewItems" + ), "candidateBundleIndexComplete" => true, "candidatePayloadCommitted" => false, "engineeringReviewCompleted" => true, @@ -503,7 +519,8 @@ def notice_markdown( license_notice_paths, license_review_entries, license_review_result_entries, - license_notice_candidates + license_notice_candidates, + license_notice_review_results ) package_rows = packages.map do |package| "| `#{package[:name]}` | `#{package[:version]}` | " \ @@ -532,6 +549,16 @@ def notice_markdown( resolved_origins = license_review_result_entries.reject do |entry| !entry.fetch("remainingReviewItems").empty? end + candidate_review_sources = + license_notice_review_results.fetch("sources") + candidate_complete_origins = + candidate_review_sources.count do |entry| + entry.fetch("remainingReviewItems").empty? + end + candidate_open_origins = + license_notice_review_results.fetch( + "sourceOriginsWithRemainingReviewItems" + ) <<~MARKDOWN # Pinned RootFS attribution inventory @@ -565,12 +592,17 @@ def notice_markdown( #{remaining_origins.length} source origins still have package-specific open items, so this is not a complete or legally approved license/NOTICE bundle. `LICENSE-NOTICE-CANDIDATES.json` now pins an external candidate bundle for - those open origins: #{license_notice_candidates.fetch(:remote_payloads).length} - remote reference/attribution payloads and + those open origins: #{license_notice_candidates.fetch(:remote_payloads).length} remote + reference/attribution payloads and #{license_notice_candidates.fetch(:aports_paths).length} supplemental aports files, together with all checksum-bound reviewed evidence. The repository tool can materialize and re-verify that bundle outside the repository. - These collected candidates still require engineering and legal review. + `LICENSE-NOTICE-REVIEW-RESULTS.json` records checksum-bound engineering + review of all #{license_notice_review_results.fetch("reviewedPayloadFileCount")} indexed + payload files. #{candidate_complete_origins} origins have no remaining + candidate-material engineering items; #{candidate_open_origins} origins + still require package-specific material. Legal review and redistribution + approval remain open. ## Corresponding-source status @@ -602,7 +634,8 @@ def evidence( inspected, license_review_entries, license_review_result_entries, - license_notice_candidates + license_notice_candidates, + license_notice_review_results ) { "schemaVersion" => 1, @@ -646,7 +679,13 @@ def evidence( "pinnedRemoteLicenseNoticePayloads" => license_notice_candidates.fetch(:remote_payloads).length, "supplementalAportsCandidateFiles" => - license_notice_candidates.fetch(:aports_paths).length + license_notice_candidates.fetch(:aports_paths).length, + "engineeringReviewedLicenseNoticeCandidatePayloads" => + license_notice_review_results.fetch("reviewedPayloadFileCount"), + "sourceOriginsWithRemainingCandidatePayloadReviewItems" => + license_notice_review_results.fetch( + "sourceOriginsWithRemainingReviewItems" + ) }, "engineeringStatus" => { "completeInstalledPackageInventory" => true, @@ -657,7 +696,7 @@ def evidence( "licenseCandidateEngineeringReviewCompleted" => true, "completeLicenseNoticeCandidateBundleIndex" => true, "licenseNoticeCandidatePayloadCommitted" => false, - "licenseNoticeCandidateEngineeringReviewApproved" => false, + "licenseNoticeCandidateEngineeringReviewCompleted" => true, "completeLicenseAndNoticeBundle" => false, "correspondingSourceBundleCollected" => false, "redistributionApproved" => false @@ -670,7 +709,8 @@ def build_outputs( source_acquisition, license_review, license_review_results, - license_notice_candidates + license_notice_candidates, + license_notice_review_results ) inspected = inspect_archive(archive) content = inspected.fetch(:content) @@ -742,6 +782,25 @@ def build_outputs( raise ComplianceError, "Invalid license/NOTICE candidate manifest: #{error.message}" end + begin + RootFSLicenseNoticeReviewResults.validate_manifest( + license_notice_review_results.fetch(:document), + license_notice_candidates.fetch(:document), + prior_results: license_review_results.fetch(:document), + license_review: license_review.fetch(:document), + source_acquisition: source_acquisition.fetch(:document), + source_inventory: generated_source_inventory, + candidate_bytes: license_notice_candidates.fetch(:contents), + prior_results_bytes: license_review_results.fetch(:contents), + license_review_bytes: license_review.fetch(:contents), + source_acquisition_bytes: source_acquisition.fetch(:contents) + ) + rescue RootFSLicenseNoticeReviewResults::ValidationError => error + raise ComplianceError, + "Invalid license/NOTICE candidate review results: #{error.message}" + end + validated_license_notice_review_results = + license_notice_review_results.fetch(:document) outputs = { "EVIDENCE.json" => pretty_json( evidence( @@ -750,7 +809,8 @@ def build_outputs( inspected, license_review_entries, license_review_result_entries, - validated_license_notice_candidates + validated_license_notice_candidates, + validated_license_notice_review_results ) ), "LICENSE-INVENTORY.json" => pretty_json( @@ -759,11 +819,14 @@ def build_outputs( inspected.fetch(:license_notice_paths), license_review_entries, license_review_result_entries, - validated_license_notice_candidates + validated_license_notice_candidates, + validated_license_notice_review_results ) ), "LICENSE-NOTICE-CANDIDATES.json" => license_notice_candidates.fetch(:contents), + "LICENSE-NOTICE-REVIEW-RESULTS.json" => + license_notice_review_results.fetch(:contents), "LICENSE-REVIEW.json" => license_review.fetch(:contents), "LICENSE-REVIEW-RESULTS.json" => license_review_results.fetch(:contents), @@ -773,7 +836,8 @@ def build_outputs( inspected.fetch(:license_notice_paths), license_review_entries, license_review_result_entries, - validated_license_notice_candidates + validated_license_notice_candidates, + validated_license_notice_review_results ), "PACKAGE-INVENTORY.tsv" => package_inventory_tsv(packages), "RUNTIME-CONFIGURATION.json" => pretty_json( @@ -841,6 +905,10 @@ def write_outputs(output_directory, expected) options.fetch(:license_notice_candidates), "License/NOTICE candidate manifest" ) + license_notice_review_results = load_json_document( + options.fetch(:license_notice_review_results), + "License/NOTICE candidate review results" + ) source_acquisition = verify_source_acquisition(options.fetch(:source_acquisition)) outputs = build_outputs( @@ -848,7 +916,8 @@ def write_outputs(output_directory, expected) source_acquisition, license_review, license_review_results, - license_notice_candidates + license_notice_candidates, + license_notice_review_results ) output_directory = Pathname(options.fetch(:output)) diff --git a/Scripts/rootfs-license-notice-review-results.rb b/Scripts/rootfs-license-notice-review-results.rb new file mode 100644 index 0000000..fd9f639 --- /dev/null +++ b/Scripts/rootfs-license-notice-review-results.rb @@ -0,0 +1,441 @@ +#!/usr/bin/env ruby + +require "digest" +require "json" +require "optparse" +require "pathname" +require_relative "rootfs-license-notice-candidates" + +module RootFSLicenseNoticeReviewResults + DEFAULT_MANIFEST_DIRECTORY = Pathname("Compliance/RootFS/v0.3.3") + ARCHIVE_VERSION = "v0.3.3" + ARCHIVE_SHA256 = + "be0f3c133f78f28b023288459b33dc28fa253a6ef29f7123bc5f3892edf90ad4" + STATUS = "candidate-payloads-engineering-reviewed-open-release-gates" + REVIEW_STATE = + "candidate-payloads-engineering-reviewed-legal-review-open" + REVIEW_CONCLUSIONS = %w[ + additional-package-material-required + candidate-material-complete-engineering-only + ].freeze + COVERAGE = %w[complete partial reference-only].freeze + SHA256_PATTERN = /\A[0-9a-f]{64}\z/ + CANDIDATE_PAYLOAD_TREE_FORMAT = "sha256-path-lines-v1" + EXPECTED_REVIEWED_PAYLOAD_FILES = 75 + MAX_REVIEWED_PAYLOAD_BYTES = 8 * 1_024 * 1_024 + TOP_LEVEL_KEYS = %w[ + allIndexedCandidatePayloadsReviewed archive candidateManifestSha256 + candidatePayloadTreeFormat candidatePayloadTreeSha256 + completePackageLicenseNoticeSetPresent engineeringReviewCompleted + legalReviewApproved redistributionApproved referenceLicenseTextsReviewed + reviewedClosedOriginEvidenceCount reviewedPayloadFileCount schemaVersion + sourceOriginsWithRemainingReviewItems sources status + ].freeze + SOURCE_KEYS = %w[ + attributionCoverage declaredLicenseExpressions engineeringConclusion + licenseTextCoverage remainingReviewItems resolvedReviewItems + reviewState reviewedExistingEvidenceCount reviewedReferenceLicenseCount + reviewedRemoteEvidenceCount reviewedSupplementalAportsCount sourceOrigin + ].freeze + + class ValidationError < StandardError + end + + module_function + + def load_json(path, label) + pathname = Pathname(path) + raise ValidationError, "#{label} is not a regular file: #{path}" unless pathname.file? + + JSON.parse(pathname.binread) + rescue JSON::ParserError => error + raise ValidationError, "#{label} is invalid JSON: #{error.message}" + end + + def require_hash(value, label) + raise ValidationError, "#{label} must be an object" unless value.is_a?(Hash) + + value + end + + def require_string_array(value, label, allow_empty: false) + unless value.is_a?(Array) && + (allow_empty || !value.empty?) && + value.all? { |entry| entry.is_a?(String) && !entry.empty? } && + value.uniq.length == value.length + raise ValidationError, "#{label} must be a unique string array" + end + + value + end + + def parse_options(arguments) + options = {} + parser = OptionParser.new do |commands| + commands.banner = + "Usage: ruby Scripts/rootfs-license-notice-review-results.rb " \ + "[options] [RESULTS CANDIDATES PRIOR_RESULTS REVIEW " \ + "SOURCE_ACQUISITION SOURCE_INVENTORY]" + commands.on( + "--bundle DIR", + "Verify the reviewed candidate payload directory" + ) do |value| + options[:bundle] = value + end + end + remaining = arguments.dup + parser.parse!(remaining) + [options, resolve_manifest_paths(remaining)] + end + + def resolve_manifest_paths(arguments) + unless arguments.length <= 6 + raise ValidationError, + "expected at most six manifest paths" + end + directory = + Pathname( + arguments.fetch( + 0, + DEFAULT_MANIFEST_DIRECTORY + .join("LICENSE-NOTICE-REVIEW-RESULTS.json") + .to_s + ) + ).dirname + + [ + arguments.fetch( + 0, + directory.join("LICENSE-NOTICE-REVIEW-RESULTS.json").to_s + ), + arguments.fetch( + 1, + directory.join("LICENSE-NOTICE-CANDIDATES.json").to_s + ), + arguments.fetch( + 2, + directory.join("LICENSE-REVIEW-RESULTS.json").to_s + ), + arguments.fetch(3, directory.join("LICENSE-REVIEW.json").to_s), + arguments.fetch( + 4, + directory.join("SOURCE-ACQUISITION.json").to_s + ), + arguments.fetch(5, directory.join("SOURCE-INVENTORY.json").to_s) + ] + end + + def validate_manifest( + manifest, + candidates, + prior_results:, + license_review:, + source_acquisition:, + source_inventory:, + candidate_bytes:, + prior_results_bytes:, + license_review_bytes:, + source_acquisition_bytes:, + allow_file_urls: false + ) + require_hash(manifest, "license/NOTICE candidate review results") + require_hash(candidates, "license/NOTICE candidate manifest") + unless manifest.keys.sort == TOP_LEVEL_KEYS.sort + raise ValidationError, + "license/NOTICE candidate review results have unexpected fields" + end + unless manifest["schemaVersion"] == 1 && + manifest["archive"] == { + "version" => ARCHIVE_VERSION, + "sha256" => ARCHIVE_SHA256 + } + raise ValidationError, + "license/NOTICE candidate review results do not match the pinned archive" + end + unless candidates.eql?(JSON.parse(candidate_bytes)) && + manifest["candidateManifestSha256"] == + Digest::SHA256.hexdigest(candidate_bytes) + raise ValidationError, + "license/NOTICE candidate review results do not match candidate manifest bytes" + end + + validated_candidates = + begin + RootFSLicenseNoticeCandidates.validate_manifest( + candidates, + prior_results, + license_review: license_review, + source_acquisition: source_acquisition, + source_inventory: source_inventory, + results_bytes: prior_results_bytes, + license_review_bytes: license_review_bytes, + source_acquisition_bytes: source_acquisition_bytes, + allow_file_urls: allow_file_urls + ) + rescue RootFSLicenseNoticeCandidates::ValidationError, + JSON::ParserError => error + raise ValidationError, + "candidate review results reference invalid inputs: #{error.message}" + end + + unless manifest["status"] == STATUS && + manifest["engineeringReviewCompleted"] == true && + manifest["allIndexedCandidatePayloadsReviewed"] == true && + manifest["referenceLicenseTextsReviewed"] == true && + manifest["completePackageLicenseNoticeSetPresent"] == false && + manifest["legalReviewApproved"] == false && + manifest["redistributionApproved"] == false && + manifest["candidatePayloadTreeFormat"] == + CANDIDATE_PAYLOAD_TREE_FORMAT && + manifest["candidatePayloadTreeSha256"].is_a?(String) && + manifest["candidatePayloadTreeSha256"].match?(SHA256_PATTERN) + raise ValidationError, + "license/NOTICE candidate review results do not preserve open release gates" + end + + candidate_sources = validated_candidates.fetch(:sources) + result_sources = manifest["sources"] + unless result_sources.is_a?(Array) && + result_sources.length == candidate_sources.length + raise ValidationError, + "candidate review results must cover every open source origin" + end + + open_source_count = 0 + reviewed_payload_references = [] + result_sources.each_with_index do |result, offset| + candidate = candidate_sources.fetch(offset) + origin = candidate.fetch("sourceOrigin") + result = require_hash(result, "candidate review result for #{origin}") + unless result.keys.sort == SOURCE_KEYS.sort + raise ValidationError, + "candidate review result for #{origin} has unexpected fields" + end + unless result["sourceOrigin"] == origin && + result["declaredLicenseExpressions"] == + candidate["declaredLicenseExpressions"] && + result["reviewState"] == REVIEW_STATE && + COVERAGE.include?(result["licenseTextCoverage"]) && + COVERAGE.include?(result["attributionCoverage"]) && + REVIEW_CONCLUSIONS.include?(result["engineeringConclusion"]) + raise ValidationError, + "candidate review result metadata does not match #{origin}" + end + + expected_counts = { + "reviewedExistingEvidenceCount" => + candidate.fetch("existingEvidencePaths").length, + "reviewedReferenceLicenseCount" => + candidate.fetch("referenceLicensePaths").length, + "reviewedSupplementalAportsCount" => + candidate.fetch("supplementalAportsPaths").length, + "reviewedRemoteEvidenceCount" => + candidate.fetch("remoteEvidencePaths").length + } + unless expected_counts.all? do |key, expected| + result[key].is_a?(Integer) && result[key] == expected + end + raise ValidationError, + "candidate review result counts do not match #{origin}" + end + + resolved = require_string_array( + result["resolvedReviewItems"], + "resolvedReviewItems for #{origin}", + allow_empty: true + ) + remaining = require_string_array( + result["remainingReviewItems"], + "remainingReviewItems for #{origin}", + allow_empty: true + ) + expected_items = candidate.fetch("remainingReviewItems") + unless (resolved & remaining).empty? && + (resolved + remaining).sort == expected_items.sort + raise ValidationError, + "candidate review item disposition for #{origin} is incomplete" + end + expected_conclusion = + if remaining.empty? + "candidate-material-complete-engineering-only" + else + "additional-package-material-required" + end + unless result["engineeringConclusion"] == expected_conclusion + raise ValidationError, + "candidate review conclusion does not match open items for #{origin}" + end + open_source_count += 1 unless remaining.empty? + reviewed_payload_references.concat( + candidate.fetch("existingEvidencePaths"), + candidate.fetch("referenceLicensePaths"), + candidate.fetch("supplementalAportsPaths"), + candidate.fetch("remoteEvidencePaths") + ) + end + + unless manifest["sourceOriginsWithRemainingReviewItems"] == + open_source_count + raise ValidationError, + "candidate review open-source summary count does not match source results" + end + unique_payload_count = + validated_candidates.fetch(:existing_evidence_paths).length + + validated_candidates.fetch(:aports_paths).length + + validated_candidates.fetch(:remote_payloads).length + open_existing_paths = + candidate_sources.flat_map do |source| + source.fetch("existingEvidencePaths") + end + closed_origin_evidence_count = + validated_candidates.fetch(:existing_evidence_paths).length - + open_existing_paths.length + unless unique_payload_count == EXPECTED_REVIEWED_PAYLOAD_FILES && + manifest["reviewedPayloadFileCount"] == unique_payload_count && + manifest["reviewedClosedOriginEvidenceCount"] == + closed_origin_evidence_count && + reviewed_payload_references.uniq.length + + closed_origin_evidence_count == unique_payload_count + raise ValidationError, + "candidate review payload counts do not cover the indexed payload set" + end + if open_source_count.zero? + raise ValidationError, + "completePackageLicenseNoticeSetPresent must be revisited when no items remain" + end + + validated_candidates + rescue KeyError => error + raise ValidationError, + "license/NOTICE candidate review results are incomplete: #{error.message}" + end + + def expected_payloads(validated_candidates) + expected = {} + validated_candidates.fetch(:existing_evidence).each do |path, metadata| + expected[path] = metadata.fetch("sha256") + end + validated_candidates.fetch(:remote_payloads).each do |payload| + expected[payload.fetch("outputPath")] = payload.fetch("sha256") + end + validated_candidates.fetch(:aports_paths).each do |path| + expected["supplemental/#{path}"] = nil + end + expected + end + + def verify_reviewed_bundle(path, validated_candidates, expected_tree_sha256) + root = Pathname(path) + unless root.absolute? && root.directory? && !root.symlink? + raise ValidationError, + "reviewed bundle must be an absolute real directory" + end + root = root.realpath + payloads = expected_payloads(validated_candidates) + actual_paths = [] + %w[evidence licenses supplemental].each do |directory| + subtree = root.join(directory) + raise ValidationError, "reviewed bundle is missing #{directory}/" unless subtree.directory? + + subtree.find do |entry| + next if entry == subtree + + relative = entry.relative_path_from(root).to_s + stat = entry.lstat + if stat.symlink? || (!stat.directory? && !stat.file?) + raise ValidationError, + "reviewed bundle contains a link or special node: #{relative}" + end + actual_paths << relative if stat.file? + end + end + unless actual_paths.sort == payloads.keys.sort + raise ValidationError, + "reviewed bundle payload path set does not match the candidate index" + end + + lines = actual_paths.sort.map do |relative| + pathname = root.join(relative) + if pathname.size > MAX_REVIEWED_PAYLOAD_BYTES + raise ValidationError, + "reviewed bundle payload exceeds size limit: #{relative}" + end + digest = Digest::SHA256.file(pathname).hexdigest + expected_digest = payloads.fetch(relative) + if expected_digest && digest != expected_digest + raise ValidationError, + "reviewed bundle payload digest mismatch: #{relative}" + end + "#{digest} #{relative}\n" + end + actual_tree_sha256 = Digest::SHA256.hexdigest(lines.join) + unless actual_tree_sha256 == expected_tree_sha256 + raise ValidationError, + "reviewed bundle payload tree digest mismatch: expected " \ + "#{expected_tree_sha256}, got #{actual_tree_sha256}" + end + + actual_paths.length + end +end + +if $PROGRAM_NAME == __FILE__ + begin + options, paths = + RootFSLicenseNoticeReviewResults.parse_options(ARGV) + results_path, + candidate_path, + prior_results_path, + review_path, + source_acquisition_path, + source_inventory_path = paths + candidate_bytes = Pathname(candidate_path).binread + prior_results_bytes = Pathname(prior_results_path).binread + license_review_bytes = Pathname(review_path).binread + source_acquisition_bytes = Pathname(source_acquisition_path).binread + source_acquisition = JSON.parse(source_acquisition_bytes) + allow_file_urls = + ENV["POCKETROOT_TEST_ALLOW_FILE_URLS"] == "1" && + source_acquisition["testFixture"] == true + manifest = + RootFSLicenseNoticeReviewResults.load_json( + results_path, + "license/NOTICE candidate review results" + ) + validated = + RootFSLicenseNoticeReviewResults.validate_manifest( + manifest, + JSON.parse(candidate_bytes), + prior_results: JSON.parse(prior_results_bytes), + license_review: JSON.parse(license_review_bytes), + source_acquisition: source_acquisition, + source_inventory: + RootFSLicenseNoticeReviewResults.load_json( + source_inventory_path, + "source inventory" + ), + candidate_bytes: candidate_bytes, + prior_results_bytes: prior_results_bytes, + license_review_bytes: license_review_bytes, + source_acquisition_bytes: source_acquisition_bytes, + allow_file_urls: allow_file_urls + ) + if options[:bundle] + count = + RootFSLicenseNoticeReviewResults.verify_reviewed_bundle( + options.fetch(:bundle), + validated, + manifest.fetch("candidatePayloadTreeSha256") + ) + puts "Verified #{count} reviewed RootFS license/NOTICE candidate payloads." + else + puts "RootFS license/NOTICE candidate review results are valid " \ + "(#{validated.fetch(:sources).length} source origins)." + end + rescue RootFSLicenseNoticeReviewResults::ValidationError, + JSON::ParserError, KeyError, OptionParser::ParseError, + SystemCallError => error + warn error.message + exit 1 + end +end diff --git a/Tests/Scripts/RootFSLicenseNoticeReviewResultsTests.rb b/Tests/Scripts/RootFSLicenseNoticeReviewResultsTests.rb new file mode 100644 index 0000000..e634dd7 --- /dev/null +++ b/Tests/Scripts/RootFSLicenseNoticeReviewResultsTests.rb @@ -0,0 +1,282 @@ +#!/usr/bin/env ruby + +require "digest" +require "fileutils" +require "json" +require "minitest/autorun" +require "open3" +require "pathname" +require "tmpdir" +require_relative "../../Scripts/rootfs-license-notice-review-results" + +class RootFSLicenseNoticeReviewResultsTests < Minitest::Test + REPOSITORY_ROOT = Pathname(__dir__).join("../..").realpath + COMPLIANCE_ROOT = REPOSITORY_ROOT.join("Compliance/RootFS/v0.3.3") + RESULTS_PATH = + COMPLIANCE_ROOT.join("LICENSE-NOTICE-REVIEW-RESULTS.json") + CANDIDATE_PATH = + COMPLIANCE_ROOT.join("LICENSE-NOTICE-CANDIDATES.json") + PRIOR_RESULTS_PATH = + COMPLIANCE_ROOT.join("LICENSE-REVIEW-RESULTS.json") + REVIEW_PATH = COMPLIANCE_ROOT.join("LICENSE-REVIEW.json") + SOURCE_ACQUISITION_PATH = + COMPLIANCE_ROOT.join("SOURCE-ACQUISITION.json") + SOURCE_INVENTORY_PATH = + COMPLIANCE_ROOT.join("SOURCE-INVENTORY.json") + SCRIPT_PATH = + REPOSITORY_ROOT.join( + "Scripts/rootfs-license-notice-review-results.rb" + ) + + def setup + @candidate_bytes = CANDIDATE_PATH.binread + @prior_results_bytes = PRIOR_RESULTS_PATH.binread + @review_bytes = REVIEW_PATH.binread + @source_acquisition_bytes = SOURCE_ACQUISITION_PATH.binread + @results = JSON.parse(RESULTS_PATH.binread) + @candidates = JSON.parse(@candidate_bytes) + @prior_results = JSON.parse(@prior_results_bytes) + @review = JSON.parse(@review_bytes) + @source_acquisition = JSON.parse(@source_acquisition_bytes) + @source_inventory = JSON.parse(SOURCE_INVENTORY_PATH.binread) + end + + def test_validates_pinned_candidate_review_results + validated = validate + + assert_equal 8, validated.fetch(:sources).length + assert_equal 8, validated.fetch(:remote_payloads).length + assert_equal 46, validated.fetch(:aports_paths).length + assert_equal 75, @results.fetch("reviewedPayloadFileCount") + assert_equal 6, + @results.fetch("sourceOriginsWithRemainingReviewItems") + assert_equal %w[apk-tools pax-utils], + @results.fetch("sources") + .select { |source| source.fetch("remainingReviewItems").empty? } + .map { |source| source.fetch("sourceOrigin") } + end + + def test_rejects_legal_or_redistribution_approval + @results["legalReviewApproved"] = true + + error = assert_raises( + RootFSLicenseNoticeReviewResults::ValidationError + ) { validate } + + assert_includes error.message, "open release gates" + end + + def test_rejects_candidate_manifest_digest_drift + @candidates["status"] = "changed" + + error = assert_raises( + RootFSLicenseNoticeReviewResults::ValidationError + ) { validate } + + assert_includes error.message, "candidate manifest bytes" + end + + def test_rejects_incomplete_review_item_disposition + @results.fetch("sources").first.fetch("remainingReviewItems").clear + + error = assert_raises( + RootFSLicenseNoticeReviewResults::ValidationError + ) { validate } + + assert_includes error.message, "disposition" + end + + def test_rejects_conclusion_that_does_not_match_open_items + @results.fetch("sources").first["engineeringConclusion"] = + "candidate-material-complete-engineering-only" + + error = assert_raises( + RootFSLicenseNoticeReviewResults::ValidationError + ) { validate } + + assert_includes error.message, "conclusion" + end + + def test_rejects_payload_count_drift + @results["reviewedPayloadFileCount"] = 74 + + error = assert_raises( + RootFSLicenseNoticeReviewResults::ValidationError + ) { validate } + + assert_includes error.message, "payload counts" + end + + def test_requires_revisiting_completion_gate_when_no_items_remain + @results.fetch("sources").each do |source| + source["resolvedReviewItems"] += source["remainingReviewItems"] + source["remainingReviewItems"] = [] + source["engineeringConclusion"] = + "candidate-material-complete-engineering-only" + end + @results["sourceOriginsWithRemainingReviewItems"] = 0 + + error = assert_raises( + RootFSLicenseNoticeReviewResults::ValidationError + ) { validate } + + assert_includes error.message, + "completePackageLicenseNoticeSetPresent must be revisited" + end + + def test_verifies_reviewed_payload_tree + Dir.mktmpdir("rootfs-notice-reviewed-bundle") do |directory| + root = Pathname(directory) + root.join("evidence/origin").mkpath + root.join("licenses").mkpath + root.join("supplemental/aports/origin").mkpath + evidence = "reviewed evidence\n" + license = "reviewed license\n" + patch = "reviewed patch\n" + root.join("evidence/origin/LICENSE").binwrite(evidence) + root.join("licenses/License.txt").binwrite(license) + root.join("supplemental/aports/origin/fix.patch").binwrite(patch) + validated = reviewed_bundle_fixture(evidence, license) + tree_sha256 = + reviewed_tree_sha256( + root, + %w[ + evidence/origin/LICENSE + licenses/License.txt + supplemental/aports/origin/fix.patch + ] + ) + + count = + RootFSLicenseNoticeReviewResults.verify_reviewed_bundle( + root, + validated, + tree_sha256 + ) + + assert_equal 3, count + end + end + + def test_rejects_reviewed_payload_tree_drift + Dir.mktmpdir("rootfs-notice-reviewed-bundle") do |directory| + root = Pathname(directory) + root.join("evidence/origin").mkpath + root.join("licenses").mkpath + root.join("supplemental/aports/origin").mkpath + evidence = "reviewed evidence\n" + license = "reviewed license\n" + root.join("evidence/origin/LICENSE").binwrite(evidence) + root.join("licenses/License.txt").binwrite(license) + root.join("supplemental/aports/origin/fix.patch") + .binwrite("changed patch\n") + + error = assert_raises( + RootFSLicenseNoticeReviewResults::ValidationError + ) do + RootFSLicenseNoticeReviewResults.verify_reviewed_bundle( + root, + reviewed_bundle_fixture(evidence, license), + "0" * 64 + ) + end + + assert_includes error.message, "tree digest mismatch" + end + end + + def test_rejects_symlink_in_reviewed_payload_tree + Dir.mktmpdir("rootfs-notice-reviewed-bundle") do |directory| + root = Pathname(directory) + root.join("evidence/origin").mkpath + root.join("licenses").mkpath + root.join("supplemental/aports/origin").mkpath + evidence = "reviewed evidence\n" + license = "reviewed license\n" + root.join("evidence/origin/LICENSE").binwrite(evidence) + root.join("licenses/License.txt").binwrite(license) + root.join("supplemental/aports/origin/fix.patch") + .make_symlink(root.join("evidence/origin/LICENSE")) + + error = assert_raises( + RootFSLicenseNoticeReviewResults::ValidationError + ) do + RootFSLicenseNoticeReviewResults.verify_reviewed_bundle( + root, + reviewed_bundle_fixture(evidence, license), + "0" * 64 + ) + end + + assert_includes error.message, "link or special node" + end + end + + def test_custom_results_path_uses_adjacent_manifests + Dir.mktmpdir("rootfs-notice-review-results") do |directory| + root = Pathname(directory) + [ + RESULTS_PATH, + CANDIDATE_PATH, + PRIOR_RESULTS_PATH, + REVIEW_PATH, + SOURCE_ACQUISITION_PATH, + SOURCE_INVENTORY_PATH + ].each do |source| + FileUtils.cp(source, root.join(source.basename)) + end + + stdout, stderr, status = Open3.capture3( + RbConfig.ruby, + SCRIPT_PATH.to_s, + root.join(RESULTS_PATH.basename).to_s, + chdir: REPOSITORY_ROOT.to_s + ) + + assert status.success?, stderr + assert_includes stdout, + "candidate review results are valid (8 source origins)." + end + end + + private + + def validate + RootFSLicenseNoticeReviewResults.validate_manifest( + @results, + @candidates, + prior_results: @prior_results, + license_review: @review, + source_acquisition: @source_acquisition, + source_inventory: @source_inventory, + candidate_bytes: @candidate_bytes, + prior_results_bytes: @prior_results_bytes, + license_review_bytes: @review_bytes, + source_acquisition_bytes: @source_acquisition_bytes + ) + end + + def reviewed_bundle_fixture(evidence, license) + { + existing_evidence: { + "evidence/origin/LICENSE" => { + "sha256" => Digest::SHA256.hexdigest(evidence) + } + }, + remote_payloads: [ + { + "outputPath" => "licenses/License.txt", + "sha256" => Digest::SHA256.hexdigest(license) + } + ], + aports_paths: ["aports/origin/fix.patch"] + } + end + + def reviewed_tree_sha256(root, paths) + lines = paths.sort.map do |relative| + "#{Digest::SHA256.file(root.join(relative)).hexdigest} #{relative}\n" + end + Digest::SHA256.hexdigest(lines.join) + end +end From 805b50149dd7094d5f5bc7de992a7dd0fbc6f07c Mon Sep 17 00:00:00 2001 From: Jintao Date: Sun, 26 Jul 2026 11:43:51 +0800 Subject: [PATCH 2/2] Harden reviewed bundle node checks --- .../rootfs-license-notice-review-results.rb | 32 +++++++++------- .../RootFSLicenseNoticeReviewResultsTests.rb | 37 +++++++++++++++++++ 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/Scripts/rootfs-license-notice-review-results.rb b/Scripts/rootfs-license-notice-review-results.rb index fd9f639..8ebd0de 100644 --- a/Scripts/rootfs-license-notice-review-results.rb +++ b/Scripts/rootfs-license-notice-review-results.rb @@ -1,6 +1,7 @@ #!/usr/bin/env ruby require "digest" +require "find" require "json" require "optparse" require "pathname" @@ -333,20 +334,25 @@ def verify_reviewed_bundle(path, validated_candidates, expected_tree_sha256) root = root.realpath payloads = expected_payloads(validated_candidates) actual_paths = [] - %w[evidence licenses supplemental].each do |directory| - subtree = root.join(directory) - raise ValidationError, "reviewed bundle is missing #{directory}/" unless subtree.directory? - - subtree.find do |entry| - next if entry == subtree + payload_directories = %w[evidence licenses supplemental] + root.find do |entry| + next if entry == root - relative = entry.relative_path_from(root).to_s - stat = entry.lstat - if stat.symlink? || (!stat.directory? && !stat.file?) - raise ValidationError, - "reviewed bundle contains a link or special node: #{relative}" - end - actual_paths << relative if stat.file? + relative = entry.relative_path_from(root).to_s + stat = entry.lstat + if stat.symlink? || (!stat.directory? && !stat.file?) + raise ValidationError, + "reviewed bundle contains a link or special node: #{relative}" + end + if stat.file? && payload_directories.include?(relative.split("/", 2).first) + actual_paths << relative + end + end + payload_directories.each do |directory| + subtree = root.join(directory) + unless subtree.directory? && !subtree.symlink? + raise ValidationError, + "reviewed bundle is missing #{directory}/" end end unless actual_paths.sort == payloads.keys.sort diff --git a/Tests/Scripts/RootFSLicenseNoticeReviewResultsTests.rb b/Tests/Scripts/RootFSLicenseNoticeReviewResultsTests.rb index e634dd7..4042ac8 100644 --- a/Tests/Scripts/RootFSLicenseNoticeReviewResultsTests.rb +++ b/Tests/Scripts/RootFSLicenseNoticeReviewResultsTests.rb @@ -212,6 +212,43 @@ def test_rejects_symlink_in_reviewed_payload_tree end end + def test_rejects_symlink_outside_reviewed_payload_tree + Dir.mktmpdir("rootfs-notice-reviewed-bundle") do |directory| + root = Pathname(directory) + root.join("evidence/origin").mkpath + root.join("licenses").mkpath + root.join("supplemental/aports/origin").mkpath + evidence = "reviewed evidence\n" + license = "reviewed license\n" + patch = "reviewed patch\n" + root.join("evidence/origin/LICENSE").binwrite(evidence) + root.join("licenses/License.txt").binwrite(license) + root.join("supplemental/aports/origin/fix.patch").binwrite(patch) + root.join("BUNDLE-RECEIPT.json") + .make_symlink(root.join("evidence/origin/LICENSE")) + + error = assert_raises( + RootFSLicenseNoticeReviewResults::ValidationError + ) do + RootFSLicenseNoticeReviewResults.verify_reviewed_bundle( + root, + reviewed_bundle_fixture(evidence, license), + reviewed_tree_sha256( + root, + %w[ + evidence/origin/LICENSE + licenses/License.txt + supplemental/aports/origin/fix.patch + ] + ) + ) + end + + assert_includes error.message, "link or special node" + assert_includes error.message, "BUNDLE-RECEIPT.json" + end + end + def test_custom_results_path_uses_adjacent_manifests Dir.mktmpdir("rootfs-notice-review-results") do |directory| root = Pathname(directory)