diff --git a/apps/web/package.json b/apps/web/package.json index 2db97322e..4320cadce 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,7 +33,7 @@ "@radix-ui/react-separator": "^1.2.0", "@radix-ui/react-tabs": "^1.2.1", "@sentry/nextjs": "^11.10.0", - "@stripe/stripe-js": "^9.17.0", + "@stripe/stripe-js": "^10.0.0", "@supabase/supabase-js": "^2.118.0", "@upstash/redis": "^1.40.0", "@upstash/search": "^0.1.7", diff --git a/package-lock.json b/package-lock.json index dad1ceda3..d457a7050 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,7 @@ "@radix-ui/react-separator": "^1.2.0", "@radix-ui/react-tabs": "^1.2.1", "@sentry/nextjs": "^11.10.0", - "@stripe/stripe-js": "^9.17.0", + "@stripe/stripe-js": "^10.0.0", "@supabase/supabase-js": "^2.118.0", "@upstash/redis": "^1.40.0", "@upstash/search": "^0.1.7", @@ -6504,9 +6504,9 @@ "license": "MIT" }, "node_modules/@stripe/stripe-js": { - "version": "9.17.0", - "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.17.0.tgz", - "integrity": "sha512-uidL4OJum5kivsOAQnVfQmYLJ2zdgirtmT0zWgskASZ4TPXxpXQ0MNrzW4NsXB90uEqmPHpSEtciLFbF1QQvNA==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-10.2.0.tgz", + "integrity": "sha512-C0F6LKxvsa9ONtxfJ4JG/vo8XjramVyy7tOZNZvoaZkw0M82OTm9LD3Ifcvw0QMDI7qPA14h+IP3qagkuC3b2Q==", "license": "MIT", "engines": { "node": ">=12.16" @@ -8182,16 +8182,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -8200,13 +8200,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -8237,9 +8237,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { @@ -8250,13 +8250,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -8264,14 +8264,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -8280,9 +8280,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -8290,13 +8290,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", + "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -16531,9 +16531,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -17511,19 +17511,19 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -17551,12 +17551,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/scripts/ci/record_dependency_review.py b/scripts/ci/record_dependency_review.py index 1624429f3..7d577e476 100644 --- a/scripts/ci/record_dependency_review.py +++ b/scripts/ci/record_dependency_review.py @@ -8,7 +8,7 @@ import re import subprocess import tomllib -from pathlib import Path +from pathlib import Path, PurePosixPath from urllib.parse import parse_qsl, urljoin, urlsplit from urllib.request import Request, urlopen @@ -69,20 +69,138 @@ def _uv_lock_components(sha: str, manifest: str) -> set[tuple[str, str, str]]: } +def _unchanged_requirements_resolution( + manifest: str, changes: list[dict], base: str, head: str +) -> dict: + """Prove unchanged, self-contained source; retain the resolver's entire delta. + + An unlocked pip scan can choose a newer transitive release at the same + source commit. This proof does not establish repeatable Python resolution + or waive the dependency-review action's vulnerability/license verdict. + """ + + def reject(reason: str) -> None: + raise ValueError( + "Dependency graph includes an unverified change in an unchanged manifest: " + f"{manifest!r} ({reason})" + ) + + path = PurePosixPath(manifest) + if ( + path.is_absolute() + or path.as_posix() != manifest + or ".." in path.parts + or any(ord(character) < 32 for character in manifest) + or not re.fullmatch(r"requirements[A-Za-z0-9_.-]*\.(txt|in)", path.name) + ): + reject("not a supported requirements path") + + entries = [] + for sha in (base, head): + result = subprocess.run( + ["git", "--literal-pathspecs", "ls-tree", "-z", sha, "--", manifest], + capture_output=True, + ) + entries_on_side = [entry for entry in result.stdout.split(b"\0") if entry] + if result.returncode or len(entries_on_side) != 1: + reject("source is missing or unreadable") + fields = entries_on_side[0].split(b"\t", 1)[0].split() + if ( + len(fields) != 3 + or fields[0] not in {b"100644", b"100755"} + or fields[1] != b"blob" + ): + reject("source is not a regular Git file") + entries.append(fields) + if entries[0] != entries[1]: + reject("base and head source differ") + blob_sha = entries[0][2].decode("ascii") + content = subprocess.run( + ["git", "cat-file", "blob", blob_sha], check=True, capture_output=True + ).stdout + try: + lines = [ + re.sub(r"(^|\s+)#.*$", "", line).strip() + for line in content.decode("utf-8").splitlines() + ] + except UnicodeDecodeError: + reject("source is not UTF-8") + declarations = [line for line in lines if line] + package_name = r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?" + # Includes, constraints, options, extras, markers, pinned ranges and local/ + # remote references need their own provenance. Never silently exempt them. + if not declarations or not all( + re.fullmatch(package_name, line) for line in declarations + ): + reject("source is not self-contained bare unpinned requirements") + + # Deliberately support numeric release segments only. Epochs, pre/post/dev + # releases and local labels need explicit parsing before this proof can + # accept them; arbitrary strings must not be called version-only changes. + packages: dict[str, list[dict]] = {} + for change in changes: + name, version = change.get("name"), change.get("version") + if ( + change.get("ecosystem") != "pip" + or not isinstance(name, str) + or not re.fullmatch(package_name, name) + or not isinstance(version, str) + or not re.fullmatch(r"[0-9]+(?:\.[0-9]+)*", version) + or change.get("scope") not in {"runtime", "development"} + or change.get("change_type") not in {"added", "removed"} + ): + reject("invalid pip resolution row") + packages.setdefault(name, []).append(change) + for rows in packages.values(): + if ( + len(rows) != 2 + or {row["change_type"] for row in rows} != {"added", "removed"} + or len({row["version"] for row in rows}) != 2 + or len({row["scope"] for row in rows}) != 1 + ): + reject("not a paired version-only resolution change") + + return { + "manifest": manifest, + "classification": "unchanged_unlocked_requirements_resolution", + "base_blob_sha": blob_sha, + "head_blob_sha": blob_sha, + "content_sha256": hashlib.sha256(content).hexdigest(), + "declared_packages": declarations, + "changes": changes, + "limitation": ( + "The manifest is not a source change; its unlocked resolver delta is " + "retained in full. This does not prove repeatable Python resolution " + "or waive any vulnerability/license finding or the review outcome." + ), + } + + def _validate_changes( changes: list[dict], base: str, head: str, changed_files: set[str] -) -> None: +) -> list[dict]: + unchanged: dict[str, list[dict]] = {} + for change in changes: + manifest = change.get("manifest") + if isinstance(manifest, str) and manifest not in changed_files: + unchanged.setdefault(manifest, []).append(change) + resolutions = [ + _unchanged_requirements_resolution(manifest, rows, base, head) + for manifest, rows in sorted(unchanged.items()) + ] npm_locks: dict[tuple[str, str], set[tuple[str, str, str]]] = {} observed_npm: dict[str, dict[str, set[tuple[str, str, str]]]] = {} uv_locks: dict[tuple[str, str], set[tuple[str, str, str]]] = {} observed_uv: dict[str, dict[str, set[tuple[str, str, str]]]] = {} for change in changes: manifest = change.get("manifest") - if not isinstance(manifest, str) or manifest not in changed_files: + if not isinstance(manifest, str): raise ValueError( "Dependency graph includes a change in an unchanged manifest: " f"{manifest!r}" ) + if manifest in unchanged: + continue if manifest == "uv.lock": name, version, scope, kind = ( change.get("name"), change.get("version"), @@ -151,6 +269,8 @@ def _validate_changes( if observed_uv.get(manifest, {"added": set(), "removed": set()}) != expected: raise ValueError("uv dependency diff does not match lockfile versions and scopes") + return resolutions + MAX_COMPARE_PAGES = 1000 @@ -290,7 +410,7 @@ def record_dependency_review( next_url = _next_page_url(response.headers) page_url = urljoin(page_url, next_url) if next_url else None raw_diff = first_page if len(visited) == 1 else json.dumps(changes).encode() - _validate_changes( + resolutions = _validate_changes( changes, base, head, @@ -298,7 +418,7 @@ def record_dependency_review( ) evidence = { - "schema_version": 2, + "schema_version": 3, "base_sha": base, "head_sha": head, "run_id": environment["GITHUB_RUN_ID"], @@ -308,6 +428,7 @@ def record_dependency_review( "dependency_diff_count": len(changes), "dependency_diff_sha256": hashlib.sha256(raw_diff).hexdigest(), "dependency_diff_file": "dependency-diff.json", + "unchanged_requirements_resolutions": resolutions, } # Finish every environment lookup and hash before the first write, and # replace each file atomically, so an evidence-assembly failure cannot leave diff --git a/tests/unit/test_dependency_review_unlocked_requirements.py b/tests/unit/test_dependency_review_unlocked_requirements.py new file mode 100644 index 000000000..5a45d3a65 --- /dev/null +++ b/tests/unit/test_dependency_review_unlocked_requirements.py @@ -0,0 +1,313 @@ +"""Keep complete source-verified evidence when an unlocked pip scan resolves again.""" + +from __future__ import annotations + +import hashlib +import io +import json +import subprocess + +import pytest + +from scripts.ci.record_dependency_review import record_dependency_review + +MANIFEST = "docs/knowledge_prototypes/universal-automation-service/requirements.txt" +BARE_REQUIREMENTS = "python-dotenv\nrequests\ngoogle-generativeai\nyoutube-transcript-api\ntorch\ntransformers\nnumpy\npsutil\n" +# These two rows reproduce the actual 2026-11-02 PR2398 comparison, whose +# source manifest did not change between ffd07a and d6fef801. +PIP_ROWS = [ + { + "change_type": "added", + "manifest": MANIFEST, + "ecosystem": "pip", + "name": "filelock", + "version": "4.2.7", + "package_url": "pkg:pypi/filelock@4.2.7", + "license": "MIT", + "source_repository_url": None, + "scope": "runtime", + "vulnerabilities": [], + }, + { + "change_type": "removed", + "manifest": MANIFEST, + "ecosystem": "pip", + "name": "filelock", + "version": "4.2.6", + "package_url": "pkg:pypi/filelock", + "license": "MIT", + "source_repository_url": None, + "scope": "runtime", + "vulnerabilities": [], + }, +] + + +@pytest.fixture +def comparison(tmp_path, monkeypatch): + repo = tmp_path / "repo" + repo.mkdir() + monkeypatch.chdir(repo) + + def git(*args): + return subprocess.run( + [ + "git", + "-c", + "user.name=Evidence test", + "-c", + "user.email=evidence@example.invalid", + *args, + ], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + git("init", "-q") + manifest = repo / MANIFEST + manifest.parent.mkdir(parents=True) + manifest.write_text(BARE_REQUIREMENTS) + + def commit(version, dev): + (repo / "package-lock.json").write_text( + json.dumps( + { + "lockfileVersion": 3, + "packages": { + "node_modules/leaf": {"version": version}, + "node_modules/vite": {"version": "8.3.0", **dev}, + }, + } + ) + ) + git("add", ".") + git("commit", "-qm", version) + return git("rev-parse", "HEAD") + + base = commit("1.0.0", {"dev": True}) + head = commit("2.0.0", {"devOptional": True}) + npm_rows = [ + { + "manifest": "package-lock.json", + "ecosystem": "npm", + "name": name, + "version": version, + "scope": scope, + "change_type": kind, + } + for name, version, scope, kind in [ + ("leaf", "1.0.0", "runtime", "removed"), + ("leaf", "2.0.0", "runtime", "added"), + ("vite", "8.3.0", "development", "removed"), + ("vite", "8.3.0", "runtime", "added"), + ] + ] + env = { + "BASE_SHA": base, + "HEAD_SHA": head, + "GITHUB_REPOSITORY": "groupthinking/EventRelay", + "GITHUB_TOKEN": "offline-test-token", + "GITHUB_RUN_ID": "41525920691", + "GITHUB_RUN_ATTEMPT": "1", + "REVIEW_OUTCOME": "failure", + } + return repo, manifest, git, env, npm_rows + + +def record(comparison, destination, rows, **kwargs): + raw = json.dumps(rows).encode() + + class Response(io.BytesIO): + headers = {} + + result = record_dependency_review( + comparison[3], + destination, + opener=lambda request, timeout: Response(raw), + **kwargs, + ) + return result, raw + + +@pytest.mark.parametrize("outcome", ["success", "failure"]) +def test_unchanged_unlocked_pip_resolution_keeps_all_rows_and_review_verdict( + comparison, tmp_path, outcome +): + comparison[3]["REVIEW_OUTCOME"] = outcome + rows = comparison[4] + PIP_ROWS + # A finding in these rows must remain visible and subject to the action's + # existing policy; recording source provenance is never a security waiver. + rows[3] = { + **rows[3], + "vulnerabilities": [ + {"advisory_ghsa_id": "GHSA-test-case-only", "severity": "high"} + ], + } + rows[-2] = { + **rows[-2], + "vulnerabilities": [ + {"advisory_ghsa_id": "GHSA-resolution-test-only", "severity": "moderate"} + ], + } + destination = tmp_path / "evidence" + result, raw = record(comparison, destination, rows) + assert (destination / "dependency-diff.json").read_bytes() == raw + assert json.loads((destination / "review.json").read_text()) == result + assert result["review_outcome"] == outcome + assert result["dependency_diff_count"] == len(rows) + assert result["dependency_diff_sha256"] == hashlib.sha256(raw).hexdigest() + (proof,) = result["unchanged_requirements_resolutions"] + assert proof["manifest"] == MANIFEST + assert ( + proof["base_blob_sha"] + == proof["head_blob_sha"] + == comparison[2]("rev-parse", comparison[3]["HEAD_SHA"] + ":" + MANIFEST) + ) + assert ( + proof["content_sha256"] + == hashlib.sha256(BARE_REQUIREMENTS.encode()).hexdigest() + ) + assert proof["classification"] == "unchanged_unlocked_requirements_resolution" + assert proof["changes"] == rows[-2:] + assert "not a source change" in proof["limitation"] + + +@pytest.mark.parametrize( + "content", + [ + "-r other.txt\n", + "--requirement other.txt\n", + "-c constraints.txt\n", + "torch==9.0\n", + "torch>=9\n", + "torch[extra]\n", + "-e .\n", + "./local-package\n", + "https://example.invalid/package.whl\n", + "--index-url https://example.invalid\n", + "torch; python_version > '3.10'\n", + "# empty\n", + "torch#not-a-comment\n", + "torch#bad # later comment\n", + ], +) +def test_unverifiable_requirement_forms_fail_closed(comparison, tmp_path, content): + repo, manifest, git, env, npm_rows = comparison + git("checkout", "--detach", env["BASE_SHA"]) + manifest.write_text(content) + git("add", ".") + git("commit", "-qm", "unsupported source") + env["BASE_SHA"] = git("rev-parse", "HEAD") + git("checkout", "--detach", env["HEAD_SHA"]) + manifest.write_text(content) + git("add", ".") + git("commit", "-qm", "unsupported source") + env["HEAD_SHA"] = git("rev-parse", "HEAD") + with pytest.raises(ValueError, match="unchanged manifest"): + record( + comparison, + tmp_path / "evidence", + npm_rows + PIP_ROWS, + changed_files={"package-lock.json"}, + ) + assert not (tmp_path / "evidence").exists() + + +@pytest.mark.parametrize("change", ["changed", "missing", "symlink"]) +def test_unchanged_claim_requires_existing_identical_regular_git_blobs( + comparison, tmp_path, change +): + repo, manifest, git, env, npm_rows = comparison + if change == "changed": + manifest.write_text("torch\nnew-package\n") + elif change == "missing": + manifest.unlink() + else: + manifest.unlink() + manifest.symlink_to("torch") + git("add", ".") + git("commit", "-qm", change) + env["HEAD_SHA"] = git("rev-parse", "HEAD") + with pytest.raises(ValueError, match="unchanged manifest"): + record( + comparison, + tmp_path / "evidence", + npm_rows + PIP_ROWS, + changed_files={"package-lock.json"}, + ) + assert not (tmp_path / "evidence").exists() + + +@pytest.mark.parametrize( + "mutation", + [ + "missing-npm-row", + "wrong-npm-scope", + "extra-npm-version", + "unpaired-pip", + "duplicate-pip", + "pip-scope-change", + "wrong-ecosystem", + "invalid-version", + "traversal-path", + ], +) +def test_resolution_drift_cannot_hide_real_graph_mismatch( + comparison, tmp_path, mutation +): + rows = [dict(row) for row in comparison[4] + PIP_ROWS] + if mutation == "missing-npm-row": + rows.pop(0) + elif mutation == "wrong-npm-scope": + rows[3]["scope"] = "development" + elif mutation == "extra-npm-version": + rows.append({**rows[1], "version": "99.0.0"}) + elif mutation == "unpaired-pip": + rows.pop() + elif mutation == "duplicate-pip": + rows.append(rows[-1]) + elif mutation == "pip-scope-change": + rows[-1]["scope"] = "development" + elif mutation == "wrong-ecosystem": + rows[-1]["ecosystem"] = "npm" + elif mutation == "invalid-version": + rows[-1]["version"] = " 4.2.6 " + elif mutation == "traversal-path": + for row in rows[-2:]: + row["manifest"] = "../requirements.txt" + with pytest.raises(ValueError): + record(comparison, tmp_path / "evidence", rows) + assert not (tmp_path / "evidence").exists() + + +@pytest.mark.parametrize("version", ["4..2", "1!+_-", "4.2rc1", "1!4.2", "4.2+linux"]) +def test_only_explicit_numeric_release_versions_are_supported( + comparison, tmp_path, version +): + rows = [dict(row) for row in comparison[4] + PIP_ROWS] + rows[-2]["version"] = version + with pytest.raises(ValueError, match="invalid pip resolution row"): + record(comparison, tmp_path / "evidence", rows) + assert not (tmp_path / "evidence").exists() + + +def test_only_whitespace_delimited_requirement_comments_are_removed( + comparison, tmp_path +): + repo, manifest, git, env, npm_rows = comparison + content = "# full-line comment\ntorch # inline comment\nrequests\t# tab comment\n" + for side in ("BASE_SHA", "HEAD_SHA"): + git("checkout", "--detach", env[side]) + manifest.write_text(content) + git("add", ".") + git("commit", "-qm", "recognized comments") + env[side] = git("rev-parse", "HEAD") + result, _ = record( + comparison, + tmp_path / "evidence", + npm_rows + PIP_ROWS, + changed_files={"package-lock.json"}, + ) + (proof,) = result["unchanged_requirements_resolutions"] + assert proof["declared_packages"] == ["torch", "requests"]