diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 7afbe01..ef57251 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.10" + "ref": "v0.14.11" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.14.10", + "version": "0.14.11", "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.10/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.11/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.10/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.11/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 402d9bb..b76c5fa 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.10+codex.20260923", + "version": "0.14.11+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 cc304a5..9e7970a 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.14.10", + "version": "0.14.11", "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 1021024..308024f 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -27,6 +27,7 @@ flowchart TD ``` `execution` 只记录进程证据,不把非零退出自动认定为代码违规。`verdict`、CVE 与 Dockerfile 报告解析决定 PASS、FAIL 或 UNVERIFIED;入口仅按各自协议呈现和聚合退出码。Git 提交检查使用 index 或预测暂存快照,推送检查使用 HEAD;保存钩子只给反馈。计划、未执行和未验证不能宣传为通过。 +Git 准确快照的二进制读取独立于文本检查执行器:`cat-file` 两阶段响应逐项核对 ID、blob 类型、大小与边界,并按 SHA-1/SHA-256 blob 对象格式复算内容哈希;缺项、截断、尾部脏数据或同长度内容替换均不交付临时树,而是明确标记 Git UNVERIFIED。原始 index 不在观察过程中改写。该校验只证明读取到的对象与列出的对象一致,不是执行沙箱或完整 Git/Shell 模拟。 `language_check` 将 `PlanExecution` 的实际命令依序映射为应用层有界 `execution_trace`(检查、修复、复检阶段);`check_application` 将其进一步压成 MCP 安全元数据,不回显原始 argv、环境覆盖或捕获输出。`auto_fix` 的 `fix_results` 也只公开状态与安全元数据;formatter stderr 尾部写入私有 `.codeguard-fix.log` 后只公开路径,日志不可用时不退回公开原文。失败日志保留各已执行检查的完整输出,可能含敏感文本,应排除出版本控制。项目日志和门禁截断日志共用 `storage.write_private_text` 的私有原子落盘;预置日志文件链接不被跟随,默认 `out` 目录为链接或不可写时不返回虚假日志路径,也不覆盖检查结论。终止命令仍决定既有状态、退出码和旧字段;未运行的计划命令不进入证据。 Git 命令的仓库定位与拟暂存解析共用入口传入的 cwd;显式 `cd`/`git -C` 无法绑定 Git 工作树时,硬门禁给出目标未验证并阻断,不退回到调用者仓或无关子仓。未给出显式目标且调用目录非仓时仍保留 workspace 子仓兜底。静态命令解析不等于完整 Shell 执行模拟。 workspace 兜底产生的每个子仓是合成目标,暂存观察也绑定该子仓根目录;不能继续以非 Git 的 workspace cwd 解析,从而把脏子仓的已知违规变成未知放行。 @@ -47,7 +48,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 0630ee7..4e9b13b 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.10", + "version": "0.14.11", "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 d3b1eb4..b8e18cd 100644 --- a/openspec/changes/refactor-codeguard-architecture/design.md +++ b/openspec/changes/refactor-codeguard-architecture/design.md @@ -76,6 +76,13 @@ MCP 的 `auto_fix` 还必须在应用层单独投影公开结果:`run_fix` 保 `path_policy` 是扫描排除表、产物识别与拟入库路径规则的单一来源;`scope` 和 `gate_lib` 只兼容导出/调用。策略接收已经确定的路径,不读取 Git,更不会执行建议中的移除操作。Git 快照采集仍在 git_snapshot,反馈格式仍在 reporting。 +`git_snapshot` 保持二进制 Git 基础设施边界,不经文本检查执行器转换 blob。对象列举之后, +同一个 Git 字节执行入口读取 `cat-file --batch-check` 和 `--batch`;逐项确认对象 ID、类型、 +大小、数量、顺序、终止分隔符与尾部边界,并以 SHA-1/SHA-256 Git blob 对象格式重新计算 +payload 哈希。任一不一致转为 `SnapshotError`,准确门禁沿既有策略显示 UNVERIFIED;不把 +错误对象或同长度但被替换的内容交给语言检查器。256 MiB 预算按已核对的对象大小累计, +读取与写入保留原始二进制字节且不改真实 index。这仍非恶意文件系统或 Git 进程沙箱。 + 直接命令链的拟暂存分析分成静态事件观察与按提交时间投影:先把同仓 `add`/`commit` 及其调用目录记录为不可变事件,再只将最后一个 commit 及以前的 add 送往 Git pathspec 查询和内容快照。这样后续 add 不会回溯污染已完成提交,也不跳过两个 commit 之间的 add。旧的独立 `staging_intent` 调用默认仍返回整条链的暂存意图;硬门禁只在没有一层间接脚本或 Shell 命令替换时启用按提交投影。`$(...)`/反引号的内层实际先执行,而旧静态展开把它附在文本尾部;间接脚本与外层 add 的跨层顺序也尚无统一事件时钟。这两类命令继续采用保守并集,不能以局部排序缩小检查面。 这不是完整 Shell 解释器:分隔符/引用/动态拼接和复杂相对 cd 仍有既有边界。显式 `cd` 或 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 c495530..26a61a4 100644 --- a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md +++ b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md @@ -231,6 +231,24 @@ CLI 呈现 MUST 各有单一所有者。只有有效报告且执行状态可解 - **WHEN** npm 修复命令执行后复扫失败或返回干净报告 - **THEN** 分别返回 UNVERIFIED 或 PASS,保留修复前报告和实际修复执行证据 +### Requirement: Accurate Git snapshots SHALL validate object transport + +Git 准确快照 MUST 对 index/HEAD 列出的每个对象,逐项核对 `cat-file --batch-check` 与 +`--batch` 响应的对象 ID、blob 类型、非负大小、顺序和完整字节边界。响应缺项、重复项、 +内容截断或多余尾部字节 MUST 报告快照 UNVERIFIED,不得把错误对象或部分内容交给语言检查器。 +实际 payload MUST 按仓库对象格式重新计算 blob 哈希并与 index/HEAD 对象 ID 一致; +同长度内容被替换也不能通过仅核对响应头而被接受。 +大小预算 MUST 基于已验证的逐项大小;二进制内容中的换行与 NUL 必须原样保留。 +观察过程不得改写真实 Git index。此要求不改变既有 Hook 对未知结果的 fail-open/可见告警政策。 + +#### Scenario: Git batch response disagrees with the requested objects +- **WHEN** 批量读取的对象 ID、类型、大小、数量或顺序与已列出的 Git 对象不一致 +- **THEN** 准确快照不可交付,门禁报告 Git UNVERIFIED,而不是检查错位或部分内容 + +#### Scenario: Batch payload has an invalid boundary or identity +- **WHEN** 对象内容截断、缺少协议分隔符、尾随额外数据或同长度内容与对象哈希不符 +- **THEN** 不交付临时树,不改变真实 index;有效二进制 blob 仍可逐字节重建 + ### Requirement: Predicted staging SHALL preserve repository and pathspec scope 拟暂存范围 MUST 绑定每个 git add 的实际仓库与工作目录,而非整条命令链的最终 cd 或全局 diff --git a/openspec/changes/refactor-codeguard-architecture/tasks.md b/openspec/changes/refactor-codeguard-architecture/tasks.md index 7f88b85..8aee7d3 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 和逐条规格追溯通过;第二十四批本地 521 项单测、143 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 -- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;第二十三批已发布 v0.14.9(插件 PR #55、市场 PR #13、源码 PR/main CI、注释 tag/正式 Release 均核对;市场 PR 无 CI 检查)。第二十四批静态 Git 命令顺序修正目前仅在本地,发布需独立验证。宿主安装运行独立记录,未授权不得改宿主。 +- [x] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过;第二十五批本地 526 项单测、143 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 +- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;第二十四批已发布 v0.14.10(插件 PR #56、市场 PR #14、源码 PR/main CI、注释 tag/正式 Release 均核对;市场 PR 无 CI 检查)。第二十五批 Git blob 传输校验仍在隔离 worktree,本地实现不等于新版本发布。宿主安装运行独立记录,未授权不得改宿主。 - [ ] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。 diff --git a/openspec/changes/refactor-codeguard-architecture/verification.md b/openspec/changes/refactor-codeguard-architecture/verification.md index 5240b42..99021d1 100644 --- a/openspec/changes/refactor-codeguard-architecture/verification.md +++ b/openspec/changes/refactor-codeguard-architecture/verification.md @@ -254,3 +254,11 @@ - 继续沿 CodeGraph 的 `_flatten_substitutions → resolve_git_operations/staging_intent` 审查时发现安全反例:`$(git add .env)` 与反引号在外层 commit 前执行,但静态展开把内层文本附到末尾。真实 Hook 测试先 RED(敏感文件被错误放行);现含命令替换的链保守并集,不启用文本顺序截止。两种带有效提交消息的形式均转绿,原始 index 不改写。单引号中的字面替换可能保守误拦,这一历史解析边界未宣称解决。 - 本地完整单测 **521/521、0 skipped**,真实 Hook 回归 **143/0/0**;Ruff、架构依赖/循环、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 通过。受管技能及 lock 未改;已发布的 v0.14.9 不含本批工作树。当前只证明无脚本/命令替换的直接静态链顺序归属,跨一层脚本与命令替换仍保守合并;Windows、三宿主已安装运行、在线 CVE、大型 Maven/Gradle 与完整 Shell 控制流仍未验收。 - 宿主只读复核:本机 ZCode `installed_plugins.json` 的 `codeguard@full-stack-plugins` 仍登记 `0.14.7`,不能拿 v0.14.9 的 Release 或市场 ref 当作该宿主的当前加载/触发证据。未修改任何宿主安装。 + +- 第二十四批随后发布为 v0.14.10:源码 PR #56 合并到 `aa7a04aec9f5dbe220acfcc411a9fa10a3784367`,市场 PR #14 合并到 `a649eaf88673225d3dba54e1fb1a090d1a97daa3`;源码 PR 的 `vendor-check` 与合并后 main 的 `skills-check` 均成功。远端注释 tag `v0.14.10` 解引用到源码合并提交,正式 Release 非 draft、非 prerelease。市场 Codex/ZCode/Kimi 清单全量校验通过;市场 PR 无 CI 检查,不称其 CI 已通过。此发布不覆盖以下第二十五批隔离 worktree。 + +## 第二十五批实际验证:Git blob 批量传输的对象身份 + +- 最新 main 的隔离临时 CodeGraph 索引沿 `validation_tree → cat-file --batch-check/--batch → gate.run_gate` 追到准确快照的信任边界:旧实现只汇总大小,再以 `--batch` 头部长度切片,未逐项核对 ID、类型、数量、大小、尾部边界或 payload 哈希。独立故障注入证明错误对象 ID 的内容仍被接受;新增测试中 9 类协议错位/截断及门禁 UNVERIFIED 场景先 RED,真实二进制 blob 对照保持通过。 +- `git_snapshot` 的字节型 Git 入口统一执行错误归一化;批量大小响应和内容响应按 index/HEAD 的同一对象顺序验证。每段 blob 按 SHA-1 或 SHA-256 Git 对象格式重新计算哈希,因此同长度替换也不能通过伪造响应头被接受;有效包含换行/NUL 的内容仍原样重建。错误响应转 `SnapshotError`,准确门禁返回 `git UNVERIFIED`,真实 index 字节不变。进一步用应用层集成用例确认:同批已经暂存的 `.env` 仍被安全规则 exit 2 拦截,未知快照不会抹掉已知违规;既有未知项 fail-open/可见告警政策未改。 +- 隔离 worktree 本地完整单测 **526/526、0 skipped**,真实 Hook 回归 **143/0/0**;Ruff、架构依赖/循环、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 均通过。当前只证明本地静态/故障注入契约与真实 SHA-1/SHA-256 仓库,不等于三宿主已安装现场、Windows、在线 CVE、大型 Maven/Gradle 或恶意 Git 进程沙箱验收。第二十五批尚未 bump、提交、推送或发布。 diff --git a/scripts/git_snapshot.py b/scripts/git_snapshot.py index d821705..77f0232 100644 --- a/scripts/git_snapshot.py +++ b/scripts/git_snapshot.py @@ -2,6 +2,7 @@ from __future__ import annotations import contextlib +import hashlib import os import subprocess import tempfile @@ -12,10 +13,10 @@ class SnapshotError(RuntimeError): """无法准确取得拟提交内容,调用者必须报告未验证。""" -def git(root: Path, *args: str) -> bytes: +def git(root: Path, *args: str, input_data: bytes | None = None) -> bytes: try: proc = subprocess.run(["git", *args], cwd=root, capture_output=True, - check=False, timeout=30) + input=input_data, check=False, timeout=30) except (OSError, subprocess.TimeoutExpired) as exc: raise SnapshotError(str(exc)) from exc if proc.returncode: @@ -79,6 +80,62 @@ def proposed_paths(root: Path, mode="commit", *, lanes=None, extra=None, return sorted(selected) +def _batch_header(line: bytes, oid: str, *, expected_size: int | None = None) -> int: + """验证 cat-file 单项响应,不能只相信响应声明的长度。""" + fields = line.split(b" ") + if len(fields) != 3 or fields[0] != oid.encode("ascii") or fields[1] != b"blob": + raise SnapshotError("Git 对象批量响应身份或类型与 index/HEAD 不一致") + try: + size = int(fields[2]) + except ValueError as exc: + raise SnapshotError("Git 对象批量响应大小无效") from exc + if size < 0 or (expected_size is not None and size != expected_size): + raise SnapshotError("Git 对象批量响应大小与 index/HEAD 不一致") + return size + + +def _batch_sizes(raw: bytes, entries: list[tuple[str, str, str]]) -> tuple[int, ...]: + """严格核对 batch-check 的数量、顺序与总量预算。""" + if entries and not raw.endswith(b"\n"): + raise SnapshotError("Git 对象大小响应不完整") + lines = raw[:-1].split(b"\n") if raw else [] + if len(lines) != len(entries): + raise SnapshotError("Git 对象大小响应数量与 index/HEAD 不一致") + sizes = [] + total = 0 + for line, (_, oid, _) in zip(lines, entries, strict=True): + size = _batch_header(line, oid) + total += size + if total > 256 * 1024 * 1024: + raise SnapshotError("快照超过 256 MiB 上限,需要独立 CI 检查") + sizes.append(size) + return tuple(sizes) + + +def _batch_blob(raw: bytes, offset: int, oid: str, expected_size: int) -> tuple[memoryview, int]: + """只交付身份、大小和完整分隔符均已验证的 blob 字节。""" + header_end = raw.find(b"\n", offset) + if header_end < 0: + raise SnapshotError("Git 对象内容响应缺少头部") + size = _batch_header(raw[offset:header_end], oid, expected_size=expected_size) + start = header_end + 1 + end = start + size + if end >= len(raw) or raw[end] != 10: + raise SnapshotError("Git 对象内容响应截断或分隔符无效") + content = memoryview(raw)[start:end] + if len(oid) == 40: + digest = hashlib.sha1() + elif len(oid) == 64: + digest = hashlib.sha256() + else: + raise SnapshotError("Git 对象 ID 格式未验证") + digest.update(f"blob {size}\0".encode("ascii")) + digest.update(content) + if digest.hexdigest() != oid: + raise SnapshotError("Git 对象内容哈希与 index/HEAD 不一致") + return content, end + 1 + + @contextlib.contextmanager def validation_tree(root: Path, mode="commit", *, lanes=None, extra=None, pending_commit=False): """原始 Git blobs 构成快照;拒绝外链、submodule、冲突和超限内容。""" @@ -97,27 +154,20 @@ def validation_tree(root: Path, mode="commit", *, lanes=None, extra=None, pendin if len(entries) > 20000: raise SnapshotError("快照超过 20000 文件上限,需要独立 CI 检查") object_ids = "".join(oid + "\n" for _, oid, _ in entries).encode() - try: - sizes = subprocess.run(["git", "cat-file", "--batch-check"], input=object_ids, - cwd=root, capture_output=True, check=True, timeout=30).stdout - if sum(int(line.split()[-1]) for line in sizes.splitlines()) > 256 * 1024 * 1024: - raise SnapshotError("快照超过 256 MiB 上限,需要独立 CI 检查") - blob = subprocess.run(["git", "cat-file", "--batch"], input=object_ids, - cwd=root, capture_output=True, check=True, timeout=30).stdout - except (ValueError, OSError, subprocess.SubprocessError) as exc: - raise SnapshotError(f"读取 Git 内容失败: {exc}") from exc + 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) with tempfile.TemporaryDirectory(prefix="codeguard-snapshot-") as directory: target = Path(directory) offset = 0 - for mode_bits, _oid, name in entries: - end = blob.index(b"\n", offset) - size = int(blob[offset:end].split()[-1]) + for (mode_bits, oid, name), size in zip(entries, sizes, strict=True): + content, offset = _batch_blob(blob, offset, oid, size) dest = safe_path(target, name) dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(blob[end + 1:end + 1 + size]) + dest.write_bytes(content) dest.chmod(0o755 if mode_bits == "100755" else 0o644) - offset = end + size + 2 + if offset != len(blob): + raise SnapshotError("Git 对象内容响应存在多余尾部字节") if not head: for name in overlays(root, lanes, extra): source, dest = safe_path(root, name), safe_path(target, name) diff --git a/tests/test_git_snapshot_protocol.py b/tests/test_git_snapshot_protocol.py new file mode 100644 index 0000000..3fbafe3 --- /dev/null +++ b/tests/test_git_snapshot_protocol.py @@ -0,0 +1,128 @@ +"""准确 Git 快照必须验证批量对象协议,不得把错位内容交给检查器。""" +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import git_snapshot +from codeguard.gate import run_gate +from codeguard.git_guard_application import evaluate_git_command + + +class GitSnapshotProtocolTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="cg-snapshot-protocol-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + subprocess.run(["git", "init", "-q"], cwd=self.root, check=True, capture_output=True) + self.content = b"first line\n\x00second line\r\n" + (self.root / "sample.bin").write_bytes(self.content) + subprocess.run(["git", "add", "sample.bin"], cwd=self.root, + check=True, capture_output=True) + + @staticmethod + def corrupt_run(variant: str): + real_run = subprocess.run + + def run(argv, *args, **kwargs): + result = real_run(argv, *args, **kwargs) + if argv[:2] != ["git", "cat-file"]: + return result + raw = result.stdout + header, separator, rest = raw.partition(b"\n") + if argv[2] == "--batch-check": + if variant == "missing_check": + raw = b"" + elif variant == "duplicate_check": + raw += raw + elif variant == "wrong_check_size": + oid, kind, size = header.split() + raw = b" ".join((oid, kind, str(int(size) + 1).encode())) + separator + rest + elif argv[2] == "--batch": + if variant in ("wrong_oid", "wrong_type", "wrong_batch_size"): + oid, kind, size = header.split() + if variant == "wrong_oid": + oid = b"0" * len(oid) + elif variant == "wrong_type": + kind = b"tree" + else: + size = str(int(size) - 1).encode() + raw = b" ".join((oid, kind, size)) + separator + rest + elif variant == "truncated": + raw = raw[:-2] + elif variant == "missing_delimiter": + raw = raw[:-1] + elif variant == "trailing": + 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 run + + def test_real_binary_blob_is_preserved_without_index_mutation(self): + before = (self.root / ".git/index").read_bytes() + with git_snapshot.validation_tree(self.root) as (snapshot, changed): + self.assertEqual(self.content, (snapshot / "sample.bin").read_bytes()) + self.assertEqual(["sample.bin"], changed) + self.assertEqual(before, (self.root / ".git/index").read_bytes()) + + def test_sha256_repository_uses_its_object_format(self): + with tempfile.TemporaryDirectory(prefix="cg-snapshot-sha256-") as directory: + root = Path(directory).resolve() + subprocess.run(["git", "init", "-q", "--object-format=sha256"], cwd=root, + check=True, capture_output=True) + (root / "sample.bin").write_bytes(self.content) + subprocess.run(["git", "add", "sample.bin"], cwd=root, + check=True, capture_output=True) + before = (root / ".git/index").read_bytes() + with git_snapshot.validation_tree(root) as (snapshot, changed): + self.assertEqual(self.content, (snapshot / "sample.bin").read_bytes()) + self.assertEqual(["sample.bin"], changed) + self.assertEqual(before, (root / ".git/index").read_bytes()) + + def test_inconsistent_batch_protocol_never_yields_a_snapshot(self): + before = (self.root / ".git/index").read_bytes() + variants = ("missing_check", "duplicate_check", "wrong_check_size", + "wrong_oid", "wrong_type", "wrong_batch_size", "truncated", + "missing_delimiter", "trailing", "wrong_content_same_size") + for variant in variants: + with (self.subTest(variant=variant), + patch.object(git_snapshot.subprocess, "run", + side_effect=self.corrupt_run(variant)), + self.assertRaises(git_snapshot.SnapshotError), + git_snapshot.validation_tree(self.root)): + pass + self.assertEqual(before, (self.root / ".git/index").read_bytes()) + + def test_exact_gate_reports_corrupt_blob_as_unverified(self): + with patch.object(git_snapshot.subprocess, "run", + side_effect=self.corrupt_run("wrong_oid")): + failures, skipped = run_gate(self.root, {}, ["python"], exact=True) + self.assertEqual([], failures) + self.assertTrue(any("git UNVERIFIED" in item for item in skipped), skipped) + + def test_known_sensitive_path_still_blocks_when_blob_is_unverified(self): + (self.root / ".env").write_text("FIXTURE_ONLY=not-a-secret\n", encoding="utf-8") + 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", + side_effect=self.corrupt_run("wrong_oid")): + result = evaluate_git_command("git commit -m fixture", cwd=self.root, + load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertTrue(any("git UNVERIFIED" in item for item in result.contexts), result.contexts) + self.assertIn(".env", result.stderr) + self.assertEqual(before, (self.root / ".git/index").read_bytes()) + + +if __name__ == "__main__": + unittest.main()