修复浏览器缓存内部链接复制并冻结门禁候选(09-06-release-gate-integrity)
这个提交包含在:
@@ -7,6 +7,7 @@
|
|||||||
- `qa-stage-record.mjs` records full-local phases through failure cleanup. Keep plain shell calls under errexit: wrapping compound helpers in `if`/`||` can suppress their internal failures. Preserve the original failing exit code if evidence cleanup also fails.
|
- `qa-stage-record.mjs` records full-local phases through failure cleanup. Keep plain shell calls under errexit: wrapping compound helpers in `if`/`||` can suppress their internal failures. Preserve the original failing exit code if evidence cleanup also fails.
|
||||||
- Persist actual browser selection separately from discovery: exact derived spec arguments, supplementary arguments, mode, retries, forbid-only and worker count. Ordinary release verification rejects supplementary filters, shards, missing projects, changed budgets, skips, retries, expected failures and inconsistent statistics/time ranges.
|
- Persist actual browser selection separately from discovery: exact derived spec arguments, supplementary arguments, mode, retries, forbid-only and worker count. Ordinary release verification rejects supplementary filters, shards, missing projects, changed budgets, skips, retries, expected failures and inconsistent statistics/time ranges.
|
||||||
- Native environment probes retain separate Chromium/WebKit observations even on launch or cleanup failure. Record the failing stage and allowlisted error category, never raw browser messages, endpoints or paths. Cleanup failures append without replacing the primary cause; every failure still rejects the environment. A Chromium trace cannot diagnose WebKit. A standalone blank-page diagnostic is not a managed gate receipt or evidence of business coverage.
|
- Native environment probes retain separate Chromium/WebKit observations even on launch or cleanup failure. Record the failing stage and allowlisted error category, never raw browser messages, endpoints or paths. Cleanup failures append without replacing the primary cause; every failure still rejects the environment. A Chromium trace cannot diagnose WebKit. A standalone blank-page diagnostic is not a managed gate receipt or evidence of business coverage.
|
||||||
|
- Prepare owned browser caches with `python3 scripts/qa/copy-browser-cache.py <source> <new-destination> <new-receipt>`. Use an explicitly selected physical cache directory. Preserve internal relative links to regular files; reject absolute, external, broken, cyclic or directory links and special files. Verify source before/after, copied bytes/modes/link structure and independent file inodes; matching expanded bytes alone is insufficient for native library aliases. Never flatten an entire bundle or execute from a historical shared cache. Keep Unix-socket runtime roots short and private; evidence directories need not be runtime directories. Copy receipts confer no release eligibility.
|
||||||
- A dirty development tree may have `localChecksPassed=true`, but returns PARTIAL/exit 2 and `deploymentEligible=false`. Offline `verify` re-reads and hashes evidence and recomputes against the current checkout. Local proof does not discharge the 12 same-candidate public obligations, independent reproduction, or operator approval.
|
- A dirty development tree may have `localChecksPassed=true`, but returns PARTIAL/exit 2 and `deploymentEligible=false`. Offline `verify` re-reads and hashes evidence and recomputes against the current checkout. Local proof does not discharge the 12 same-candidate public obligations, independent reproduction, or operator approval.
|
||||||
- Existing project-wide skip flags accept only exact 0/1. Each or both require nonempty reason/operator/approval reference, persist affected stages, and end PARTIAL/exit 2. Other release modes reject them before stage side effects. These metadata do not grant authorization; no approval time is invented.
|
- Existing project-wide skip flags accept only exact 0/1. Each or both require nonempty reason/operator/approval reference, persist affected stages, and end PARTIAL/exit 2. Other release modes reject them before stage side effects. These metadata do not grant authorization; no approval time is invented.
|
||||||
- Validate with pure receipt counterexamples, actual shell/fake-tool invocation, source mutations, and existing deployment lifecycle/safety regressions. Fake tools must intercept every external action. Evidence is auditable, not a cryptographic signature.
|
- Validate with pure receipt counterexamples, actual shell/fake-tool invocation, source mutations, and existing deployment lifecycle/safety regressions. Fake tools must intercept every external action. Evidence is auditable, not a cryptographic signature.
|
||||||
|
|||||||
@@ -1,3 +1,19 @@
|
|||||||
|
## 2026-09-19 修正后最终候选单次启动
|
||||||
|
|
||||||
|
新ignored启动器固定最终commit+clean和运行输入摘要;唯一源为三组对照C保留cache,源结构/字节/模式/131条内部链接指纹冻结。调用已独立测试的copy-browser-cache.py生成本轮新副本与独立复制回执,不从历史缓存执行。用已审comparison execute_case函数做一次双引擎先验,只导入函数、不运行三组main/旧marker;新副本及原无外网namespace拓扑,失败即停。
|
||||||
|
|
||||||
|
先验成功并确认清理/源码仍clean后,只调用一次原bash scripts/qa/run-release-gate.sh verify-candidate;不改static、12个full-local阶段、所有预算/worker/零重试或分层归属。原门禁内native与guest仍独立运行;先验只防环境前置失败浪费完整启动成本。runtime使用短私有HOME/TMP/XDG及空npm/Docker配置、合成静态DATABASE_URL;managed runner保留原白名单(转发HOME,其TMP/XDG仍采用现有默认/私有HOME派生),不在本轮修改runner。
|
||||||
|
|
||||||
|
整体只有本轮新marker,先验和完整门禁各最多一次,旧marker不动。日志脱敏,原始浏览器stderr仅安全分类;运行结束只离线verify读证据。源前后指纹/代码输入/clean与短临时目录清理必须核对;本轮浏览器副本、失败和回执全部保留。新wrapper先由Claude只读审,再启动;不重复12或30/7单测。真实缓存复制、先验、完整门禁结果均尚未运行,不预填通过。
|
||||||
|
|
||||||
|
## 2026-09-19 安全内部链接复制工具
|
||||||
|
|
||||||
|
新增scripts/qa/copy-browser-cache.py及一份Node合成CLI测试,调用Python3标准库,不新增依赖。source必须是已明确选择的物理缓存目录(本候选后续选成功C组保留的cache);拒绝顶层目录链接及包内外部/目录链接,因此不会盲跟旧总缓存里指向用户共享缓存的Chromium目录别名。普通文件使用独占目标/no-follow源描述符复制并保留mode/mtime,包内相对file symlink保留;同目标别名仍在本副本内共用目标文件,源目标所有常规文件inode独立。
|
||||||
|
|
||||||
|
先完整枚举结构/权限/摘要,校验目标不与源交叠、目标/回执不存在;复制后再次比较源与目标结构/字节/模式,所有源目标file inode不同。错误只出稳定码,失败复制品保留,不删除/覆盖旧材料。回执包含安全相对清单/摘要及实际核验布尔,不含绝对路径或原始错误。此工具仅准备缓存,不能签发发布资格。
|
||||||
|
|
||||||
|
新启动准备代码复用此正式工具,源为对照C组保留的完整缓存,不从已知物化坏副本推测恢复链接;无新增下载。原copytree物化启动器保持历史证据,不修改或重启。Claude本批仅运行新CLI测试和治理/格式并只读审查;回收通过后Codex按既有授权本地提交、冻结SHA,再制定实际一次完整门禁启动请求(含便宜的双引擎先验,失败不进昂贵阶段)。不重复空白页三组对照。
|
||||||
|
|
||||||
## 2026-09-19 WebKit固定三组对照(本地根因诊断)
|
## 2026-09-19 WebKit固定三组对照(本地根因诊断)
|
||||||
|
|
||||||
原30项探针测试与补充7项环境测试覆盖同一未改代码,独立复核通过,现有观测补丁可按既有授权先本地提交。真实native诊断仍FAIL,绝不把观测修正当成浏览器修复。
|
原30项探针测试与补充7项环境测试覆盖同一未改代码,独立复核通过,现有观测补丁可按既有授权先本地提交。真实native诊断仍FAIL,绝不把观测修正当成浏览器修复。
|
||||||
|
|||||||
@@ -1,3 +1,27 @@
|
|||||||
|
## 2026-09-19 缓存修正12/12及独立复核通过:提交最终候选并交Claude单次门禁(当前)
|
||||||
|
|
||||||
|
已核原报告/单测日志及7份after、14输入摘要,12passed/0failed/0skipped、exit0;只读复核无问题。复制工具、测试、spec保持已审字节,仅本任务四份接收/执行记录更新。真实缓存复制尚未验证,不因合成通过宣称发布资格。
|
||||||
|
|
||||||
|
- [x] 接收复制工具定向PASS及独立PASS,保留旧失败。
|
||||||
|
- [x] 决定按用户既有明确授权保存本批7路径本地提交;实际SHA/clean以final-candidate/local-commit.json为准。
|
||||||
|
- [ ] Claude只读审新启动器,执行新副本复制与一次双引擎先验;成功后直接接原完整verify-candidate,不再等额外确认。
|
||||||
|
- [ ] 收到完整门禁/离线verify/owner清理及源码前后一致证据后,判断本地资格;公网、推送部署及真实业务仍按独立授权边界处理。
|
||||||
|
|
||||||
|
启动说明:artifacts/qa-governance/r1-enforcement-20260919/cache-copy-20260919/final-candidate/claude-request.md。主会话只静态检查与本地提交,不运行测试/浏览器/门禁。冻结期间包括task记录也不再编辑,结果写ignored目录。无下载、生产ARM、真实配置密钥备份或旧恢复。保留旧整轮FAIL、37项FAIL、registry FAIL、CSV NOT_RUN、docs22提示及12公网义务;当前deploymentEligible=false。
|
||||||
|
|
||||||
|
## 2026-09-19 A/B失败、C通过:实施缓存复制修正(当前)
|
||||||
|
|
||||||
|
已接收三组完整对照:A213字节与B28字节全物化均WebKit page失败/code134/abort+assertion且清理前退出;C28字节恢复131内部链接后双引擎通过。真实wrapper exit1;后台外壳exit0不能抵扣。三组缓存均实际保留(A原地保存,summary中false非实际缺失)。原源码/46输入/缓存/链接/marker不变。证据见cache-copy-20260919/comparison-reception.json。
|
||||||
|
|
||||||
|
- [x] 确定保留内部file链接、拒绝外部/目录链接的新复制合同,保留所有原失败。
|
||||||
|
- [x] 实施正式复制工具及合成CLI边界测试、现有规约;待下列静态检查与Claude运行结果。
|
||||||
|
- [ ] Claude仅新复制工具测试、治理/格式及增量只读复核;不重跑探针30/7、浏览器矩阵或业务全量。
|
||||||
|
- [ ] 通过后本地提交最终候选、冻结整树,安排一次正确缓存复制+先验+原完整门禁;本次不启动。
|
||||||
|
|
||||||
|
交接:artifacts/qa-governance/r1-enforcement-20260919/cache-copy-20260919/claude-request.md。仅新工具一份Node合成测试,源码预期12项不冒作已运行计数;不启动浏览器/DB/完整门禁。
|
||||||
|
|
||||||
|
观测修正已提交6e57d337(8路径、clean),本批以此为基线。选择已保留的成功C缓存作为后续明确源目录,独立复制而非共享执行;源缓存及其旧结果不修改。无推送部署/生产ARM/真实配置密钥备份访问,无新下载,deploymentEligible=false。旧整轮FAIL、37项FAIL、registry FAIL、CSV NOT_RUN、docs22提示及12公网义务保持。
|
||||||
|
|
||||||
## 2026-09-19 观测补丁定向通过:本地提交,Claude固定三组定位WebKit(当前)
|
## 2026-09-19 观测补丁定向通过:本地提交,Claude固定三组定位WebKit(当前)
|
||||||
|
|
||||||
已实核34条输入无漂移。环境文件短TMPDIR补验7/7,原探针文件30项通过、源码未改;独立审查与治理/格式通过。原37项FAIL保留,不说同轮37通过。真实诊断约12.6秒、exit1:Chromium145.0.7632.6在线通过;WebKit26.0在page阶段报browser_process_closed,trace_stop追加同类;拓扑/namespace/清理及源缓存完整性通过。未采集真实父进程退出事件,根因仍UNKNOWN。
|
已实核34条输入无漂移。环境文件短TMPDIR补验7/7,原探针文件30项通过、源码未改;独立审查与治理/格式通过。原37项FAIL保留,不说同轮37通过。真实诊断约12.6秒、exit1:Chromium145.0.7632.6在线通过;WebKit26.0在page阶段报browser_process_closed,trace_stop追加同类;拓扑/namespace/清理及源缓存完整性通过。未采集真实父进程退出事件,根因仍UNKNOWN。
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
## 2026-09-19 缓存修正定向验收通过,冻结最终本地候选
|
||||||
|
|
||||||
|
复制工具12/12合成测试及独立只读复核通过,7份增量和14条输入实算一致。按既有授权本地提交该补丁;随后Claude执行一次“独占保留内部链接复制→双引擎空白页先验→原verify-candidate完整门禁”。先验失败不得进入昂贵阶段;先验不能抵扣原门禁内native/guest或业务阶段。原失败、所有旧marker和12公网等外部义务不变。无推送部署或真实业务授权。
|
||||||
|
|
||||||
|
## 2026-09-19 修正本地浏览器缓存复制合同
|
||||||
|
|
||||||
|
三组对照A/B物化副本均WebKit page失败、真实父进程code134且清理前退出;等长路径C保留131个内部链接后双引擎通过。支持当前条件下内部链接结构相关,不把历史根因唯一性或完整门禁写成已证明。
|
||||||
|
|
||||||
|
本批实现可复用的本地缓存复制工具:普通文件独立复制,内部相对且最终指向本缓存常规文件的链接保持原样;外部/绝对/悬空/循环/目录链接和特殊文件拒绝。比较源前后、目标字节/模式/链接结构及源目标inode独立,失败拒绝、不覆盖既有目标或证据。后续启动器统一调用它,不能再用symlinks=False物化整个包。工具不启动浏览器、联网、下载安装或访问真实配置。
|
||||||
|
|
||||||
|
只验证新复制工具的合成边界,不重复已通过30/7探针测试。独立通过后保存最终候选,再安排一次完整门禁;本次不启动完整门禁。新完整尝试须用成功C组缓存的独占复制、短私有运行目录及原全部门禁要求,原失败/marker/外部义务保留。
|
||||||
|
|
||||||
## 2026-09-19 native观测补丁已定向验收,WebKit根因继续本地诊断
|
## 2026-09-19 native观测补丁已定向验收,WebKit根因继续本地诊断
|
||||||
|
|
||||||
探针文件原30项通过,环境文件短TMPDIR补充7/7通过;补丁独立复核及治理/格式通过。旧37项FAIL不改写,非同次37全绿。真实双引擎诊断Chromium通过、WebKit在page阶段报browser_process_closed,底层父进程是否退出及原因未采集,不能仅凭类别断定系统进程退出原因。
|
探针文件原30项通过,环境文件短TMPDIR补充7/7通过;补丁独立复核及治理/格式通过。旧37项FAIL不改写,非同次37全绿。真实双引擎诊断Chromium通过、WebKit在page阶段报browser_process_closed,底层父进程是否退出及原因未采集,不能仅凭类别断定系统进程退出原因。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "release-gate-integrity",
|
"id": "release-gate-integrity",
|
||||||
"name": "release-gate-integrity",
|
"name": "release-gate-integrity",
|
||||||
"title": "部署门禁完整性与候选验收回执",
|
"title": "部署门禁完整性与候选验收回执",
|
||||||
"description": "native观测修正已定向验证及独立复核;按授权本地提交。真实WebKit在page阶段失败,根因待固定三组本地对照,完整门禁未通过。",
|
"description": "浏览器缓存复制12/12及独立复核通过,按授权本地提交最终候选;待一次正确复制、双引擎先验及原完整门禁。无推送部署授权。",
|
||||||
"status": "in_progress",
|
"status": "in_progress",
|
||||||
"dev_type": null,
|
"dev_type": null,
|
||||||
"scope": "release-gate",
|
"scope": "release-gate",
|
||||||
@@ -226,7 +226,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nativeProbeRepair": {
|
"nativeProbeRepair": {
|
||||||
"status": "SCOPED_VERIFIED_READY_FOR_LOCAL_COMMIT",
|
"status": "COMMITTED_SCOPED_VERIFIED",
|
||||||
"failureReception": "artifacts/qa-governance/r1-enforcement-20260919/native-probe-20260919/failure-reception.json",
|
"failureReception": "artifacts/qa-governance/r1-enforcement-20260919/native-probe-20260919/failure-reception.json",
|
||||||
"historicalGateStatus": "FAIL",
|
"historicalGateStatus": "FAIL",
|
||||||
"fullLocalStagesRun": 0,
|
"fullLocalStagesRun": 0,
|
||||||
@@ -257,16 +257,54 @@
|
|||||||
"probePriorPassed": 30,
|
"probePriorPassed": 30,
|
||||||
"environmentSupplementalPassed": 7,
|
"environmentSupplementalPassed": 7,
|
||||||
"singleRun37Pass": false
|
"singleRun37Pass": false
|
||||||
}
|
},
|
||||||
|
"localCommit": "6e57d337157753efb8eb82a1dd725ffa33544f1a"
|
||||||
},
|
},
|
||||||
"webkitComparison": {
|
"webkitComparison": {
|
||||||
"status": "PLANNED",
|
"status": "COMPLETE_A_FAIL_B_FAIL_C_PASS",
|
||||||
"cases": [
|
"cases": [
|
||||||
"deep-materialized",
|
"deep-materialized",
|
||||||
"short-materialized",
|
"short-materialized",
|
||||||
"short-internal-links"
|
"short-internal-links"
|
||||||
],
|
],
|
||||||
"maxAttemptsPerCase": 1,
|
"maxAttemptsPerCase": 1,
|
||||||
|
"deploymentEligible": false,
|
||||||
|
"caseExits": [
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"conclusion": "INTERNAL_LINK_STRUCTURE_RELATED",
|
||||||
|
"historyUniqueRootCauseProven": false
|
||||||
|
},
|
||||||
|
"browserCacheCopy": {
|
||||||
|
"status": "SCOPED_VERIFIED_LOCAL_COMMIT_AND_FINAL_GATE_HANDOFF",
|
||||||
|
"evidence": "artifacts/qa-governance/r1-enforcement-20260919/cache-copy-20260919",
|
||||||
|
"testExecutor": "Claude",
|
||||||
|
"fullGateStarted": false,
|
||||||
|
"deploymentEligible": false,
|
||||||
|
"executionFile": "scripts/qa/copy-browser-cache.py",
|
||||||
|
"testFile": "scripts/qa/tests/browser-cache-copy.test.mjs",
|
||||||
|
"handoff": "artifacts/qa-governance/r1-enforcement-20260919/cache-copy-20260919/claude-request.md",
|
||||||
|
"testsRunByCodex": false,
|
||||||
|
"testResult": {
|
||||||
|
"passed": 12,
|
||||||
|
"failed": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
"exitCode": 0
|
||||||
|
},
|
||||||
|
"independentReview": "PASS",
|
||||||
|
"reception": "artifacts/qa-governance/r1-enforcement-20260919/cache-copy-20260919/final-candidate/review-reception.json",
|
||||||
|
"nextHandoff": "artifacts/qa-governance/r1-enforcement-20260919/cache-copy-20260919/final-candidate/claude-request.md",
|
||||||
|
"localCommitReceipt": "artifacts/qa-governance/r1-enforcement-20260919/cache-copy-20260919/final-candidate/local-commit.json"
|
||||||
|
},
|
||||||
|
"finalLinkedCacheGate": {
|
||||||
|
"status": "PLANNED_NOT_RUN",
|
||||||
|
"copyAndNativePreflight": "REQUIRED_BEFORE_ORIGINAL_FULL_GATE",
|
||||||
|
"maxCompleteAttempts": 1,
|
||||||
|
"maxNativePreflightAttempts": 1,
|
||||||
|
"pushAuthorized": false,
|
||||||
|
"deploymentAuthorized": false,
|
||||||
"deploymentEligible": false
|
"deploymentEligible": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Copy a frozen local browser cache without flattening internal file aliases."""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
class CacheCopyError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def regular_reader(file):
|
||||||
|
descriptor = os.open(file, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||||
|
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
||||||
|
os.close(descriptor)
|
||||||
|
raise CacheCopyError('special_file')
|
||||||
|
return os.fdopen(descriptor, 'rb')
|
||||||
|
|
||||||
|
|
||||||
|
def file_digest(file):
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with regular_reader(file) as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(source):
|
||||||
|
source = Path(source)
|
||||||
|
if source.is_symlink() or not source.is_dir():
|
||||||
|
raise CacheCopyError('source_not_physical_directory')
|
||||||
|
root = source.resolve(strict=True)
|
||||||
|
entries = {}
|
||||||
|
def walk_error(_error):
|
||||||
|
raise CacheCopyError('source_unreadable')
|
||||||
|
# Classify and validate every link before opening any linked file content.
|
||||||
|
for parent, directories, files in os.walk(root, followlinks=False, onerror=walk_error):
|
||||||
|
for name in sorted(directories + files):
|
||||||
|
file = Path(parent) / name
|
||||||
|
mode = file.lstat().st_mode
|
||||||
|
relative = file.relative_to(root).as_posix()
|
||||||
|
if stat.S_ISLNK(mode):
|
||||||
|
target = os.readlink(file)
|
||||||
|
if os.path.isabs(target):
|
||||||
|
raise CacheCopyError('absolute_link')
|
||||||
|
try:
|
||||||
|
resolved = file.resolve(strict=True)
|
||||||
|
except (OSError, RuntimeError):
|
||||||
|
raise CacheCopyError('broken_or_cyclic_link') from None
|
||||||
|
if not resolved.is_relative_to(root):
|
||||||
|
raise CacheCopyError('external_link')
|
||||||
|
if not resolved.is_file():
|
||||||
|
raise CacheCopyError('link_target_not_regular_file')
|
||||||
|
entries[relative] = {'kind': 'link', 'target': target, 'resolved': resolved.relative_to(root).as_posix()}
|
||||||
|
elif stat.S_ISDIR(mode):
|
||||||
|
entries[relative] = {'kind': 'directory', 'mode': stat.S_IMODE(mode)}
|
||||||
|
elif stat.S_ISREG(mode):
|
||||||
|
entries[relative] = {'kind': 'file', 'mode': stat.S_IMODE(mode), 'size': file.stat().st_size}
|
||||||
|
else:
|
||||||
|
raise CacheCopyError('special_file')
|
||||||
|
for name, entry in entries.items():
|
||||||
|
if entry['kind'] == 'file':
|
||||||
|
entry['sha256'] = file_digest(root / name)
|
||||||
|
for entry in entries.values():
|
||||||
|
if entry['kind'] == 'link':
|
||||||
|
target = entries.get(entry['resolved'])
|
||||||
|
if not target or target['kind'] != 'file':
|
||||||
|
raise CacheCopyError('link_target_not_in_manifest')
|
||||||
|
return {'rootMode': stat.S_IMODE(root.stat().st_mode), 'entries': dict(sorted(entries.items()))}
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_digest(value):
|
||||||
|
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def check_paths(source, destination):
|
||||||
|
if source.is_symlink() or not source.is_dir():
|
||||||
|
raise CacheCopyError('source_not_physical_directory')
|
||||||
|
source_root = source.resolve(strict=True)
|
||||||
|
target_root = destination.resolve(strict=False)
|
||||||
|
if target_root.is_relative_to(source_root) or source_root.is_relative_to(target_root):
|
||||||
|
raise CacheCopyError('overlapping_directories')
|
||||||
|
if os.path.lexists(destination):
|
||||||
|
raise CacheCopyError('destination_exists')
|
||||||
|
if not destination.parent.is_dir():
|
||||||
|
raise CacheCopyError('destination_parent_missing')
|
||||||
|
return source_root, target_root
|
||||||
|
|
||||||
|
|
||||||
|
def copy_regular(source, destination):
|
||||||
|
# Exclusive destination and no-follow source: never turn a file alias into
|
||||||
|
# an extra loaded library, or reuse a pre-existing destination inode.
|
||||||
|
with regular_reader(source) as reader, destination.open('xb') as writer:
|
||||||
|
metadata = os.fstat(reader.fileno())
|
||||||
|
shutil.copyfileobj(reader, writer)
|
||||||
|
os.fchmod(writer.fileno(), stat.S_IMODE(metadata.st_mode))
|
||||||
|
os.utime(destination, ns=(metadata.st_atime_ns, metadata.st_mtime_ns), follow_symlinks=False)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_cache(source, destination):
|
||||||
|
source, destination = check_paths(Path(source), Path(destination))
|
||||||
|
before = snapshot(source)
|
||||||
|
destination.mkdir(mode=0o700)
|
||||||
|
directories = [name for name, entry in before['entries'].items() if entry['kind'] == 'directory']
|
||||||
|
for name in sorted(directories, key=lambda value: (value.count('/'), value)):
|
||||||
|
(destination / name).mkdir(mode=0o700)
|
||||||
|
for name, entry in before['entries'].items():
|
||||||
|
if entry['kind'] == 'file':
|
||||||
|
copy_regular(source / name, destination / name)
|
||||||
|
for name, entry in before['entries'].items():
|
||||||
|
if entry['kind'] == 'link':
|
||||||
|
(destination / name).symlink_to(entry['target'])
|
||||||
|
for name in reversed(sorted(directories, key=lambda value: (value.count('/'), value))):
|
||||||
|
(destination / name).chmod(before['entries'][name]['mode'])
|
||||||
|
destination.chmod(before['rootMode'])
|
||||||
|
after = snapshot(source)
|
||||||
|
copied = snapshot(destination)
|
||||||
|
if before != after:
|
||||||
|
raise CacheCopyError('source_changed')
|
||||||
|
if copied != before:
|
||||||
|
raise CacheCopyError('copy_identity_mismatch')
|
||||||
|
regular = [name for name, entry in before['entries'].items() if entry['kind'] == 'file']
|
||||||
|
source_inodes = {(entry.st_dev, entry.st_ino) for entry in ((source / name).stat() for name in regular)}
|
||||||
|
if any((entry.st_dev, entry.st_ino) in source_inodes for entry in ((destination / name).stat() for name in regular)):
|
||||||
|
raise CacheCopyError('source_inode_shared')
|
||||||
|
return {
|
||||||
|
'schemaVersion': 1, 'status': 'PASS', 'scope': 'LOCAL_BROWSER_CACHE_COPY_ONLY',
|
||||||
|
'sourceUnchanged': True, 'sourceInodesIndependent': True, 'bytesModesAndLinksMatch': True,
|
||||||
|
'regularFiles': len(regular), 'internalFileLinks': sum(entry['kind'] == 'link' for entry in before['entries'].values()),
|
||||||
|
'sourceBeforeSha256': snapshot_digest(before), 'sourceAfterSha256': snapshot_digest(after),
|
||||||
|
'destinationSha256': snapshot_digest(copied), 'manifest': before, 'deploymentEligible': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(arguments):
|
||||||
|
if len(arguments) != 3:
|
||||||
|
raise CacheCopyError('usage_source_destination_receipt_required')
|
||||||
|
source, destination, receipt = map(Path, arguments)
|
||||||
|
source_root, target_root = check_paths(source, destination)
|
||||||
|
receipt_root = receipt.resolve(strict=False)
|
||||||
|
if receipt_root.is_relative_to(source_root) or receipt_root.is_relative_to(target_root):
|
||||||
|
raise CacheCopyError('receipt_inside_cache')
|
||||||
|
if os.path.lexists(receipt):
|
||||||
|
raise CacheCopyError('receipt_exists')
|
||||||
|
# Preserve failures as new evidence; never erase a partially copied cache.
|
||||||
|
with receipt.open('x', encoding='utf8') as stream:
|
||||||
|
os.fchmod(stream.fileno(), 0o600)
|
||||||
|
try:
|
||||||
|
result = copy_cache(source, destination)
|
||||||
|
except CacheCopyError as error:
|
||||||
|
result = {'status': 'FAIL', 'errorKind': str(error), 'deploymentEligible': False}
|
||||||
|
except OSError:
|
||||||
|
result = {'status': 'FAIL', 'errorKind': 'filesystem_error', 'deploymentEligible': False}
|
||||||
|
json.dump(result, stream, ensure_ascii=False, indent=2)
|
||||||
|
stream.write('\n')
|
||||||
|
print(json.dumps({key: value for key, value in result.items() if key != 'manifest'}))
|
||||||
|
return 0 if result['status'] == 'PASS' else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
|
except CacheCopyError as error:
|
||||||
|
print(json.dumps({'status': 'FAIL', 'errorKind': str(error), 'deploymentEligible': False}))
|
||||||
|
raise SystemExit(1)
|
||||||
|
except OSError:
|
||||||
|
print(json.dumps({'status': 'FAIL', 'errorKind': 'filesystem_error', 'deploymentEligible': False}))
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const script = fileURLToPath(new URL('../copy-browser-cache.py', import.meta.url));
|
||||||
|
function fixture(t) {
|
||||||
|
const root = mkdtempSync(path.join(os.tmpdir(), 'hyb-cache-copy-'));
|
||||||
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
const source = path.join(root, 'source');
|
||||||
|
mkdirSync(path.join(source, 'lib'), { recursive: true });
|
||||||
|
writeFileSync(path.join(source, 'lib', 'engine.so.1.2'), 'synthetic-library-bytes');
|
||||||
|
chmodSync(path.join(source, 'lib', 'engine.so.1.2'), 0o751);
|
||||||
|
return { root, source, destination: path.join(root, 'copied'), receipt: path.join(root, 'receipt.json') };
|
||||||
|
}
|
||||||
|
function run(f, command = script, args = [f.source, f.destination, f.receipt]) {
|
||||||
|
const result = spawnSync('python3', [command, ...args], { encoding: 'utf8', env: { PATH: process.env.PATH, LANG: 'C.UTF-8', HOME: f.root, TMPDIR: f.root, PYTHONDONTWRITEBYTECODE: '1' } });
|
||||||
|
assert.equal(result.error, undefined);
|
||||||
|
assert.equal(result.stderr, '');
|
||||||
|
assert.doesNotMatch(result.stdout, /Traceback|synthetic-private-data/);
|
||||||
|
assert.equal(result.stdout.includes(f.root), false, 'CLI must not echo absolute paths');
|
||||||
|
return { ...result, summary: JSON.parse(result.stdout) };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('内部别名保留同一目标身份;副本与源文件独立且保留字节/模式/mtime', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
symlinkSync('engine.so.1.2', path.join(f.source, 'lib', 'engine.so.1'));
|
||||||
|
symlinkSync('engine.so.1', path.join(f.source, 'lib', 'engine.so'));
|
||||||
|
mkdirSync(path.join(f.source, 'plugins'));
|
||||||
|
symlinkSync('../lib/engine.so', path.join(f.source, 'plugins', 'alias.so'));
|
||||||
|
const result = run(f);
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
const receipt = JSON.parse(readFileSync(f.receipt, 'utf8'));
|
||||||
|
assert.equal(receipt.internalFileLinks, 3);
|
||||||
|
assert.equal(receipt.regularFiles, 1);
|
||||||
|
assert.equal(receipt.deploymentEligible, false);
|
||||||
|
assert.equal(receipt.sourceBeforeSha256, receipt.sourceAfterSha256);
|
||||||
|
assert.equal(receipt.destinationSha256, receipt.sourceBeforeSha256);
|
||||||
|
const original = path.join(f.source, 'lib', 'engine.so.1.2');
|
||||||
|
const copied = path.join(f.destination, 'lib', 'engine.so.1.2');
|
||||||
|
const alias = path.join(f.destination, 'plugins', 'alias.so');
|
||||||
|
assert.ok(lstatSync(alias).isSymbolicLink());
|
||||||
|
assert.equal(readlinkSync(alias), '../lib/engine.so');
|
||||||
|
assert.equal(realpathSync(alias), copied);
|
||||||
|
assert.equal(statSync(alias).ino, statSync(copied).ino);
|
||||||
|
assert.notEqual(statSync(copied).ino, statSync(original).ino);
|
||||||
|
assert.equal(statSync(copied).mode & 0o777, 0o751);
|
||||||
|
assert.equal(statSync(copied, { bigint: true }).mtimeNs, statSync(original, { bigint: true }).mtimeNs);
|
||||||
|
assert.equal(readFileSync(alias, 'utf8'), 'synthetic-library-bytes');
|
||||||
|
writeFileSync(copied, 'changed only in owned copy');
|
||||||
|
assert.equal(readFileSync(original, 'utf8'), 'synthetic-library-bytes');
|
||||||
|
assert.equal(readlinkSync(path.join(f.source, 'lib', 'engine.so')), 'engine.so.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const variant of ['external', 'absolute', 'dangling', 'cycle', 'directory']) {
|
||||||
|
test(`拒绝${variant}链接且不创建副本、不泄露文件内容`, t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
writeFileSync(path.join(f.root, 'outside'), 'synthetic-private-data');
|
||||||
|
const targets = { external: '../outside', absolute: path.join(f.source, 'lib', 'engine.so.1.2'), dangling: 'missing', cycle: 'alias', directory: 'lib' };
|
||||||
|
symlinkSync(targets[variant], path.join(f.source, 'alias'));
|
||||||
|
const result = run(f);
|
||||||
|
const expected = { external: 'external_link', absolute: 'absolute_link', dangling: 'broken_or_cyclic_link', cycle: 'broken_or_cyclic_link', directory: 'link_target_not_regular_file' };
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.equal(result.summary.errorKind, expected[variant]);
|
||||||
|
assert.equal(existsSync(f.destination), false);
|
||||||
|
assert.equal(readFileSync(path.join(f.root, 'outside'), 'utf8'), 'synthetic-private-data');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('顶层缓存目录链接不自动跟随', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const alias = path.join(f.root, 'source-alias');
|
||||||
|
symlinkSync(f.source, alias);
|
||||||
|
const result = run({ ...f, source: alias });
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.equal(result.summary.errorKind, 'source_not_physical_directory');
|
||||||
|
assert.equal(existsSync(f.destination), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('已有目标与回执保持不动;拒绝源目标交叠或把回执写入缓存', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
mkdirSync(f.destination);
|
||||||
|
writeFileSync(path.join(f.destination, 'kept'), 'keep');
|
||||||
|
assert.equal(run(f).summary.errorKind, 'destination_exists');
|
||||||
|
assert.equal(readFileSync(path.join(f.destination, 'kept'), 'utf8'), 'keep');
|
||||||
|
const destination = path.join(f.root, 'new-copy');
|
||||||
|
writeFileSync(f.receipt, 'old-evidence');
|
||||||
|
assert.equal(run({ ...f, destination }).summary.errorKind, 'receipt_exists');
|
||||||
|
assert.equal(readFileSync(f.receipt, 'utf8'), 'old-evidence');
|
||||||
|
assert.equal(existsSync(destination), false);
|
||||||
|
assert.equal(run({ ...f, destination: path.join(f.source, 'nested') }).summary.errorKind, 'overlapping_directories');
|
||||||
|
assert.equal(run({ ...f, destination: f.root }).summary.errorKind, 'overlapping_directories');
|
||||||
|
assert.equal(run({ ...f, destination, receipt: path.join(f.source, 'receipt') }).summary.errorKind, 'receipt_inside_cache');
|
||||||
|
assert.equal(run({ ...f, destination, receipt: path.join(destination, 'receipt') }).summary.errorKind, 'receipt_inside_cache');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('特殊文件拒绝,不尝试读取FIFO', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const create = spawnSync('python3', ['-c', 'import os,sys;os.mkfifo(sys.argv[1])', path.join(f.source, 'pipe')], { encoding: 'utf8' });
|
||||||
|
assert.equal(create.status, 0);
|
||||||
|
const result = run(f);
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.equal(result.summary.errorKind, 'special_file');
|
||||||
|
assert.equal(existsSync(f.destination), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('复制中源内容变化必须失败,保留失败副本而不虚报PASS', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const injection = path.join(f.root, 'source-drift.py');
|
||||||
|
writeFileSync(injection, `import importlib.util,sys
|
||||||
|
from pathlib import Path
|
||||||
|
spec=importlib.util.spec_from_file_location('cache_copy',sys.argv[1])
|
||||||
|
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||||
|
original=m.copy_regular
|
||||||
|
def changed(source,destination):
|
||||||
|
original(source,destination)
|
||||||
|
source.write_text('intentional synthetic source drift')
|
||||||
|
m.copy_regular=changed
|
||||||
|
raise SystemExit(m.main(sys.argv[2:]))
|
||||||
|
`);
|
||||||
|
const result = run(f, injection, [script, f.source, f.destination, f.receipt]);
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.equal(result.summary.errorKind, 'source_changed');
|
||||||
|
assert.equal(existsSync(path.join(f.destination, 'lib', 'engine.so.1.2')), true);
|
||||||
|
assert.equal(JSON.parse(readFileSync(f.receipt, 'utf8')).status, 'FAIL');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('把symlink误改成普通副本的变异被结构核验拒绝', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
symlinkSync('engine.so.1.2', path.join(f.source, 'lib', 'engine.so'));
|
||||||
|
const injection = path.join(f.root, 'flatten-links.py');
|
||||||
|
writeFileSync(injection, `import importlib.util,sys
|
||||||
|
from pathlib import Path
|
||||||
|
spec=importlib.util.spec_from_file_location('cache_copy',sys.argv[1])
|
||||||
|
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||||
|
def flattened(self,target,target_is_directory=False):
|
||||||
|
m.copy_regular((self.parent/target).resolve(strict=True),self)
|
||||||
|
Path.symlink_to=flattened
|
||||||
|
raise SystemExit(m.main(sys.argv[2:]))
|
||||||
|
`);
|
||||||
|
const result = run(f, injection, [script, f.source, f.destination, f.receipt]);
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.equal(result.summary.errorKind, 'copy_identity_mismatch');
|
||||||
|
assert.equal(lstatSync(path.join(f.destination, 'lib', 'engine.so')).isSymbolicLink(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字节模式相同的硬链接副本也必须拒绝', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const injection = path.join(f.root, 'shared-inode.py');
|
||||||
|
writeFileSync(injection, `import importlib.util,os,sys
|
||||||
|
spec=importlib.util.spec_from_file_location('cache_copy',sys.argv[1])
|
||||||
|
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||||
|
m.copy_regular=lambda source,destination:os.link(source,destination)
|
||||||
|
raise SystemExit(m.main(sys.argv[2:]))
|
||||||
|
`);
|
||||||
|
const result = run(f, injection, [script, f.source, f.destination, f.receipt]);
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.equal(result.summary.errorKind, 'source_inode_shared');
|
||||||
|
assert.equal(statSync(path.join(f.source, 'lib', 'engine.so.1.2')).ino, statSync(path.join(f.destination, 'lib', 'engine.so.1.2')).ino);
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户