diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 1346a9b..1e30f62 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -9,20 +9,20 @@ "source": { "source": "url", "url": "https://github.com/partme-ai/partme-codeguard-plugin.git", - "ref": "v0.14.14" + "ref": "v0.14.15" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.14.14", + "version": "0.14.15", "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", - "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.14/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.15/assets/official-logo.png", "interface": { "displayName": "代码规范守卫", "shortDescription": "Trustworthy code checks and Java impact analysis", - "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.14/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.15/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 9db9a4e..d633737 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.14+codex.20260923", + "version": "0.14.15+codex.20260923", "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", "author": { "name": "Full Stack Skills / PartMe.AI", diff --git a/.zcode-plugin/plugin.json b/.zcode-plugin/plugin.json index b5abe8d..a14c4b6 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.14.14", + "version": "0.14.15", "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", "description_i18n": { "en": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 7009fb5..f5a1581 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -27,7 +27,7 @@ flowchart TD ``` `execution` 只记录进程证据,不把非零退出自动认定为代码违规;stdout/stderr 合计按默认 16 MiB 捕获预算并发读取,超限终止执行、保留预算内诊断且返回 `output_limit`/UNVERIFIED,不把片段当成完整日志。`verdict`、CVE 与 Dockerfile 报告解析决定 PASS、FAIL 或 UNVERIFIED;入口仅按各自协议呈现和聚合退出码。Git 提交检查使用 index 或预测暂存快照,推送检查使用 HEAD;保存钩子只给反馈。计划、未执行和未验证不能宣传为通过。 -Git 准确快照的二进制读取独立于文本检查执行器:先严格解析 index/HEAD 对象列表的 NUL 帧、模式、ID、类型/阶段和唯一文件名,并与独立路径列举核对;`cat-file` 两阶段响应再逐项核对 ID、blob 类型、大小与边界,按 SHA-1/SHA-256 blob 对象格式复算内容哈希。缺项、截断、尾部脏数据或同长度内容替换均不交付临时树,而是明确标记 Git UNVERIFIED。原始 index 不在观察过程中改写。该校验不是恶意 Git 进程沙箱或完整 Git/Shell 模拟。 +Git 准确快照复用统一执行器的原始字节模式:路径列表默认最多捕获 16 MiB,`cat-file --batch-check` 按对象数分配响应预算;通过大小响应确认对象总量不超过 256 MiB 后,`--batch` 才按总 blob 大小和逐项头部预算读取。stdout/stderr 合计超限时及时终止 Git,抛出 `SnapshotError`,不交付截断内容。预测暂存的工作树覆盖层只列举一次,并以同一集合决定 `changed` 和物化内容;最多 20,000 项、单文件 32 MiB、实际复制总量 256 MiB,以 64 KiB 块流式读取。打开时使用非阻塞与不跟随末级链接的可用平台标志,避免类型检查后变成 FIFO 时卡住。特殊文件、读取期间变化和超限不交付临时树,文件↔目录转换按父路径先处理。随后严格解析 index/HEAD 对象列表的 NUL 帧、模式、ID、类型/阶段和唯一文件名,并与独立路径列举核对;`cat-file` 两阶段响应再逐项核对 ID、blob 类型、大小与边界,按 SHA-1/SHA-256 blob 对象格式复算内容哈希。缺项、截断、尾部脏数据或同长度内容替换均不交付临时树,而是明确标记 Git UNVERIFIED。原始 index 不在观察过程中改写。该校验不是恶意 Git 进程沙箱或完整 Git/Shell 模拟。 `language_check` 将 `PlanExecution` 的实际命令依序映射为应用层有界 `execution_trace`(检查、修复、复检阶段);`check_application` 将其进一步压成 MCP 安全元数据,不回显原始 argv、环境覆盖或捕获输出。MCP `auto_fix` 以 `fingerprint.file_content` 对仓内改动文件在修复前、formatter 后、复检后分别采集有总量预算的流式身份,仓外路径、符号链接或读取故障不启动 formatter;顶层 `fixed` 只归因于 formatter 且复检后仍保留的变化。公开 `fix_results` 将命令成功 `formatter_succeeded` 与逐项修复确认 `fixed` 分离;只有唯一成功执行的 formatter 且内容变化可验证时才把全局变化归给该项,多 formatter 执行不猜测归属。后续身份不可采集、formatter 失败后仍有内容变化或检查器改写目标文件时,保留实际执行证据、整体标为 UNVERIFIED,不把未知副作用声称为已确认修复。`fix_results` 只公开状态与安全元数据;formatter stderr 尾部写入私有 `.codeguard-fix.log` 后只公开路径,日志不可用时不退回公开原文。失败日志保留各已执行检查在捕获预算内的输出;输出超限时日志只含片段,不能称为完整诊断,且可能含敏感文本,应排除出版本控制。项目日志和门禁截断日志共用 `storage.write_private_text` 的私有原子落盘;预置日志文件链接不被跟随,默认 `out` 目录为链接或不可写时不返回虚假日志路径,也不覆盖检查结论。终止命令仍决定既有状态、退出码和旧字段;未运行的计划命令不进入证据。 `run_fix` 的历史 `fixed` 字段仍表示 formatter 命令以 0 退出,供旧调用方决定是否复检;它本身不是内容变化证明。CLI `fix` 因而只报告 formatter 执行成功与文件变化未验证,不再把退出码 0 呈现为已确认修复。 Git 命令的仓库定位与拟暂存解析共用入口传入的 cwd;显式 `cd`/`git -C` 无法绑定 Git 工作树时,硬门禁给出目标未验证并阻断,不退回到调用者仓或无关子仓。未给出显式目标且调用目录非仓时仍保留 workspace 子仓兜底。静态命令解析不等于完整 Shell 执行模拟。 @@ -49,7 +49,7 @@ workspace 兜底产生的每个子仓是合成目标,暂存观察也绑定该 | `java_build.py`、`java_impact.py`、`java_planning.py` 等 | 构建读取、纯影响闭包、命令选择与环境观察 | 默认保留跳过测试的行为;复杂构建保守扩大检查范围 | | `cve_reports.py`、`cve_policy.py`、`cve_scanners.py`、`cve.py` | 漏洞报告、阈值、进程适配与复扫编排 | 无有效结构化报告就没有安全通过结论 | | `dockerfile_reports.py`、`dockerfile.py` | hadolint/Trivy 结构化证据与逐文件扫描 | `dockerfile_security.py` 只解析参数和呈现报告 | -| `execution.py`、`git_staging.py`、`git_snapshot.py`、`storage.py` | 外部进程、Git 内容面、原子状态读改写 | Git blob 保持字节,不经过文本检查执行器;准确快照必须核对批量对象协议与内容身份 | +| `execution.py`、`git_staging.py`、`git_snapshot.py`、`storage.py` | 外部进程、Git 内容面、原子状态读改写 | Git blob 走有界原始字节通道、不经文本解码;准确快照必须核对批量对象协议与内容身份 | | `registry.py`、`registry_schema.py`、`discovery.py`、`config.py` | 已校验的语言表、发现与配置 | `languages.json` 是语言清单的事实源 | 这些是源代码层的边界。`scripts/check_architecture.py` 对第一方静态导入和环做门禁;动态加载、进程副作用和宿主行为仍需测试。 diff --git a/kimi.plugin.json b/kimi.plugin.json index 50df0c7..6703e88 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.14", + "version": "0.14.15", "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", "author": { "name": "Full Stack Skills / PartMe.AI" diff --git a/openspec/changes/refactor-codeguard-architecture/design.md b/openspec/changes/refactor-codeguard-architecture/design.md index 4cf4572..14a70d2 100644 --- a/openspec/changes/refactor-codeguard-architecture/design.md +++ b/openspec/changes/refactor-codeguard-architecture/design.md @@ -52,7 +52,7 @@ flowchart TD 超时保留部分输出;使用 UTF-8 替换无法解码的字节,防止 decode error 把整个门禁变成未知异常。缺命令=127、超时=124、其他启动 OS 错误=126;与已有 UNVERIFIED 语义一致。执行器不将非预期程序错误吞成正常进程结果;并行门禁在每个语言任务的应用边界把未预期异常显式标为 UNVERIFIED,继续保留其它语言已确认的结论,且不把异常原文注入宿主。 -第二十八批审计确认 `subprocess.run(capture_output=True)` 会先无界收集检查器输出,应用层截断不能保护进程内存。统一执行器改为两流并发读取,默认合计最多保留 16 MiB;输出超限时停止进程,返回独立 `output_limit` 故障与退出码 125,保留预算内的诊断前缀,不声称存在完整日志。POSIX 上使用独立进程组终止子进程;Windows 仍只终止直接进程,子孙生命周期需实机验证。调用方可以为确定性测试传入更小预算,但正常预算内的 argv、cwd、输出和退出契约不变。这是外部工具资源护栏,不是执行沙箱;Git 二进制快照入口另有自身预算,不经过文本执行器。 +第二十八批审计确认 `subprocess.run(capture_output=True)` 会先无界收集检查器输出,应用层截断不能保护进程内存。统一执行器改为两流并发读取,默认合计最多保留 16 MiB;输出超限时停止进程,返回独立 `output_limit` 故障与退出码 125,保留预算内的诊断前缀,不声称存在完整日志。POSIX 上使用独立进程组终止子进程;Windows 仍只终止直接进程,子孙生命周期需实机验证。调用方可以为确定性测试传入更小预算,但正常预算内的 argv、cwd、输出和退出契约不变。这是外部工具资源护栏,不是执行沙箱。后续快照审计将同一有界捕获分成文本与原始字节两种结果:Git `cat-file` 保留完整二进制协议,先按数量限制大小响应,再用经验证的 blob 大小分配内容预算,任何超限都抛出快照未验证,不交付部分内容。 ### 3. 策略差异必须显式,不能强行拉平 @@ -81,7 +81,7 @@ MCP 的 `auto_fix` 还必须在应用层单独投影公开结果:`run_fix` 保 `path_policy` 是扫描排除表、产物识别与拟入库路径规则的单一来源;`scope` 和 `gate_lib` 只兼容导出/调用。策略接收已经确定的路径,不读取 Git,更不会执行建议中的移除操作。Git 快照采集仍在 git_snapshot,反馈格式仍在 reporting。 -`git_snapshot` 保持二进制 Git 基础设施边界,不经文本检查执行器转换 blob。对象列举之后, +`git_snapshot` 保持二进制 Git 基础设施边界,复用统一执行器的原始字节模式而不解码 blob。对象列举之后, 同一个 Git 字节执行入口读取 `cat-file --batch-check` 和 `--batch`;逐项确认对象 ID、类型、 大小、数量、顺序、终止分隔符与尾部边界,并以 SHA-1/SHA-256 Git blob 对象格式重新计算 payload 哈希。任一不一致转为 `SnapshotError`,准确门禁沿既有策略显示 UNVERIFIED;不把 @@ -92,6 +92,7 @@ payload 哈希。任一不一致转为 `SnapshotError`,准确门禁沿既有 或 `ls-tree --name-only` 路径面交叉核对,防止单个响应在完整记录边界被截断后仍交付 部分树。通用 `-z` 路径列举同样拒绝未终止或空记录;并发 index/HEAD 变化宁可转 UNVERIFIED,不把两个时点拼成一次确定快照。这不是对恶意 Git 进程的完整双源证明。 +工作树预测暂存覆盖层与 Git blob 是两种不同来源:前者不是 `cat-file` 的预算一部分,故另限 20,000 路径、单文件 32 MiB、实际复制总量 256 MiB,并通过有界块读取避免 `read_bytes()` 的整文件内存副本。覆盖路径仅列举一次,原样传给 changed 路径计算与临时树物化,避免两次 Git 查询之间集合变化造成“报告 A、检查 B”。源文件打开在支持的平台上使用 `O_NOFOLLOW | O_NONBLOCK`,类型检查与打开之间即使变成 FIFO 也不阻塞;源描述符在复制前后核对类型、大小与时间戳。超限、特殊文件和读取故障均拒绝交付。临时树按路径深度处理覆盖项,允许文件转目录;目录转文件时只清理一次性临时树中的旧子项,不改原仓。这个机制仍不承诺对抗父目录并发替换或同内容同元数据伪装的完整文件系统快照隔离。 直接命令链的拟暂存分析分成静态事件观察与按提交时间投影:先把同仓 `add`/`commit` 及其调用目录记录为不可变事件,再只将最后一个 commit 及以前的 add 送往 Git pathspec 查询和内容快照。这样后续 add 不会回溯污染已完成提交,也不跳过两个 commit 之间的 add。旧的独立 `staging_intent` 调用默认仍返回整条链的暂存意图;硬门禁只在没有一层间接脚本或 Shell 命令替换时启用按提交投影。`$(...)`/反引号的内层实际先执行,而旧静态展开把它附在文本尾部;间接脚本与外层 add 的跨层顺序也尚无统一事件时钟。这两类命令继续采用保守并集,不能以局部排序缩小检查面。 diff --git a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md index 3eb5fef..db87d8e 100644 --- a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md +++ b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md @@ -9,6 +9,9 @@ ### Requirement: Tool execution SHALL preserve process evidence 执行结果 MUST 保留实际 argv、工作目录、原始退出码、标准输出和标准错误;若外部工具输出超过明确的捕获预算,则 MUST 终止该执行并保留预算内已捕获的诊断,以独立故障标识报告 UNVERIFIED,不得伪造完整输出或 PASS。参数 MUST 以 argv 传递,不隐式增加 shell;工具是否发现代码违规由工具判定策略解释,不能由进程执行器推断。 +Git 快照使用同一有界进程边界的原始字节模式,MUST 保留 NUL 与 blob 字节且不在协议校验前解码;路径列举、大小响应及对象内容 MUST 各有明确捕获预算。任何超限或执行故障 MUST 阻止交付快照并成为 SnapshotError,不能以截断数据继续门禁。对象总量预算必须在读取 batch 内容之前由大小响应确定。 +工作树预测暂存覆盖层 MUST 以有界缓冲复制普通文件,并同时限制文件数量、单文件与累计字节数;检查期间文件超出预算、变成特殊文件或读取失败 MUST 拒绝交付临时树,不能把不完整覆盖层当准确 Git 快照。删除及文件与目录之间双向转换的预测语义必须保留,不能因覆盖路径迭代顺序不同而改变快照。 +同一次准确快照中,拟提交路径与实际物化的覆盖层 MUST 使用同一份已列举的路径集合;不得分别查询两次 Git 工作树后把不同时点的路径清单拼成一个已验证结论。 #### Scenario: A checker exits nonzero - **WHEN** 同一检查器经 CLI、保存 hook、Git 门禁或 CVE 执行并以非零退出 @@ -22,6 +25,22 @@ - **WHEN** 外部检查器向 stdout/stderr 持续写入超过进程执行预算的内容 - **THEN** 执行器有界地保留两个流的已有诊断,终止该检查,返回明确的输出超限故障与非成功退出;下游不得把截断的成功前缀判为 PASS 或宣称已有完整诊断日志 +#### Scenario: Git emits more data than the snapshot budget +- **WHEN** Git 路径列举、batch-check 或 batch 内容超过相应捕获预算,即使进程继续输出 +- **THEN** 执行器及时终止进程;快照入口报告未验证且不交付部分内容、不得据此前的成功前缀放行 + +#### Scenario: Many predicted worktree files exceed the aggregate overlay budget +- **WHEN** 每个文件均小于单文件上限,但所有拟暂存工作树文件的实际读取字节总数超过覆盖层预算 +- **THEN** 快照不交付部分临时树,门禁报告未验证,原工作树与 Git index 保持不变 + +#### Scenario: Overlay listing changes between observations +- **WHEN** Git 工作树路径列举在两次调用之间返回不同集合 +- **THEN** 单次快照只使用一次列举结果来计算 changed 与构建临时树;不能把第一份 changed 与第二份物化内容配对 + +#### Scenario: A predicted path is a special file +- **WHEN** 拟暂存路径在快照复制时变成 FIFO、socket 或其它非普通文件 +- **THEN** 快照拒绝该内容,即使类型检查与打开之间发生替换也不阻塞读取特殊文件,且不把它当作成功删除 + ### Requirement: Execution failures SHALL remain observable 超时 MUST 保留已捕获输出并记录超时故障;缺失命令与权限/工作目录故障 MUST 返回明确不可验证的执行结果。非 UTF-8 输出 MUST 保留可解码文本并替换非法字节,不能让编码异常吞掉整次检查。不得捕获用户取消或任意编程异常作为成功结果。 diff --git a/openspec/changes/refactor-codeguard-architecture/tasks.md b/openspec/changes/refactor-codeguard-architecture/tasks.md index 626d0a7..b308359 100644 --- a/openspec/changes/refactor-codeguard-architecture/tasks.md +++ b/openspec/changes/refactor-codeguard-architecture/tasks.md @@ -33,6 +33,6 @@ - [x] 5.1 增加自动架构依赖/循环检查并接入 CI,临时源码树注入反向依赖/函数内导入/相对导入环后确实拒绝;本地已验证,远端 CI 待发布阶段。不以文件行数当合格标准。 - [x] 5.2 四个历史 skipped 已全部恢复;Dockerfile CLI 接入统一报告解析与扫描应用,14 项真实进程边界测试通过;基线比较按诊断内容和次数核验并要求基线实际 FAIL,三项误豁免用例先红后绿。完整 433 项单测零跳过、142 项回归零失败。 - [x] 5.3 新增当前架构 Mermaid 与扩展指南;报告版本读取插件 manifest,双语 README 指向当前证据,旧设计标明历史范围;双语标题/链接/版本及本地链接存在性测试通过。发布版本仍需在 5.5 检查。 -- [x] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过;第二十八批本地 549 项单测、143 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 +- [x] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过;第二十九批本地 559 项单测、143 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 - [x] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;第二十八批已发布 v0.14.14(插件 PR #62、市场 PR #18、源码 PR/main CI、注释 tag/正式 Release 均核对;市场 PR 无 CI 检查)。宿主安装运行另行记录,未授权不得改宿主;发布证据见 verification.md。 - [ ] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。 diff --git a/openspec/changes/refactor-codeguard-architecture/verification.md b/openspec/changes/refactor-codeguard-architecture/verification.md index 39158e2..5665827 100644 --- a/openspec/changes/refactor-codeguard-architecture/verification.md +++ b/openspec/changes/refactor-codeguard-architecture/verification.md @@ -294,3 +294,14 @@ - 该护栏只约束文本检查执行器;Git 二进制快照使用独立 `git_snapshot.git()`,其 `cat-file` 传输、覆盖层累计磁盘预算与恶意 Git 输出仍需另行审计。本批未 bump、推送或发布;三宿主现场、Windows、在线 CVE 与大型 Maven/Gradle 项目没有获得新证据。 - 第二十八批随后发布为 v0.14.14:源码 PR #62 合并到 `479d87815be8478d409e598ab473412dc6997383`,市场 PR #18 合并到 `9bea1d06a07d1c111c93f597b5cc0da7e89720ae`。源码 PR 的 `vendor-check` 与合并后 main 的 `skills-check` 均成功;市场 PR 无 CI 检查,不称其 CI 已通过。受保护注释 tag `v0.14.14` 解引用到源码 main 合并提交,正式 Release 非 draft、非 prerelease,Codex/ZCode/Kimi 市场元数据和中英文导航均为 0.14.14。本次发布不证明三宿主已安装运行、Windows、在线 CVE、大型 Maven/Gradle 或 Git 二进制快照的资源上限;task 5.6 全目标审计、规格同步与归档保持未完成。 + +## 第二十九批本地验证:Git 二进制输出有界捕获 + +- 从 v0.14.14 main 的隔离工作树重新建立临时 CodeGraph 索引(141 文件、2,240 节点、5,047 边),沿 `git_snapshot.git → _snapshot_entries/validation_tree → gate` 确认:Git `capture_output=True` 在解析 20,000 文件/256 MiB blob 预算之前无界收集输出。此前的预算只能拒绝已装入内存的过量结果,无法保护执行进程。 +- 同一执行器现在提供原始字节结果,不解码 Git NUL 列表或 blob;Git 路径响应限 16 MiB,batch-check 按对象数限额,blob 响应在已验证大小总量后按内容与头部限额捕获。两流共享预算,超限终止并抛 `SnapshotError`,不交付截断的成功前缀。真实进程验证了 NUL/非法 UTF-8 原样保留、stdin 字节传递、双流洪泛早停;真实 Git 仓以 8 字节预算触发拒绝且 index 字节不变,既有 SHA-1/SHA-256、截断/错位/同长度替换和门禁可见性测试继续通过。 +- 首轮目标测试暴露 macOS 上已结束进程的 `killpg` 权限竞态;清理逻辑现只在进程或读取线程仍存活时尝试终止,并在权限故障下保守回退。首轮目标 27/27、全量 552/552;以下覆盖层审计沿同一尚未发布的工作树继续,不把该中间计数称为最终结果。 +- CodeGraph 沿 `validation_tree → overlays → read_bytes/write_bytes` 发现:旧实现虽限单文件 32 MiB,却未限覆盖层累计字节或文件数,且整文件载入内存;特殊 FIFO 可被静默当作删除,文件↔目录转换依赖 set 迭代顺序。五项真实 Git 目标场景先 RED(含总量、数量、FIFO 和双向转换),修复后以 64 KiB 块流式复制,最多 20,000 项/单文件 32 MiB/累计 256 MiB;源描述符前后核验大小与 mtime,异常不交付临时树。旧目录只在自动清理的一次性快照中被普通文件替换,原仓及 index 未改。 +- 随后在直接调用覆盖层复制器的隔离子进程中复现 FIFO 打开阻塞(超时 2 秒 RED);在支持的平台上使用非阻塞打开,并以 `fstat` 拒绝特殊文件后,目标用例 0.06 秒通过。最终本地完整单测 **558/558、0 skipped**,真实 Hook **143/0/0**;Ruff、架构检查、语言 schema **57 项/11 规则**、vendor 离线及在线、本 change strict、diff whitespace 均通过。此处尚未记入新版本发布证据;Windows 子进程树、异常大的真实仓库性能、敌对并发文件系统、在线 CVE 与三宿主现场仍待独立验收。 +- 同日只读分层审计:插件远端 `main` 仍为 `95b793f`(v0.14.14 后文档合并),市场远端 `main` 新增其它插件 `processon-design 0.2.8` 的提交 `db08d83`,其 `catalog.json` 内 Codeguard 仍为 0.14.14;本机 ZCode 的 `installed_plugins.json` 将 Codeguard 指向 0.14.7 且对应缓存目录存在。Codex/Kimi 的本次默认缓存路径搜索未取得当前版本证据,不据此断言未安装。故源、市场和已安装宿主仍是不同证明层,本批未改宿主配置或触发更新。原始插件检出位于另一条带未提交改动的分支,隔离工作树没有覆盖它。 +- 再沿 `validation_tree → proposed_paths/overlays → gate` 审计发现,同一快照会先为 `changed` 列举一次覆盖层,物化时再列举一次;若结果不同,就可能报告已检查第一组文件、实际只复制第二组。真实 Git 仓中对两次路径列举注入不同结果先 RED(`first.txt` 在 changed 中但不在快照),现一次列举并将同一集合传给路径计算和物化,原 `proposed_paths` 外部调用仍自行观察。该修复不等于 Git index 与工作树之间的原子事务;内容读取期间仍以已有大小/mtime/预算校验拒绝可见变化。 +- 最终本地完整单测 **559/559、0 skipped**,真实 Hook **143/0/0**;Ruff、架构检查、语言 schema **57 项/11 规则**、vendor 离线及在线、本 change strict、diff whitespace 均通过。仍是隔离工作树未发布代码,不能用 v0.14.14 的远端 CI、Release 或旧宿主安装证明它已经交付。 diff --git a/scripts/check_architecture.py b/scripts/check_architecture.py index f26f2e9..6305aff 100644 --- a/scripts/check_architecture.py +++ b/scripts/check_architecture.py @@ -25,7 +25,7 @@ "codeguard.models": {"__future__", "dataclasses", "pathlib", "typing"}, "codeguard.verdict": {"__future__", "re", "posixpath", "collections", "collections.abc", "pathlib", "codeguard.models"}, - "codeguard.execution": {"__future__", "os", "signal", "subprocess", "threading", "time", + "codeguard.execution": {"__future__", "os", "signal", "subprocess", "tempfile", "contextlib", "threading", "time", "collections", "pathlib", "codeguard.models"}, # 报告仍决定日志路径与呈现;私有原子落盘交给 storage,不称纯模型。 "codeguard.reporting": {"__future__", "re", "json", "pathlib", "hashlib", "tempfile", diff --git a/scripts/codeguard/execution.py b/scripts/codeguard/execution.py index 9766510..506b4f4 100644 --- a/scripts/codeguard/execution.py +++ b/scripts/codeguard/execution.py @@ -4,12 +4,14 @@ import os import signal import subprocess +import tempfile import threading import time from collections.abc import Mapping, Sequence +from contextlib import ExitStack from pathlib import Path -from .models import CheckPlan, PlanExecution, ProcessResult +from .models import BinaryProcessResult, CheckPlan, PlanExecution, ProcessResult DEFAULT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024 _READ_SIZE = 64 * 1024 @@ -29,6 +31,17 @@ def execute(argv: Sequence[str], cwd: Path, timeout: float, *, 普通工具的退出码(包括工具自己返回的 124/127)原样保留,由领域 判定解释;failure 只描述执行器实际观察到的故障。 """ + raw = execute_bytes(argv, cwd, timeout, env=env, stdin_null=stdin_null, + max_output_bytes=max_output_bytes) + return ProcessResult(raw.argv, raw.cwd, raw.returncode, + _text(raw.stdout), _text(raw.stderr), raw.failure) + + +def execute_bytes(argv: Sequence[str], cwd: Path, timeout: float, *, + env: Mapping[str, str] | None = None, stdin_null: bool = False, + input_data: bytes | None = None, + max_output_bytes: int | None = None) -> BinaryProcessResult: + """与文本执行共用的有界原始字节通道;不解释任何业务协议。""" if max_output_bytes is None: max_output_bytes = DEFAULT_MAX_OUTPUT_BYTES if max_output_bytes < 1: @@ -36,18 +49,23 @@ def execute(argv: Sequence[str], cwd: Path, timeout: float, *, command = tuple(argv) root = Path(cwd) try: - proc = subprocess.Popen( - list(command), cwd=root, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - env={**os.environ, **env} if env is not None else None, - stdin=subprocess.DEVNULL if stdin_null else None, - start_new_session=os.name == "posix", - ) + with ExitStack() as stack: + input_file = stack.enter_context(tempfile.TemporaryFile()) if input_data is not None else None + if input_file is not None: + input_file.write(input_data) + input_file.seek(0) + proc = subprocess.Popen( + list(command), cwd=root, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env={**os.environ, **env} if env is not None else None, + stdin=input_file if input_file is not None else subprocess.DEVNULL if stdin_null else None, + start_new_session=os.name == "posix", + ) except FileNotFoundError as exc: - return ProcessResult(command, root, 127, stderr=f"command not found: {exc}", - failure="not_found") + return BinaryProcessResult(command, root, 127, + stderr=f"command not found: {exc}".encode(), failure="not_found") except OSError as exc: - return ProcessResult(command, root, 126, stderr=f"cannot start command: {exc}", - failure="os_error") + return BinaryProcessResult(command, root, 126, + stderr=f"cannot start command: {exc}".encode(), failure="os_error") captured = (bytearray(), bytearray()) guard = threading.Lock() @@ -75,11 +93,16 @@ def drain(stream, output: bytearray) -> None: reader.start() def stop_process() -> None: + if proc.poll() is not None and not any(reader.is_alive() for reader in readers): + return if os.name == "posix": try: os.killpg(proc.pid, signal.SIGKILL) except ProcessLookupError: pass + except PermissionError: + if proc.poll() is None: + proc.kill() elif proc.poll() is None: proc.kill() @@ -117,15 +140,15 @@ def stop_process() -> None: proc.wait() raise - stdout, stderr = (_text(bytes(stream)) for stream in captured) + stdout, stderr = (bytes(stream) for stream in captured) if fault is not None: message = {"timeout": f"timeout after {timeout}s", "output_limit": f"output limit exceeded ({max_output_bytes} bytes)", - "os_error": "cannot finish reading command output"}[fault] - stderr += ("\n" if stderr and not stderr.endswith("\n") else "") + message - return ProcessResult(command, root, {"timeout": 124, "output_limit": 125, - "os_error": 126}.get(fault, proc.returncode), - stdout, stderr, fault) + "os_error": "cannot finish reading command output"}[fault].encode() + stderr += (b"\n" if stderr and not stderr.endswith(b"\n") else b"") + message + return BinaryProcessResult(command, root, {"timeout": 124, "output_limit": 125, + "os_error": 126}.get(fault, proc.returncode), + stdout, stderr, fault) def execute_plan(plan: CheckPlan, timeout: float) -> PlanExecution: diff --git a/scripts/codeguard/models.py b/scripts/codeguard/models.py index 777f993..b282f2c 100644 --- a/scripts/codeguard/models.py +++ b/scripts/codeguard/models.py @@ -53,6 +53,18 @@ def as_tuple(self) -> tuple[int, str, str]: return self.returncode, self.stdout, self.stderr +@dataclass(frozen=True) +class BinaryProcessResult: + """未解码的有界进程证据,供 Git 等二进制协议使用。""" + + argv: tuple[str, ...] + cwd: Path + returncode: int + stdout: bytes = b"" + stderr: bytes = b"" + failure: Literal["timeout", "not_found", "os_error", "output_limit"] | None = None + + @dataclass(frozen=True) class PlanExecution: """已实际执行的有序证据,不包含尚未运行的计划项。""" diff --git a/scripts/git_snapshot.py b/scripts/git_snapshot.py index 149ac2e..179ed55 100644 --- a/scripts/git_snapshot.py +++ b/scripts/git_snapshot.py @@ -5,21 +5,32 @@ import hashlib import os import re -import subprocess +import shutil +import stat import tempfile from pathlib import Path, PurePosixPath +from codeguard.execution import execute_bytes + +_LISTING_MAX_BYTES = 16 * 1024 * 1024 +_MAX_BLOB_BYTES = 256 * 1024 * 1024 +_MAX_OVERLAY_FILE_BYTES = 32 * 1024 * 1024 +_MAX_OVERLAY_BYTES = 256 * 1024 * 1024 +_MAX_OVERLAY_FILES = 20000 +_COPY_CHUNK_BYTES = 64 * 1024 + class SnapshotError(RuntimeError): """无法准确取得拟提交内容,调用者必须报告未验证。""" -def git(root: Path, *args: str, input_data: bytes | None = None) -> bytes: - try: - proc = subprocess.run(["git", *args], cwd=root, capture_output=True, - input=input_data, check=False, timeout=30) - except (OSError, subprocess.TimeoutExpired) as exc: - raise SnapshotError(str(exc)) from exc +def git(root: Path, *args: str, input_data: bytes | None = None, + max_output_bytes: int = _LISTING_MAX_BYTES) -> bytes: + proc = execute_bytes(["git", *args], root, 30, input_data=input_data, + stdin_null=True, max_output_bytes=max_output_bytes) + if proc.failure: + raise SnapshotError(f"Git 执行未验证 ({proc.failure}): " + + proc.stderr.decode(errors="replace")[-500:]) if proc.returncode: raise SnapshotError(proc.stderr.decode(errors="replace")[:500]) return proc.stdout @@ -78,13 +89,15 @@ def overlays(root: Path, lanes=None, extra=None) -> set[str]: def proposed_paths(root: Path, mode="commit", *, lanes=None, extra=None, - pending_commit=False, include_deleted=False) -> list[str]: + pending_commit=False, include_deleted=False, + overlay_paths: set[str] | None = None) -> list[str]: """拟入库的新/修改路径,保留敏感文件与产物,删除文件不算新入库。""" selected = push_paths(root, include_deleted) if mode == "push" else set() if mode != "push" or pending_commit: selected |= names(root, "diff", "--cached", "--name-only", "--no-renames", "--diff-filter=ACMRD" if include_deleted else "--diff-filter=ACMR", "-z") - for name in overlays(root, lanes, extra): + candidate_paths = overlays(root, lanes, extra) if overlay_paths is None else overlay_paths + for name in candidate_paths: path = safe_path(root, name) if include_deleted or path.exists() or path.is_symlink(): selected.add(name) @@ -119,7 +132,7 @@ def _batch_sizes(raw: bytes, entries: list[tuple[str, str, str]]) -> tuple[int, for line, (_, oid, _) in zip(lines, entries, strict=True): size = _batch_header(line, oid) total += size - if total > 256 * 1024 * 1024: + if total > _MAX_BLOB_BYTES: raise SnapshotError("快照超过 256 MiB 上限,需要独立 CI 检查") sizes.append(size) return tuple(sizes) @@ -192,15 +205,54 @@ def _snapshot_entries(root: Path, head: bool) -> list[tuple[str, str, str]]: return entries +def _copy_overlay(source: Path, dest: Path, remaining_bytes: int) -> int: + """以源文件描述符和有界块复制预测内容;资源不足不交付半成品。""" + cap = min(_MAX_OVERLAY_FILE_BYTES, remaining_bytes) + if cap < 0: + raise SnapshotError("工作树覆盖层超过总量上限") + try: + descriptor = os.open(source, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | + getattr(os, "O_NONBLOCK", 0)) + with os.fdopen(descriptor, "rb") as reader: + before = os.fstat(reader.fileno()) + if not stat.S_ISREG(before.st_mode): + raise SnapshotError(f"工作树覆盖层包含特殊文件: {source}") + if before.st_size > cap: + raise SnapshotError(f"工作树覆盖层超过单文件或总量上限: {source}") + dest.parent.mkdir(parents=True, exist_ok=True) + # 仅在一次性临时树内清理被普通文件替换的旧 Git 目录。 + if dest.is_dir() and not dest.is_symlink(): + shutil.rmtree(dest) + copied = 0 + with dest.open("wb") as writer: + while chunk := reader.read(min(_COPY_CHUNK_BYTES, cap - copied + 1)): + copied += len(chunk) + if copied > cap: + raise SnapshotError(f"工作树覆盖层读取时超过上限: {source}") + writer.write(chunk) + after = os.fstat(reader.fileno()) + if copied != before.st_size or after.st_size != before.st_size or \ + after.st_mtime_ns != before.st_mtime_ns: + raise SnapshotError(f"工作树覆盖层读取期间发生变化: {source}") + dest.chmod(before.st_mode & 0o777) + return copied + except OSError as exc: + raise SnapshotError(f"工作树覆盖层无法读取: {source}: {exc}") from exc + + @contextlib.contextmanager def validation_tree(root: Path, mode="commit", *, lanes=None, extra=None, pending_commit=False): """原始 Git blobs 构成快照;拒绝外链、submodule、冲突和超限内容。""" head = mode == "push" and not pending_commit entries = _snapshot_entries(root, head) object_ids = "".join(oid + "\n" for _, oid, _ in entries).encode() - sizes = _batch_sizes(git(root, "cat-file", "--batch-check", input_data=object_ids), entries) - blob = git(root, "cat-file", "--batch", input_data=object_ids) - changed = proposed_paths(root, mode, lanes=lanes, extra=extra, pending_commit=pending_commit, include_deleted=True) + sizes = _batch_sizes(git(root, "cat-file", "--batch-check", input_data=object_ids, + max_output_bytes=max(1024, 128 * len(entries))), entries) + blob = git(root, "cat-file", "--batch", input_data=object_ids, + max_output_bytes=max(1024, sum(sizes) + 128 * len(entries))) + selected_overlays = overlays(root, lanes, extra) if not head else set() + changed = proposed_paths(root, mode, lanes=lanes, extra=extra, pending_commit=pending_commit, + include_deleted=True, overlay_paths=selected_overlays) with tempfile.TemporaryDirectory(prefix="codeguard-snapshot-") as directory: target = Path(directory) offset = 0 @@ -213,16 +265,18 @@ def validation_tree(root: Path, mode="commit", *, lanes=None, extra=None, pendin if offset != len(blob): raise SnapshotError("Git 对象内容响应存在多余尾部字节") if not head: - for name in overlays(root, lanes, extra): + if len(selected_overlays) > _MAX_OVERLAY_FILES: + raise SnapshotError("工作树覆盖层超过文件数量上限") + copied_total = 0 + # 父路径先处理:文件转目录时须先移除旧 blob,才能写入新子项。 + for name in sorted(selected_overlays, key=lambda value: (value.count("/"), value)): source, dest = safe_path(root, name), safe_path(target, name) if source.is_symlink() or not source.resolve().is_relative_to(root.resolve()): raise SnapshotError(f"不跟随仓外路径/符号链接: {name}") if source.is_file(): - if source.stat().st_size > 32 * 1024 * 1024: - raise SnapshotError(f"工作树文件过大: {name}") - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(source.read_bytes()) - dest.chmod(source.stat().st_mode & 0o777) + copied_total += _copy_overlay(source, dest, _MAX_OVERLAY_BYTES - copied_total) + elif source.exists() and not source.is_dir(): + raise SnapshotError(f"工作树覆盖层包含特殊文件: {name}") elif dest.is_file(): dest.unlink() yield target, changed diff --git a/tests/test_execution_kernel.py b/tests/test_execution_kernel.py index 3f2cb35..9618e30 100644 --- a/tests/test_execution_kernel.py +++ b/tests/test_execution_kernel.py @@ -22,7 +22,7 @@ import dockerfile_security import post_tool_lint import run_per_language -from codeguard.execution import execute +from codeguard.execution import execute, execute_bytes from verdict import UNVERIFIED, lint_verdict @@ -88,6 +88,31 @@ def test_excessive_output_is_bounded_and_unverified(self): lint_verdict(outcome.returncode, argv, outcome.stdout + outcome.stderr, failure=outcome.failure)) + def test_binary_capture_preserves_nul_and_bounds_both_streams(self): + argv = [sys.executable, "-u", "-c", ( + "import os,time; os.write(1,b'\\x00blob\\xff'); " + "os.write(2,b'context\\x00'); time.sleep(0.1); " + "os.write(1,b'x'*200000); time.sleep(30)" + )] + started = time.monotonic() + outcome = execute_bytes(argv, self.root, 5, input_data=b"ignored\x00", + max_output_bytes=1024) + self.assertLess(time.monotonic() - started, 3) + self.assertEqual("output_limit", outcome.failure) + self.assertEqual(125, outcome.returncode) + self.assertIn(b"\x00blob\xff", outcome.stdout) + self.assertIn(b"context\x00", outcome.stderr) + self.assertLessEqual(len(outcome.stdout) + len(outcome.stderr.split(b"output limit exceeded")[0]), + 1025) + + def test_binary_capture_passes_exact_stdin_bytes(self): + argv = [sys.executable, "-c", "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())"] + payload = b"oid\x00\xff\n" + outcome = execute_bytes(argv, self.root, 5, input_data=payload, max_output_bytes=1024) + self.assertEqual(0, outcome.returncode) + self.assertIsNone(outcome.failure) + self.assertEqual(payload, outcome.stdout) + def test_language_check_keeps_output_limit_as_unverified(self): from codeguard import execution, language_check diff --git a/tests/test_git_snapshot_protocol.py b/tests/test_git_snapshot_protocol.py index 154f14b..9064fdf 100644 --- a/tests/test_git_snapshot_protocol.py +++ b/tests/test_git_snapshot_protocol.py @@ -1,10 +1,12 @@ """准确 Git 快照必须验证批量对象协议,不得把错位内容交给检查器。""" from __future__ import annotations +import os import subprocess import sys import tempfile import unittest +from dataclasses import replace from pathlib import Path from unittest.mock import patch @@ -29,7 +31,7 @@ def setUp(self): @staticmethod def corrupt_run(variant: str): - real_run = subprocess.run + real_run = git_snapshot.execute_bytes def run(argv, *args, **kwargs): result = real_run(argv, *args, **kwargs) @@ -63,13 +65,13 @@ def run(argv, *args, **kwargs): raw += b"unexpected" elif variant == "wrong_content_same_size": raw = header + separator + b"X" + rest[1:] - return subprocess.CompletedProcess(result.args, result.returncode, raw, result.stderr) + return replace(result, stdout=raw) return run @staticmethod def corrupt_listing(variant: str): - real_run = subprocess.run + real_run = git_snapshot.execute_bytes def run(argv, *args, **kwargs): result = real_run(argv, *args, **kwargs) @@ -98,7 +100,7 @@ def run(argv, *args, **kwargs): raw = b" ".join(fields) + b"\t" + name + raw[len(first):] elif variant == "invalid_header": raw = b"bogus\t" + first.split(b"\t", 1)[1] + raw[len(first):] - return subprocess.CompletedProcess(result.args, result.returncode, raw, result.stderr) + return replace(result, stdout=raw) return run @@ -109,6 +111,92 @@ def test_real_binary_blob_is_preserved_without_index_mutation(self): self.assertEqual(["sample.bin"], changed) self.assertEqual(before, (self.root / ".git/index").read_bytes()) + def test_git_binary_response_over_limit_never_returns_partial_records(self): + before = (self.root / ".git/index").read_bytes() + with self.assertRaisesRegex(git_snapshot.SnapshotError, "output_limit"): + git_snapshot.git(self.root, "ls-files", "--stage", "-z", max_output_bytes=8) + self.assertEqual(before, (self.root / ".git/index").read_bytes()) + + def test_predicted_overlays_have_an_aggregate_budget(self): + (self.root / "first.txt").write_bytes(b"a" * 10) + (self.root / "second.txt").write_bytes(b"b" * 10) + before = (self.root / ".git/index").read_bytes() + with (patch.object(git_snapshot, "_MAX_OVERLAY_BYTES", 15), + self.assertRaisesRegex(git_snapshot.SnapshotError, "覆盖层"), + git_snapshot.validation_tree(self.root, lanes={"untracked"})): + pass + self.assertEqual(before, (self.root / ".git/index").read_bytes()) + + def test_predicted_overlay_count_is_bounded_before_materialization(self): + (self.root / "first.txt").write_bytes(b"") + (self.root / "second.txt").write_bytes(b"") + before = (self.root / ".git/index").read_bytes() + with (patch.object(git_snapshot, "_MAX_OVERLAY_FILES", 1), + self.assertRaisesRegex(git_snapshot.SnapshotError, "文件数量"), + git_snapshot.validation_tree(self.root, lanes={"untracked"})): + pass + self.assertEqual(before, (self.root / ".git/index").read_bytes()) + + def test_changed_paths_and_materialized_overlay_share_one_listing(self): + (self.root / "first.txt").write_bytes(b"first") + (self.root / "second.txt").write_bytes(b"second") + with (patch.object(git_snapshot, "overlays", + side_effect=({"first.txt"}, {"second.txt"})) as listing, + git_snapshot.validation_tree(self.root, lanes={"untracked"}) as (snapshot, changed)): + self.assertIn("first.txt", changed) + self.assertNotIn("second.txt", changed) + self.assertEqual(b"first", (snapshot / "first.txt").read_bytes()) + self.assertFalse((snapshot / "second.txt").exists()) + self.assertEqual(1, listing.call_count) + + def test_predicted_file_to_directory_transition_is_order_independent(self): + (self.root / "slot").write_bytes(b"old file") + subprocess.run(["git", "add", "slot"], cwd=self.root, check=True, capture_output=True) + (self.root / "slot").unlink() + (self.root / "slot").mkdir() + (self.root / "slot" / "child.txt").write_bytes(b"new child") + with (patch.object(git_snapshot, "overlays", return_value=("slot/child.txt", "slot")), + git_snapshot.validation_tree(self.root, lanes={"unstaged", "untracked"}) as (snapshot, _)): + self.assertEqual(b"new child", (snapshot / "slot" / "child.txt").read_bytes()) + + def test_predicted_directory_to_file_transition_replaces_old_children(self): + (self.root / "slot").mkdir() + (self.root / "slot" / "child.txt").write_bytes(b"old child") + subprocess.run(["git", "add", "slot"], cwd=self.root, check=True, capture_output=True) + (self.root / "slot" / "child.txt").unlink() + (self.root / "slot").rmdir() + (self.root / "slot").write_bytes(b"new file") + with git_snapshot.validation_tree(self.root, lanes={"unstaged", "untracked"}) as (snapshot, _): + self.assertEqual(b"new file", (snapshot / "slot").read_bytes()) + self.assertFalse((snapshot / "slot" / "child.txt").exists()) + + @unittest.skipUnless(hasattr(os, "mkfifo"), "FIFO fixture requires POSIX") + def test_predicted_fifo_is_not_silently_treated_as_deleted(self): + (self.root / "pipe").write_bytes(b"ordinary before replacement") + subprocess.run(["git", "add", "pipe"], cwd=self.root, check=True, capture_output=True) + (self.root / "pipe").unlink() + os.mkfifo(self.root / "pipe") + with (self.assertRaisesRegex(git_snapshot.SnapshotError, "特殊文件"), + git_snapshot.validation_tree(self.root, lanes={"unstaged"})): + pass + + @unittest.skipUnless(hasattr(os, "mkfifo"), "FIFO fixture requires POSIX") + def test_fifo_replacement_at_open_cannot_block_snapshot(self): + os.mkfifo(self.root / "pipe") + code = ( + "import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); " + "from git_snapshot import SnapshotError, _copy_overlay\n" + "try:\n" + " _copy_overlay(Path(sys.argv[2]), Path(sys.argv[3]), 1024)\n" + "except SnapshotError:\n" + " raise SystemExit(0)\n" + "raise SystemExit(1)\n" + ) + outcome = subprocess.run([sys.executable, "-c", code, str(ROOT / "scripts"), + str(self.root / "pipe"), str(self.root / "dest")], + capture_output=True, timeout=2, check=False) + self.assertEqual(0, outcome.returncode, outcome.stderr.decode(errors="replace")) + def test_empty_index_remains_a_valid_empty_snapshot(self): with tempfile.TemporaryDirectory(prefix="cg-snapshot-empty-") as directory: root = Path(directory).resolve() @@ -147,7 +235,7 @@ def test_inconsistent_batch_protocol_never_yields_a_snapshot(self): "missing_delimiter", "trailing", "wrong_content_same_size") for variant in variants: with (self.subTest(variant=variant), - patch.object(git_snapshot.subprocess, "run", + patch.object(git_snapshot, "execute_bytes", side_effect=self.corrupt_run(variant)), self.assertRaises(git_snapshot.SnapshotError), git_snapshot.validation_tree(self.root)): @@ -163,7 +251,7 @@ def test_incomplete_index_listing_never_yields_a_snapshot(self): "empty", "empty_record", "empty_name", "invalid_oid", "invalid_header") for variant in variants: with (self.subTest(variant=variant), - patch.object(git_snapshot.subprocess, "run", + patch.object(git_snapshot, "execute_bytes", side_effect=self.corrupt_listing(variant)), self.assertRaises(git_snapshot.SnapshotError), git_snapshot.validation_tree(self.root)): @@ -179,28 +267,27 @@ def test_incomplete_head_listing_never_yields_a_push_snapshot(self): for variant in ("missing_terminator", "missing_complete_record", "empty", "duplicate", "invalid_oid"): with (self.subTest(variant=variant), - patch.object(git_snapshot.subprocess, "run", + patch.object(git_snapshot, "execute_bytes", side_effect=self.corrupt_listing(variant)), self.assertRaises(git_snapshot.SnapshotError), git_snapshot.validation_tree(self.root, mode="push")): pass def test_incomplete_path_name_response_is_not_treated_as_empty(self): - real_run = subprocess.run + real_run = git_snapshot.execute_bytes def run(argv, *args, **kwargs): result = real_run(argv, *args, **kwargs) if argv[:4] == ["git", "diff", "--cached", "--name-only"]: - return subprocess.CompletedProcess(result.args, result.returncode, - result.stdout[:-1], result.stderr) + return replace(result, stdout=result.stdout[:-1]) return result - with (patch.object(git_snapshot.subprocess, "run", side_effect=run), + with (patch.object(git_snapshot, "execute_bytes", side_effect=run), self.assertRaises(git_snapshot.SnapshotError)): git_snapshot.proposed_paths(self.root) def test_malformed_listing_is_visible_to_exact_gate(self): - with patch.object(git_snapshot.subprocess, "run", + with patch.object(git_snapshot, "execute_bytes", side_effect=self.corrupt_listing("missing_terminator")): failures, skipped = run_gate(self.root, {}, ["python"], exact=True) self.assertEqual([], failures) @@ -216,7 +303,7 @@ def test_special_filename_is_preserved_by_nul_listing(self): self.assertIn(name, changed) def test_exact_gate_reports_corrupt_blob_as_unverified(self): - with patch.object(git_snapshot.subprocess, "run", + with patch.object(git_snapshot, "execute_bytes", side_effect=self.corrupt_run("wrong_oid")): failures, skipped = run_gate(self.root, {}, ["python"], exact=True) self.assertEqual([], failures) @@ -227,7 +314,7 @@ def test_known_sensitive_path_still_blocks_when_blob_is_unverified(self): subprocess.run(["git", "add", ".env"], cwd=self.root, check=True, capture_output=True) before = (self.root / ".git/index").read_bytes() - with patch.object(git_snapshot.subprocess, "run", + with patch.object(git_snapshot, "execute_bytes", side_effect=self.corrupt_run("wrong_oid")): result = evaluate_git_command("git commit -m fixture", cwd=self.root, load_config=dict)