From 83df560fe9f2f5541a53e3574492dbcc2838c895 Mon Sep 17 00:00:00 2001 From: eatdrop Date: Mon, 3 Aug 2026 16:34:28 +0800 Subject: [PATCH] Add real Bottle integration --- .github/workflows/ci.yml | 36 +++- CHANGELOG.md | 18 ++ Makefile | 10 +- README.md | 17 +- docs/assets/patchproof-demo.svg | 2 +- .../bottle-http-preconditions/README.md | 65 +++++++ .../UPSTREAM_LICENSE.txt | 19 ++ .../bottle-http-preconditions/case.json | 17 ++ .../hidden/test_etag_disabled_precedence.py | 41 ++++ .../test_precondition_precedence.py | 41 ++++ .../bottle-http-preconditions/run_case.py | 177 ++++++++++++++++++ .../upstream-fix.diff | 15 ++ pyproject.toml | 2 +- src/patchproof/__init__.py | 2 +- src/patchproof/cli.py | 5 + src/patchproof/patch.py | 4 +- src/patchproof/repository.py | 1 + src/patchproof/runner.py | 24 ++- src/patchproof/validator.py | 20 +- tests/test_bottle_integration.py | 42 +++++ tests/test_cli.py | 3 + tests/test_patch.py | 13 ++ tests/test_repository.py | 7 + tests/test_runner.py | 35 ++++ tests/test_validator.py | 29 +++ 25 files changed, 628 insertions(+), 17 deletions(-) create mode 100644 integrations/bottle-http-preconditions/README.md create mode 100644 integrations/bottle-http-preconditions/UPSTREAM_LICENSE.txt create mode 100644 integrations/bottle-http-preconditions/case.json create mode 100644 integrations/bottle-http-preconditions/hidden/test_etag_disabled_precedence.py create mode 100644 integrations/bottle-http-preconditions/reproduction/test_precondition_precedence.py create mode 100755 integrations/bottle-http-preconditions/run_case.py create mode 100644 integrations/bottle-http-preconditions/upstream-fix.diff create mode 100644 tests/test_bottle_integration.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb2d76a..6040866 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Compile package and scripts - run: python -m compileall -q src tests skills + run: python -m compileall -q src tests skills integrations - name: Run tests run: make test - name: Install build backend @@ -73,3 +73,37 @@ jobs: path: /tmp/patchproof-audit/ if-no-files-found: error retention-days: 14 + + bottle-integration: + runs-on: ubuntu-latest + env: + PATCHPROOF_IMAGE: python:3.12.10-slim@sha256:fd95fa221297a88e1cf49c55ec1828edd7c5a428187e67b5d1805692d11588db + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Check out pinned Bottle pre-fix commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: bottlepy/bottle + ref: 2a743a302a71460bfe4c0b8b7cb99a306b0328c6 + path: external/bottle + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Pull pinned validation image + run: docker pull "$PATCHPROOF_IMAGE" + - name: Validate the real upstream Bottle fix + run: | + PYTHONPATH=src python integrations/bottle-http-preconditions/run_case.py \ + --repo external/bottle \ + --audit-dir /tmp/patchproof-bottle-audit \ + --docker-image "$PATCHPROOF_IMAGE" \ + > /tmp/patchproof-bottle-result.json + python -c 'import json; result=json.load(open("/tmp/patchproof-bottle-result.json")); assert result["proof_grade"] is True; assert result["repository_unchanged"] is True; assert result["phases"][2]["tests_run"] == 359' + - name: Upload Bottle proof artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: patchproof-bottle-integration + path: /tmp/patchproof-bottle-audit/ + if-no-files-found: error + retention-days: 14 diff --git a/CHANGELOG.md b/CHANGELOG.md index 55bc90c..1d5b52b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes are recorded here. The project remains alpha before `1.0.0`, and minor releases may change experimental interfaces. +## 0.1.2 — 2026-08-03 + +### 新增 / Added + +- 新增 Bottle 真实历史缺陷集成:固定修复前提交与准确的上游源码修复 Hunk,配套独立复现和边界测试,并运行 359 项 Bottle 原生回归测试。 + Added a real historical Bottle defect integration with a pinned pre-fix commit, the exact upstream source-fix hunk, independent reproduction and edge-case tests, and 359 project-native regressions. +- 新增独立 `bottle-integration` Docker CI 作业并上传内容寻址审计产物。 + Added a dedicated Docker-backed `bottle-integration` CI job with uploaded content-addressed audit artifacts. + +### 变更 / Changed + +- 固定 `unittest discover` Runner 现在可选安全的仓库相对 discovery top-level,从而支持包含相对导入的测试包。 + The fixed `unittest discover` runner now accepts an optional safe repository-relative discovery top-level for test packages with relative imports. +- 默认文本快照新增 `.tpl`,使项目原生模板测试夹具能够进入只读副本和快照绑定。 + Default text snapshots now include `.tpl` files so project-native template fixtures enter the read-only copy and snapshot binding. +- 外置测试复制会忽略标准 `__pycache__` 生成物,同时继续拒绝其他非 Python 文件与符号链接。 + External test copying now ignores standard `__pycache__` artifacts while continuing to reject other non-Python files and symbolic links. + ## 0.1.1 — 2026-08-03 ### 新增 / Added diff --git a/Makefile b/Makefile index 495b449..f246068 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,10 @@ PYTHON ?= python3 RUN_DIR ?= /tmp/patchproof-demo IMAGE ?= python:3.12.10-slim@sha256:fd95fa221297a88e1cf49c55ec1828edd7c5a428187e67b5d1805692d11588db +BOTTLE_REPO ?= /tmp/patchproof-bottle +BOTTLE_AUDIT_DIR ?= /tmp/patchproof-bottle-audit -.PHONY: test demo-local demo clean +.PHONY: test demo-local demo bottle-local clean test: PYTHONPATH=src $(PYTHON) -m unittest discover -s tests -v @@ -10,6 +12,12 @@ test: demo-local: PYTHONPATH=src $(PYTHON) scripts/run_demo.py +bottle-local: + PYTHONPATH=src $(PYTHON) integrations/bottle-http-preconditions/run_case.py \ + --repo $(BOTTLE_REPO) \ + --audit-dir $(BOTTLE_AUDIT_DIR) \ + --unsafe-local + demo: PYTHONPATH=src $(PYTHON) -m patchproof propose \ --repo fixtures/calculator \ diff --git a/README.md b/README.md index b234c83..e04d615 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ An evidence-grade patch validator for coding-agent platforms, evaluations, and C > > The four phase results come from the repository's real fixture and `make demo-local`; `proof_grade=true` is established by the network-disabled, read-only, non-root Docker CI job. +**真实第三方证据 / Real third-party evidence:**PatchProof 现在固定并验证 Bottle 的真实 HTTP 条件请求修复:修复前失败、修复后通过、359 项上游回归通过、独立边界测试通过,目标 checkout 保持不变。查看 [Bottle 集成案例](integrations/bottle-http-preconditions/README.md)。 + +PatchProof now pins and validates a real Bottle HTTP-precondition fix: fail before, pass after, 359 upstream regressions passing, a distinct edge case passing, and an unchanged target checkout. See the [Bottle integration case](integrations/bottle-http-preconditions/README.md). + ## 3 秒看懂 / Understand It in 3 Seconds | | 中文 | English | @@ -243,12 +247,14 @@ The command revalidates strict structure, phase semantics, and the content hash, ## 可验证证据 / Verifiable Evidence -- **71 项自动化测试 / 71 automated tests**:覆盖 Diff、路径、符号链接、快照、审批、补丁应用、Runner、CLI、Demo、回执与 Skill 脚本。 - They cover diffs, paths, symlinks, snapshots, approvals, patch application, runners, CLI, demo behavior, receipts, and Skill scripts. +- **80 项自动化测试 / 80 automated tests**:覆盖 Diff、路径、符号链接、快照、审批、补丁应用、Runner、CLI、Demo、回执、真实集成 Manifest 与 Skill 脚本。 + They cover diffs, paths, symlinks, snapshots, approvals, patch application, runners, CLI, demo behavior, receipts, the real-integration manifest, and Skill scripts. - **Python 3.11/3.12 CI**:每次 PR 与 `main` 推送均运行。 It runs on every pull request and `main` push. - **真实 Docker Shadow Validation / Real Docker shadow validation**:在 GitHub Actions 中执行完整四阶段闭环并上传审计产物。 It executes the full four-phase loop in GitHub Actions and uploads audit artifacts. +- **第三方开源集成 / Third-party open-source integration**:[Bottle HTTP 条件请求案例](integrations/bottle-http-preconditions/README.md)绑定真实修复前提交与上游源码修复 Hunk,并运行 359 项项目原生回归测试。 + The [Bottle HTTP-precondition case](integrations/bottle-http-preconditions/README.md) binds a real pre-fix commit and the exact upstream source-fix hunk, then runs 359 project-native regression tests. - **只读前向案例 / Read-only forward case**:[IssueLens 复用记录](docs/issuelens-case-study.md)扫描 63 个真实项目文件并验证前后快照一致。 The [IssueLens reuse record](docs/issuelens-case-study.md) scans 63 real project files and verifies identical before/after snapshots. - **可安装 Release / Installable release**:[GitHub Releases](https://github.com/eatdrop/patchproof/releases)包含经过构建与隔离安装验证的 wheel。 @@ -259,14 +265,14 @@ The command revalidates strict structure, phase semantics, and the content hash, | 已实现并测试 / Implemented and tested | 明确不声称 / Explicitly not claimed | |---|---| | 有界 UTF-8 文本与 Unified Diff / Bounded UTF-8 text and unified diffs | 任意二进制、重命名或所有 Diff 方言 / Arbitrary binaries, renames, or every diff dialect | -| 固定 Python `unittest discover` / Fixed Python `unittest discover` | pytest/tox/nox、依赖安装或任意命令 / pytest/tox/nox, dependency installation, or arbitrary commands | +| 固定 Python `unittest discover`,支持包式 discovery top-level / Fixed Python `unittest discover` with package top-level support | pytest/tox/nox、依赖安装或任意命令 / pytest/tox/nox, dependency installation, or arbitrary commands | | 单租户可信宿主上的 Docker 加固 / Docker hardening on a trusted single-tenant host | 生产级恶意多租户沙箱 / Production hostile multi-tenant sandbox | | 哈希完整性与内容寻址 / Hash integrity and content addressing | 数字签名、可信时间戳或身份认证 / Digital signatures, trusted timestamps, or identity authentication | | 独立协议、测试与真实前向审计 / Independent protocol, tests, and real forward audit | 已被大量团队采用或证明适用于所有 Agent / Broad adoption or proof for every agent system | -v0.1 的优先级是把边界做窄、做真、做可拒绝。扩大 Runner 与语言生态之前,项目更需要第二个真实系统集成和外部用户反馈。 +v0.1 的优先级是把边界做窄、做真、做可拒绝。第二个真实系统集成已经由 Bottle 案例补齐;下一阶段重点是外部用户反馈、更多独立案例,以及在不扩大任意命令权限的前提下评估 Runner 适配器。 -The v0.1 priority is to keep the boundary narrow, truthful, and rejectable. Before broadening runner and language support, the project needs a second real-system integration and external user feedback. +The v0.1 priority is to keep the boundary narrow, truthful, and rejectable. The Bottle case now supplies the second real-system integration; the next priorities are external user feedback, more independent cases, and runner adapters that do not open arbitrary-command authority. ## 常见问题 / FAQ @@ -359,6 +365,7 @@ The repository also includes two independently usable, structurally validated wo - [架构与信任边界 / Architecture and trust boundaries](docs/architecture.md) - [安全模型与限制 / Security model and limitations](SECURITY.md) - [工程文章:把不确定补丁放进确定性边界 / Engineering article: putting uncertain patches inside deterministic boundaries](docs/engineering-boundaries.md) +- [Bottle 真实第三方集成 / Real third-party Bottle integration](integrations/bottle-http-preconditions/README.md) - [IssueLens 真实复用案例 / Real IssueLens reuse case](docs/issuelens-case-study.md) - [版本记录 / Changelog](CHANGELOG.md) diff --git a/docs/assets/patchproof-demo.svg b/docs/assets/patchproof-demo.svg index 06d056b..04bfeab 100644 --- a/docs/assets/patchproof-demo.svg +++ b/docs/assets/patchproof-demo.svg @@ -65,7 +65,7 @@ TESTS / 自动化测试 - 71 passing + 80 passing RUNTIME / 运行依赖 diff --git a/integrations/bottle-http-preconditions/README.md b/integrations/bottle-http-preconditions/README.md new file mode 100644 index 0000000..840fcee --- /dev/null +++ b/integrations/bottle-http-preconditions/README.md @@ -0,0 +1,65 @@ +# Bottle HTTP 条件请求真实集成 / Real Bottle HTTP-Precondition Integration + +这个案例把 PatchProof 应用于第三方开源项目 [Bottle](https://github.com/bottlepy/bottle) 的真实历史缺陷,而不是仓库内自造的计算器样例。案例固定修复前提交、复用上游提交中准确的 `bottle.py` 修复 Hunk,并用 PatchProof 自己编写且放在目标仓库之外的复现测试和边界测试完成四阶段验证。上游提交自带的测试改动没有进入候选补丁。 + +This case applies PatchProof to a real historical defect in the third-party open-source [Bottle](https://github.com/bottlepy/bottle) project instead of another repository-owned calculator example. It pins the pre-fix commit, reuses the exact `bottle.py` fix hunk from upstream, and runs independently authored reproduction and edge-case tests from outside the target repository. The upstream commit's own test change is not included in the candidate patch. + +## 案例证据 / Case Evidence + +| 项目 / Item | 固定值 / Pinned value | +|---|---| +| 缺陷 / Defect | `If-None-Match` 存在时错误地继续处理 `If-Modified-Since` / `If-Modified-Since` was still evaluated when `If-None-Match` was present | +| 修复前提交 / Pre-fix commit | [`2a743a302a71460bfe4c0b8b7cb99a306b0328c6`](https://github.com/bottlepy/bottle/commit/2a743a302a71460bfe4c0b8b7cb99a306b0328c6) | +| 上游修复 / Upstream fix | [`b73bd1db5b7a915cf6a78656955c4059f58195ae`](https://github.com/bottlepy/bottle/commit/b73bd1db5b7a915cf6a78656955c4059f58195ae) | +| 候选改动 / Candidate change | 仅 `bottle.py`,新增 5 行、删除 4 行 / `bottle.py` only, 5 additions and 4 deletions | +| 目标快照 / Target snapshot | 67 个受支持文本文件的 SHA-256 绑定 / SHA-256 binding over 67 supported text files | +| 完整回归 / Full regression | Bottle 自己的 359 项 `unittest` / Bottle's own 359 `unittest` cases | +| 外置评测 / External grading | 1 项复现 + 1 项不同边界测试 / 1 reproduction + 1 distinct edge-case test | + +复现测试证明:非匹配的 `If-None-Match` 应覆盖一个原本会触发 `304` 的较新 `If-Modified-Since`,因此正确结果是 `200`。边界测试进一步关闭响应 ETag 生成,验证优先级规则仍然成立。两个测试均不修改 Bottle 仓库,也不包含在候选补丁中。 + +The reproduction proves that a non-matching `If-None-Match` must override a newer `If-Modified-Since` that would otherwise produce `304`, so the correct response is `200`. The separate edge case disables response ETag generation and verifies that the precedence rule still holds. Neither test modifies the Bottle checkout or appears in the candidate patch. + +这里的“hidden”表示测试位于候选工作区之外并在应用补丁后注入,不表示它在这个公开仓库中保密。 + +Here, “hidden” means outside the candidate workspace and injected only after patch application; it does not mean confidential inside this public repository. + +## 本地复验 / Reproduce Locally + +本地模式适合验证行为,但因为没有 Docker 隔离,会如实输出 `proof_grade=false`。 + +Local mode is suitable for checking behavior, but honestly reports `proof_grade=false` because Docker isolation is absent. + +```bash +git clone https://github.com/bottlepy/bottle.git /tmp/patchproof-bottle +git -C /tmp/patchproof-bottle checkout 2a743a302a71460bfe4c0b8b7cb99a306b0328c6 + +PYTHONPATH=src python3 integrations/bottle-http-preconditions/run_case.py \ + --repo /tmp/patchproof-bottle \ + --audit-dir /tmp/patchproof-bottle-audit \ + --unsafe-local +``` + +隔离验证使用 README 中同一个摘要固定镜像: + +Isolated validation uses the same digest-pinned image documented in the main README: + +```bash +IMAGE='python:3.12.10-slim@sha256:fd95fa221297a88e1cf49c55ec1828edd7c5a428187e67b5d1805692d11588db' +docker pull "$IMAGE" + +PYTHONPATH=src python3 integrations/bottle-http-preconditions/run_case.py \ + --repo /tmp/patchproof-bottle \ + --audit-dir /tmp/patchproof-bottle-proof \ + --docker-image "$IMAGE" +``` + +成功结果应包含 `baseline_reproduction=1`、`patched_reproduction=1`、`full_regression=359`、`hidden_tests=1`,且真实 Bottle checkout 的前后快照一致。 + +A successful result contains `baseline_reproduction=1`, `patched_reproduction=1`, `full_regression=359`, and `hidden_tests=1`, while the real Bottle checkout retains the same before/after snapshot. + +## 来源与许可证 / Source and License + +`upstream-fix.diff` 来自 Bottle 上游修复提交,版权属于 Marcel Hellkamp,并按 Bottle 的 MIT 许可证再分发;完整许可证见 [`UPSTREAM_LICENSE.txt`](UPSTREAM_LICENSE.txt)。其余集成脚本和独立测试属于 PatchProof。 + +`upstream-fix.diff` comes from the Bottle upstream fix commit, remains copyright Marcel Hellkamp, and is redistributed under Bottle's MIT license; see [`UPSTREAM_LICENSE.txt`](UPSTREAM_LICENSE.txt). The remaining integration runner and independent tests belong to PatchProof. diff --git a/integrations/bottle-http-preconditions/UPSTREAM_LICENSE.txt b/integrations/bottle-http-preconditions/UPSTREAM_LICENSE.txt new file mode 100644 index 0000000..ca03980 --- /dev/null +++ b/integrations/bottle-http-preconditions/UPSTREAM_LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2009-2025, Marcel Hellkamp. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/integrations/bottle-http-preconditions/case.json b/integrations/bottle-http-preconditions/case.json new file mode 100644 index 0000000..9af68b4 --- /dev/null +++ b/integrations/bottle-http-preconditions/case.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "case_id": "bottle-http-precondition-precedence", + "source_repository": "https://github.com/bottlepy/bottle", + "source_commit": "2a743a302a71460bfe4c0b8b7cb99a306b0328c6", + "upstream_fix_commit": "b73bd1db5b7a915cf6a78656955c4059f58195ae", + "upstream_fix_url": "https://github.com/bottlepy/bottle/commit/b73bd1db5b7a915cf6a78656955c4059f58195ae", + "license": "MIT", + "expected_snapshot": "7bdf801882e663cc8e4804504ec88de768efe89f1ded8a05b73a07fb795e5ac8", + "expected_snapshot_files": 67, + "patch_sha256": "ef2d5defef3aa5fcdb28c71474abe5186d4aa85d75f26eb9e10edeb20007ab99", + "changed_paths": [ + "bottle.py" + ], + "full_test_directory": "test", + "full_test_top_level": "." +} diff --git a/integrations/bottle-http-preconditions/hidden/test_etag_disabled_precedence.py b/integrations/bottle-http-preconditions/hidden/test_etag_disabled_precedence.py new file mode 100644 index 0000000..8df643e --- /dev/null +++ b/integrations/bottle-http-preconditions/hidden/test_etag_disabled_precedence.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import os +import tempfile +import unittest +import wsgiref.util +from pathlib import Path + +import bottle + + +class ConditionalRequestHiddenTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.filename = "without-etag.txt" + asset = self.root / self.filename + asset.write_text("external hidden edge case\n", encoding="utf-8") + os.utime(asset, (1_000_000, 1_000_000)) + environment: dict[str, object] = {} + wsgiref.util.setup_testing_defaults(environment) + bottle.request.bind(environment) + bottle.response.bind() + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_precedence_still_applies_when_response_etag_is_disabled(self) -> None: + bottle.request.environ["HTTP_IF_NONE_MATCH"] = '"client-supplied-tag"' + bottle.request.environ["HTTP_IF_MODIFIED_SINCE"] = bottle.http_date(2_000_000) + + result = bottle.static_file(self.filename, root=str(self.root), etag=False) + body = result.body + if hasattr(body, "close"): + self.addCleanup(body.close) + + self.assertEqual(200, result.status_code) + + +if __name__ == "__main__": + unittest.main() diff --git a/integrations/bottle-http-preconditions/reproduction/test_precondition_precedence.py b/integrations/bottle-http-preconditions/reproduction/test_precondition_precedence.py new file mode 100644 index 0000000..3e6415a --- /dev/null +++ b/integrations/bottle-http-preconditions/reproduction/test_precondition_precedence.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import os +import tempfile +import unittest +import wsgiref.util +from pathlib import Path + +import bottle + + +class ConditionalRequestPrecedenceTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.filename = "asset.txt" + asset = self.root / self.filename + asset.write_text("PatchProof Bottle integration\n", encoding="utf-8") + os.utime(asset, (1_000_000, 1_000_000)) + environment: dict[str, object] = {} + wsgiref.util.setup_testing_defaults(environment) + bottle.request.bind(environment) + bottle.response.bind() + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_if_none_match_suppresses_newer_if_modified_since(self) -> None: + bottle.request.environ["HTTP_IF_NONE_MATCH"] = '"different-etag"' + bottle.request.environ["HTTP_IF_MODIFIED_SINCE"] = bottle.http_date(2_000_000) + + result = bottle.static_file(self.filename, root=str(self.root)) + body = result.body + if hasattr(body, "close"): + self.addCleanup(body.close) + + self.assertEqual(200, result.status_code) + + +if __name__ == "__main__": + unittest.main() diff --git a/integrations/bottle-http-preconditions/run_case.py b/integrations/bottle-http-preconditions/run_case.py new file mode 100755 index 0000000..9664367 --- /dev/null +++ b/integrations/bottle-http-preconditions/run_case.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Run the pinned Bottle HTTP-precondition integration case.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from pathlib import Path +from typing import Any, Sequence + +from patchproof.approval import PatchApproval +from patchproof.proposal import PatchProposal +from patchproof.repository import ReadOnlyRepository +from patchproof.runner import DockerRunner, UnsafeLocalRunner +from patchproof.validator import store_validation, validate_patch + + +CASE_ROOT = Path(__file__).resolve().parent +REQUIRED_FIELDS = { + "schema_version", + "case_id", + "source_repository", + "source_commit", + "upstream_fix_commit", + "upstream_fix_url", + "license", + "expected_snapshot", + "expected_snapshot_files", + "patch_sha256", + "changed_paths", + "full_test_directory", + "full_test_top_level", +} + + +class IntegrationError(RuntimeError): + """Raised when the checked-out fixture does not match the pinned case.""" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Run PatchProof against the pinned Bottle HTTP-precondition fix." + ) + parser.add_argument("--repo", required=True, help="Bottle checkout at the pinned commit.") + parser.add_argument("--audit-dir", required=True) + parser.add_argument("--docker-image") + parser.add_argument("--timeout", type=int, default=120) + parser.add_argument( + "--unsafe-local", + action="store_true", + help="Run this trusted public fixture locally without isolation.", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + case = _load_case() + patch_path = CASE_ROOT / "upstream-fix.diff" + patch_bytes = patch_path.read_bytes() + if hashlib.sha256(patch_bytes).hexdigest() != case["patch_sha256"]: + raise IntegrationError("upstream patch does not match the pinned SHA-256") + + repository = ReadOnlyRepository(args.repo) + audit_dir = Path(args.audit_dir).expanduser() + audit_comparison = audit_dir.resolve(strict=False) + if ( + audit_comparison == repository.root + or repository.root in audit_comparison.parents + ): + raise IntegrationError("audit artifacts must stay outside the Bottle checkout") + before = repository.snapshot() + if before.digest != case["expected_snapshot"]: + raise IntegrationError( + "Bottle checkout snapshot does not match the pinned source commit" + ) + if len(before.files) != case["expected_snapshot_files"]: + raise IntegrationError("Bottle checkout file count does not match the manifest") + + proposal = PatchProposal.create( + unified_diff=patch_bytes.decode("utf-8"), + base_snapshot=before.digest, + allowed_paths=case["changed_paths"], + ) + if list(proposal.changed_paths) != case["changed_paths"]: + raise IntegrationError("patch changed paths do not match the manifest") + approval = PatchApproval.create( + run_id=case["case_id"], + proposal=proposal, + approved_by="patchproof-integration-maintainer", + supplied_proposal_hash=proposal.proposal_hash, + ) + + if args.unsafe_local: + runner = UnsafeLocalRunner(timeout_seconds=args.timeout) + else: + if not args.docker_image: + raise IntegrationError( + "--docker-image is required unless --unsafe-local is explicit" + ) + runner = DockerRunner( + image=args.docker_image, + timeout_seconds=args.timeout, + ) + + receipt = validate_patch( + repository=repository, + proposal=proposal, + approval=approval, + reproduction_tests=CASE_ROOT / "reproduction", + hidden_tests=CASE_ROOT / "hidden", + runner=runner, + full_test_directory=case["full_test_directory"], + full_test_top_level=case["full_test_top_level"], + ) + stored = store_validation(receipt, audit_dir) + unchanged = repository.snapshot() == before + print( + json.dumps( + { + "status": "passed" if receipt.success else "failed", + "case_id": case["case_id"], + "source_commit": case["source_commit"], + "upstream_fix_commit": case["upstream_fix_commit"], + "snapshot_files": len(before.files), + "repository_unchanged": unchanged, + "isolated": receipt.isolated, + "proof_grade": receipt.proof_grade, + "receipt_hash": receipt.receipt_hash, + "receipt_path": str(stored.receipt_path), + "report_path": str(stored.report_path), + "phases": [ + { + "name": phase.name, + "passed": phase.passed, + "tests_run": phase.tests_run, + } + for phase in receipt.phases + ], + }, + ensure_ascii=False, + sort_keys=True, + ) + ) + return 0 if receipt.success and unchanged else 2 + except (IntegrationError, OSError, RuntimeError, ValueError) as exc: + print( + json.dumps( + {"status": "error", "error": str(exc)}, + ensure_ascii=False, + sort_keys=True, + ), + file=sys.stderr, + ) + return 1 + + +def _load_case() -> dict[str, Any]: + value = json.loads((CASE_ROOT / "case.json").read_text(encoding="utf-8")) + if not isinstance(value, dict) or set(value) != REQUIRED_FIELDS: + raise IntegrationError("case manifest fields do not match schema version 1") + if value["schema_version"] != 1: + raise IntegrationError("unsupported case manifest schema") + if not isinstance(value["changed_paths"], list) or not all( + isinstance(path, str) for path in value["changed_paths"] + ): + raise IntegrationError("changed_paths must be an array of strings") + if type(value["expected_snapshot_files"]) is not int: + raise IntegrationError("expected_snapshot_files must be an integer") + return value + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/integrations/bottle-http-preconditions/upstream-fix.diff b/integrations/bottle-http-preconditions/upstream-fix.diff new file mode 100644 index 0000000..168137e --- /dev/null +++ b/integrations/bottle-http-preconditions/upstream-fix.diff @@ -0,0 +1,15 @@ +diff --git a/bottle.py b/bottle.py +index 053f9b4..7d3ecef 100755 +--- a/bottle.py ++++ b/bottle.py +@@ -2825,3 +2825,4 @@ def static_file(filename, root, +- check = getenv('HTTP_IF_NONE_MATCH') +- if check and check == etag: +- return HTTPResponse(status=304, **headers) ++ ++ inm = getenv('HTTP_IF_NONE_MATCH') ++ if inm and inm == etag: ++ return HTTPResponse(status=304, **headers) +@@ -2830 +2831 @@ def static_file(filename, root, +- if ims: ++ if ims and not inm: diff --git a/pyproject.toml b/pyproject.toml index 6ae52cd..b5a9351 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "patchproof" -version = "0.1.1" +version = "0.1.2" description = "Verify AI coding-agent patches with fail-before/pass-after tests, isolated execution, and auditable receipts." readme = "README.md" requires-python = ">=3.11" diff --git a/src/patchproof/__init__.py b/src/patchproof/__init__.py index 968d773..e3ebb11 100644 --- a/src/patchproof/__init__.py +++ b/src/patchproof/__init__.py @@ -16,4 +16,4 @@ "validate_patch", ] -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/src/patchproof/cli.py b/src/patchproof/cli.py index cec375a..5a7ad56 100644 --- a/src/patchproof/cli.py +++ b/src/patchproof/cli.py @@ -41,6 +41,10 @@ def build_parser() -> argparse.ArgumentParser: validate.add_argument("--reproduction-tests", required=True) validate.add_argument("--hidden-tests", required=True) validate.add_argument("--full-tests", default="tests") + validate.add_argument( + "--full-tests-top-level", + help="Optional repository-relative unittest discovery top-level.", + ) validate.add_argument("--audit-dir", required=True) validate.add_argument("--docker-image") validate.add_argument("--timeout", type=int, default=60) @@ -163,6 +167,7 @@ def _validate(args: argparse.Namespace) -> int: hidden_tests=Path(args.hidden_tests).expanduser().resolve(strict=True), runner=runner, full_test_directory=args.full_tests, + full_test_top_level=args.full_tests_top_level, ) stored = store_validation(receipt, audit_dir) print( diff --git a/src/patchproof/patch.py b/src/patchproof/patch.py index 74c8355..a35d57d 100644 --- a/src/patchproof/patch.py +++ b/src/patchproof/patch.py @@ -100,11 +100,13 @@ def copy_test_bundle( for path in sorted(source.rglob("*")): if path.is_symlink(): raise PatchApplicationError("test bundle cannot contain symbolic links") + relative = path.relative_to(source) + if "__pycache__" in relative.parts: + continue if path.is_dir(): continue if not path.is_file() or path.suffix != ".py": raise PatchApplicationError("test bundle may contain only Python files") - relative = path.relative_to(source) if ".." in relative.parts or any(part.startswith(".") for part in relative.parts): raise PatchApplicationError("test bundle contains a reserved path") metadata = path.stat() diff --git a/src/patchproof/repository.py b/src/patchproof/repository.py index 89c6b2d..883650b 100644 --- a/src/patchproof/repository.py +++ b/src/patchproof/repository.py @@ -38,6 +38,7 @@ class RepositoryError(ValueError): ".py", ".rst", ".toml", + ".tpl", ".txt", ".yaml", ".yml", diff --git a/src/patchproof/runner.py b/src/patchproof/runner.py index c0a9cbd..fda7b0f 100644 --- a/src/patchproof/runner.py +++ b/src/patchproof/runner.py @@ -19,6 +19,7 @@ class RunnerError(RuntimeError): class TestSpec: name: str start_directory: str + top_level_directory: str | None = None def __post_init__(self) -> None: candidate = Path(self.start_directory) @@ -29,6 +30,22 @@ def __post_init__(self) -> None: or not candidate.parts ): raise RunnerError("test specification contains an unsafe path") + if self.top_level_directory is None: + return + top_level = Path(self.top_level_directory) + if self.top_level_directory != "." and ( + top_level.is_absolute() + or ".." in top_level.parts + or not top_level.parts + ): + raise RunnerError("test specification contains an unsafe top-level path") + if self.top_level_directory != "." and top_level != candidate: + try: + candidate.relative_to(top_level) + except ValueError as exc: + raise RunnerError( + "test discovery top-level must contain the start directory" + ) from exc @dataclass(frozen=True, slots=True) @@ -110,7 +127,7 @@ def build_command( spec: TestSpec, container_name: str, ) -> list[str]: - return [ + command = [ self.docker_binary, "run", "--rm", @@ -139,6 +156,9 @@ def build_command( "test*.py", "-v", ] + if spec.top_level_directory is not None: + command.extend(["-t", spec.top_level_directory]) + return command def run(self, workspace: Path, spec: TestSpec) -> CommandResult: container_name = f"patchproof-{uuid.uuid4().hex[:20]}" @@ -182,6 +202,8 @@ def run(self, workspace: Path, spec: TestSpec) -> CommandResult: "test*.py", "-v", ] + if spec.top_level_directory is not None: + command.extend(["-t", spec.top_level_directory]) return _run_process( command, name=spec.name, diff --git a/src/patchproof/validator.py b/src/patchproof/validator.py index 34c3440..5526f80 100644 --- a/src/patchproof/validator.py +++ b/src/patchproof/validator.py @@ -19,7 +19,7 @@ from .patch import FileChange, apply_proposal, copy_test_bundle, make_tree_read_only from .proposal import PatchProposal from .repository import ReadOnlyRepository -from .runner import CommandResult, Runner, TestSpec +from .runner import CommandResult, Runner, RunnerError, TestSpec class ValidationError(RuntimeError): @@ -275,6 +275,7 @@ def validate_patch( hidden_tests: Path, runner: Runner, full_test_directory: str = "tests", + full_test_top_level: str | None = None, ) -> ValidationReceipt: before = repository.snapshot() proposal.require_current(before.digest) @@ -288,6 +289,18 @@ def validate_patch( raise ValidationError("full test directory must be repository-relative") if not (repository.root / full_tests).is_dir(): raise ValidationError("full regression test directory does not exist") + try: + full_test_spec = TestSpec( + "full_regression", + full_test_directory, + top_level_directory=full_test_top_level, + ) + except RunnerError as exc: + raise ValidationError(str(exc)) from exc + if full_test_top_level is not None: + top_level = repository.root / full_test_top_level + if not top_level.is_dir(): + raise ValidationError("full regression top-level directory does not exist") phases: list[PhaseEvidence] = [] patched_snapshot = "" @@ -346,10 +359,7 @@ def validate_patch( ) phases.append( PhaseEvidence.from_result( - runner.run( - patched_root, - TestSpec("full_regression", full_test_directory), - ), + runner.run(patched_root, full_test_spec), expected="pass", ) ) diff --git a/tests/test_bottle_integration.py b/tests/test_bottle_integration.py new file mode 100644 index 0000000..15de99c --- /dev/null +++ b/tests/test_bottle_integration.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import hashlib +import json +import unittest +from pathlib import Path + +from patchproof.proposal import PatchProposal + + +ROOT = Path(__file__).resolve().parents[1] +CASE_ROOT = ROOT / "integrations" / "bottle-http-preconditions" + + +class BottleIntegrationTests(unittest.TestCase): + def test_manifest_binds_upstream_patch_and_changed_path(self) -> None: + case = json.loads((CASE_ROOT / "case.json").read_text(encoding="utf-8")) + patch = (CASE_ROOT / "upstream-fix.diff").read_bytes() + self.assertEqual(hashlib.sha256(patch).hexdigest(), case["patch_sha256"]) + proposal = PatchProposal.create( + unified_diff=patch.decode("utf-8"), + base_snapshot=case["expected_snapshot"], + allowed_paths=case["changed_paths"], + ) + self.assertEqual(proposal.changed_paths, ("bottle.py",)) + self.assertEqual(case["expected_snapshot_files"], 67) + + def test_case_has_independent_test_bundles_and_attribution(self) -> None: + reproduction = list((CASE_ROOT / "reproduction").glob("test*.py")) + hidden = list((CASE_ROOT / "hidden").glob("test*.py")) + self.assertEqual(len(reproduction), 1) + self.assertEqual(len(hidden), 1) + self.assertNotEqual(reproduction[0].read_bytes(), hidden[0].read_bytes()) + license_text = (CASE_ROOT / "UPSTREAM_LICENSE.txt").read_text(encoding="utf-8") + self.assertIn("Copyright (c) 2009-2025, Marcel Hellkamp", license_text) + readme = (CASE_ROOT / "README.md").read_text(encoding="utf-8") + self.assertIn("真实集成", readme) + self.assertIn("Real Bottle", readme) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index daf7baa..672378c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -102,6 +102,7 @@ def test_end_to_end_unsafe_local_is_explicitly_not_proof_grade(self) -> None: with tempfile.TemporaryDirectory() as directory: parent = Path(directory) repo = make_repository(parent / "repo") + (repo / "tests" / "__init__.py").write_text("", encoding="utf-8") reproduction = make_test_bundle(parent / "reproduction") hidden = make_test_bundle(parent / "hidden", hidden=True) diff = parent / "candidate.diff" @@ -143,6 +144,8 @@ def test_end_to_end_unsafe_local_is_explicitly_not_proof_grade(self) -> None: str(reproduction), "--hidden-tests", str(hidden), + "--full-tests-top-level", + ".", "--audit-dir", str(audit), "--unsafe-local", diff --git a/tests/test_patch.py b/tests/test_patch.py index 81524f5..bb3737c 100644 --- a/tests/test_patch.py +++ b/tests/test_patch.py @@ -82,6 +82,19 @@ def test_test_bundle_accepts_only_python_regular_files(self) -> None: with self.assertRaisesRegex(PatchApplicationError, "only Python"): copy_test_bundle(source, parent / "target") + def test_test_bundle_ignores_generated_python_cache(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + source = parent / "source" + cache = source / "__pycache__" + cache.mkdir(parents=True) + (source / "test_case.py").write_text("VALUE = 1\n", encoding="utf-8") + (cache / "test_case.cpython-312.pyc").write_bytes(b"generated") + target = parent / "target" + copy_test_bundle(source, target) + self.assertTrue((target / "test_case.py").is_file()) + self.assertFalse((target / "__pycache__").exists()) + def test_empty_test_bundle_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as directory: parent = Path(directory) diff --git a/tests/test_repository.py b/tests/test_repository.py index a64707c..c7f635e 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -11,6 +11,13 @@ class RepositoryTests(unittest.TestCase): + def test_template_files_are_included_for_unittest_fixtures(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = make_repository(Path(directory)) + (root / "views").mkdir() + (root / "views" / "page.tpl").write_text("hello {{name}}\n", encoding="utf-8") + self.assertIn("views/page.tpl", ReadOnlyRepository(root).list_files()) + def test_snapshot_is_stable_and_content_sensitive(self) -> None: with tempfile.TemporaryDirectory() as directory: root = make_repository(Path(directory)) diff --git a/tests/test_runner.py b/tests/test_runner.py index 57bb46e..2dcfcc4 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -42,6 +42,14 @@ def test_docker_command_contains_security_boundaries(self) -> None: ): self.assertIn(expected, joined) + def test_docker_command_supports_package_discovery_top_level(self) -> None: + command = DockerRunner(image=IMAGE).build_command( + Path("/tmp/workspace"), + TestSpec("phase", "test", top_level_directory="."), + "patchproof-test", + ) + self.assertEqual(command[-2:], ["-t", "."]) + def test_runner_fingerprint_changes_with_resources(self) -> None: first = DockerRunner(image=IMAGE, memory="256m") second = DockerRunner(image=IMAGE, memory="512m") @@ -68,6 +76,12 @@ def test_test_spec_rejects_parent_traversal(self) -> None: with self.assertRaisesRegex(RunnerError, "unsafe"): TestSpec("phase", "../tests") + def test_test_spec_rejects_top_level_outside_start_directory(self) -> None: + with self.assertRaisesRegex(RunnerError, "must contain"): + TestSpec("phase", "tests", top_level_directory="other") + with self.assertRaisesRegex(RunnerError, "unsafe top-level"): + TestSpec("phase", "tests", top_level_directory="../") + def test_unsafe_local_runner_executes_actual_tests(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -84,6 +98,27 @@ def test_unsafe_local_runner_executes_actual_tests(self) -> None: self.assertEqual(result.tests_run, 1) self.assertFalse(UnsafeLocalRunner().isolated) + def test_unsafe_local_runner_discovers_package_relative_imports(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + tests = root / "test" + tests.mkdir() + (tests / "__init__.py").write_text("", encoding="utf-8") + (tests / "support.py").write_text("VALUE = 7\n", encoding="utf-8") + (tests / "test_package.py").write_text( + "import unittest\n" + "from .support import VALUE\n" + "class T(unittest.TestCase):\n" + " def test_value(self): self.assertEqual(VALUE, 7)\n", + encoding="utf-8", + ) + result = UnsafeLocalRunner().run( + root, + TestSpec("package", "test", top_level_directory="."), + ) + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(result.tests_run, 1) + def test_timeout_cleanup_failure_preserves_timeout_evidence(self) -> None: timeout = TimeoutExpired(["docker", "run"], 1, output="partial output") with patch("patchproof.runner.subprocess.run", side_effect=[timeout, OSError()]): diff --git a/tests/test_validator.py b/tests/test_validator.py index 0164d34..6bc54fb 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -24,6 +24,7 @@ class FakeRunner: def __init__(self, results: list[CommandResult], *, isolated: bool = True) -> None: self.results = list(results) self._isolated = isolated + self.specs: list[TestSpec] = [] @property def isolated(self) -> bool: @@ -34,6 +35,7 @@ def fingerprint(self) -> str: return "f" * 64 def run(self, workspace: Path, spec: TestSpec) -> CommandResult: + self.specs.append(spec) result = self.results.pop(0) return CommandResult( name=spec.name, @@ -133,6 +135,33 @@ def test_real_repository_remains_unchanged(self) -> None: self._validate([result(1), result(0), result(0), result(0)]) self.assertEqual(before, self.repository.snapshot()) + def test_full_regression_top_level_is_forwarded_to_runner(self) -> None: + runner = FakeRunner([result(1), result(0), result(0), result(0)]) + receipt = validate_patch( + repository=self.repository, + proposal=self.proposal, + approval=self.approval, + reproduction_tests=self.reproduction, + hidden_tests=self.hidden, + runner=runner, + full_test_top_level=".", + ) + self.assertTrue(receipt.success) + self.assertEqual(runner.specs[2].start_directory, "tests") + self.assertEqual(runner.specs[2].top_level_directory, ".") + + def test_full_regression_top_level_must_contain_start_directory(self) -> None: + with self.assertRaisesRegex(ValidationError, "must contain"): + validate_patch( + repository=self.repository, + proposal=self.proposal, + approval=self.approval, + reproduction_tests=self.reproduction, + hidden_tests=self.hidden, + runner=FakeRunner([]), + full_test_top_level="other", + ) + def test_receipt_tampering_is_rejected(self) -> None: receipt = self._validate([result(1), result(0), result(0), result(0)]) payload = json.loads(receipt.to_json())