diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3233a49..eb6981c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,21 +39,29 @@ jobs: ruby -c Scripts/prepare-rootfs-smoke-manifest.rb 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/prepare-rootfs-license-review.rb + ruby -c Scripts/prepare-rootfs-license-notice-bundle.rb ruby -c Scripts/rootfs-source-acquisition.rb ruby -c Scripts/prepare-rootfs-source-bundle.rb bash -n Tests/Scripts/RuntimeSmokeScriptTests.sh bash -n Tests/Scripts/RootFSCandidateSmokeManifestTests.sh ruby -c Tests/Scripts/RootFSLicenseReviewTests.rb ruby -c Tests/Scripts/RootFSLicenseReviewResultsTests.rb + ruby -c Tests/Scripts/RootFSLicenseNoticeCandidatesTests.rb + ruby -c Tests/Scripts/RootFSLicenseNoticeBundleTests.rb ruby -c Tests/Scripts/RootFSSourceBundleTests.rb bash Tests/Scripts/RuntimeSmokeScriptTests.sh bash Tests/Scripts/RootFSCandidateSmokeManifestTests.sh ruby Tests/Scripts/RootFSLicenseReviewTests.rb ruby Tests/Scripts/RootFSLicenseReviewResultsTests.rb + ruby Tests/Scripts/RootFSLicenseNoticeCandidatesTests.rb + ruby Tests/Scripts/RootFSLicenseNoticeBundleTests.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/prepare-rootfs-license-notice-bundle.rb --validate-only ruby Scripts/prepare-rootfs-source-bundle.rb --validate-only - name: Report Apple toolchain diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index 0bee400..cbae006 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -36,6 +36,11 @@ All notable PocketRoot changes are recorded here. Semantic Versioning begins wit pinned RootFS license/NOTICE candidates have engineering review results; eight source origins retain package-level open items, and legal and redistribution gates remain closed. +- A checksum-bound `LICENSE-NOTICE-CANDIDATES.json`, strict validator, and + outside-repository atomic materializer for the eight remaining RootFS source + 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. - 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 e89f804..dcb2d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,10 @@ PocketRoot 的重要变化记录在这里。首个公开版本发布后遵循 Se - 加入 checksum-bound `LICENSE-REVIEW-RESULTS.json` 和严格验证器;21/21 个固定 RootFS license/NOTICE 候选已完成工程复核,8 个 source origin 仍有包级未决项, 法律与再分发门禁保持关闭。 +- 为剩余 8 个 RootFS source origin 加入 checksum-bound + `LICENSE-NOTICE-CANDIDATES.json`、严格验证器和仓库外原子 materializer;索引 + 8 份远端许可证/attribution 材料、46 份 aports 文件与 21 份既有证据,支持完整 + 复验,但不提交 payload,也不解除工程、法律或再分发门禁。 - 加入完整 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 a2697b0..35c639a 100644 --- a/Compliance/RootFS/v0.3.3/EVIDENCE.json +++ b/Compliance/RootFS/v0.3.3/EVIDENCE.json @@ -41,7 +41,10 @@ "licenseOrNoticeFilesInGuestTemplate": 0, "indexedExternalLicenseReviewCandidates": 21, "engineeringReviewedLicenseCandidates": 21, - "sourceOriginsWithRemainingLicenseReviewItems": 8 + "sourceOriginsWithRemainingLicenseReviewItems": 8, + "indexedLicenseNoticeCandidateOrigins": 8, + "pinnedRemoteLicenseNoticePayloads": 8, + "supplementalAportsCandidateFiles": 46 }, "engineeringStatus": { "completeInstalledPackageInventory": true, @@ -50,6 +53,9 @@ "completeSourceAcquisitionManifest": true, "completeLicenseReviewCandidateIndex": true, "licenseCandidateEngineeringReviewCompleted": true, + "completeLicenseNoticeCandidateBundleIndex": true, + "licenseNoticeCandidatePayloadCommitted": false, + "licenseNoticeCandidateEngineeringReviewApproved": false, "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 07ce779..41ece52 100644 --- a/Compliance/RootFS/v0.3.3/LICENSE-INVENTORY.json +++ b/Compliance/RootFS/v0.3.3/LICENSE-INVENTORY.json @@ -30,6 +30,11 @@ "engineeringReviewedCandidates": 21, "sourceOriginsWithOpenReviewItems": 10, "sourceOriginsWithRemainingReviewItems": 8, + "indexedOpenSourceOrigins": 8, + "pinnedRemoteReferencePayloads": 8, + "supplementalAportsFiles": 46, + "candidateBundleIndexComplete": true, + "candidatePayloadCommitted": false, "engineeringReviewCompleted": true, "completeLicenseTextBundlePresent": false, "completePackageNoticeSetPresent": false, diff --git a/Compliance/RootFS/v0.3.3/LICENSE-NOTICE-CANDIDATES.json b/Compliance/RootFS/v0.3.3/LICENSE-NOTICE-CANDIDATES.json new file mode 100644 index 0000000..59c2665 --- /dev/null +++ b/Compliance/RootFS/v0.3.3/LICENSE-NOTICE-CANDIDATES.json @@ -0,0 +1,340 @@ +{ + "schemaVersion": 1, + "archive": { + "version": "v0.3.3", + "sha256": "be0f3c133f78f28b023288459b33dc28fa253a6ef29f7123bc5f3892edf90ad4" + }, + "licenseReviewResultsSha256": "3c7f786d9551d716c2a8b374d8cc63d11a46d67827be2fa0cea73b51b6b92eda", + "status": "candidate-bundle-indexed-engineering-review-required", + "candidateBundleIndexComplete": true, + "candidatePayloadCommitted": false, + "engineeringReviewApproved": false, + "legalReviewApproved": false, + "redistributionApproved": false, + "remotePayloads": [ + { + "kind": "spdx-license-text", + "spdxIdentifiers": [ + "Apache-2.0" + ], + "sourceRevision": "c4a7237ec8f4654e867546f9f409749300f1bf4c", + "retrievalURLs": [ + "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/text/Apache-2.0.txt" + ], + "cacheKey": "Apache-2.0.txt", + "outputPath": "licenses/Apache-2.0.txt", + "byteCount": 10280, + "sha256": "074e6e32c86a4c0ef8b3ed25b721ca23aca83df277cd88106ef7177c354615ff" + }, + { + "kind": "spdx-license-text", + "spdxIdentifiers": [ + "BSD-2-Clause" + ], + "sourceRevision": "c4a7237ec8f4654e867546f9f409749300f1bf4c", + "retrievalURLs": [ + "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/text/BSD-2-Clause.txt" + ], + "cacheKey": "BSD-2-Clause.txt", + "outputPath": "licenses/BSD-2-Clause.txt", + "byteCount": 1267, + "sha256": "f32fb3b417a194167cfad068223fc975ba96c5960513a10f66a3c28720aec1df" + }, + { + "kind": "spdx-license-text", + "spdxIdentifiers": [ + "GPL-2.0-only", + "GPL-2.0-or-later" + ], + "sourceRevision": "c4a7237ec8f4654e867546f9f409749300f1bf4c", + "retrievalURLs": [ + "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/text/GPL-2.0-only.txt" + ], + "cacheKey": "GPL-2.0-only.txt", + "outputPath": "licenses/GPL-2.0.txt", + "byteCount": 17337, + "sha256": "aaf135472f81c5b4a0dca9367e5bb5e9750032b5bebe5442b36e4c0a47430df3" + }, + { + "kind": "spdx-license-text", + "spdxIdentifiers": [ + "MIT" + ], + "sourceRevision": "c4a7237ec8f4654e867546f9f409749300f1bf4c", + "retrievalURLs": [ + "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/text/MIT.txt" + ], + "cacheKey": "MIT.txt", + "outputPath": "licenses/MIT-reference.txt", + "byteCount": 1078, + "sha256": "b05785f9f18e6716bab63424b11454513b9943a222595b70411009202fc592b5" + }, + { + "kind": "spdx-license-text", + "spdxIdentifiers": [ + "MPL-2.0" + ], + "sourceRevision": "c4a7237ec8f4654e867546f9f409749300f1bf4c", + "retrievalURLs": [ + "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/text/MPL-2.0.txt" + ], + "cacheKey": "MPL-2.0.txt", + "outputPath": "licenses/MPL-2.0.txt", + "byteCount": 16727, + "sha256": "66a3107d5ad6a058aab753eaac2047ccb2ed0e39465dd0fe5844da3e300d5172" + }, + { + "kind": "package-attribution", + "sourceOrigin": "alpine-baselayout", + "retrievalURLs": [ + "https://salsa.debian.org/md/netbase/-/raw/v6.4/debian/copyright", + "https://sources.debian.org/data/main/n/netbase/6.4/debian/copyright" + ], + "cacheKey": "netbase-6.4-copyright", + "outputPath": "supplemental/alpine-baselayout/netbase-6.4-copyright", + "byteCount": 1234, + "sha256": "795b66147ea5ad692991caa7008ece551fb0fa88b9c53656223bd1518dc58ab2" + }, + { + "kind": "package-attribution", + "sourceOrigin": "openssl", + "retrievalURLs": [ + "https://raw.githubusercontent.com/openssl/openssl/01d5e2318405362b4de5e670c90d9b40a351d053/README.md" + ], + "cacheKey": "openssl-3.1.4-README.md", + "outputPath": "supplemental/openssl/README.md", + "byteCount": 6635, + "sha256": "b226dd8531c618ff85a96a185c22c803168afbda1d8e4d8c13552c455b29b044" + }, + { + "kind": "package-attribution", + "sourceOrigin": "pax-utils", + "retrievalURLs": [ + "https://raw.githubusercontent.com/gentoo/pax-utils/d49fa503588cb9a89eda7eb7141b65507fa126ce/README.md" + ], + "cacheKey": "pax-utils-1.3.7-README.md", + "outputPath": "supplemental/pax-utils/README.md", + "byteCount": 3052, + "sha256": "599e49671db6ecf69cbc3a78a6c5d1166e92290a57ffdf96adab77bda11d0a6a" + } + ], + "sources": [ + { + "sourceOrigin": "alpine-baselayout", + "declaredLicenseExpressions": [ + "GPL-2.0-only" + ], + "existingEvidencePaths": [ + "evidence/alpine-baselayout/APKBUILD" + ], + "referenceLicensePaths": [ + "licenses/GPL-2.0.txt" + ], + "supplementalAportsPaths": [], + "remoteEvidencePaths": [ + "supplemental/alpine-baselayout/netbase-6.4-copyright" + ], + "remainingReviewItems": [ + "collect-complete-gpl-2.0-license-text", + "identify-package-specific-copyright-and-notice" + ], + "candidateState": "collected-engineering-review-required" + }, + { + "sourceOrigin": "alpine-keys", + "declaredLicenseExpressions": [ + "MIT" + ], + "existingEvidencePaths": [ + "evidence/alpine-keys/APKBUILD" + ], + "referenceLicensePaths": [ + "licenses/MIT-reference.txt" + ], + "supplementalAportsPaths": [], + "remoteEvidencePaths": [], + "remainingReviewItems": [ + "collect-mit-license-grant-and-copyright-notice" + ], + "candidateState": "collected-engineering-review-required" + }, + { + "sourceOrigin": "apk-tools", + "declaredLicenseExpressions": [ + "GPL-2.0-only" + ], + "existingEvidencePaths": [ + "evidence/apk-tools/AUTHORS", + "evidence/apk-tools/LICENSE" + ], + "referenceLicensePaths": [ + "licenses/GPL-2.0.txt" + ], + "supplementalAportsPaths": [ + "aports/apk-tools/fix-recursive-solve-1.patch", + "aports/apk-tools/fix-recursive-solve-2.patch" + ], + "remoteEvidencePaths": [], + "remainingReviewItems": [ + "confirm-package-specific-attribution-and-aports-patch-notices" + ], + "candidateState": "collected-engineering-review-required" + }, + { + "sourceOrigin": "busybox", + "declaredLicenseExpressions": [ + "GPL-2.0-only" + ], + "existingEvidencePaths": [ + "evidence/busybox/AUTHORS", + "evidence/busybox/LICENSE", + "evidence/busybox/bzip2-LICENSE" + ], + "referenceLicensePaths": [ + "licenses/GPL-2.0.txt" + ], + "supplementalAportsPaths": [ + "aports/busybox/0001-modutils-check-ELF-header-before-calling-finit_modul.patch", + "aports/busybox/0002-adduser-default-to-sbin-nologin-as-shell-for-system-.patch", + "aports/busybox/0003-ash-add-built-in-BB_ASH_VERSION-variable.patch", + "aports/busybox/0004-Avoid-redefined-warnings-when-buiding-with-utmps.patch", + "aports/busybox/0005-libbb-sockaddr2str-ensure-only-printable-characters-.patch", + "aports/busybox/0006-modinfo-add-k-option-for-kernel-version.patch", + "aports/busybox/0007-nologin-Install-applet-to-sbin-instead-of-usr-sbin.patch", + "aports/busybox/0008-pgrep-add-support-for-matching-against-UID-and-RUID.patch", + "aports/busybox/0009-properly-fix-wget-https-support.patch", + "aports/busybox/0010-sed-check-errors-writing-file-with-sed-i.patch", + "aports/busybox/0011-fsck-resolve-LABEL-.-UUID-.-spec-to-device.patch", + "aports/busybox/0012-nslookup-sanitize-all-printed-strings-with-printable.patch", + "aports/busybox/0013-ash-exec-busybox.static.patch", + "aports/busybox/0014-app-location-for-cpio-vi-and-lspci.patch", + "aports/busybox/0015-udhcpc-set-default-discover-retries-to-5.patch", + "aports/busybox/0016-ping-make-ping-work-without-root-privileges.patch", + "aports/busybox/0017-fbsplash-support-console-switching.patch", + "aports/busybox/0018-fbsplash-support-image-and-bar-alignment-and-positio.patch", + "aports/busybox/0019-depmod-support-generating-kmod-binary-index-files.patch", + "aports/busybox/0020-Add-flag-for-not-following-symlinks-when-recursing.patch", + "aports/busybox/0021-udhcpc-Don-t-background-if-n-is-given.patch", + "aports/busybox/0023-tests-fix-tarball-creation.patch", + "aports/busybox/0024-tests-musl-doesn-t-seem-to-recognize-UTC0-as-a-timez.patch", + "aports/busybox/0025-Hackfix-to-disable-HW-acceleration-for-MD5-SHA1-on-x.patch", + "aports/busybox/0026-lineedit-Handle-SIGWINCH-gracefully.patch", + "aports/busybox/0027-umount-Implement-O-option-to-unmount-by-mount-option.patch", + "aports/busybox/0028-ash-use-after-free-in-bash-pattern-substitution.patch", + "aports/busybox/0029-awk-fix-use-after-realloc-CVE-2021-42380-closes-1560.patch", + "aports/busybox/0030-shell-avoid-segfault-on-0-0-0-09J-.-Closes-15216.patch", + "aports/busybox/0031-ash-initialize-basepf.buf-in-ash.patch", + "aports/busybox/0032-install-Fix-chown-resetting-suid-sgid-bits-from-chmo.patch", + "aports/busybox/0033-find-fix-xdev-depth-and-delete.patch", + "aports/busybox/0034-udhcp-Avoid-leaking-uninitialized-stale-data.patch", + "aports/busybox/bbsuid.c", + "aports/busybox/dad.if-up", + "aports/busybox/default.script" + ], + "remoteEvidencePaths": [], + "remainingReviewItems": [ + "review-bundled-third-party-license-and-attribution-coverage", + "confirm-aports-patch-notices" + ], + "candidateState": "collected-engineering-review-required" + }, + { + "sourceOrigin": "ca-certificates", + "declaredLicenseExpressions": [ + "MPL-2.0 AND MIT" + ], + "existingEvidencePaths": [ + "evidence/ca-certificates/certdata.txt", + "evidence/ca-certificates/mk-ca-bundle.pl" + ], + "referenceLicensePaths": [ + "licenses/MIT-reference.txt", + "licenses/MPL-2.0.txt" + ], + "supplementalAportsPaths": [], + "remoteEvidencePaths": [], + "remainingReviewItems": [ + "collect-complete-mpl-2.0-license-text", + "confirm-certificate-attribution-and-trust-store-requirements", + "confirm-mit-script-notices-relevant-to-shipped-bundle" + ], + "candidateState": "collected-engineering-review-required" + }, + { + "sourceOrigin": "musl", + "declaredLicenseExpressions": [ + "MIT", + "MIT AND BSD-2-Clause AND GPL-2.0-or-later" + ], + "existingEvidencePaths": [ + "evidence/musl/APKBUILD", + "evidence/musl/COPYRIGHT", + "evidence/musl/getconf.c", + "evidence/musl/getent.c", + "evidence/musl/iconv.c" + ], + "referenceLicensePaths": [ + "licenses/BSD-2-Clause.txt", + "licenses/GPL-2.0.txt", + "licenses/MIT-reference.txt" + ], + "supplementalAportsPaths": [ + "aports/musl/0001-elf.h-add-typedefs-for-Elf-_Relr.patch", + "aports/musl/handle-aux-at_base.patch", + "aports/musl/remove-dns-63-records-limit.patch" + ], + "remoteEvidencePaths": [], + "remainingReviewItems": [ + "confirm-third-party-musl-files-and-aports-helper-notice-coverage" + ], + "candidateState": "collected-engineering-review-required" + }, + { + "sourceOrigin": "openssl", + "declaredLicenseExpressions": [ + "Apache-2.0" + ], + "existingEvidencePaths": [ + "evidence/openssl/AUTHORS.md", + "evidence/openssl/LICENSE.txt" + ], + "referenceLicensePaths": [ + "licenses/Apache-2.0.txt" + ], + "supplementalAportsPaths": [ + "aports/openssl/CVE-2023-5678.patch", + "aports/openssl/CVE-2023-6129.patch", + "aports/openssl/CVE-2023-6237.patch", + "aports/openssl/CVE-2024-0727.patch", + "aports/openssl/man-section.patch" + ], + "remoteEvidencePaths": [ + "supplemental/openssl/README.md" + ], + "remainingReviewItems": [ + "confirm-required-apache-notice-and-attribution-material" + ], + "candidateState": "collected-engineering-review-required" + }, + { + "sourceOrigin": "pax-utils", + "declaredLicenseExpressions": [ + "GPL-2.0-only" + ], + "existingEvidencePaths": [ + "evidence/pax-utils/COPYING" + ], + "referenceLicensePaths": [ + "licenses/GPL-2.0.txt" + ], + "supplementalAportsPaths": [], + "remoteEvidencePaths": [ + "supplemental/pax-utils/README.md" + ], + "remainingReviewItems": [ + "confirm-package-specific-copyright-and-aports-patch-notices" + ], + "candidateState": "collected-engineering-review-required" + } + ] +} diff --git a/Compliance/RootFS/v0.3.3/NOTICE.md b/Compliance/RootFS/v0.3.3/NOTICE.md index dfedc1f..10d0c40 100644 --- a/Compliance/RootFS/v0.3.3/NOTICE.md +++ b/Compliance/RootFS/v0.3.3/NOTICE.md @@ -42,6 +42,13 @@ of all 21 candidates. All indexed review items are resolved 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 +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. ## Corresponding-source status diff --git a/Compliance/RootFS/v0.3.3/README.md b/Compliance/RootFS/v0.3.3/README.md index 061633c..4bb7e70 100644 --- a/Compliance/RootFS/v0.3.3/README.md +++ b/Compliance/RootFS/v0.3.3/README.md @@ -20,6 +20,9 @@ pinned RootFS archive. It does not store the RootFS payload. attribution、声明与内联 notice 的路径、大小、SHA-256 和逐包未决审查项; - `LICENSE-REVIEW-RESULTS.json`:对全部 21 个候选的 checksum-bound 工程复核 结论、coverage 和未决项处置;不表示法律或再分发批准; +- `LICENSE-NOTICE-CANDIDATES.json`:为剩余 8 个 source origin 固定 8 份远端 + 许可证/attribution 材料、46 份 aports 补充文件及现有 21 份复核证据的外置候选包; + payload 不提交,工程、法律和再分发门禁保持关闭; - `RUNTIME-CONFIGURATION.json`:guest、`apk`、repository、world 和 DNS 默认配置; - `NOTICE.md`:可复现 attribution inventory 与尚未完成事项; - `EVIDENCE.json`:输入成员摘要、数量和明确的工程/发行状态; @@ -32,7 +35,11 @@ manifest, not a committed source archive or redistribution grant. 10 source origins; it is an engineering review index, not legal approval. `LICENSE-REVIEW-RESULTS.json` records the engineering review of all 21 pinned candidates. Two source origins have no remaining indexed review items; eight -still have package-specific open items. +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. ## 重新生成 / Regenerate @@ -128,12 +135,46 @@ rechecks byte counts, SHA-256 digests, the exact path set, and the no-symlink/ special-node boundary. The results manifest proves engineering review of all 21 candidates; the external output is still not a product-ready NOTICE bundle. +剩余 8 个 source origin 的材料可继续组装为外置候选包。先校验清单;实际物化 +必须同时提供已经通过 `--verify` 的 source-review 和 license-review 目录,以及 +一个尚不存在的仓库外输出路径: + +The remaining eight origins can be assembled into an external candidate +bundle. Validate the manifest first. Materialization requires source-review +and license-review directories that already pass `--verify`, plus a new output +path outside the repository: + +```bash +ruby Scripts/rootfs-license-notice-candidates.rb +ruby Scripts/prepare-rootfs-license-notice-bundle.rb --validate-only + +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 \ + --output /absolute/new/rootfs-v0.3.3-license-notice-candidates + +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 +``` + +远端材料由固定 URL、字节数和 SHA-256 约束;可选 `--download-cache` 只读取以 +`cacheKey` 命名的本地普通文件并再次校验。输出包含候选 NOTICE、receipt 和 +`SHA256SUMS`,但仍是工程审查输入,不是产品 NOTICE、法律意见或再分发批准。 + +Remote payloads are pinned by URL, byte count, and SHA-256. An optional +`--download-cache` reads only local regular files named by each `cacheKey` and +revalidates them. The output includes a candidate NOTICE, receipt, and +`SHA256SUMS`; it remains engineering-review input, not a product NOTICE, legal +advice, or redistribution approval. + ## 未解除的门禁 / Open gates 这些文件不构成完整第三方 LICENSE/NOTICE bundle、经审查的 copyleft corresponding-source 交付、法律意见或再分发授权。源码获取清单已完整覆盖固定 inventory,21 个候选也都有工程复核结果;`libc-dev`、`zlib` 已关闭索引项,另外 -8 个 source origin 仍需补齐许可证正文或包级版权/notice。修改说明、构建完整性、 +8 个 source origin 的新候选材料仍需工程/法律复核和包级版权/notice 确认。修改说明、构建完整性、 源码提供方式、App Store 2.5.2 产品策略和负责人批准仍是发行阻塞项。 These files are not a complete third-party LICENSE/NOTICE bundle, reviewed @@ -141,6 +182,7 @@ 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 -license-text or package-specific notice follow-up. Modification, build +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. diff --git a/Compliance/RootFS/v0.3.3/SHA256SUMS b/Compliance/RootFS/v0.3.3/SHA256SUMS index 099becc..bec2a05 100644 --- a/Compliance/RootFS/v0.3.3/SHA256SUMS +++ b/Compliance/RootFS/v0.3.3/SHA256SUMS @@ -1,8 +1,9 @@ -548ae2904dda0e6da3f743971ae7282ebe713c1c28edc293aa8cb15ce30e7115 EVIDENCE.json -76d9cf7ded5e7ca4627b470ff6ee855ab92336c9c0d320c3583ef2f9b10eaab3 LICENSE-INVENTORY.json +cae8a2d6049ce8f948fe2e9bb852de2eb3601d287993b885e7e83eb0463f09eb EVIDENCE.json +2cfbeb2bf48698cc1f9e2ca22acc90041333cddee31f8dc67c98748949d24544 LICENSE-INVENTORY.json +55e13487c9c0b0ad0160e91c440120b6faa1ef75b0cf467676c67834ca1083cb LICENSE-NOTICE-CANDIDATES.json 3c7f786d9551d716c2a8b374d8cc63d11a46d67827be2fa0cea73b51b6b92eda LICENSE-REVIEW-RESULTS.json 3d483714a09cb2194e1b4af9aef5dfec2fbcfd44c70d521899398a3893f1908a LICENSE-REVIEW.json -cc9e74478b947919bbca290bb7ff07fe5dd874132a41bc7f015acddb4c35a9b8 NOTICE.md +793f7e9b04896b261593b85878fdaeaabcde4bfacdd5c2340edd7e613c3fd531 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 30d6ed2..c9a5d3e 100644 --- a/Docs/ReleaseCompliance.md +++ b/Docs/ReleaseCompliance.md @@ -75,8 +75,10 @@ aports snapshot/upstream distfile 获取清单和仓库外 materializer,并固 全部 10 个 source origin 的 21 个 license、attribution、声明与内联 notice 候选。 第二个仓库外工具从已验证 source-review 目录提取这些候选;固定结果清单记录 21/21 个候选均已工程复核,`libc-dev`、`zlib` 的索引项已关闭,另外 8 个 source -origin 仍有逐包未决项。修改与构建完整性、源码提供方式和法律审查仍未完成,不能 -视为完整 NOTICE 或已批准的对应源码交付。 +origin 仍有逐包未决项。仓库现已为这 8 个 origin 固定外置候选包:8 份远端 +许可证/attribution 材料、46 份 aports 补充文件和全部既有复核证据;工具可在 +仓库外原子生成并复验,但材料尚未通过工程/法律批准。修改与构建完整性、源码 +提供方式和法律审查仍未完成,不能视为完整 NOTICE 或已批准的对应源码交付。 ## 当前仓库保护 @@ -173,6 +175,8 @@ Alpine `apk` 可以下载、安装和执行新增代码。即使初始 RootFS - [x] 从固定 APK database 生成完整 package inventory。 - [x] 生成并通过 SPDX 2.3 JSON schema 校验的 machine-readable SBOM。 - [x] 对固定的 21 个 license/NOTICE 候选完成 checksum-bound 工程复核。 +- [x] 为剩余 8 个 source origin 建立 checksum-bound 外置候选包索引与可复验 + materializer;payload 不提交且批准门禁保持关闭。 - [ ] 收集 license text 和 NOTICE。 - [ ] 建立 copyleft corresponding source bundle。 - [x] 固定 DNS、repository 和 package-manager 默认配置事实。 diff --git a/Docs/Roadmap.md b/Docs/Roadmap.md index b207869..31166b2 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 个 source origin 的未决项、完成对应源码交付审查与负责人批准 | +| License-reviewed RootFS | 阻塞 | 15 包 inventory、10 source origin、SPDX SBOM、默认配置证据、外置源码获取流程和 21/21 候选工程复核已完成;剩余 8 个 origin 的外置候选包已有 checksum-bound 索引与 materializer,下一步完成候选材料工程/法律复核、对应源码交付审查与负责人批准 | | App Store 2.5.2 | 阻塞 | guest download/execute policy 有书面结论 | ### 后续 runtime 执行顺序 diff --git a/Docs/RootFS.md b/Docs/RootFS.md index 40299a8..f466f12 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 个 license/NOTICE 候选的工程复核结果;剩余 8 个 source origin 的外置候选包也已建立可复现索引,但候选材料的工程/法律复核、完整 NOTICE、对应源码交付审查与发行批准尚未闭环。以下 URL 与命令用于审计和本地开发,不构成公开再分发授权。应用必须先完成自己的法律与发行审查。 ## 1. 固定清单 @@ -126,6 +126,28 @@ SHA-256、精确路径集合与无链接/特殊节点边界。固定结果清单 工程复核,其中 `libc-dev`、`zlib` 的索引项已关闭,另外 8 个 source origin 仍有 未决项;输出不能直接视为完整 NOTICE 或对应源码交付材料。 +剩余 8 个 origin 的外置 LICENSE/NOTICE 候选包清单还固定了 8 份远端许可证/ +attribution 材料与 46 份 aports 补充文件。清单可独立校验;实际物化和复验必须 +同时提供上面已经验证的两个仓库外目录: + +```bash +ruby Scripts/rootfs-license-notice-candidates.rb +ruby Scripts/prepare-rootfs-license-notice-bundle.rb --validate-only + +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 \ + --output /absolute/new/path/outside-the-repository/rootfs-v0.3.3-license-notice-candidates + +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 +``` + +工具对远端材料强制 HTTPS、重定向次数、响应大小、固定字节数与 SHA-256,并原子 +创建输出;输出中的候选 NOTICE 和 receipt 仍只是工程审查输入,不代表已批准发行。 + 不要把归档放入 `Sources/PocketRootResources/Resources`、Demo resources 或 Git LFS。合规完成前,`PocketRootBundledRootFSProvider` 的资源查找预期返回 `nil`。 把 Files/document picker 或受控下载结果复制进 App sandbox,并生成 diff --git a/Docs/en/ReleaseCompliance.md b/Docs/en/ReleaseCompliance.md index 87945dc..80594eb 100644 --- a/Docs/en/ReleaseCompliance.md +++ b/Docs/en/ReleaseCompliance.md @@ -40,9 +40,13 @@ outside-repository tool pins, extracts, and verifies 21 license, attribution, declaration, and inline-notice candidates across all 10 source origins. A pinned result manifest records engineering review of all 21 candidates. `libc-dev` and `zlib` have no remaining indexed items; eight source origins -still have package-level follow-up. Modification, build-completeness, -source-offer, and legal reviews remain unresolved, so the output is neither a -complete NOTICE set nor approved corresponding-source delivery. +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. ## Repository safeguards @@ -112,6 +116,9 @@ The current code does not provide a complete product-level privacy policy. - [x] Generate a machine-readable SBOM validated against the SPDX 2.3 JSON schema. - [x] Complete checksum-bound engineering review of all 21 pinned license/NOTICE candidates. +- [x] Index a checksum-bound external candidate bundle and reproducible + materializer for the eight remaining origins; payloads stay uncommitted and + approval gates stay closed. - [ ] 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 fc11852..b4264d5 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; close the remaining items for eight origins, finish corresponding-source delivery review, and obtain authorized approval | +| 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 | | 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 b7b4535..65a1911 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. Eight source origins still have package-level open items; 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 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. ## Pinned manifest @@ -104,6 +104,30 @@ all 21 candidates: `libc-dev` and `zlib` have no remaining indexed items, while eight source origins still require follow-up. The output is not a completed NOTICE or corresponding-source delivery bundle. +The external LICENSE/NOTICE candidate manifest for those eight origins also +pins 8 remote license/attribution payloads and 46 supplemental aports files. +Validate it independently, or materialize and re-verify it using both external +directories verified above: + +```bash +ruby Scripts/rootfs-license-notice-candidates.rb +ruby Scripts/prepare-rootfs-license-notice-bundle.rb --validate-only + +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 \ + --output /absolute/new/path/outside-the-repository/rootfs-v0.3.3-license-notice-candidates + +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 +``` + +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. + Do not put it in package resources, Demo resources, Git, or Git LFS. See the [integration guide](IntegrationGuide.md#move-the-archive-into-the-app-sandbox) diff --git a/Scripts/generate-rootfs-compliance.rb b/Scripts/generate-rootfs-compliance.rb index 2bedb1c..3135c1e 100755 --- a/Scripts/generate-rootfs-compliance.rb +++ b/Scripts/generate-rootfs-compliance.rb @@ -8,6 +8,7 @@ require "rubygems/package" require "uri" require "zlib" +require_relative "rootfs-license-notice-candidates" require_relative "rootfs-license-review" require_relative "rootfs-license-review-results" require_relative "rootfs-source-acquisition" @@ -43,6 +44,8 @@ def parse_options "Compliance/RootFS/v0.3.3/LICENSE-REVIEW.json", license_review_results: "Compliance/RootFS/v0.3.3/LICENSE-REVIEW-RESULTS.json", + license_notice_candidates: + "Compliance/RootFS/v0.3.3/LICENSE-NOTICE-CANDIDATES.json", check: false } @@ -73,6 +76,12 @@ def parse_options ) do |path| options[:license_review_results] = path end + commands.on( + "--license-notice-candidates PATH", + "Pinned external license/NOTICE candidate manifest" + ) do |path| + options[:license_notice_candidates] = path + end commands.on("--check", "Compare generated evidence without writing files") do options[:check] = true end @@ -438,7 +447,8 @@ def license_inventory( packages, license_notice_paths, license_review_entries, - license_review_result_entries + license_review_result_entries, + license_notice_candidates ) expressions = Hash.new(0) packages.each { |package| expressions[package[:license]] += 1 } @@ -471,6 +481,14 @@ def license_inventory( license_review_result_entries.count do |entry| !entry.fetch("remainingReviewItems").empty? end, + "indexedOpenSourceOrigins" => + license_notice_candidates.fetch(:sources).length, + "pinnedRemoteReferencePayloads" => + license_notice_candidates.fetch(:remote_payloads).length, + "supplementalAportsFiles" => + license_notice_candidates.fetch(:aports_paths).length, + "candidateBundleIndexComplete" => true, + "candidatePayloadCommitted" => false, "engineeringReviewCompleted" => true, "completeLicenseTextBundlePresent" => false, "completePackageNoticeSetPresent" => false, @@ -484,7 +502,8 @@ def notice_markdown( source_entries, license_notice_paths, license_review_entries, - license_review_result_entries + license_review_result_entries, + license_notice_candidates ) package_rows = packages.map do |package| "| `#{package[:name]}` | `#{package[:version]}` | " \ @@ -545,6 +564,13 @@ def notice_markdown( for #{resolved_origins.map { |entry| "`#{entry.fetch("sourceOrigin")}`" }.join(", ")}. #{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 + #{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. ## Corresponding-source status @@ -575,7 +601,8 @@ def evidence( source_entries, inspected, license_review_entries, - license_review_result_entries + license_review_result_entries, + license_notice_candidates ) { "schemaVersion" => 1, @@ -613,7 +640,13 @@ def evidence( "sourceOriginsWithRemainingLicenseReviewItems" => license_review_result_entries.count do |entry| !entry.fetch("remainingReviewItems").empty? - end + end, + "indexedLicenseNoticeCandidateOrigins" => + license_notice_candidates.fetch(:sources).length, + "pinnedRemoteLicenseNoticePayloads" => + license_notice_candidates.fetch(:remote_payloads).length, + "supplementalAportsCandidateFiles" => + license_notice_candidates.fetch(:aports_paths).length }, "engineeringStatus" => { "completeInstalledPackageInventory" => true, @@ -622,6 +655,9 @@ def evidence( "completeSourceAcquisitionManifest" => true, "completeLicenseReviewCandidateIndex" => true, "licenseCandidateEngineeringReviewCompleted" => true, + "completeLicenseNoticeCandidateBundleIndex" => true, + "licenseNoticeCandidatePayloadCommitted" => false, + "licenseNoticeCandidateEngineeringReviewApproved" => false, "completeLicenseAndNoticeBundle" => false, "correspondingSourceBundleCollected" => false, "redistributionApproved" => false @@ -633,7 +669,8 @@ def build_outputs( archive, source_acquisition, license_review, - license_review_results + license_review_results, + license_notice_candidates ) inspected = inspect_archive(archive) content = inspected.fetch(:content) @@ -689,6 +726,22 @@ def build_outputs( raise ComplianceError, "Invalid license-review results: #{error.message}" end + begin + validated_license_notice_candidates = + RootFSLicenseNoticeCandidates.validate_manifest( + license_notice_candidates.fetch(:document), + license_review_results.fetch(:document), + license_review: license_review.fetch(:document), + source_acquisition: source_acquisition.fetch(:document), + source_inventory: generated_source_inventory, + results_bytes: license_review_results.fetch(:contents), + license_review_bytes: license_review.fetch(:contents), + source_acquisition_bytes: source_acquisition.fetch(:contents) + ) + rescue RootFSLicenseNoticeCandidates::ValidationError => error + raise ComplianceError, + "Invalid license/NOTICE candidate manifest: #{error.message}" + end outputs = { "EVIDENCE.json" => pretty_json( evidence( @@ -696,7 +749,8 @@ def build_outputs( source_entries, inspected, license_review_entries, - license_review_result_entries + license_review_result_entries, + validated_license_notice_candidates ) ), "LICENSE-INVENTORY.json" => pretty_json( @@ -704,9 +758,12 @@ def build_outputs( packages, inspected.fetch(:license_notice_paths), license_review_entries, - license_review_result_entries + license_review_result_entries, + validated_license_notice_candidates ) ), + "LICENSE-NOTICE-CANDIDATES.json" => + license_notice_candidates.fetch(:contents), "LICENSE-REVIEW.json" => license_review.fetch(:contents), "LICENSE-REVIEW-RESULTS.json" => license_review_results.fetch(:contents), @@ -715,7 +772,8 @@ def build_outputs( source_entries, inspected.fetch(:license_notice_paths), license_review_entries, - license_review_result_entries + license_review_result_entries, + validated_license_notice_candidates ), "PACKAGE-INVENTORY.tsv" => package_inventory_tsv(packages), "RUNTIME-CONFIGURATION.json" => pretty_json( @@ -779,13 +837,18 @@ def write_outputs(output_directory, expected) options.fetch(:license_review_results), "License-review results" ) + license_notice_candidates = load_json_document( + options.fetch(:license_notice_candidates), + "License/NOTICE candidate manifest" + ) source_acquisition = verify_source_acquisition(options.fetch(:source_acquisition)) outputs = build_outputs( archive, source_acquisition, license_review, - license_review_results + license_review_results, + license_notice_candidates ) output_directory = Pathname(options.fetch(:output)) diff --git a/Scripts/prepare-rootfs-license-notice-bundle.rb b/Scripts/prepare-rootfs-license-notice-bundle.rb new file mode 100644 index 0000000..2be8be4 --- /dev/null +++ b/Scripts/prepare-rootfs-license-notice-bundle.rb @@ -0,0 +1,785 @@ +#!/usr/bin/env ruby + +require "digest" +require "fileutils" +require "find" +require "json" +require "net/http" +require "open3" +require "openssl" +require "optparse" +require "pathname" +require "rbconfig" +require "securerandom" +require "uri" +require_relative "rootfs-license-notice-candidates" + +module RootFSLicenseNoticeBundle + MAX_LOCAL_PAYLOAD_BYTES = 8 * 1_024 * 1_024 + MAX_COMMAND_OUTPUT_BYTES = 64 * 1_024 + MAX_REDIRECTS = 3 + OPEN_TIMEOUT_SECONDS = 15 + READ_TIMEOUT_SECONDS = 30 + + class BundleError < StandardError + end + + module_function + + def parse_options(arguments) + options = { + candidates: + "Compliance/RootFS/v0.3.3/LICENSE-NOTICE-CANDIDATES.json", + results: + "Compliance/RootFS/v0.3.3/LICENSE-REVIEW-RESULTS.json", + review: "Compliance/RootFS/v0.3.3/LICENSE-REVIEW.json", + source_acquisition: + "Compliance/RootFS/v0.3.3/SOURCE-ACQUISITION.json", + source_inventory: + "Compliance/RootFS/v0.3.3/SOURCE-INVENTORY.json", + validate_only: false + } + parser = OptionParser.new do |commands| + commands.banner = + "Usage: ruby Scripts/prepare-rootfs-license-notice-bundle.rb [options]" + commands.on("--candidates PATH", "Candidate bundle manifest") do |value| + options[:candidates] = value + end + commands.on("--results PATH", "Engineering review results") do |value| + options[:results] = value + end + commands.on("--review-manifest PATH", "License-review manifest") do |value| + options[:review] = value + end + commands.on("--source-manifest PATH", "Source-acquisition manifest") do |value| + options[:source_acquisition] = value + end + commands.on("--source-inventory PATH", "Generated source inventory") do |value| + options[:source_inventory] = value + end + commands.on("--source-bundle DIR", "Verified external source bundle") do |value| + options[:source_bundle] = value + end + commands.on("--license-review DIR", "Verified external license review") do |value| + options[:license_review] = value + end + commands.on("--download-cache DIR", "Optional pinned remote payload cache") do |value| + options[:download_cache] = value + end + commands.on("--output DIR", "New external candidate bundle directory") do |value| + options[:output] = value + end + commands.on("--verify DIR", "Verify an external candidate bundle") do |value| + options[:verify] = value + end + commands.on("--validate-only", "Validate manifests without payloads") do + options[:validate_only] = true + end + end + parser.parse!(arguments) + unless arguments.empty? + raise OptionParser::InvalidOption, arguments.join(" ") + end + + modes = [ + options.fetch(:validate_only), + !options[:output].nil?, + !options[:verify].nil? + ].count(true) + unless modes == 1 + raise OptionParser::InvalidOption, + "select exactly one of --validate-only, --output, or --verify" + end + unless options.fetch(:validate_only) + raise OptionParser::MissingArgument, "--source-bundle" unless options[:source_bundle] + raise OptionParser::MissingArgument, "--license-review" unless options[:license_review] + end + + options + end + + def repository_root + Pathname(__dir__).parent.realpath + end + + def within_path?(candidate, parent) + candidate == parent || + candidate.to_s.start_with?("#{parent}#{File::SEPARATOR}") + end + + def resolve_external_directory(value, label) + directory = Pathname(value) + raise BundleError, "#{label} must be absolute" unless directory.absolute? + raise BundleError, "#{label} must not be a symlink" if directory.symlink? + raise BundleError, "#{label} is not a directory" unless directory.directory? + + resolved = directory.realpath + if within_path?(resolved, repository_root) + raise BundleError, "#{label} must be outside the repository" + end + resolved + end + + def validate_new_output(value, inputs) + output = Pathname(value) + raise BundleError, "--output must be absolute" unless output.absolute? + if output.exist? || output.symlink? + raise BundleError, "--output already exists: #{output}" + end + unless output.parent.directory? && !output.parent.symlink? + raise BundleError, "--output parent must be a real directory" + end + + resolved = output.parent.realpath.join(output.basename) + if within_path?(resolved, repository_root) + raise BundleError, "--output must be outside the repository" + end + inputs.each do |input| + if within_path?(resolved, input) || within_path?(input, resolved) + raise BundleError, "--output must not overlap an input directory" + end + end + resolved + end + + def require_regular_file(root, relative, label, maximum_bytes) + unless RootFSLicenseNoticeCandidates.safe_relative_path?(relative) + raise BundleError, "#{label} has an unsafe path" + end + + current = root + relative.split("/").each do |component| + current = current.join(component) + raise BundleError, "#{label} must not contain a symlink" if current.symlink? + end + unless current.exist? && current.lstat.file? + raise BundleError, "#{label} is not a regular file" + end + if current.size > maximum_bytes + raise BundleError, "#{label} exceeds #{maximum_bytes} bytes" + end + current + end + + def read_regular_file(root, relative, label, maximum_bytes) + require_regular_file(root, relative, label, maximum_bytes).binread + end + + def load_document(path, label) + pathname = Pathname(path) + raise BundleError, "#{label} is not a regular file: #{path}" unless pathname.file? + + contents = pathname.binread + { + path: pathname, + contents: contents, + document: JSON.parse(contents) + } + rescue JSON::ParserError => error + raise BundleError, "#{label} is invalid JSON: #{error.message}" + end + + def validate_documents(options) + candidates = load_document(options.fetch(:candidates), "candidate manifest") + results = load_document(options.fetch(:results), "review results") + review = load_document(options.fetch(:review), "review manifest") + source = + load_document( + options.fetch(:source_acquisition), + "source-acquisition manifest" + ) + inventory = + load_document(options.fetch(:source_inventory), "source inventory") + allow_file_urls = + ENV["POCKETROOT_TEST_ALLOW_FILE_URLS"] == "1" && + source.fetch(:document)["testFixture"] == true + validated = RootFSLicenseNoticeCandidates.validate_manifest( + candidates.fetch(:document), + results.fetch(:document), + license_review: review.fetch(:document), + source_acquisition: source.fetch(:document), + source_inventory: inventory.fetch(:document), + results_bytes: results.fetch(:contents), + license_review_bytes: review.fetch(:contents), + source_acquisition_bytes: source.fetch(:contents), + allow_file_urls: allow_file_urls + ) + { + candidates: candidates, + results: results, + review: review, + source: source, + inventory: inventory, + validated: validated, + allow_file_urls: allow_file_urls + } + rescue RootFSLicenseNoticeCandidates::ValidationError => error + raise BundleError, error.message + end + + def run_verifier(command, label) + output = +"".b + output_lock = Mutex.new + truncated = false + status = nil + Open3.popen3(*command) do |stdin, stdout, stderr, wait_thread| + stdin.close + readers = [stdout, stderr].map do |stream| + Thread.new do + loop do + chunk = stream.readpartial(16 * 1_024) + output_lock.synchronize do + remaining = MAX_COMMAND_OUTPUT_BYTES - output.bytesize + if remaining.positive? + output << chunk.byteslice(0, remaining) + end + truncated = true if chunk.bytesize > remaining + end + end + rescue EOFError + nil + end + end + readers.each(&:join) + status = wait_thread.value + end + output << "\n[output truncated]\n" if truncated + return if status.success? + + raise BundleError, + "#{label} failed (exit #{status.exitstatus}):\n#{output}" + end + + def verify_inputs(source_bundle, license_review, documents) + source_command = [ + RbConfig.ruby, + repository_root.join("Scripts/prepare-rootfs-source-bundle.rb").to_s, + "--manifest", + documents.fetch(:source).fetch(:path).to_s, + "--source-inventory", + documents.fetch(:inventory).fetch(:path).to_s, + "--verify", + source_bundle.to_s + ] + run_verifier(source_command, "source bundle verification") + + review_command = [ + RbConfig.ruby, + repository_root.join("Scripts/prepare-rootfs-license-review.rb").to_s, + "--review-manifest", + documents.fetch(:review).fetch(:path).to_s, + "--source-manifest", + documents.fetch(:source).fetch(:path).to_s, + "--source-inventory", + documents.fetch(:inventory).fetch(:path).to_s, + "--source-bundle", + source_bundle.to_s, + "--verify", + license_review.to_s + ] + run_verifier(review_command, "license review verification") + end + + def verify_payload_bytes(contents, payload, label) + unless contents.bytesize == payload.fetch("byteCount") + raise BundleError, + "#{label} byte count mismatch: expected " \ + "#{payload.fetch("byteCount")}, got #{contents.bytesize}" + end + digest = Digest::SHA256.hexdigest(contents) + unless digest == payload.fetch("sha256") + raise BundleError, + "#{label} SHA-256 mismatch: expected #{payload.fetch("sha256")}, got #{digest}" + end + contents + end + + def verify_sha256(contents, expected, label) + digest = Digest::SHA256.hexdigest(contents) + unless digest == expected + raise BundleError, + "#{label} SHA-256 mismatch: expected #{expected}, got #{digest}" + end + contents + end + + def source_tree_file_digests(source_bundle, expected_paths) + contents = + read_regular_file( + source_bundle, + "TREE-MANIFEST.json", + "source bundle tree manifest", + MAX_LOCAL_PAYLOAD_BYTES + ) + document = JSON.parse(contents) + entries = document["entries"] + unless document["schemaVersion"] == 1 && entries.is_a?(Array) + raise BundleError, "source bundle tree manifest is invalid" + end + file_digests = {} + entries.each do |entry| + next unless entry.is_a?(Hash) && entry["type"] == "file" + + path = entry["path"] + digest = entry["sha256"] + unless path.is_a?(String) && + !path.empty? && + digest.is_a?(String) && + digest.match?(RootFSLicenseNoticeCandidates::SHA256_PATTERN) && + !file_digests.key?(path) + raise BundleError, "source bundle tree manifest has invalid file entries" + end + file_digests[path] = digest + end + unless expected_paths.all? { |path| file_digests.key?(path) } + raise BundleError, + "source bundle tree manifest does not cover candidate aports files" + end + file_digests + rescue JSON::ParserError => error + raise BundleError, + "source bundle tree manifest is invalid JSON: #{error.message}" + end + + def read_cached_payload(cache, payload) + read_regular_file( + cache, + payload.fetch("cacheKey"), + "cached remote payload #{payload.fetch("cacheKey")}", + RootFSLicenseNoticeCandidates::MAX_REMOTE_PAYLOAD_BYTES + ) + end + + def download_https(url, maximum_bytes, redirects = MAX_REDIRECTS) + raise BundleError, "too many redirects while downloading #{url}" if redirects.negative? + + uri = URI.parse(url) + unless uri.is_a?(URI::HTTPS) && !uri.host.to_s.empty? && + uri.userinfo.nil? && uri.fragment.nil? + raise BundleError, "remote payload URL is not safe HTTPS: #{url}" + end + request = Net::HTTP::Get.new(uri.request_uri) + result = nil + Net::HTTP.start( + uri.host, + uri.port, + use_ssl: true, + open_timeout: OPEN_TIMEOUT_SECONDS, + read_timeout: READ_TIMEOUT_SECONDS + ) do |http| + http.request(request) do |response| + result = + case response + when Net::HTTPSuccess + content_length = response["content-length"] + if content_length && Integer(content_length, 10) > maximum_bytes + raise BundleError, + "remote payload exceeds #{maximum_bytes} bytes: #{url}" + end + contents = +"".b + response.read_body do |chunk| + contents << chunk + if contents.bytesize > maximum_bytes + raise BundleError, + "remote payload exceeds #{maximum_bytes} bytes: #{url}" + end + end + [:success, contents] + when Net::HTTPRedirection + [:redirect, response["location"]] + else + [:failure, response.code] + end + end + end + unless result + raise BundleError, "remote payload request returned no response: #{url}" + end + case result.fetch(0) + when :success + result.fetch(1) + when :redirect + location = result.fetch(1) + raise BundleError, "remote payload redirect has no location: #{url}" unless location + + download_https(URI.join(uri, location).to_s, maximum_bytes, redirects - 1) + else + raise BundleError, + "remote payload request failed with HTTP #{result.fetch(1)}: #{url}" + end + rescue ArgumentError, URI::InvalidURIError, Net::ProtocolError, SocketError, + SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError => error + raise BundleError, "remote payload request failed for #{url}: #{error.message}" + end + + def read_file_url(url, maximum_bytes) + uri = URI.parse(url) + pathname = Pathname(URI.decode_www_form_component(uri.path)) + unless pathname.absolute? && pathname.file? && !pathname.symlink? + raise BundleError, "fixture payload is not a regular absolute file: #{url}" + end + if pathname.size > maximum_bytes + raise BundleError, "fixture payload exceeds #{maximum_bytes} bytes: #{url}" + end + pathname.binread + end + + def fetch_remote_payload(payload, cache, allow_file_urls) + if cache + return verify_payload_bytes( + read_cached_payload(cache, payload), + payload, + "cached remote payload #{payload.fetch("cacheKey")}" + ) + end + + failures = [] + payload.fetch("retrievalURLs").each do |url| + begin + contents = + if allow_file_urls && URI.parse(url).scheme == "file" + read_file_url( + url, + RootFSLicenseNoticeCandidates::MAX_REMOTE_PAYLOAD_BYTES + ) + else + download_https( + url, + RootFSLicenseNoticeCandidates::MAX_REMOTE_PAYLOAD_BYTES + ) + end + return verify_payload_bytes( + contents, + payload, + "remote payload #{payload.fetch("cacheKey")}" + ) + rescue BundleError => error + failures << error.message + end + end + + raise BundleError, + "all retrieval URLs failed for #{payload.fetch("cacheKey")}:\n- " \ + "#{failures.join("\n- ")}" + end + + def pretty_json(value) + "#{JSON.pretty_generate(value)}\n" + end + + def notice_markdown(sources, counts) + rows = sources.map do |source| + origin = source.fetch("sourceOrigin") + expressions = source.fetch("declaredLicenseExpressions").join(", ") + open_items = + source.fetch("remainingReviewItems") + .map { |item| "`#{item}`" } + .join("
") + "| `#{origin}` | #{expressions} | #{open_items} |" + end + + <<~MARKDOWN + # RootFS v0.3.3 LICENSE/NOTICE candidate bundle + + This external directory collects checksum-verified engineering candidates + for the eight source origins that still have open license or attribution + review items. It contains #{counts.fetch(:existing)} previously reviewed + evidence files, #{counts.fetch(:remote)} pinned remote reference files, + and #{counts.fetch(:aports)} aports patch/helper files. + + > This is not a legal NOTICE, redistribution approval, or a complete + > corresponding-source delivery. Generic SPDX texts—especially the MIT + > reference with placeholders—do not replace package-specific grants, + > copyright notices, or legal review. + + | Source origin | Declared expressions | Review items still open | + | --- | --- | --- | + #{rows.join("\n")} + + `LICENSE-NOTICE-CANDIDATES.json` records the source and checksum of every + remote payload. `LICENSE-REVIEW-RESULTS.json` and `LICENSE-REVIEW.json` + preserve the reviewed evidence chain. `BUNDLE-RECEIPT.json` records the + manifest bindings and payload counts. `SHA256SUMS` covers every other file + in this directory. + MARKDOWN + end + + def receipt(documents, counts) + { + "schemaVersion" => 1, + "archive" => { + "version" => RootFSLicenseNoticeCandidates::ARCHIVE_VERSION, + "sha256" => RootFSLicenseNoticeCandidates::ARCHIVE_SHA256 + }, + "candidateManifestSha256" => + Digest::SHA256.hexdigest( + documents.fetch(:candidates).fetch(:contents) + ), + "licenseReviewResultsSha256" => + Digest::SHA256.hexdigest(documents.fetch(:results).fetch(:contents)), + "licenseReviewManifestSha256" => + Digest::SHA256.hexdigest(documents.fetch(:review).fetch(:contents)), + "sourceAcquisitionManifestSha256" => + Digest::SHA256.hexdigest(documents.fetch(:source).fetch(:contents)), + "counts" => { + "openSourceOrigins" => counts.fetch(:origins), + "existingEvidenceFiles" => counts.fetch(:existing), + "remotePayloadFiles" => counts.fetch(:remote), + "supplementalAportsFiles" => counts.fetch(:aports), + "candidatePayloadFiles" => + counts.fetch(:existing) + + counts.fetch(:remote) + + counts.fetch(:aports) + }, + "status" => + RootFSLicenseNoticeCandidates::STATUS, + "engineeringReviewApproved" => false, + "legalReviewApproved" => false, + "redistributionApproved" => false + } + end + + def build_payloads( + source_bundle, + license_review, + documents, + remote_contents + ) + validated = documents.fetch(:validated) + payloads = { + "LICENSE-NOTICE-CANDIDATES.json" => + documents.fetch(:candidates).fetch(:contents), + "LICENSE-REVIEW-RESULTS.json" => + documents.fetch(:results).fetch(:contents), + "LICENSE-REVIEW.json" => + documents.fetch(:review).fetch(:contents) + } + existing_evidence = validated.fetch(:existing_evidence) + validated.fetch(:existing_evidence_paths).each do |relative| + expected = existing_evidence.fetch(relative) + contents = + read_regular_file( + license_review, + relative, + "review evidence #{relative}", + MAX_LOCAL_PAYLOAD_BYTES + ) + payloads[relative] = + verify_payload_bytes( + contents, + expected, + "review evidence #{relative}" + ) + end + source_digests = + source_tree_file_digests( + source_bundle, + validated.fetch(:aports_paths) + ) + validated.fetch(:aports_paths).each do |relative| + contents = + read_regular_file( + source_bundle, + relative, + "source candidate #{relative}", + MAX_LOCAL_PAYLOAD_BYTES + ) + payloads["supplemental/#{relative}"] = + verify_sha256( + contents, + source_digests.fetch(relative), + "source candidate #{relative}" + ) + end + validated.fetch(:remote_payloads).each do |payload| + payloads[payload.fetch("outputPath")] = + remote_contents.fetch(payload.fetch("outputPath")) + end + + counts = { + origins: validated.fetch(:sources).length, + existing: validated.fetch(:existing_evidence_paths).length, + remote: validated.fetch(:remote_payloads).length, + aports: validated.fetch(:aports_paths).length + } + payloads["NOTICE-CANDIDATES.md"] = + notice_markdown(validated.fetch(:sources), counts) + payloads["BUNDLE-RECEIPT.json"] = pretty_json(receipt(documents, counts)) + checksum_lines = payloads.sort.map do |relative, contents| + "#{Digest::SHA256.hexdigest(contents)} #{relative}" + end + payloads["SHA256SUMS"] = "#{checksum_lines.join("\n")}\n" + payloads + end + + def remote_contents_for_output(documents, cache) + documents.fetch(:validated).fetch(:remote_payloads).to_h do |payload| + [ + payload.fetch("outputPath"), + fetch_remote_payload( + payload, + cache, + documents.fetch(:allow_file_urls) + ) + ] + end + end + + def remote_contents_from_bundle(bundle, documents) + documents.fetch(:validated).fetch(:remote_payloads).to_h do |payload| + relative = payload.fetch("outputPath") + contents = + read_regular_file( + bundle, + relative, + "bundle remote payload #{relative}", + RootFSLicenseNoticeCandidates::MAX_REMOTE_PAYLOAD_BYTES + ) + [ + relative, + verify_payload_bytes(contents, payload, "bundle remote payload #{relative}") + ] + end + end + + def expected_path_types(files) + path_types = files.to_h { |relative| [relative, "file"] } + files.each do |relative| + parent = Pathname(relative).dirname + until parent.to_s == "." + path_types[parent.to_s] ||= "directory" + parent = parent.dirname + end + end + path_types.sort.to_h + end + + def actual_path_types(root) + path_types = {} + Find.find(root.to_s) do |entry| + pathname = Pathname(entry) + next if pathname == root + + relative = pathname.relative_path_from(root).to_s + stat = pathname.lstat + if stat.symlink? + raise BundleError, "candidate bundle contains a symlink: #{relative}" + elsif stat.directory? + path_types[relative] = "directory" + elsif stat.file? + path_types[relative] = "file" + else + raise BundleError, "candidate bundle contains a special node: #{relative}" + end + end + path_types.sort.to_h + end + + def verify_bundle(bundle, expected) + unless actual_path_types(bundle) == expected_path_types(expected.keys) + raise BundleError, + "candidate bundle path/type set does not match expected payloads" + end + expected.each do |relative, contents| + pathname = require_regular_file( + bundle, + relative, + "candidate bundle file #{relative}", + [MAX_LOCAL_PAYLOAD_BYTES, contents.bytesize].max + ) + unless pathname.size == contents.bytesize && + Digest::SHA256.file(pathname).hexdigest == + Digest::SHA256.hexdigest(contents) + raise BundleError, + "candidate bundle file does not match expected bytes: #{relative}" + end + end + end + + def materialize(output, payloads) + staging = + output.parent.join( + ".#{output.basename}.staging-#{SecureRandom.hex(8)}" + ) + begin + staging.mkdir(0o700) + payloads.each do |relative, contents| + destination = staging.join(relative) + FileUtils.mkdir_p(destination.dirname, mode: 0o700) + destination.binwrite(contents) + destination.chmod(0o600) + end + verify_bundle(staging, payloads) + File.rename(staging, output) + ensure + FileUtils.remove_entry(staging) if staging.exist? + end + end + + def execute(arguments) + options = parse_options(arguments) + documents = validate_documents(options) + validated = documents.fetch(:validated) + if options.fetch(:validate_only) + puts "RootFS license/NOTICE bundle inputs are valid " \ + "(#{validated.fetch(:sources).length} open source origins, " \ + "#{validated.fetch(:remote_payloads).length} remote payloads, " \ + "#{validated.fetch(:aports_paths).length} aports files)." + return + end + + source_bundle = + resolve_external_directory(options.fetch(:source_bundle), "--source-bundle") + license_review = + resolve_external_directory(options.fetch(:license_review), "--license-review") + verify_inputs(source_bundle, license_review, documents) + + if options[:output] + cache = + if options[:download_cache] + resolve_external_directory( + options.fetch(:download_cache), + "--download-cache" + ) + end + inputs = [source_bundle, license_review] + inputs << cache if cache + output = validate_new_output(options.fetch(:output), inputs) + remote_contents = remote_contents_for_output(documents, cache) + payloads = + build_payloads( + source_bundle, + license_review, + documents, + remote_contents + ) + verify_inputs(source_bundle, license_review, documents) + materialize(output, payloads) + puts "Materialized RootFS license/NOTICE candidate bundle at #{output}." + else + bundle = + resolve_external_directory(options.fetch(:verify), "--verify") + remote_contents = remote_contents_from_bundle(bundle, documents) + payloads = + build_payloads( + source_bundle, + license_review, + documents, + remote_contents + ) + verify_inputs(source_bundle, license_review, documents) + verify_bundle(bundle, payloads) + puts "Verified RootFS license/NOTICE candidate bundle at #{bundle}." + end + end +end + +if $PROGRAM_NAME == __FILE__ + begin + RootFSLicenseNoticeBundle.execute(ARGV) + rescue RootFSLicenseNoticeBundle::BundleError, + OptionParser::ParseError, JSON::ParserError, SystemCallError => error + warn error.message + exit 1 + end +end diff --git a/Scripts/rootfs-license-notice-candidates.rb b/Scripts/rootfs-license-notice-candidates.rb new file mode 100644 index 0000000..b4674d2 --- /dev/null +++ b/Scripts/rootfs-license-notice-candidates.rb @@ -0,0 +1,525 @@ +#!/usr/bin/env ruby + +require "digest" +require "json" +require "pathname" +require "uri" +require_relative "rootfs-license-review-results" + +module RootFSLicenseNoticeCandidates + ARCHIVE_VERSION = "v0.3.3" + ARCHIVE_SHA256 = + "be0f3c133f78f28b023288459b33dc28fa253a6ef29f7123bc5f3892edf90ad4" + STATUS = "candidate-bundle-indexed-engineering-review-required" + CANDIDATE_STATE = "collected-engineering-review-required" + SPDX_REVISION = "c4a7237ec8f4654e867546f9f409749300f1bf4c" + SHA256_PATTERN = /\A[0-9a-f]{64}\z/ + SAFE_COMPONENT_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9._+@-]*\z/ + MAX_REMOTE_PAYLOAD_BYTES = 1_048_576 + EXPECTED_OPEN_SOURCE_ORIGINS = %w[ + alpine-baselayout alpine-keys apk-tools busybox ca-certificates musl + openssl pax-utils + ].freeze + EXPECTED_EXISTING_EVIDENCE_FILES = 21 + EXPECTED_REMOTE_PAYLOAD_FILES = 8 + EXPECTED_APORTS_FILES = 46 + PAYLOAD_KINDS = %w[package-attribution spdx-license-text].freeze + TOP_LEVEL_KEYS = %w[ + archive candidateBundleIndexComplete candidatePayloadCommitted + engineeringReviewApproved legalReviewApproved licenseReviewResultsSha256 + redistributionApproved remotePayloads schemaVersion sources status + ].freeze + SOURCE_KEYS = %w[ + candidateState declaredLicenseExpressions existingEvidencePaths + referenceLicensePaths remainingReviewItems remoteEvidencePaths sourceOrigin + supplementalAportsPaths + ].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(value, label, pattern = nil) + unless value.is_a?(String) && !value.empty? + raise ValidationError, "#{label} must be a non-empty string" + end + if pattern && !value.match?(pattern) + raise ValidationError, "#{label} has an invalid format" + end + + 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 safe_relative_path?(value) + RootFSLicenseReview.safe_relative_path?(value) + end + + def require_safe_paths(value, label, allow_empty: false) + paths = require_string_array(value, label, allow_empty: allow_empty) + unless paths.all? { |path| safe_relative_path?(path) } + raise ValidationError, "#{label} contains an unsafe path" + end + + paths + end + + def paths_conflict?(left, right) + left == right || + left.start_with?("#{right}/") || + right.start_with?("#{left}/") + end + + def validate_url(value, label, allow_file_urls) + text = require_string(value, label) + uri = URI.parse(text) + allowed_schemes = allow_file_urls ? %w[file https] : %w[https] + unless allowed_schemes.include?(uri.scheme) + raise ValidationError, + "#{label} must use #{allowed_schemes.join(" or ")}" + end + if uri.userinfo || uri.fragment + raise ValidationError, + "#{label} must not contain credentials or a fragment" + end + if uri.scheme == "https" && (!uri.host || uri.host.empty?) + raise ValidationError, "#{label} must include a host" + end + if uri.scheme == "file" && + !Pathname(URI.decode_www_form_component(uri.path)).absolute? + raise ValidationError, "#{label} file path must be absolute" + end + + text + rescue URI::InvalidURIError => error + raise ValidationError, "#{label} is invalid: #{error.message}" + end + + def identifiers_in_expressions(expressions) + expressions.flat_map do |expression| + expression.scan(/[A-Za-z0-9][A-Za-z0-9.+-]*/) + .reject { |token| %w[AND OR WITH].include?(token) } + end.uniq.sort + end + + def validate_remote_payload(payload, offset, allow_file_urls) + payload = require_hash(payload, "remotePayloads[#{offset}]") + kind = require_string(payload["kind"], "remotePayloads[#{offset}].kind") + unless PAYLOAD_KINDS.include?(kind) + raise ValidationError, "remotePayloads[#{offset}].kind is unsupported" + end + common_keys = %w[ + byteCount cacheKey kind outputPath retrievalURLs sha256 + ] + specific_keys = + if kind == "spdx-license-text" + %w[sourceRevision spdxIdentifiers] + else + %w[sourceOrigin] + end + unless payload.keys.sort == (common_keys + specific_keys).sort + raise ValidationError, + "remotePayloads[#{offset}] has unexpected fields" + end + + urls = require_string_array( + payload["retrievalURLs"], + "remotePayloads[#{offset}].retrievalURLs" + ) + urls.each_with_index do |url, url_offset| + validate_url( + url, + "remotePayloads[#{offset}].retrievalURLs[#{url_offset}]", + allow_file_urls + ) + end + cache_key = require_string( + payload["cacheKey"], + "remotePayloads[#{offset}].cacheKey", + SAFE_COMPONENT_PATTERN + ) + output_path = require_string( + payload["outputPath"], + "remotePayloads[#{offset}].outputPath" + ) + unless safe_relative_path?(output_path) + raise ValidationError, + "remotePayloads[#{offset}].outputPath is unsafe" + end + unless payload["byteCount"].is_a?(Integer) && + payload["byteCount"].positive? && + payload["byteCount"] <= MAX_REMOTE_PAYLOAD_BYTES + raise ValidationError, + "remotePayloads[#{offset}].byteCount is invalid" + end + require_string( + payload["sha256"], + "remotePayloads[#{offset}].sha256", + SHA256_PATTERN + ) + + if kind == "spdx-license-text" + identifiers = require_string_array( + payload["spdxIdentifiers"], + "remotePayloads[#{offset}].spdxIdentifiers" + ) + identifiers.each do |identifier| + require_string( + identifier, + "remotePayloads[#{offset}].spdxIdentifiers entry", + SAFE_COMPONENT_PATTERN + ) + end + unless payload["sourceRevision"] == SPDX_REVISION + raise ValidationError, + "remotePayloads[#{offset}] does not use the pinned SPDX revision" + end + unless output_path.start_with?("licenses/") + raise ValidationError, + "remotePayloads[#{offset}] SPDX output must be under licenses/" + end + else + source_origin = require_string( + payload["sourceOrigin"], + "remotePayloads[#{offset}].sourceOrigin", + SAFE_COMPONENT_PATTERN + ) + unless output_path.start_with?("supplemental/#{source_origin}/") + raise ValidationError, + "remotePayloads[#{offset}] package output must be under its source origin" + end + end + + [cache_key, output_path] + end + + def validate_manifest( + manifest, + results, + license_review:, + source_acquisition:, + source_inventory:, + results_bytes:, + license_review_bytes:, + source_acquisition_bytes:, + allow_file_urls: false + ) + require_hash(manifest, "license/NOTICE candidate manifest") + require_hash(results, "license review results") + unless manifest.keys.sort == TOP_LEVEL_KEYS.sort + raise ValidationError, + "license/NOTICE candidate manifest has unexpected fields" + end + unless manifest["schemaVersion"] == 1 && + manifest["archive"] == { + "version" => ARCHIVE_VERSION, + "sha256" => ARCHIVE_SHA256 + } + raise ValidationError, + "license/NOTICE candidates do not match the pinned archive" + end + unless manifest["licenseReviewResultsSha256"] == + Digest::SHA256.hexdigest(results_bytes) && + results.eql?(JSON.parse(results_bytes)) + raise ValidationError, + "license/NOTICE candidates do not match LICENSE-REVIEW-RESULTS.json bytes" + end + + begin + result_sources = RootFSLicenseReviewResults.validate_manifest( + results, + license_review, + source_acquisition: source_acquisition, + source_inventory: source_inventory, + license_review_bytes: license_review_bytes, + source_acquisition_bytes: source_acquisition_bytes, + allow_file_urls: allow_file_urls + ) + rescue RootFSLicenseReviewResults::ValidationError, + JSON::ParserError => error + raise ValidationError, + "license/NOTICE candidates reference invalid review results: #{error.message}" + end + unless manifest["status"] == STATUS && + manifest["candidateBundleIndexComplete"] == true && + manifest["candidatePayloadCommitted"] == false && + manifest["engineeringReviewApproved"] == false && + manifest["legalReviewApproved"] == false && + manifest["redistributionApproved"] == false + raise ValidationError, + "license/NOTICE candidates do not preserve the open release gates" + end + + remote_payloads = manifest["remotePayloads"] + unless remote_payloads.is_a?(Array) && + remote_payloads.length == EXPECTED_REMOTE_PAYLOAD_FILES + raise ValidationError, + "remotePayloads must contain exactly " \ + "#{EXPECTED_REMOTE_PAYLOAD_FILES} entries" + end + cache_keys = [] + remote_output_paths = [] + remote_payloads.each_with_index do |payload, offset| + cache_key, output_path = + validate_remote_payload(payload, offset, allow_file_urls) + cache_keys << cache_key + remote_output_paths << output_path + end + unless cache_keys.uniq.length == cache_keys.length && + remote_output_paths.uniq.length == remote_output_paths.length + raise ValidationError, + "remote payload cache keys and output paths must be unique" + end + + open_results = result_sources.select do |source| + !source.fetch("remainingReviewItems").empty? + end + all_existing_paths = + result_sources.flat_map do |source| + source.fetch("candidateResults").map do |candidate| + candidate.fetch("outputPath") + end + end + existing_evidence = + license_review.fetch("sources").flat_map do |source| + source.fetch("candidateEvidence") + end.to_h do |candidate| + [ + candidate.fetch("outputPath"), + { + "byteCount" => candidate.fetch("byteCount"), + "sha256" => candidate.fetch("sha256") + } + ] + end + sources = manifest["sources"] + unless open_results.map { |source| source.fetch("sourceOrigin") } == + EXPECTED_OPEN_SOURCE_ORIGINS && + sources.is_a?(Array) && + sources.length == EXPECTED_OPEN_SOURCE_ORIGINS.length + raise ValidationError, + "license/NOTICE candidates must cover every open source origin" + end + + license_paths_by_identifier = {} + package_remote_paths = Hash.new { |origins, origin| origins[origin] = [] } + remote_payloads.each do |payload| + if payload["kind"] == "spdx-license-text" + payload.fetch("spdxIdentifiers").each do |identifier| + if license_paths_by_identifier.key?(identifier) + raise ValidationError, + "duplicate SPDX identifier mapping: #{identifier}" + end + license_paths_by_identifier[identifier] = payload.fetch("outputPath") + end + else + package_remote_paths[payload.fetch("sourceOrigin")] << + payload.fetch("outputPath") + end + end + + used_existing_paths = [] + used_aports_paths = [] + used_remote_paths = [] + used_license_paths = [] + sources.each_with_index do |source, offset| + expected = open_results.fetch(offset) + origin = expected.fetch("sourceOrigin") + source = require_hash(source, "sources[#{offset}]") + unless source.keys.sort == SOURCE_KEYS.sort + raise ValidationError, "candidate source #{origin} has unexpected fields" + end + unless source["sourceOrigin"] == origin && + source["declaredLicenseExpressions"] == + expected["declaredLicenseExpressions"] && + source["remainingReviewItems"] == expected["remainingReviewItems"] && + source["candidateState"] == CANDIDATE_STATE + raise ValidationError, + "candidate source metadata does not match #{origin}" + end + + existing_paths = require_safe_paths( + source["existingEvidencePaths"], + "existingEvidencePaths for #{origin}" + ) + expected_existing = + expected.fetch("candidateResults") + .map { |candidate| candidate.fetch("outputPath") } + .sort + unless existing_paths.sort == expected_existing + raise ValidationError, + "existing evidence paths do not match review results for #{origin}" + end + + reference_paths = require_safe_paths( + source["referenceLicensePaths"], + "referenceLicensePaths for #{origin}" + ) + identifiers = + identifiers_in_expressions(source.fetch("declaredLicenseExpressions")) + expected_reference_paths = identifiers.map do |identifier| + license_paths_by_identifier.fetch(identifier) do + raise ValidationError, + "no reference license text is indexed for #{identifier}" + end + end.uniq.sort + unless reference_paths.sort == expected_reference_paths + raise ValidationError, + "reference license paths do not cover declarations for #{origin}" + end + + aports_paths = require_safe_paths( + source["supplementalAportsPaths"], + "supplementalAportsPaths for #{origin}", + allow_empty: true + ) + unless aports_paths.all? { |path| path.start_with?("aports/#{origin}/") } + raise ValidationError, + "supplemental aports paths do not match #{origin}" + end + + remote_paths = require_safe_paths( + source["remoteEvidencePaths"], + "remoteEvidencePaths for #{origin}", + allow_empty: true + ) + unless remote_paths.sort == package_remote_paths.fetch(origin, []).sort + raise ValidationError, + "remote evidence paths do not match #{origin}" + end + + used_existing_paths.concat(existing_paths) + used_aports_paths.concat(aports_paths) + used_remote_paths.concat(remote_paths) + used_license_paths.concat(reference_paths) + end + + unless used_existing_paths.uniq.length == used_existing_paths.length && + all_existing_paths.length == EXPECTED_EXISTING_EVIDENCE_FILES && + used_aports_paths.uniq.length == used_aports_paths.length && + used_aports_paths.length == EXPECTED_APORTS_FILES && + used_remote_paths.sort == + package_remote_paths.values.flatten.sort && + used_license_paths.uniq.sort == license_paths_by_identifier.values.uniq.sort + raise ValidationError, + "license/NOTICE candidate path coverage is incomplete or duplicated" + end + materialized_paths = + all_existing_paths + + used_aports_paths.map { |path| "supplemental/#{path}" } + + remote_output_paths + if materialized_paths.combination(2).any? do |left, right| + paths_conflict?(left, right) + end + raise ValidationError, + "license/NOTICE candidate output paths overlap" + end + + { + sources: sources, + remote_payloads: remote_payloads, + existing_evidence_paths: all_existing_paths, + existing_evidence: existing_evidence, + aports_paths: used_aports_paths + } + rescue KeyError => error + raise ValidationError, + "license/NOTICE candidates are incomplete: #{error.message}" + end + + def resolve_manifest_paths(arguments) + unless arguments.length <= 5 + raise ValidationError, + "usage: rootfs-license-notice-candidates.rb " \ + "[CANDIDATES RESULTS REVIEW SOURCE_ACQUISITION SOURCE_INVENTORY]" + end + directory = + Pathname( + arguments.fetch( + 0, + "Compliance/RootFS/v0.3.3/LICENSE-NOTICE-CANDIDATES.json" + ) + ).dirname + + [ + arguments.fetch( + 0, + directory.join("LICENSE-NOTICE-CANDIDATES.json").to_s + ), + arguments.fetch(1, directory.join("LICENSE-REVIEW-RESULTS.json").to_s), + arguments.fetch(2, directory.join("LICENSE-REVIEW.json").to_s), + arguments.fetch(3, directory.join("SOURCE-ACQUISITION.json").to_s), + arguments.fetch(4, directory.join("SOURCE-INVENTORY.json").to_s) + ] + end +end + +if $PROGRAM_NAME == __FILE__ + begin + candidate_path, + results_path, + review_path, + source_acquisition_path, + source_inventory_path = + RootFSLicenseNoticeCandidates.resolve_manifest_paths(ARGV) + results_bytes = Pathname(results_path).binread + 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 + validated = RootFSLicenseNoticeCandidates.validate_manifest( + RootFSLicenseNoticeCandidates.load_json( + candidate_path, + "license/NOTICE candidate manifest" + ), + JSON.parse(results_bytes), + license_review: JSON.parse(review_bytes), + source_acquisition: source_acquisition, + source_inventory: + RootFSLicenseNoticeCandidates.load_json( + source_inventory_path, + "source inventory" + ), + results_bytes: results_bytes, + license_review_bytes: review_bytes, + source_acquisition_bytes: source_acquisition_bytes, + allow_file_urls: allow_file_urls + ) + puts "RootFS license/NOTICE candidate manifest is valid " \ + "(#{validated.fetch(:sources).length} open source origins, " \ + "#{validated.fetch(:remote_payloads).length} remote payloads, " \ + "#{validated.fetch(:aports_paths).length} aports files)." + rescue RootFSLicenseNoticeCandidates::ValidationError, + JSON::ParserError, SystemCallError => error + warn error.message + exit 1 + end +end diff --git a/Tests/Scripts/RootFSLicenseNoticeBundleTests.rb b/Tests/Scripts/RootFSLicenseNoticeBundleTests.rb new file mode 100644 index 0000000..9b229c6 --- /dev/null +++ b/Tests/Scripts/RootFSLicenseNoticeBundleTests.rb @@ -0,0 +1,172 @@ +#!/usr/bin/env ruby + +require "digest" +require "minitest/autorun" +require "pathname" +require "tmpdir" +require_relative "../../Scripts/prepare-rootfs-license-notice-bundle" + +class RootFSLicenseNoticeBundleTests < Minitest::Test + def test_reads_and_verifies_cached_remote_payload + Dir.mktmpdir("rootfs-notice-cache") do |directory| + cache = Pathname(directory) + contents = "pinned license text\n" + cache.join("license.txt").binwrite(contents) + payload = remote_payload(contents) + + actual = + RootFSLicenseNoticeBundle.fetch_remote_payload( + payload, + cache, + false + ) + + assert_equal contents, actual + end + end + + def test_rejects_cached_remote_payload_digest_drift + Dir.mktmpdir("rootfs-notice-cache") do |directory| + cache = Pathname(directory) + cache.join("license.txt").binwrite("changed\n") + payload = remote_payload("expected\n") + + error = assert_raises( + RootFSLicenseNoticeBundle::BundleError + ) do + RootFSLicenseNoticeBundle.fetch_remote_payload( + payload, + cache, + false + ) + end + + assert_includes error.message, "byte count mismatch" + end + end + + def test_streams_https_response_instead_of_using_request_return_value + contents = "downloaded license\n" + response = Net::HTTPOK.new("1.1", "200", "OK") + response["content-length"] = contents.bytesize.to_s + response.define_singleton_method(:read_body) do |&block| + block.call(contents) + end + http = Object.new + http.define_singleton_method(:request) do |_request, &block| + block.call(response) + response + end + + with_stubbed_http_start(http) do + actual = + RootFSLicenseNoticeBundle.download_https( + "https://example.invalid/license.txt", + 1_024 + ) + + assert_equal contents, actual + end + end + + def test_retries_next_url_after_http_connection_exception + contents = "fallback license\n" + response = Net::HTTPOK.new("1.1", "200", "OK") + response["content-length"] = contents.bytesize.to_s + response.define_singleton_method(:read_body) do |&block| + block.call(contents) + end + http = Object.new + http.define_singleton_method(:request) do |_request, &block| + block.call(response) + response + end + forbidden = Net::HTTPForbidden.new("1.1", "403", "Forbidden") + connection_error = + Net::HTTPClientException.new("proxy rejected CONNECT", forbidden) + starts = 0 + original = Net::HTTP.method(:start) + Net::HTTP.singleton_class.send(:define_method, :start) do |*_args, **_options, &block| + starts += 1 + raise connection_error if starts == 1 + + block.call(http) + end + payload = remote_payload(contents) + payload["retrievalURLs"] = [ + "https://first.invalid/license.txt", + "https://second.invalid/license.txt" + ] + + actual = + RootFSLicenseNoticeBundle.fetch_remote_payload( + payload, + nil, + false + ) + + assert_equal contents, actual + assert_equal 2, starts + ensure + Net::HTTP.singleton_class.send(:define_method, :start, original) if original + end + + def test_materialize_and_verify_rejects_path_drift + Dir.mktmpdir("rootfs-notice-output") do |directory| + parent = Pathname(directory) + output = parent.join("bundle") + payloads = { + "licenses/MIT.txt" => "license\n", + "NOTICE-CANDIDATES.md" => "notice\n" + } + RootFSLicenseNoticeBundle.materialize(output, payloads) + RootFSLicenseNoticeBundle.verify_bundle(output, payloads) + + output.join("extra").binwrite("unexpected\n") + error = assert_raises( + RootFSLicenseNoticeBundle::BundleError + ) do + RootFSLicenseNoticeBundle.verify_bundle(output, payloads) + end + + assert_includes error.message, "path/type set" + end + end + + def test_verify_rejects_symlink + Dir.mktmpdir("rootfs-notice-output") do |directory| + root = Pathname(directory) + root.join("payload").make_symlink(root.join("missing")) + + error = assert_raises( + RootFSLicenseNoticeBundle::BundleError + ) do + RootFSLicenseNoticeBundle.actual_path_types(root) + end + + assert_includes error.message, "symlink" + end + end + + private + + def remote_payload(contents) + { + "cacheKey" => "license.txt", + "outputPath" => "licenses/license.txt", + "byteCount" => contents.bytesize, + "sha256" => Digest::SHA256.hexdigest(contents), + "retrievalURLs" => ["https://example.invalid/license.txt"] + } + end + + def with_stubbed_http_start(http) + original = Net::HTTP.method(:start) + Net::HTTP.singleton_class.send(:define_method, :start) do |*_args, **_options, &block| + block.call(http) + end + yield + ensure + Net::HTTP.singleton_class.send(:define_method, :start, original) + end +end diff --git a/Tests/Scripts/RootFSLicenseNoticeCandidatesTests.rb b/Tests/Scripts/RootFSLicenseNoticeCandidatesTests.rb new file mode 100644 index 0000000..30e5654 --- /dev/null +++ b/Tests/Scripts/RootFSLicenseNoticeCandidatesTests.rb @@ -0,0 +1,176 @@ +#!/usr/bin/env ruby + +require "json" +require "minitest/autorun" +require "pathname" +require_relative "../../Scripts/rootfs-license-notice-candidates" + +class RootFSLicenseNoticeCandidatesTests < Minitest::Test + ROOT = Pathname(__dir__).join("../..").realpath + DIRECTORY = ROOT.join("Compliance/RootFS/v0.3.3") + + def setup + @candidate_bytes = + DIRECTORY.join("LICENSE-NOTICE-CANDIDATES.json").binread + @candidate = JSON.parse(@candidate_bytes) + @results_bytes = + DIRECTORY.join("LICENSE-REVIEW-RESULTS.json").binread + @results = JSON.parse(@results_bytes) + @review_bytes = DIRECTORY.join("LICENSE-REVIEW.json").binread + @review = JSON.parse(@review_bytes) + @source_bytes = DIRECTORY.join("SOURCE-ACQUISITION.json").binread + @source = JSON.parse(@source_bytes) + @inventory = + JSON.parse(DIRECTORY.join("SOURCE-INVENTORY.json").binread) + end + + def test_validates_complete_open_origin_candidate_index + validated = validate + + assert_equal 8, validated.fetch(:sources).length + assert_equal 8, validated.fetch(:remote_payloads).length + assert_equal 21, validated.fetch(:existing_evidence_paths).length + assert_equal 46, validated.fetch(:aports_paths).length + end + + def test_rejects_review_results_digest_drift + @candidate["licenseReviewResultsSha256"] = "0" * 64 + + error = assert_raises( + RootFSLicenseNoticeCandidates::ValidationError + ) { validate } + + assert_includes error.message, "LICENSE-REVIEW-RESULTS.json bytes" + end + + def test_rejects_opening_release_gates + @candidate["engineeringReviewApproved"] = true + + error = assert_raises( + RootFSLicenseNoticeCandidates::ValidationError + ) { validate } + + assert_includes error.message, "open release gates" + end + + def test_rejects_missing_open_source_origin + @candidate.fetch("sources").pop + + error = assert_raises( + RootFSLicenseNoticeCandidates::ValidationError + ) { validate } + + assert_includes error.message, "every open source origin" + end + + def test_rejects_unsafe_supplemental_aports_path + @candidate.fetch("sources").fetch(2) + .fetch("supplementalAportsPaths")[0] = "../outside.patch" + + error = assert_raises( + RootFSLicenseNoticeCandidates::ValidationError + ) { validate } + + assert_includes error.message, "unsafe path" + end + + def test_rejects_missing_declared_license_reference + @candidate.fetch("sources").fetch(5) + .fetch("referenceLicensePaths").delete("licenses/BSD-2-Clause.txt") + + error = assert_raises( + RootFSLicenseNoticeCandidates::ValidationError + ) { validate } + + assert_includes error.message, "do not cover declarations" + end + + def test_rejects_duplicate_remote_output_path + payloads = @candidate.fetch("remotePayloads") + payloads.fetch(1)["outputPath"] = payloads.fetch(0).fetch("outputPath") + + error = assert_raises( + RootFSLicenseNoticeCandidates::ValidationError + ) { validate } + + assert_includes error.message, "must be unique" + end + + def test_rejects_incomplete_supplemental_aports_set + @candidate.fetch("sources").fetch(3) + .fetch("supplementalAportsPaths").pop + + error = assert_raises( + RootFSLicenseNoticeCandidates::ValidationError + ) { validate } + + assert_includes error.message, "coverage is incomplete" + end + + def test_rejects_remote_evidence_assigned_to_wrong_origin + @candidate.fetch("sources").first + .fetch("remoteEvidencePaths").clear + + error = assert_raises( + RootFSLicenseNoticeCandidates::ValidationError + ) { validate } + + assert_includes error.message, "remote evidence paths" + end + + def test_rejects_overlapping_materialized_output_paths + payloads = @candidate.fetch("remotePayloads") + payloads.fetch(0)["outputPath"] = "licenses/collision" + payloads.fetch(1)["outputPath"] = "licenses/collision/child" + @candidate.fetch("sources").fetch(5)["referenceLicensePaths"] + .map! do |path| + path == "licenses/BSD-2-Clause.txt" ? "licenses/collision/child" : path + end + @candidate.fetch("sources").fetch(6)["referenceLicensePaths"] = + ["licenses/collision"] + + error = assert_raises( + RootFSLicenseNoticeCandidates::ValidationError + ) { validate } + + assert_includes error.message, "output paths overlap" + end + + def test_rejects_package_payload_path_for_another_origin + payload = @candidate.fetch("remotePayloads").fetch(6) + payload["outputPath"] = "supplemental/pax-utils/openssl-README.md" + @candidate.fetch("sources").fetch(6)["remoteEvidencePaths"] = + [payload.fetch("outputPath")] + + error = assert_raises( + RootFSLicenseNoticeCandidates::ValidationError + ) { validate } + + assert_includes error.message, "under its source origin" + end + + def test_rejects_results_object_not_backed_by_supplied_bytes + @results["reviewedCandidateCount"] = 20 + + error = assert_raises( + RootFSLicenseNoticeCandidates::ValidationError + ) { validate } + + assert_includes error.message, "LICENSE-REVIEW-RESULTS.json bytes" + end + + private + + def validate + RootFSLicenseNoticeCandidates.validate_manifest( + @candidate, + @results, + license_review: @review, + source_acquisition: @source, + source_inventory: @inventory, + results_bytes: @results_bytes, + license_review_bytes: @review_bytes, + source_acquisition_bytes: @source_bytes + ) + end +end