diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dc12d1ee9b..b83f1f536f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,14 +8,41 @@ updates: default-days: 8 - package-ecosystem: uv directory: / + versioning-strategy: increase-if-necessary schedule: interval: weekly + day: monday + time: "09:00" + timezone: Etc/UTC # Version updates wait; Dependabot security updates are exempt. cooldown: default-days: 8 + open-pull-requests-limit: 3 + groups: + python-maintenance: + applies-to: version-updates + patterns: + - "*" + update-types: + - minor + - patch + python-security: + applies-to: security-updates + patterns: + - "*" + - package-ecosystem: github-actions directory: / schedule: interval: weekly + day: monday + time: "09:30" + timezone: Etc/UTC cooldown: default-days: 8 + open-pull-requests-limit: 5 + groups: + # CodeQL init/analyze share versioned state and must change together. + codeql: + patterns: + - "github/codeql-action" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9f3f28690..72ea297200 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,11 +18,58 @@ on: permissions: contents: read +env: + UV_NO_BUILD: '1' + UV_NO_BINARY_PACKAGE: 'openai' + UNTRUSTED_BUILD_FORK: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id && '1' || '0' }} + TRUSTED_BUILD_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} + jobs: + dependency-locks: + timeout-minutes: 10 + name: dependency lock freshness + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Verify dependency source provenance before installing tools + run: | + python -I -c 'import ast, os, pathlib, re, subprocess, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); fork = os.environ.get("UNTRUSTED_BUILD_FORK", "0") == "1"; base = os.environ.get("TRUSTED_BUILD_BASE_SHA", ""); assert not fork or re.fullmatch(r"[0-9a-f]{40}", base), "Untrusted fork build base commit"; assert not fork or subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip() in {"https://github.com/openai/openai-python", "https://github.com/openai/openai-python.git"}, "Untrusted fork build base origin"; subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base], check=True) if fork else None; trusted = tomllib.loads(subprocess.check_output(["git", "show", base + ":pyproject.toml"], text=True)) if fork else config; project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); trusted_lock = tomllib.loads(subprocess.check_output(["git", "show", base + ":uv.lock"], text=True)) if fork else lock; roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); package_identity = lambda package: (canonical(package["name"]), package["version"], package.get("source", {}).get("registry", ""), tuple(sorted((artifact["url"], artifact["hash"]) for artifact in (([package["sdist"]] if "sdist" in package else []) + package.get("wheels", []))))); normalize = lambda value: re.sub(r"\s+", "", value); identity = lambda value: (canonical((item := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", value)).group(1)), item.group(2), ast.dump(ast.parse(item.group(3), mode="eval")) if item.group(3) is not None else ""); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and (not fork or sorted(package_identity(package) for package in lock["package"] if package is not roots[0]) == sorted(package_identity(package) for package in trusted_lock["package"] if package.get("source") != {"editable": "."})) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires) and build.get("build-backend") == trusted.get("build-system", {}).get("build-backend") and {identity(requirement) for requirement in requires} == {identity(requirement) for requirement in trusted.get("build-system", {}).get("requires", [])} and {identity(requirement) for requirement in constraints} == {identity(requirement) for requirement in trusted.get("tool", {}).get("uv", {}).get("build-constraint-dependencies", [])} and {identity(requirement) for requirement in reviewed} == {identity(requirement) for requirement in trusted.get("dependency-groups", {}).get("build", [])}, "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' + + - name: Require published minimums for direct security updates + if: >- + github.event_name == 'pull_request' + && github.event.pull_request.user.login == 'dependabot[bot]' + && contains(github.event.pull_request.head.ref, 'python-security') + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + [[ "$BASE_SHA" =~ ^[0-9a-f]{40}$ ]] + origin="$(git remote get-url origin)" + [[ "$origin" == "https://github.com/openai/openai-python" || "$origin" == "https://github.com/openai/openai-python.git" ]] + git fetch --no-tags --depth=1 origin "$BASE_SHA" + git show "$BASE_SHA:scripts/check-dependency-security.py" | python -I - + + - name: Set up uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version: '0.12.5' + enable-cache: true + + - name: Verify dependency groups and committed lock + run: | + uv run --no-project --python '>=3.11' python -c 'import pathlib, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); assert project["dependency-groups"]["dev"] and "dev" in project["tool"]["uv"]["default-groups"], "Keep the uv development dependency group enabled"' + uv run --no-project --python '>=3.11' python -c 'import pathlib, tomllib; project = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); text = pathlib.Path("uv.lock").read_text(); lock = tomllib.loads(text); version = project["project"]["version"]; assert next(package["version"] for package in lock["package"] if package["name"] == "openai") == version and "version = " + chr(34) + version + chr(34) + " # x-release-please-version" in text, "Preserve the uv.lock release-please marker and project version"' + uv lock --check + lint: timeout-minutes: 10 name: lint runs-on: ubuntu-latest + needs: dependency-locks if: github.event_name == 'push' || github.event_name == 'merge_group' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -44,6 +91,7 @@ jobs: run: ./scripts/lint build: + needs: dependency-locks if: github.event_name == 'push' || github.event_name == 'merge_group' || github.event.pull_request.head.repo.fork timeout-minutes: 10 name: build @@ -65,7 +113,9 @@ jobs: run: uv sync --locked --all-extras - name: Run build - run: ./scripts/build + run: | + external_packages="$(python -c 'import importlib, re, sys; parser = importlib.import_module("tomllib" if sys.version_info >= (3, 11) else "tomli"); packages = parser.loads(open("uv.lock").read())["package"]; roots = [package for package in packages if package.get("source") == {"editable": "."}]; external = [package for package in packages if package not in roots]; assert len(roots) == 1 and roots[0].get("name") == "openai" and external and all(package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("name"), str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", package["name"]) for package in external), "Refuse malformed external build package restrictions"; denied = sorted({re.sub(r"[-_.]+", "-", package["name"]).lower() for package in external}); assert denied and "openai" not in denied, "Refuse incomplete external build package restrictions"; print(" ".join(denied))')" + env -u UV_NO_BUILD UV_NO_BUILD_PACKAGE="$external_packages" ./scripts/build - name: Validate Python version metadata run: uv run --locked --all-extras python scripts/utils/validate-python-version-wheel.py @@ -96,6 +146,7 @@ jobs: timeout-minutes: 15 name: test (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest + needs: dependency-locks if: github.event_name == 'push' || github.event_name == 'merge_group' || github.event.pull_request.head.repo.fork strategy: fail-fast: false @@ -132,6 +183,7 @@ jobs: timeout-minutes: 20 name: test (HTTPX2) runs-on: ubuntu-latest + needs: dependency-locks if: github.event_name == 'push' || github.event_name == 'merge_group' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -167,6 +219,7 @@ jobs: name: examples environment: ci runs-on: ubuntu-latest + needs: dependency-locks if: github.repository == 'openai/openai-python' && github.event_name == 'push' && github.ref == 'refs/heads/main' steps: @@ -209,8 +262,11 @@ jobs: timeout-minutes: 20 name: compatibility (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest + needs: dependency-locks if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' continue-on-error: ${{ matrix.experimental }} + env: + UV_NO_BUILD: ${{ matrix.experimental && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '0' || '1' }} strategy: fail-fast: false matrix: diff --git a/.github/workflows/detect-breaking-changes.yml b/.github/workflows/detect-breaking-changes.yml index 5ca06d149d..8aab6c702a 100644 --- a/.github/workflows/detect-breaking-changes.yml +++ b/.github/workflows/detect-breaking-changes.yml @@ -11,6 +11,12 @@ on: permissions: contents: read +env: + UV_NO_BUILD: '1' + UV_NO_BINARY_PACKAGE: 'openai' + UNTRUSTED_BUILD_FORK: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id && '1' || '0' }} + TRUSTED_BUILD_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} + jobs: detect_breaking_changes: runs-on: 'ubuntu-latest' @@ -24,6 +30,10 @@ jobs: persist-credentials: false fetch-depth: 1 + - name: Verify dependency source provenance before installing tools + run: | + python -I -c 'import ast, os, pathlib, re, subprocess, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); fork = os.environ.get("UNTRUSTED_BUILD_FORK", "0") == "1"; base = os.environ.get("TRUSTED_BUILD_BASE_SHA", ""); assert not fork or re.fullmatch(r"[0-9a-f]{40}", base), "Untrusted fork build base commit"; assert not fork or subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip() in {"https://github.com/openai/openai-python", "https://github.com/openai/openai-python.git"}, "Untrusted fork build base origin"; subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base], check=True) if fork else None; trusted = tomllib.loads(subprocess.check_output(["git", "show", base + ":pyproject.toml"], text=True)) if fork else config; project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); trusted_lock = tomllib.loads(subprocess.check_output(["git", "show", base + ":uv.lock"], text=True)) if fork else lock; roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); package_identity = lambda package: (canonical(package["name"]), package["version"], package.get("source", {}).get("registry", ""), tuple(sorted((artifact["url"], artifact["hash"]) for artifact in (([package["sdist"]] if "sdist" in package else []) + package.get("wheels", []))))); normalize = lambda value: re.sub(r"\s+", "", value); identity = lambda value: (canonical((item := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", value)).group(1)), item.group(2), ast.dump(ast.parse(item.group(3), mode="eval")) if item.group(3) is not None else ""); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and (not fork or sorted(package_identity(package) for package in lock["package"] if package is not roots[0]) == sorted(package_identity(package) for package in trusted_lock["package"] if package.get("source") != {"editable": "."})) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires) and build.get("build-backend") == trusted.get("build-system", {}).get("build-backend") and {identity(requirement) for requirement in requires} == {identity(requirement) for requirement in trusted.get("build-system", {}).get("requires", [])} and {identity(requirement) for requirement in constraints} == {identity(requirement) for requirement in trusted.get("tool", {}).get("uv", {}).get("build-constraint-dependencies", [])} and {identity(requirement) for requirement in reviewed} == {identity(requirement) for requirement in trusted.get("dependency-groups", {}).get("build", [])}, "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' + - name: Fetch comparison base run: git fetch --no-tags --depth=1 origin "$BASE_SHA" @@ -57,6 +67,11 @@ jobs: persist-credentials: false path: openai-python + - name: Verify dependency source provenance before installing tools + working-directory: openai-python + run: | + python -I -c 'import ast, os, pathlib, re, subprocess, tomllib; config = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); fork = os.environ.get("UNTRUSTED_BUILD_FORK", "0") == "1"; base = os.environ.get("TRUSTED_BUILD_BASE_SHA", ""); assert not fork or re.fullmatch(r"[0-9a-f]{40}", base), "Untrusted fork build base commit"; assert not fork or subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip() in {"https://github.com/openai/openai-python", "https://github.com/openai/openai-python.git"}, "Untrusted fork build base origin"; subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base], check=True) if fork else None; trusted = tomllib.loads(subprocess.check_output(["git", "show", base + ":pyproject.toml"], text=True)) if fork else config; project = config["project"]; lock = tomllib.loads(pathlib.Path("uv.lock").read_text()); trusted_lock = tomllib.loads(subprocess.check_output(["git", "show", base + ":uv.lock"], text=True)) if fork else lock; roots = [package for package in lock["package"] if package.get("name") == project["name"] and package.get("version") == project["version"] and package.get("source") == {"editable": "."}]; build = config.get("build-system", {}); uv = config.get("tool", {}).get("uv", {}); requires = build.get("requires", []); constraints = uv.get("build-constraint-dependencies", []); reviewed = config.get("dependency-groups", {}).get("build", []); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); package_identity = lambda package: (canonical(package["name"]), package["version"], package.get("source", {}).get("registry", ""), tuple(sorted((artifact["url"], artifact["hash"]) for artifact in (([package["sdist"]] if "sdist" in package else []) + package.get("wheels", []))))); normalize = lambda value: re.sub(r"\s+", "", value); identity = lambda value: (canonical((item := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", value)).group(1)), item.group(2), ast.dump(ast.parse(item.group(3), mode="eval")) if item.group(3) is not None else ""); assert len(roots) == 1 and canonical(project["name"]) == "openai" and sum(canonical(package.get("name", "")) == "openai" for package in lock["package"]) == 1 and all(package is roots[0] or package.get("source") == {"registry": "https://pypi.org/simple"} and isinstance(package.get("wheels"), list) and bool(package.get("wheels")) and (artifacts := ([package["sdist"]] if "sdist" in package else []) + package.get("wheels", [])) and all(isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")) for artifact in artifacts) for package in lock["package"]) and (not fork or sorted(package_identity(package) for package in lock["package"] if package is not roots[0]) == sorted(package_identity(package) for package in trusted_lock["package"] if package.get("source") != {"editable": "."})) and build.get("build-backend") == "hatchling.build" and "backend-path" not in build and isinstance(requires, list) and bool(requires) and isinstance(constraints, list) and bool(constraints) and isinstance(reviewed, list) and bool(reviewed) and not any(uv.get(key) for key in ("sources", "index", "indexes", "index-url", "extra-index-url", "default-index", "find-links", "index_url", "extra_index_url", "default_index", "find_links", "no-binary", "no-binary-package", "no_binary", "no_binary_package")) and not any(pathlib.Path(path).exists() for path in ("uv.toml", ".uv.toml")) and all(isinstance(requirement, str) and (match := re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)\s*==\s*([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)(?:\s*;\s*(.+))?", requirement)) is not None and (match.group(3) is None or isinstance(marker := ast.parse(match.group(3), mode="eval").body, ast.Compare) and isinstance(marker.left, ast.Name) and len(marker.ops) == 1 and type(marker.ops[0]) in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} and len(marker.comparators) == 1 and isinstance(marker.comparators[0], ast.Constant) and isinstance(marker.comparators[0].value, str)) and any(canonical(package.get("name", "")) == canonical(match.group(1)) and package.get("version") == match.group(2) and package.get("source") == {"registry": "https://pypi.org/simple"} for package in lock["package"]) for requirement in requires + constraints + reviewed) and {normalize(requirement) for requirement in constraints} == {normalize(requirement) for requirement in reviewed} and {normalize(requirement) for requirement in requires}.issubset({normalize(requirement) for requirement in constraints}) and any(canonical(requirement.split("==", 1)[0].strip()) == "hatchling" for requirement in requires) and build.get("build-backend") == trusted.get("build-system", {}).get("build-backend") and {identity(requirement) for requirement in requires} == {identity(requirement) for requirement in trusted.get("build-system", {}).get("requires", [])} and {identity(requirement) for requirement in constraints} == {identity(requirement) for requirement in trusted.get("tool", {}).get("uv", {}).get("build-constraint-dependencies", [])} and {identity(requirement) for requirement in reviewed} == {identity(requirement) for requirement in trusted.get("dependency-groups", {}).get("build", [])}, "Use only the public PyPI registry, reviewed artifacts, the exact editable root project, and locked reviewed build requirements in uv.lock"' + - name: Set up uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: @@ -76,16 +91,26 @@ jobs: with: persist-credentials: false repository: openai/openai-agents-python + ref: 7e55afc9500d12937687988f1e91e900dcb4ad09 path: openai-agents-python - name: Link to local SDK working-directory: openai-agents-python - run: uv add ../openai-python + env: + UV_NO_BINARY_PACKAGE: 'openai openai-agents' + run: uv add --no-sync ../openai-python - name: Install dependencies working-directory: openai-agents-python - run: make sync + env: + UV_NO_BINARY_PACKAGE: 'openai openai-agents' + run: | + reviewed_sources="$(python -c 'import pathlib, re, subprocess, tomllib; origin = subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip(); assert origin in {"https://github.com/openai/openai-agents-python", "https://github.com/openai/openai-agents-python.git"}, "Use only the immutable reviewed Agents source distributions"; trusted = tomllib.loads(subprocess.check_output(["git", "show", "HEAD:uv.lock"], text=True)); current = tomllib.loads(pathlib.Path("uv.lock").read_text()); canonical = lambda value: re.sub(r"[-_.]+", "-", value).lower(); reviewed = {"aiohttp": ("3.12.15", "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2"), "markupsafe": ("3.0.2", "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"), "pyyaml": ("6.0.2", "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"), "evdev": ("1.9.2", "https://files.pythonhosted.org/packages/63/fe/a17c106a1f4061ce83f04d14bcedcfb2c38c7793ea56bfb906a6fadae8cb/evdev-1.9.2.tar.gz", "sha256:5d3278892ce1f92a74d6bf888cc8525d9f68af85dbe336c95d1c87fb8f423069")}; public = {"registry": "https://pypi.org/simple"}; approved = {name: [entry for entry in trusted["package"] if canonical(entry.get("name", "")) == name] for name in reviewed}; actual = {name: [entry for entry in current["package"] if canonical(entry.get("name", "")) == name] for name in reviewed}; safe = lambda artifact: isinstance(artifact, dict) and re.fullmatch(r"https://files\.pythonhosted\.org/packages/[^\s?#]+", artifact.get("url", "")) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact.get("hash", "")); assert all(len(approved[name]) == 1 and approved[name][0].get("version") == version and approved[name][0].get("source") == public and isinstance(approved[name][0].get("sdist"), dict) and approved[name][0]["sdist"].get("url") == url and approved[name][0]["sdist"].get("hash") == digest for name, (version, url, digest) in reviewed.items()), "Use only the immutable reviewed Agents source distributions"; assert all(len(actual[name]) <= 1 and (not actual[name] or actual[name][0].get("source") == public and (actual[name][0].get("version") == version and actual[name][0].get("sdist") == approved[name][0].get("sdist") or actual[name][0].get("version") != version and isinstance(wheels := actual[name][0].get("wheels"), list) and bool(wheels) and all(safe(artifact) for artifact in ([actual[name][0]["sdist"]] if "sdist" in actual[name][0] else []) + wheels) and all(wheel["url"].endswith(".whl") for wheel in wheels))) for name, (version, _, _) in reviewed.items()), "Use only the immutable reviewed Agents source distributions"; print(" ".join(name for name, (version, _, _) in reviewed.items() if actual[name] and actual[name][0].get("version") == version and actual[name][0].get("sdist") == approved[name][0].get("sdist")))')" + UV_NO_BINARY_PACKAGE="openai openai-agents ${reviewed_sources}" make sync - name: Run integration type checks + env: + UV_NO_BINARY_PACKAGE: 'openai openai-agents' + UV_NO_SYNC: '1' working-directory: openai-agents-python run: make mypy diff --git a/scripts/check-dependency-security.py b/scripts/check-dependency-security.py new file mode 100644 index 0000000000..11bb7a936d --- /dev/null +++ b/scripts/check-dependency-security.py @@ -0,0 +1,1030 @@ +from __future__ import annotations + +import io +import os +import re +import ast +import pathlib +import tokenize +import importlib +import subprocess +from typing import Any, TypeAlias, cast + +MarkerClause: TypeAlias = tuple[str, str, str] +MarkerContext: TypeAlias = tuple[MarkerClause, ...] +StableRelease: TypeAlias = tuple[int, tuple[int, ...], int] +PublishedBound: TypeAlias = tuple[str, int, tuple[int, ...], int, bool] +DependencyContext: TypeAlias = tuple[str, str, tuple[str, ...], MarkerContext] +RequirementMap: TypeAlias = dict[str, set[str]] +ContextRequirements: TypeAlias = dict[DependencyContext, set[str]] +ContextsByName: TypeAlias = dict[str, ContextRequirements] +ContextReplacements: TypeAlias = dict[DependencyContext, ContextRequirements] +ResolutionDomains: TypeAlias = dict[MarkerContext, set[str]] +ResolutionsByName: TypeAlias = dict[str, ResolutionDomains] +tomllib: Any = importlib.import_module("tomllib") + +base = os.environ.get("BASE_SHA", "") +if not re.fullmatch(r"[0-9a-f]{40}", base): + raise SystemExit("Untrusted security-update base commit") +origin = subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip() +if origin not in { + "https://github.com/openai/openai-python", + "https://github.com/openai/openai-python.git", + "git@github.com:openai/openai-python.git", + "ssh://git@github.com/openai/openai-python.git", +}: + raise SystemExit("Untrusted security-update comparison origin") +subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base], check=True) + + +def read_base(path: str) -> dict[str, Any]: + return cast(dict[str, Any], tomllib.loads(subprocess.check_output(["git", "show", base + ":" + path], text=True))) + + +def canonical(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def marker_context(marker: str) -> MarkerContext: + if not marker.strip(): + return () + try: + if any( + token.type == tokenize.OP and token.string in {"(", ")"} + for token in tokenize.generate_tokens(io.StringIO(marker).readline) + ): + raise ValueError("Parenthesized security dependency marker") + expression = ast.parse(marker.strip(), mode="eval").body + except (SyntaxError, tokenize.TokenError, ValueError): + raise SystemExit("Ambiguous direct security dependency marker") from None + if isinstance(expression, ast.BoolOp): + if not isinstance(expression.op, ast.And): + raise SystemExit("Ambiguous direct security dependency marker") + parts = expression.values + else: + parts = [expression] + result: list[MarkerClause] = [] + for part in parts: + if ( + not isinstance(part, ast.Compare) + or not isinstance(part.left, ast.Name) + or len(part.ops) != 1 + or len(part.comparators) != 1 + or not isinstance(part.comparators[0], ast.Constant) + or not isinstance(part.comparators[0].value, str) + or type(part.ops[0]) not in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn} + ): + raise SystemExit("Ambiguous direct security dependency marker") + result.append((part.left.id.lower(), type(part.ops[0]).__name__, part.comparators[0].value)) + return tuple(sorted(result)) + + +def direct(project: dict[str, Any], *, protected: bool = False) -> tuple[RequirementMap, ContextsByName]: + if protected: + uv = project.get("tool", {}).get("uv", {}) + groups = [ + ("uv-constraint", "", uv.get("constraint-dependencies", [])), + ("uv-build-constraint", "", uv.get("build-constraint-dependencies", [])), + ] + groups.extend( + ("dependency-group", canonical(group), requirements) + for group, requirements in project.get("dependency-groups", {}).items() + ) + else: + groups = [("runtime", "", project["project"].get("dependencies", []))] + groups.extend( + ("optional", canonical(group), requirements) + for group, requirements in project["project"].get("optional-dependencies", {}).items() + ) + result: RequirementMap = {} + contexts: ContextsByName = {} + for scope, group, requirements in groups: + for requirement in requirements: + match = re.fullmatch(r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", requirement) + if match is None: + raise SystemExit("Ambiguous direct security dependency requirement") + name = canonical(match.group(1)) + extra = match.group(2) + requested: tuple[str, ...] = () + if extra: + requested = tuple(sorted(canonical(value.strip()) for value in extra[1:-1].split(","))) + if any(not re.fullmatch(r"[a-z0-9][a-z0-9-]*", value) for value in requested): + raise SystemExit("Ambiguous direct security dependency extras") + context = (scope, group, requested, marker_context(match.group(3).partition(";")[2])) + normalized = name + (match.group(2) or "").lower() + re.sub(r"\s+", "", match.group(3)).lower() + result.setdefault(name, set()).add(normalized) + contexts.setdefault(name, {}).setdefault(context, set()).add(normalized) + return result, contexts + + +def versions(lock: dict[str, Any]) -> tuple[RequirementMap, ResolutionsByName]: + result: RequirementMap = {} + contexts: ResolutionsByName = {} + for package in lock["package"]: + name = canonical(package["name"]) + version = package["version"] + result.setdefault(name, set()).add(version) + markers = package.get("resolution-markers") + domains: list[MarkerContext] + if markers is None: + domains = [()] + else: + if ( + not isinstance(markers, list) + or not markers + or any(not isinstance(marker, str) for marker in cast(list[object], markers)) + ): + raise SystemExit("Ambiguous locked security dependency resolution marker") + domains = [marker_context(marker) for marker in cast(list[str], markers)] + for domain in domains: + contexts.setdefault(name, {}).setdefault(domain, set()).add(version) + return result, contexts + + +def stable_version(value: str) -> StableRelease: + match = re.fullmatch(r"(?:(\d+)!)?(\d+(?:\.\d+)*)(?:\.post(\d+))?", value) + if match is None: + raise SystemExit("Unsupported direct security dependency minimum") + release = tuple(int(part) for part in match.group(2).split(".")) + while release and release[-1] == 0: + release = release[:-1] + post = -1 if match.group(3) is None else int(match.group(3)) + return int(match.group(1) or 0), release, post + + +def simple_marker_overlap(requirement: MarkerContext, resolution: MarkerContext) -> bool: + clauses: dict[str, list[MarkerClause]] = {} + for variable, operator, value in requirement + resolution: + family = "python" if variable in {"python_version", "python_full_version"} else variable + clauses.setdefault(family, []).append((variable, operator, value)) + for family, constraints in clauses.items(): + if family == "python": + lower: tuple[int, int, int] = (0, 0, 0) + upper: tuple[int, int, int] | None = None + excluded: list[tuple[tuple[int, int, int], tuple[int, int, int]]] = [] + for variable, operator, value in constraints: + match = re.fullmatch(r"(\d+)\.(\d+)(?:\.(\d+))?(\.\*)?", value) + if match is None or (variable == "python_version" and match.group(3) is not None): + raise SystemExit("Ambiguous Python security dependency marker") + major, minor = int(match.group(1)), int(match.group(2)) + patch = int(match.group(3) or 0) + wildcard = match.group(4) is not None + if wildcard and operator not in {"Eq", "NotEq"}: + raise SystemExit("Ambiguous wildcard security dependency marker") + start = (major, minor, patch) + stop = (major, minor + 1, 0) if variable == "python_version" or wildcard else (major, minor, patch + 1) + if operator == "Eq": + lower = max(lower, start) + upper = stop if upper is None else min(upper, stop) + elif operator == "NotEq": + excluded.append((start, stop)) + elif operator == "Lt": + upper = start if upper is None else min(upper, start) + elif operator == "LtE": + upper = stop if upper is None else min(upper, stop) + elif operator == "Gt": + lower = max(lower, stop) + elif operator == "GtE": + lower = max(lower, start) + else: + raise SystemExit("Ambiguous Python security dependency marker") + if upper is not None and lower >= upper: + return False + candidate = lower + for start, stop in sorted(excluded): + if start <= candidate < stop: + candidate = stop + if upper is not None and candidate >= upper: + return False + else: + if family not in { + "sys_platform", + "os_name", + "platform_system", + "platform_machine", + "platform_python_implementation", + "implementation_name", + "extra", + }: + raise SystemExit("Unsupported security dependency marker variable") + equality: str | None = None + exclusions: set[str] = set() + for _, operator, value in constraints: + if operator == "Eq": + if equality is not None and equality != value: + return False + equality = value + elif operator == "NotEq": + exclusions.add(value) + else: + raise SystemExit("Ambiguous platform security dependency marker") + if equality is not None and equality in exclusions: + return False + return True + + +def marker_options(context: MarkerContext) -> list[MarkerContext]: + options: list[MarkerContext] = [()] + allowed_platforms = { + "sys_platform", + "os_name", + "platform_system", + "platform_machine", + "platform_python_implementation", + "implementation_name", + "extra", + } + for variable, operator, value in context: + if operator not in {"In", "NotIn"}: + options = [option + ((variable, operator, value),) for option in options] + continue + if variable == "python_version": + pattern = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + elif variable == "python_full_version": + pattern = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + elif variable in allowed_platforms: + pattern = r"[A-Za-z0-9][A-Za-z0-9_.-]*" + else: + raise SystemExit("Unsupported security dependency membership marker variable") + values = tuple(item.strip() for item in value.split(",")) + if ( + not values + or len(values) > 16 + or len(set(values)) != len(values) + or any(not re.fullmatch(pattern, item) for item in values) + or any( + first in second or second in first + for index, first in enumerate(values) + for second in values[index + 1 :] + ) + ): + raise SystemExit("Ambiguous security dependency membership marker") + if operator == "In": + options = [option + ((variable, "Eq", member),) for option in options for member in values] + else: + exclusions = tuple((variable, "NotEq", member) for member in values) + options = [option + exclusions for option in options] + if len(options) > 64: + raise SystemExit("Ambiguous security dependency membership marker") + return options + + +def marker_overlap(requirement: MarkerContext, resolution: MarkerContext) -> bool: + requirements, resolutions = marker_options(requirement), marker_options(resolution) + if len(requirements) * len(resolutions) > 128: + raise SystemExit("Ambiguous security dependency membership marker") + return any(simple_marker_overlap(left, right) for left in requirements for right in resolutions) + + +def minimums(requirements: set[str], *, allow_missing: bool = False, exact: bool = False) -> list[StableRelease]: + result: list[StableRelease] = [] + for requirement in requirements: + specifier = requirement.split(";", 1)[0] + pattern = r"(?=!~])(?:>=|>|==)([^,;]+)" if exact else r"(?=!~])(?:>=|>)([^,;]+)" + matches = re.findall(pattern, specifier) + if len(matches) != 1: + if allow_missing and not matches: + continue + raise SystemExit("Missing or ambiguous direct security dependency minimum") + result.append(stable_version(matches[0].strip())) + return sorted(result) + + +def matches_protected_release(requirements: set[str], release: StableRelease, *, upper_only: bool = False) -> bool: + for requirement in requirements: + expression = requirement.split(";", 1)[0] + match = re.fullmatch(r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", expression) + if match is None: + raise SystemExit("Ambiguous protected security dependency requirement") + accepted = True + for clause in match.group(3).split(","): + if not clause.strip(): + continue + bound = re.fullmatch(r"(>=|<=|==|!=|>|<)\s*(\S+)", clause.strip()) + if bound is None: + raise SystemExit("Ambiguous protected security dependency bound") + operator, value = bound.group(1), stable_version(bound.group(2)) + if upper_only and operator in {">=", ">"}: + continue + if ( + operator == ">=" + and release < value + or operator == ">" + and release <= value + or operator == "<=" + and release > value + or operator == "<" + and release >= value + or operator == "==" + and release != value + or operator == "!=" + and release == value + ): + accepted = False + if accepted: + return True + return False + + +def unchanged_nonfloor_bounds(requirement: str) -> tuple[tuple[str, StableRelease], ...]: + expression = requirement.split(";", 1)[0] + match = re.fullmatch(r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", expression) + if match is None: + raise SystemExit("Ambiguous split security dependency requirement") + bounds: list[tuple[str, StableRelease]] = [] + for clause in match.group(3).split(","): + if not clause.strip(): + continue + bound = re.fullmatch(r"(>=|<=|==|!=|>|<)\s*(\S+)", clause.strip()) + if bound is None: + raise SystemExit("Ambiguous split security dependency bound") + operator, value = bound.group(1), stable_version(bound.group(2)) + if operator not in {">=", ">", "=="}: + bounds.append((operator, value)) + return tuple(sorted(bounds)) + + +def replacement_contexts( + previous_context: DependencyContext, + previous_requirements: set[str], + current_contexts: ContextRequirements, + domains: ResolutionDomains, + *, + exact: bool = False, +) -> ContextRequirements: + current = current_contexts.get(previous_context, set()) + if len(current) >= len(previous_requirements): + return {previous_context: current} + if current or previous_context[3] or len(previous_requirements) != 1: + return {} + original = next(iter(previous_requirements)) + original_minimums = minimums(previous_requirements, allow_missing=True, exact=exact) + if len(original_minimums) != 1: + return {} + replacements = { + context: requirements + for context, requirements in current_contexts.items() + if context[:3] == previous_context[:3] and context[3] + } + if len(replacements) < 2: + return {} + for requirements in replacements.values(): + if len(requirements) != 1: + return {} + replacement = next(iter(requirements)) + if unchanged_nonfloor_bounds(replacement) != unchanged_nonfloor_bounds(original): + return {} + replacement_minimums = minimums(requirements, exact=exact) + if len(replacement_minimums) != 1 or replacement_minimums[0] < original_minimums[0]: + return {} + relevant = { + domain + for domain, versions in domains.items() + if any(matches_protected_release(previous_requirements, stable_version(version)) for version in versions) + } + if len(relevant) < 2: + return {} + covered: set[DependencyContext] = set() + opposite = { + "Eq": "NotEq", + "NotEq": "Eq", + "Lt": "GtE", + "LtE": "Gt", + "Gt": "LtE", + "GtE": "Lt", + "In": "NotIn", + "NotIn": "In", + } + for domain in relevant: + matched = [context for context in replacements if marker_overlap(context[3], domain)] + if len(matched) != 1: + return {} + for variable, operator, value in matched[0][3]: + if operator not in opposite or marker_overlap(domain, ((variable, opposite[operator], value),)): + return {} + covered.add(matched[0]) + return replacements if covered == set(replacements) else {} + + +def preserves_supported_security_branches( + previous_domains: ResolutionDomains, + current_domains: ResolutionDomains, + previous_contexts: ContextRequirements, + current_contexts: ContextRequirements, +) -> bool: + observed = False + for domain in previous_domains.keys() | current_domains.keys(): + previous_versions = previous_domains.get(domain, set()) + current_versions = current_domains.get(domain, set()) + if previous_versions == current_versions: + continue + removed = sorted(stable_version(version) for version in previous_versions - current_versions) + introduced = sorted(stable_version(version) for version in current_versions - previous_versions) + unchanged = {stable_version(version) for version in previous_versions & current_versions} + if not unchanged or not removed or len(removed) != len(introduced): + return False + observed = True + for previous_release, patched_release in zip(removed, introduced, strict=True): + if patched_release <= previous_release: + return False + supported = False + for context, previous_requirements in previous_contexts.items(): + requirements = current_contexts.get(context, set()) + if len(previous_requirements) != 1 or len(requirements) != 1 or not marker_overlap(context[3], domain): + continue + original = next(iter(previous_requirements)) + replacement = next(iter(requirements)) + bounds = unchanged_nonfloor_bounds(original) + if ( + not any(operator in {"<", "<="} for operator, _ in bounds) + or unchanged_nonfloor_bounds(replacement) != bounds + or not matches_protected_release(previous_requirements, previous_release) + or not matches_protected_release(requirements, patched_release) + ): + continue + before = minimums(previous_requirements, allow_missing=True, exact=True) + after = minimums(requirements, exact=True) + if len(before) != 1 or len(after) != 1 or after[0] < patched_release or after[0] <= before[0]: + continue + if not any( + not matches_protected_release(previous_requirements, preserved) + and any( + other != context + and marker_overlap(other[3], domain) + and other in current_contexts + and matches_protected_release(protected, preserved) + and matches_protected_release(current_contexts[other], preserved) + for other, protected in previous_contexts.items() + ) + for preserved in unchanged + ): + continue + supported = True + break + if not supported: + return False + return observed + + +def published_bounds(requirement: str) -> tuple[PublishedBound, ...]: + expression = requirement.split(";", 1)[0] + match = re.fullmatch(r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", expression) + if match is None: + raise SystemExit("Ambiguous published security dependency requirement") + clauses = match.group(3).split(",") + if len(clauses) > 256: + raise SystemExit("Unbounded published security dependency exclusions") + result: list[PublishedBound] = [] + for clause in clauses: + match = re.fullmatch( + r"(>=|<=|==|!=|>|<)\s*((?:(\d+)!)?(\d+(?:\.\d+)*)(?:\.post(\d+))?)(\.\*)?", + clause.strip(), + ) + if match is None or len(match.group(2)) > 128: + raise SystemExit("Ambiguous published security dependency bound") + components = match.group(4).split(".") + if len(components) > 16 or any(len(component) > 9 for component in components): + raise SystemExit("Unbounded published security dependency release") + wildcard = match.group(6) is not None + if wildcard and (match.group(1) != "!=" or match.group(5) is not None): + raise SystemExit("Ambiguous published security dependency wildcard") + epoch, release, post = stable_version(match.group(2)) + prefix = tuple(int(component) for component in components) + result.append((match.group(1), epoch, prefix if wildcard else release, post, wildcard)) + if len(set(result)) != len(result): + raise SystemExit("Ambiguous duplicate published security dependency bound") + return tuple(result) + + +def allows_published_release(bounds: tuple[PublishedBound, ...], release: StableRelease) -> bool: + for operator, epoch, components, post, wildcard in bounds: + if wildcard: + candidate = release[1] + (0,) * max(0, len(components) - len(release[1])) + if release[0] == epoch and candidate[: len(components)] == components: + return False + continue + bound = epoch, components, post + if ( + operator == ">=" + and release < bound + or operator == ">" + and release <= bound + or operator == "<=" + and release > bound + or operator == "<" + and release >= bound + or operator == "==" + and release != bound + or operator == "!=" + and release == bound + ): + return False + return True + + +def preserves_published_security_bound(previous: PublishedBound, current: tuple[PublishedBound, ...]) -> bool: + operator, epoch, components, post, wildcard = previous + if operator in {">", ">="}: + limit = epoch, components, post + for updated, candidate_epoch, candidate, candidate_post, candidate_wildcard in current: + if candidate_wildcard or updated not in {">", ">=", "=="}: + continue + bound = candidate_epoch, candidate, candidate_post + if bound > limit or bound == limit and (operator == ">=" or updated == ">"): + return True + return False + if operator in {"<", "<="}: + limit = epoch, components, post + for updated, candidate_epoch, candidate, candidate_post, candidate_wildcard in current: + if candidate_wildcard or updated not in {"<", "<=", "=="}: + continue + bound = candidate_epoch, candidate, candidate_post + if bound < limit or bound == limit and (operator == "<=" or updated == "<"): + return True + return False + if operator == "==": + return any(bound == previous for bound in current) + if operator != "!=": + return True + if not wildcard: + return not allows_published_release(current, (epoch, components, post)) + + start_text = str(epoch) + "!" + ".".join(str(part) for part in components) + start = stable_version(start_text) + next_components = components[:-1] + (components[-1] + 1,) + stop = stable_version(str(epoch) + "!" + ".".join(str(part) for part in next_components)) + for updated, candidate_epoch, candidate, candidate_post, candidate_wildcard in current: + if ( + updated == "!=" + and candidate_wildcard + and candidate_epoch == epoch + and len(candidate) <= len(components) + and components[: len(candidate)] == candidate + ): + return True + if candidate_wildcard: + continue + bound = candidate_epoch, candidate, candidate_post + if updated in {"<", "<="} and (bound < start or bound == start and updated == "<"): + return True + if updated in {">=", ">"} and bound >= stop: + return True + if updated == "==": + padded = candidate + (0,) * max(0, len(components) - len(candidate)) + if candidate_epoch != epoch or padded[: len(components)] != components: + return True + return False + + +def preserves_exact_pinned_release( + previous: PublishedBound, + current: tuple[PublishedBound, ...], + context: MarkerContext, + previous_domains: ResolutionDomains, + current_domains: ResolutionDomains, +) -> bool: + if preserves_published_security_bound(previous, current): + return True + operator, epoch, components, post, wildcard = previous + if operator != "==" or wildcard: + return False + pinned = epoch, components, post + replacements: list[StableRelease] = [] + for domain, versions in previous_domains.items(): + if not marker_overlap(context, domain): + continue + prior = {stable_version(version) for version in versions} + if pinned not in prior: + continue + updated = {stable_version(version) for version in current_domains.get(domain, set())} + removed = prior - updated + introduced = updated - prior + if removed != {pinned} or len(introduced) != 1: + return False + patched = next(iter(introduced)) + if patched <= pinned: + return False + replacements.append(patched) + if len(set(replacements)) != 1: + return False + patched = replacements[0] + return any( + operator == "==" and not wildcard and (epoch, components, post) == patched + for operator, epoch, components, post, wildcard in current + ) + + +def preserves_dependency_security_bounds( + previous_contexts: ContextRequirements, + replacements: ContextReplacements, + previous_domains: ResolutionDomains, + domains: ResolutionDomains, +) -> bool: + for previous_context, previous_requirements in previous_contexts.items(): + for requirement in previous_requirements: + expression = requirement.split(";", 1)[0] + match = re.fullmatch(r"\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(\[[^\]]+\])?\s*(.*)", expression) + if match is None: + raise SystemExit("Ambiguous unchanged published security dependency requirement") + clauses = match.group(3).split(",") + if not any(re.match(r"(?:!=|<=|<|>=|>|==)", clause.strip()) for clause in clauses): + continue + before = published_bounds(requirement) + protected = tuple(bound for bound in before if bound[0] in {"<", "<=", "!=", ">", ">=", "=="}) + preserve_releases = any(bound[0] in {"<", "<=", "!=", "=="} for bound in protected) + context_replacements = replacements.get(previous_context, {}) + if not context_replacements: + return False + for replacement_context, candidates in context_replacements.items(): + retained = { + stable_version(version) + for domain, versions in domains.items() + if marker_overlap(replacement_context[3], domain) + for version in versions + if allows_published_release(before, stable_version(version)) + } + preserved = False + for candidate in candidates: + after = published_bounds(candidate) + if all( + preserves_exact_pinned_release(bound, after, replacement_context[3], previous_domains, domains) + if bound[0] == "==" + else preserves_published_security_bound(bound, after) + for bound in protected + ) and ( + not preserve_releases or all(allows_published_release(after, release) for release in retained) + ): + preserved = True + break + if not preserved: + return False + return True + + +def published_lower_bound_excludes(bounds: tuple[PublishedBound, ...], epoch: int, prefix: tuple[int, ...]) -> bool: + for operator, bound_epoch, components, _post, wildcard in bounds: + if wildcard or operator not in {">=", ">"}: + continue + if epoch < bound_epoch: + return True + if epoch > bound_epoch: + continue + boundary = components + (0,) * max(0, len(prefix) - len(components)) + if prefix < boundary[: len(prefix)]: + return True + return False + + +def excludes_affected_published_branch( + previous_bounds: tuple[PublishedBound, ...], + current_bounds: tuple[PublishedBound, ...], + removed: StableRelease, + patched: StableRelease, + preserved: set[StableRelease], +) -> bool: + if ( + removed[0] != patched[0] + or not removed[1] + or not patched[1] + or removed[1][0] != patched[1][0] + or not set(previous_bounds).issubset(current_bounds) + or any(bound[0] != "!=" for bound in set(current_bounds) - set(previous_bounds)) + or not allows_published_release(previous_bounds, removed) + or allows_published_release(current_bounds, removed) + or not allows_published_release(current_bounds, patched) + ): + return False + retained = {release for release in preserved if allows_published_release(previous_bounds, release)} + if not retained or any(not allows_published_release(current_bounds, release) for release in retained): + return False + epoch, components, post = patched + work = 0 + exclusions = { + value + for operator, bound_epoch, value, _, wildcard in current_bounds + if operator == "!=" and wildcard and bound_epoch == epoch + } + for index in range(1, len(components)): + if components[index] > 256 - work: + return False + for component in range(components[index]): + work += 1 + prefix = components[:index] + (component,) + if published_lower_bound_excludes(previous_bounds, epoch, prefix): + continue + if not any( + len(exclusion) <= len(prefix) and prefix[: len(exclusion)] == exclusion for exclusion in exclusions + ): + return False + if post >= 0: + if post + 1 > 256 - work: + return False + if allows_published_release(current_bounds, (epoch, components, -1)): + return False + for earlier in range(post): + if allows_published_release(current_bounds, (epoch, components, earlier)): + return False + return True + + +def secures_supported_published_branches( + previous_domains: ResolutionDomains, + current_domains: ResolutionDomains, + previous_published: ContextRequirements, + current_published: ContextRequirements, + previous_protected: ContextRequirements, + current_protected: ContextRequirements, +) -> bool: + if not preserves_supported_security_branches( + previous_domains, current_domains, previous_protected, current_protected + ): + return False + observed = False + for domain in previous_domains.keys() | current_domains.keys(): + prior_versions = previous_domains.get(domain, set()) + updated_versions = current_domains.get(domain, set()) + if prior_versions == updated_versions: + continue + removed = sorted(stable_version(value) for value in prior_versions - updated_versions) + patched = sorted(stable_version(value) for value in updated_versions - prior_versions) + retained = {stable_version(value) for value in prior_versions & updated_versions} + if not retained or len(removed) != len(patched): + return False + for old, new in zip(removed, patched, strict=True): + covered = False + for context, previous in previous_published.items(): + current = current_published.get(context, set()) + if len(previous) != 1 or len(current) != 1 or not marker_overlap(context[3], domain): + continue + before = published_bounds(next(iter(previous))) + if not allows_published_release(before, old): + continue + after = published_bounds(next(iter(current))) + if not excludes_affected_published_branch(before, after, old, new, retained): + return False + covered = True + if not covered: + return False + observed = True + return observed + + +def covers_transitive_security_release( + requirements: ContextRequirements, + domain: MarkerContext, + removed: StableRelease, + patched: StableRelease, + current_domains: ResolutionDomains, +) -> bool: + for context, declarations in requirements.items(): + if not marker_overlap(context[3], domain): + continue + for requirement in declarations: + floors = minimums({requirement}, allow_missing=True, exact=True) + if len(floors) != 1 or floors[0] < patched: + continue + bounds = published_bounds(requirement) + if not allows_published_release(bounds, patched) or allows_published_release(bounds, removed): + continue + if any( + other != domain + and marker_overlap(context[3], other) + and any(not allows_published_release(bounds, stable_version(version)) for version in versions) + for other, versions in current_domains.items() + ): + continue + return True + return False + + +old_project = read_base("pyproject.toml") +old_lock = read_base("uv.lock") +new_project = cast(dict[str, Any], tomllib.loads(pathlib.Path("pyproject.toml").read_text())) +new_lock = cast(dict[str, Any], tomllib.loads(pathlib.Path("uv.lock").read_text())) +old_direct, old_contexts = direct(old_project) +new_direct, new_contexts = direct(new_project) +old_versions, old_resolution_contexts = versions(old_lock) +new_versions, new_resolution_contexts = versions(new_lock) +old_protected, old_protected_contexts = direct(old_project, protected=True) +new_protected, new_protected_contexts = direct(new_project, protected=True) +for name, previous in old_protected.items(): + requirements = new_protected.get(name, set()) + previous_contexts = old_protected_contexts.get(name, {}) + current_contexts = new_protected_contexts.get(name, {}) + previous_domains = old_resolution_contexts.get(name, {}) + current_domains = new_resolution_contexts.get(name, {}) + if previous == requirements and previous_contexts == current_contexts and previous_domains == current_domains: + continue + if previous != requirements or previous_contexts != current_contexts: + security_replacements: ContextReplacements = { + context: replacement_contexts(context, prior_requirements, current_contexts, previous_domains, exact=True) + for context, prior_requirements in previous_contexts.items() + } + if not preserves_dependency_security_bounds( + previous_contexts, security_replacements, previous_domains, current_domains + ): + raise SystemExit("Do not weaken a protected dependency security exclusion or upper bound for " + name) + prior_minimums = minimums(previous, allow_missing=True, exact=True) + if not prior_minimums: + continue + updated_minimums = minimums(requirements, exact=True) + mapped_contexts: ContextReplacements = {} + for context, prior_requirements in previous_contexts.items(): + context_minimums = minimums(prior_requirements, allow_missing=True, exact=True) + if not context_minimums: + continue + replacements = replacement_contexts(context, prior_requirements, current_contexts, previous_domains, exact=True) + if not replacements: + raise SystemExit("Do not lower a contextual protected security minimum for " + name) + mapped_contexts[context] = replacements + for context_requirements in replacements.values(): + updated_context_minimums = minimums(context_requirements, exact=True) + if len(updated_context_minimums) != len(context_minimums) or any( + updated < previous for previous, updated in zip(context_minimums, updated_context_minimums, strict=True) + ): + raise SystemExit("Do not lower a contextual protected security minimum for " + name) + split = any(context not in replacements for context, replacements in mapped_contexts.items()) + if split: + if set(current_contexts) != { + replacement for replacements in mapped_contexts.values() for replacement in replacements + }: + raise SystemExit("Do not replace a protected security dependency context for " + name) + elif len(updated_minimums) != len(prior_minimums) or any( + updated < previous for previous, updated in zip(prior_minimums, updated_minimums, strict=True) + ): + raise SystemExit("Do not lower a protected dependency security minimum for " + name) + if previous_domains == current_domains: + continue + protected_patched_domains: dict[MarkerContext, list[tuple[StableRelease, StableRelease]]] = {} + for domain in previous_domains.keys() | current_domains.keys(): + prior_versions = previous_domains.get(domain, set()) + updated_versions = current_domains.get(domain, set()) + if prior_versions == updated_versions: + continue + introduced = sorted(stable_version(version) for version in updated_versions - prior_versions) + removed = sorted(stable_version(version) for version in prior_versions - updated_versions) + if ( + not introduced + or len(introduced) != len(removed) + or any(updated <= previous for previous, updated in zip(removed, introduced, strict=True)) + ): + raise SystemExit("Missing contextual upgraded protected security dependency release for " + name) + protected_patched_domains[domain] = list(zip(removed, introduced, strict=True)) + for context, prior_requirements in previous_contexts.items(): + prior_context_minimums = minimums(prior_requirements, allow_missing=True, exact=True) + if not prior_context_minimums: + continue + for replacement, context_requirements in mapped_contexts[context].items(): + patched = [ + introduced + for domain, upgrades in protected_patched_domains.items() + if marker_overlap(replacement[3], domain) + for removed, introduced in upgrades + if matches_protected_release(prior_requirements, removed) + ] + if not patched: + continue + updated_context_minimums = minimums(context_requirements, exact=True) + if ( + not updated_context_minimums + or any(updated < max(patched) for updated in updated_context_minimums) + or any( + not matches_protected_release(context_requirements, release, upper_only=True) for release in patched + ) + ): + raise SystemExit("Raise the contextual protected security minimum to the patched release for " + name) + +for name in old_versions.keys() & new_versions.keys(): + if name in old_direct or name in new_direct: + continue + previous_contexts = old_protected_contexts.get(name, {}) + if any(minimums(requirements, allow_missing=True, exact=True) for requirements in previous_contexts.values()): + continue + previous_domains = old_resolution_contexts.get(name, {}) + current_domains = new_resolution_contexts.get(name, {}) + for domain in previous_domains.keys() | current_domains.keys(): + prior_versions = previous_domains.get(domain, set()) + updated_versions = current_domains.get(domain, set()) + if prior_versions == updated_versions or not updated_versions: + continue + removed = sorted(stable_version(version) for version in prior_versions - updated_versions) + introduced = sorted(stable_version(version) for version in updated_versions - prior_versions) + if not introduced: + continue + if ( + not removed + or len(introduced) != len(removed) + or any(updated <= previous for previous, updated in zip(removed, introduced, strict=True)) + ): + raise SystemExit("Missing contextual upgraded transitive security dependency release for " + name) + for removed_release, patched_release in zip(removed, introduced, strict=True): + if not covers_transitive_security_release( + new_protected_contexts.get(name, {}), domain, removed_release, patched_release, current_domains + ): + raise SystemExit("Add a reviewed contextual transitive security dependency boundary for " + name) + +direct_replacements: dict[str, ContextReplacements] = {} +for name, previous_contexts in old_contexts.items(): + current_contexts = new_contexts.get(name, {}) + mapped_contexts = {} + for context, previous_requirements in previous_contexts.items(): + replacements = replacement_contexts( + context, previous_requirements, current_contexts, old_resolution_contexts.get(name, {}) + ) + if not replacements: + raise SystemExit("Do not remove a published direct dependency or its original context for " + name) + mapped_contexts[context] = replacements + if any(context not in replacements for context, replacements in mapped_contexts.items()) and set( + current_contexts + ) != {replacement for replacements in mapped_contexts.values() for replacement in replacements}: + raise SystemExit("Do not replace a published direct dependency context for " + name) + direct_replacements[name] = mapped_contexts +for name, requirements in new_direct.items(): + previous = old_direct.get(name, set()) + previous_contexts = old_contexts.get(name, {}) + current_contexts = new_contexts.get(name, {}) + if previous != requirements or previous_contexts != current_contexts: + previous_minimums = minimums(previous, allow_missing=True) + if previous_minimums: + updated_minimums = minimums(requirements) + mapped_contexts = direct_replacements.get(name, {}) + split = any(context not in replacements for context, replacements in mapped_contexts.items()) + if not split and ( + len(updated_minimums) != len(previous_minimums) + or any( + updated < previous for previous, updated in zip(previous_minimums, updated_minimums, strict=True) + ) + ): + raise SystemExit("Do not lower a published security-fixed minimum for " + name) + for context, prior_requirements in previous_contexts.items(): + prior_minimums = minimums(prior_requirements, allow_missing=True) + if not prior_minimums: + continue + for context_requirements in mapped_contexts[context].values(): + context_minimums = minimums(context_requirements) + if len(context_minimums) != len(prior_minimums) or any( + updated < previous for previous, updated in zip(prior_minimums, context_minimums, strict=True) + ): + raise SystemExit("Do not lower a contextual security-fixed minimum for " + name) + previous_domains = old_resolution_contexts.get(name, {}) + current_domains = new_resolution_contexts.get(name, {}) + if (previous != requirements or previous_contexts != current_contexts) and not preserves_dependency_security_bounds( + previous_contexts, direct_replacements.get(name, {}), previous_domains, current_domains + ): + raise SystemExit("Do not weaken a published security exclusion or upper bound for " + name) + if old_versions.get(name, set()) == new_versions.get(name, set()) and previous_domains == current_domains: + continue + if previous == requirements: + raise SystemExit("Raise the published security-fixed minimum for " + name) + if secures_supported_published_branches( + previous_domains, + current_domains, + previous_contexts, + current_contexts, + old_protected_contexts.get(name, {}), + new_protected_contexts.get(name, {}), + ): + continue + patched_domains: dict[MarkerContext, StableRelease] = {} + for domain in previous_domains.keys() | current_domains.keys(): + prior_versions = previous_domains.get(domain, set()) + updated_versions = current_domains.get(domain, set()) + if prior_versions == updated_versions: + continue + introduced = sorted(stable_version(version) for version in updated_versions - prior_versions) + removed = sorted(stable_version(version) for version in prior_versions - updated_versions) + if ( + not introduced + or len(introduced) != len(removed) + or any(updated <= previous for previous, updated in zip(removed, introduced, strict=True)) + ): + raise SystemExit("Missing contextual upgraded security dependency release for " + name) + patched_domains[domain] = introduced[-1] + if not patched_domains: + raise SystemExit("Missing upgraded direct security dependency release for " + name) + covered: set[MarkerContext] = set() + for context, context_requirements in current_contexts.items(): + domains = {domain for domain in patched_domains if marker_overlap(context[3], domain)} + if not domains: + continue + covered.update(domains) + patched_minimum = max(patched_domains[domain] for domain in domains) + updated_minimums = minimums(context_requirements, exact=True) + original_context = next( + ( + original + for original, replacements in direct_replacements.get(name, {}).items() + if context in replacements + ), + context, + ) + previous_minimums = minimums(previous_contexts.get(original_context, set()), allow_missing=True, exact=True) + if ( + not updated_minimums + or any(updated < patched_minimum for updated in updated_minimums) + or previous_minimums + and (len(updated_minimums) != len(previous_minimums) or updated_minimums[0] <= previous_minimums[0]) + ): + raise SystemExit("Raise the contextual security-fixed minimum for " + name) + if covered != set(patched_domains): + raise SystemExit("Raise the published security-fixed minimum for " + name) diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index ed9636f93a..ae2b33bf25 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -4,6 +4,7 @@ import re import sys import json +import shutil import subprocess from typing import Any, cast from pathlib import Path @@ -71,6 +72,3796 @@ def test_dependabot_delays_only_ordinary_version_updates() -> None: assert "open-pull-requests-limit: 0" not in entry +def dependency_lock_source_command() -> str: + path = ROOT / ".github/workflows/ci.yml" + if not path.exists(): + pytest.skip("GitHub workflows are not included in source distributions") + + line = next( + entry + for entry in path.read_text().splitlines() + if "python -I -c '" in entry and "Use only the public PyPI registry" in entry + ) + command = line.split("python -I -c '", 1)[1].rsplit("'", 1)[0] + if sys.version_info < (3, 11): + command = "import sys, tomli; sys.modules['tomllib'] = tomli; " + command + return command + + +def run_dependency_lock_source_check( + tmp_path: Path, + packages: list[dict[str, object]], + *, + build_requires: list[str] | None = None, + build_group: list[str] | None = None, + build_constraints: list[str] | None = None, + backend: str = "hatchling.build", + backend_path: list[str] | None = None, + uv_sources: dict[str, dict[str, str]] | None = None, + uv_index_url: str | None = None, + uv_overrides: dict[str, object] | None = None, + extra_uv_config: str | None = None, + project_name: str | None = None, + trusted_fork: bool = False, + trusted_base_requires: list[str] | None = None, + trusted_base_group: list[str] | None = None, + trusted_base_constraints: list[str] | None = None, + trusted_base_backend: str = "hatchling.build", + trusted_base_sha: str = "a" * 40, + trusted_base_lock: str | None = None, + trusted_origin: str = "https://github.com/openai/openai-python.git", +) -> subprocess.CompletedProcess[str]: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + requires = ["hatchling==1.27.0"] if build_requires is None else build_requires + group = ["hatchling==1.27.0"] if build_group is None else build_group + constraints = ["hatchling==1.27.0"] if build_constraints is None else build_constraints + if project_name is None: + project_name = project["name"] + configuration = ( + f"[project]\nname = {json.dumps(project_name)}\nversion = {json.dumps(project['version'])}\n" + + f"[build-system]\nrequires = {json.dumps(requires)}\nbuild-backend = {json.dumps(backend)}\n" + ) + if backend_path is not None: + configuration += "backend-path = " + json.dumps(backend_path) + "\n" + configuration += ( + "[dependency-groups]\nbuild = " + + json.dumps(group) + + "\n[tool.uv]\nbuild-constraint-dependencies = " + + json.dumps(constraints) + + "\n" + ) + if uv_index_url is not None: + configuration += "index-url = " + json.dumps(uv_index_url) + "\n" + if uv_overrides is not None: + for name, value in uv_overrides.items(): + configuration += name + " = " + json.dumps(value) + "\n" + if uv_sources is not None: + configuration += "[tool.uv.sources]\n" + for name, source in uv_sources.items(): + values = ", ".join(key + " = " + json.dumps(value) for key, value in source.items()) + configuration += name + " = { " + values + " }\n" + (tmp_path / "pyproject.toml").write_text(configuration) + if extra_uv_config is not None: + (tmp_path / extra_uv_config).write_text('no-binary-package = ["reviewed-dependency"]\n') + + lines: list[str] = [] + reviewed: dict[str, object] = { + "name": "hatchling", + "version": "1.27.0", + "source": {"registry": "https://pypi.org/simple"}, + } + fixtures = packages if any(package.get("name") == "hatchling" for package in packages) else [*packages, reviewed] + for package in fixtures: + lines.extend( + [ + "[[package]]", + f"name = {json.dumps(package['name'])}", + f"version = {json.dumps(package['version'])}", + ] + ) + source = package.get("source") + if source is not None: + assert isinstance(source, dict) + source_values = cast(dict[str, object], source) + values = ", ".join(f"{key} = {json.dumps(value)}" for key, value in source_values.items()) + lines.append("source = { " + values + " }") + artifact = package.get("sdist") + if "sdist" not in package and source == {"registry": "https://pypi.org/simple"}: + artifact = { + "url": "https://files.pythonhosted.org/packages/reviewed-1.0.0.tar.gz", + "hash": "sha256:" + "a" * 64, + } + if artifact is not None: + assert isinstance(artifact, dict) + artifact_values = cast(dict[str, object], artifact) + values = ", ".join(f"{key} = {json.dumps(value)}" for key, value in artifact_values.items()) + lines.append("sdist = { " + values + " }") + wheels = package.get("wheels") + if "wheels" not in package and source == {"registry": "https://pypi.org/simple"}: + wheels = ( + [] + if package.get("sdist_only") or "sdist" in package and package["sdist"] is None + else [ + { + "url": "https://files.pythonhosted.org/packages/reviewed-1.0.0-py3-none-any.whl", + "hash": "sha256:" + "b" * 64, + } + ] + ) + if wheels is not None: + assert isinstance(wheels, list) + typed_wheels = cast(list[dict[str, object]], wheels) + wheel_values = [ + "{ " + ", ".join(f"{key} = {json.dumps(value)}" for key, value in wheel.items()) + " }" + for wheel in typed_wheels + ] + lines.append("wheels = [" + ", ".join(wheel_values) + "]") + lines.append("") + + (tmp_path / "uv.lock").write_text("\n".join(lines)) + environment = dict( + os.environ, + UNTRUSTED_BUILD_FORK="1" if trusted_fork else "0", + TRUSTED_BUILD_BASE_SHA=trusted_base_sha, + ) + if trusted_fork: + reviewed_requires = ["hatchling==1.27.0"] if trusted_base_requires is None else trusted_base_requires + reviewed_group = ["hatchling==1.27.0"] if trusted_base_group is None else trusted_base_group + reviewed_constraints = ["hatchling==1.27.0"] if trusted_base_constraints is None else trusted_base_constraints + trusted_configuration = ( + "[build-system]\nrequires = " + + json.dumps(reviewed_requires) + + "\nbuild-backend = " + + json.dumps(trusted_base_backend) + + "\n[dependency-groups]\nbuild = " + + json.dumps(reviewed_group) + + "\n[tool.uv]\nbuild-constraint-dependencies = " + + json.dumps(reviewed_constraints) + + "\n" + ) + (tmp_path / "trusted-base.toml").write_text(trusted_configuration) + (tmp_path / "trusted-base.lock").write_text( + (tmp_path / "uv.lock").read_text() if trusted_base_lock is None else trusted_base_lock + ) + fake_git = tmp_path / "git" + fake_git.write_text( + f"#!{sys.executable}\n" + "import pathlib, sys\n" + f"root = pathlib.Path({str(tmp_path)!r})\n" + f"origin = {trusted_origin!r}\n" + f"sha = {trusted_base_sha!r}\n" + "arguments = sys.argv[1:]\n" + "if arguments == ['remote', 'get-url', 'origin']:\n" + " print(origin)\n" + "elif arguments == ['fetch', '--no-tags', '--depth=1', 'origin', sha]:\n" + " pass\n" + "elif arguments == ['show', sha + ':pyproject.toml']:\n" + " print((root / 'trusted-base.toml').read_text(), end='')\n" + "elif arguments == ['show', sha + ':uv.lock']:\n" + " print((root / 'trusted-base.lock').read_text(), end='')\n" + "else:\n" + " raise SystemExit('Unexpected or unsafe git operation')\n" + ) + fake_git.chmod(0o755) + environment["PATH"] = str(tmp_path) + os.pathsep + environment["PATH"] + return subprocess.run( + [sys.executable, "-c", dependency_lock_source_command()], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + check=False, + ) + + +@pytest.mark.parametrize( + ("source", "accepted"), + [ + pytest.param({"registry": "https://pypi.org/simple"}, True, id="public-pypi"), + pytest.param({"git": "https://github.com/unreviewed/package"}, False, id="git"), + pytest.param({"url": "https://unreviewed.example/package.whl"}, False, id="url"), + pytest.param({"path": "../unreviewed"}, False, id="path"), + pytest.param({"directory": "../unreviewed"}, False, id="directory"), + pytest.param({"editable": "."}, False, id="third-party-editable-root"), + pytest.param({"registry": "https://private.example/simple"}, False, id="private-registry"), + pytest.param({}, False, id="empty-source"), + pytest.param(None, False, id="missing-source"), + pytest.param({"unknown": "unreviewed"}, False, id="unknown-source"), + pytest.param( + {"registry": "https://pypi.org/simple", "git": "https://github.com/unreviewed/package"}, + False, + id="hybrid-registry-source", + ), + ], +) +def test_dependency_lock_accepts_only_public_registry_dependencies( + tmp_path: Path, source: dict[str, str] | None, accepted: bool +) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + dependency: dict[str, object] = {"name": "reviewed-dependency", "version": "1.0.0"} + if source is not None: + dependency["source"] = source + + result = run_dependency_lock_source_check(tmp_path, [root, dependency]) + + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + if not accepted: + assert "Use only the public PyPI registry" in result.stderr + + +@pytest.mark.parametrize("kind", ["sdist", "wheel"]) +@pytest.mark.parametrize( + ("url", "digest", "accepted"), + [ + pytest.param("https://files.pythonhosted.org/packages/reviewed.whl", "a" * 64, True, id="public-pypi"), + pytest.param("https://unreviewed.example/packages/reviewed.whl", "a" * 64, False, id="foreign-host"), + pytest.param("http://files.pythonhosted.org/packages/reviewed.whl", "a" * 64, False, id="insecure-http"), + pytest.param( + "https://user:pass@files.pythonhosted.org/packages/reviewed.whl", + "a" * 64, + False, + id="credentials", + ), + pytest.param("https://files.pythonhosted.org:443/packages/reviewed.whl", "a" * 64, False, id="port"), + pytest.param("https://files.pythonhosted.org/packages/reviewed.whl?redirect=1", "a" * 64, False, id="query"), + pytest.param("https://files.pythonhosted.org/packages/reviewed.whl#redirect", "a" * 64, False, id="fragment"), + pytest.param("https://files.pythonhosted.org/redirect/reviewed.whl", "a" * 64, False, id="path"), + pytest.param( + "https://files.pythonhosted.org.attacker.test/packages/reviewed.whl", "a" * 64, False, id="suffix" + ), + pytest.param("https://files.pythonhosted.org/packages/reviewed.whl", "invalid", False, id="hash"), + ], +) +def test_dependency_lock_rejects_untrusted_distribution_artifacts( + tmp_path: Path, kind: str, url: str, digest: str, accepted: bool +) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + artifact = {"url": url, "hash": "sha256:" + digest} + dependency: dict[str, object] = { + "name": "reviewed-dependency", + "version": "1.0.0", + "source": {"registry": "https://pypi.org/simple"}, + } + if kind == "sdist": + dependency["sdist"] = artifact + else: + dependency["sdist"] = None + dependency["wheels"] = [artifact] + + result = run_dependency_lock_source_check(tmp_path, [root, dependency]) + + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize("artifact", [None, {}, {"url": "https://files.pythonhosted.org/packages/reviewed.whl"}]) +def test_dependency_lock_rejects_missing_distribution_artifacts( + tmp_path: Path, artifact: dict[str, str] | None +) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + dependency: dict[str, object] = { + "name": "reviewed-dependency", + "version": "1.0.0", + "source": {"registry": "https://pypi.org/simple"}, + "sdist": artifact, + } + result = run_dependency_lock_source_check(tmp_path, [root, dependency]) + assert result.returncode != 0, result.stdout + result.stderr + + +@pytest.mark.parametrize( + "variant", ["missing", "duplicate", "wrong-name", "wrong-version", "wrong-path", "hybrid-root"] +) +def test_dependency_lock_requires_one_exact_editable_root(tmp_path: Path, variant: str) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + dependency: dict[str, object] = { + "name": "reviewed-dependency", + "version": "1.0.0", + "source": {"registry": "https://pypi.org/simple"}, + } + if variant == "wrong-name": + root["name"] = "unreviewed-root" + elif variant == "wrong-version": + root["version"] = "0.0.0" + elif variant == "wrong-path": + root["source"] = {"editable": "../unreviewed"} + elif variant == "hybrid-root": + root["source"] = {"editable": ".", "registry": "https://pypi.org/simple"} + + packages = [dependency] if variant == "missing" else [root, dependency] + if variant == "duplicate": + packages.append(dict(root)) + + result = run_dependency_lock_source_check(tmp_path, packages) + + assert result.returncode == 1, result.stdout + result.stderr + assert "Use only the public PyPI registry" in result.stderr + + +@pytest.mark.parametrize( + ("project_name", "registry_name", "accepted"), + [ + pytest.param("openai", "reviewed-dependency", True, id="only-reviewed-editable-root"), + pytest.param("openai", "openai", False, id="public-registry-root-name-collision"), + pytest.param("openai", "OpenAI", False, id="public-registry-root-case-alias"), + pytest.param("openai", "OPENAI", False, id="public-registry-root-uppercase-alias"), + pytest.param("renamed-root", "openai", False, id="renamed-root-exempts-public-openai"), + pytest.param("open_ai", "openai", False, id="root-normalization-cannot-change-exemption"), + ], +) +def test_source_build_exemption_only_covers_the_unique_reviewed_editable_root( + tmp_path: Path, project_name: str, registry_name: str, accepted: bool +) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project_name, + "version": project["version"], + "source": {"editable": "."}, + } + dependency: dict[str, object] = { + "name": registry_name, + "version": "1.0.0", + "source": {"registry": "https://pypi.org/simple"}, + } + + result = run_dependency_lock_source_check(tmp_path, [root, dependency], project_name=project_name) + + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + if not accepted: + assert "Use only the public PyPI registry" in result.stderr + + +def test_dependency_lock_source_check_accepts_the_committed_lock() -> None: + result = subprocess.run( + [sys.executable, "-c", dependency_lock_source_command()], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("reviewed-pinned-backend", True, id="reviewed-pinned-backend"), + pytest.param("safe-python-marker", True, id="reviewed-pinned-python-marker"), + pytest.param("empty-sources", True, id="benign-empty-source-overrides"), + pytest.param("direct-url", False, id="root-build-direct-url"), + pytest.param("git", False, id="root-build-git-source"), + pytest.param("path", False, id="root-build-local-path"), + pytest.param("private-index", False, id="root-build-private-index"), + pytest.param("unpinned", False, id="root-build-unpinned-requirement"), + pytest.param("missing-pin", False, id="root-build-missing-reviewed-pin"), + pytest.param("unlocked-version", False, id="root-build-version-missing-from-lock"), + pytest.param("constraint-url", False, id="build-constraint-direct-url"), + pytest.param("group-mismatch", False, id="reviewed-build-group-mismatch"), + pytest.param("unlocked-group", False, id="reviewed-build-group-unlocked-dependency"), + pytest.param("backend", False, id="unreviewed-build-backend"), + pytest.param("backend-path", False, id="local-build-backend-path"), + pytest.param("source-git", False, id="pinned-hatchling-git-override"), + pytest.param("source-path", False, id="pinned-hatchling-path-override"), + pytest.param("index-override", False, id="pinned-hatchling-private-index-override"), + pytest.param("marker-code", False, id="untrusted-build-marker-expression"), + ], +) +def test_root_build_requirements_must_be_public_locked_and_reviewed( + tmp_path: Path, variant: str, accepted: bool +) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + requires = ["hatchling==1.27.0"] + group = ["hatchling==1.27.0"] + constraints = ["hatchling==1.27.0"] + backend = "hatchling.build" + backend_path: list[str] | None = None + sources: dict[str, dict[str, str]] | None = None + index: str | None = None + if variant == "safe-python-marker": + requires = group = constraints = ["hatchling==1.27.0; python_version < '3.11'"] + elif variant == "empty-sources": + sources = {} + elif variant == "direct-url": + requires = ["hatchling @ https://unreviewed.example/hatchling.whl"] + elif variant == "git": + requires = ["hatchling @ git+https://github.com/unreviewed/hatchling"] + elif variant == "path": + requires = ["hatchling @ file:///tmp/unreviewed"] + elif variant == "private-index": + requires = ["hatchling==1.27.0 --index-url https://private.example/simple"] + elif variant == "unpinned": + requires = ["hatchling>=1.27.0"] + elif variant == "missing-pin": + requires = [] + elif variant == "unlocked-version": + requires = group = constraints = ["hatchling==9.9.9"] + elif variant == "constraint-url": + group = constraints = ["hatchling @ https://unreviewed.example/hatchling.whl"] + elif variant == "group-mismatch": + group = ["hatchling==1.26.0"] + elif variant == "unlocked-group": + group = constraints = ["hatchling==1.27.0", "packaging==26.3"] + elif variant == "backend": + backend = "unreviewed.build" + elif variant == "backend-path": + backend_path = ["."] + elif variant == "source-git": + sources = {"hatchling": {"git": "https://github.com/unreviewed/hatchling"}} + elif variant == "source-path": + sources = {"hatchling": {"path": "../unreviewed"}} + elif variant == "index-override": + index = "https://private.example/simple" + elif variant == "marker-code": + requires = group = constraints = ['hatchling==1.27.0; __import__("os")'] + result = run_dependency_lock_source_check( + tmp_path, + [root], + build_requires=requires, + build_group=group, + build_constraints=constraints, + backend=backend, + backend_path=backend_path, + uv_sources=sources, + uv_index_url=index, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("reviewed-fork", True, id="fork-keeps-trusted-base-build-pins"), + pytest.param("fork-hatchling-downgrade", False, id="fork-cannot-downgrade-public-hatchling"), + pytest.param("fork-hatchling-upgrade", False, id="fork-cannot-swap-public-hatchling-release"), + pytest.param("fork-build-pin-downgrade", False, id="fork-cannot-change-transitive-backend-pin"), + pytest.param("fork-extra-build-pin", False, id="fork-cannot-add-unreviewed-backend-package"), + pytest.param("fork-marker-change", False, id="fork-cannot-change-reviewed-build-marker"), + pytest.param("fork-marker-literal-case", False, id="fork-cannot-change-case-sensitive-marker"), + pytest.param("fork-backend-change", False, id="fork-backend-must-match-trusted-base"), + pytest.param("fork-invalid-base-sha", False, id="fork-rejects-untrusted-base-sha"), + pytest.param("fork-foreign-origin", False, id="fork-rejects-untrusted-git-origin"), + pytest.param("fork-credential-origin", False, id="fork-rejects-credential-bearing-origin"), + pytest.param("fork-canonical-reorder", True, id="fork-allows-canonical-pins-and-reordering"), + pytest.param("fork-reviewed-base-update", True, id="fork-allows-already-reviewed-base-update"), + pytest.param("trusted-maintainer-update", True, id="same-repo-maintainer-can-update-build-pins"), + pytest.param("trusted-dependabot-update", True, id="same-repo-security-bot-can-update-build-pins"), + ], +) +def test_fork_build_backend_must_match_immutable_reviewed_base(tmp_path: Path, variant: str, accepted: bool) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + hatchling: dict[str, object] = { + "name": "hatchling", + "version": "1.27.0", + "source": {"registry": "https://pypi.org/simple"}, + } + packages: list[dict[str, object]] = [root, hatchling] + requires = ["hatchling==1.27.0"] + group = ["hatchling==1.27.0"] + constraints = ["hatchling==1.27.0"] + base_requires = ["hatchling==1.27.0"] + base_group = ["hatchling==1.27.0"] + base_constraints = ["hatchling==1.27.0"] + base_backend = "hatchling.build" + base_sha = "a" * 40 + origin = "https://github.com/openai/openai-python.git" + fork = not variant.startswith("trusted-") + + if variant in {"fork-hatchling-downgrade", "fork-hatchling-upgrade"}: + version = "1.26.0" if variant == "fork-hatchling-downgrade" else "1.28.0" + hatchling["version"] = version + requires = group = constraints = ["hatchling==" + version] + elif variant in {"fork-build-pin-downgrade", "fork-extra-build-pin", "fork-canonical-reorder"}: + version = "25.0" if variant == "fork-build-pin-downgrade" else "26.3" + packages.append( + { + "name": "packaging", + "version": version, + "source": {"registry": "https://pypi.org/simple"}, + } + ) + if variant == "fork-canonical-reorder": + group = constraints = ["PACKAGING == 26.3", "hatchling==1.27.0"] + else: + group = constraints = ["hatchling==1.27.0", "packaging==" + version] + if variant != "fork-extra-build-pin": + base_group = base_constraints = ["hatchling==1.27.0", "packaging==26.3"] + elif variant in {"fork-marker-change", "fork-marker-literal-case"}: + packages.append( + { + "name": "tomli", + "version": "2.4.1", + "source": {"registry": "https://pypi.org/simple"}, + } + ) + if variant == "fork-marker-change": + head_marker = "python_version < '3.12'" + base_marker = "python_version < '3.11'" + else: + head_marker = "sys_platform == 'linux'" + base_marker = "sys_platform == 'Linux'" + group = constraints = ["hatchling==1.27.0", "tomli==2.4.1; " + head_marker] + base_group = base_constraints = ["hatchling==1.27.0", "tomli==2.4.1; " + base_marker] + elif variant == "fork-backend-change": + base_backend = "reviewed.backend" + elif variant == "fork-invalid-base-sha": + base_sha = "a" * 39 + "Z" + elif variant == "fork-foreign-origin": + origin = "https://github.com/unreviewed/openai-python.git" + elif variant == "fork-credential-origin": + origin = "https://token@github.com/openai/openai-python.git" + elif variant in {"fork-reviewed-base-update", "trusted-maintainer-update", "trusted-dependabot-update"}: + hatchling["version"] = "1.28.0" + requires = group = constraints = ["hatchling==1.28.0"] + if variant == "fork-reviewed-base-update": + base_requires = base_group = base_constraints = ["hatchling==1.28.0"] + + result = run_dependency_lock_source_check( + tmp_path, + packages, + build_requires=requires, + build_group=group, + build_constraints=constraints, + trusted_fork=fork, + trusted_base_requires=base_requires, + trusted_base_group=base_group, + trusted_base_constraints=base_constraints, + trusted_base_backend=base_backend, + trusted_base_sha=base_sha, + trusted_origin=origin, + ) + + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("added-default", False, id="fork-cannot-add-unreviewed-default-wheel"), + pytest.param("added-optional", False, id="fork-cannot-add-unreviewed-optional-wheel"), + pytest.param("removed-package", False, id="fork-cannot-remove-immutable-reviewed-identity"), + pytest.param("replaced-name", False, id="fork-cannot-swap-reviewed-package-name"), + pytest.param("replaced-version", False, id="fork-cannot-swap-reviewed-package-version"), + pytest.param("replaced-wheel-url", False, id="fork-cannot-swap-reviewed-wheel-url"), + pytest.param("replaced-wheel-hash", False, id="fork-cannot-swap-reviewed-wheel-hash"), + pytest.param("replaced-sdist-url", False, id="fork-cannot-swap-reviewed-source-url"), + pytest.param("replaced-sdist-hash", False, id="fork-cannot-swap-reviewed-source-hash"), + pytest.param("added-wheel", False, id="fork-cannot-add-an-unreviewed-wheel"), + pytest.param("removed-wheel", False, id="fork-cannot-drop-an-immutable-reviewed-wheel"), + pytest.param("duplicate-package", False, id="fork-cannot-hide-an-extra-identity-in-a-set"), + pytest.param("reordered-wheels", True, id="fork-may-reorder-identical-reviewed-artifacts"), + pytest.param("canonical-name", True, id="fork-may-canonicalize-identical-reviewed-name"), + pytest.param("same-repo-maintainer", True, id="same-repo-maintainer-may-update-wheel"), + pytest.param("same-repo-dependabot", True, id="same-repo-security-bot-may-add-wheel"), + ], +) +def test_fork_dependency_identities_must_match_immutable_reviewed_lock( + tmp_path: Path, variant: str, accepted: bool +) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + reviewed: dict[str, object] = { + "name": "reviewed_dependency", + "version": "2.0", + "source": {"registry": "https://pypi.org/simple"}, + "sdist": { + "url": "https://files.pythonhosted.org/packages/reviewed-2.0.tar.gz", + "hash": "sha256:" + "a" * 64, + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/reviewed-2.0-py3-none-any.whl", + "hash": "sha256:" + "b" * 64, + }, + { + "url": "https://files.pythonhosted.org/packages/reviewed-2.0-linux.whl", + "hash": "sha256:" + "c" * 64, + }, + ], + } + baseline = run_dependency_lock_source_check(tmp_path, [root, reviewed], trusted_fork=True) + assert baseline.returncode == 0, baseline.stdout + baseline.stderr + trusted_lock = (tmp_path / "uv.lock").read_text() + packages = cast(list[dict[str, object]], json.loads(json.dumps([root, reviewed]))) + package = packages[1] + wheels = cast(list[dict[str, str]], package["wheels"]) + sdist = cast(dict[str, str], package["sdist"]) + fork = not variant.startswith("same-repo-") + + if variant in {"added-default", "added-optional", "same-repo-dependabot"}: + packages.append( + { + "name": "attacker-owned-plugin", + "version": "1.0", + "source": {"registry": "https://pypi.org/simple"}, + } + ) + elif variant == "removed-package": + packages.pop() + elif variant == "replaced-name": + package["name"] = "attacker-owned-plugin" + elif variant in {"replaced-version", "same-repo-maintainer"}: + package["version"] = "2.1" + elif variant == "replaced-wheel-url": + wheels[0]["url"] = "https://files.pythonhosted.org/packages/attacker-2.0-py3-none-any.whl" + elif variant == "replaced-wheel-hash": + wheels[0]["hash"] = "sha256:" + "d" * 64 + elif variant == "replaced-sdist-url": + sdist["url"] = "https://files.pythonhosted.org/packages/attacker-2.0.tar.gz" + elif variant == "replaced-sdist-hash": + sdist["hash"] = "sha256:" + "d" * 64 + elif variant == "added-wheel": + wheels.append( + { + "url": "https://files.pythonhosted.org/packages/attacker-2.0-linux.whl", + "hash": "sha256:" + "d" * 64, + } + ) + elif variant == "removed-wheel": + wheels.pop() + elif variant == "duplicate-package": + packages.append(cast(dict[str, object], json.loads(json.dumps(package)))) + elif variant == "reordered-wheels": + wheels.reverse() + packages.reverse() + elif variant == "canonical-name": + package["name"] = "Reviewed.Dependency" + + result = run_dependency_lock_source_check( + tmp_path, + packages, + trusted_fork=fork, + trusted_base_lock=trusted_lock, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +def test_fork_build_provenance_uses_immutable_pull_request_identity() -> None: + for name in ("ci.yml", "detect-breaking-changes.yml"): + workflow = (ROOT / ".github/workflows" / name).read_text() + environment = workflow.split("\njobs:\n", 1)[0].rsplit("\nenv:\n", 1)[1] + fork = next(line for line in environment.splitlines() if "UNTRUSTED_BUILD_FORK:" in line) + base = next(line for line in environment.splitlines() if "TRUSTED_BUILD_BASE_SHA:" in line) + assert "github.event_name == 'pull_request'" in fork + assert "github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id" in fork + assert "github.event_name == 'pull_request'" in base + assert "github.event.pull_request.base.sha" in base + assert "github.sha" not in base + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("reviewed-wheel", True, id="reviewed-public-wheel"), + pytest.param("sdist-only", False, id="unreviewed-source-distribution-backend"), + pytest.param("empty-wheels", False, id="empty-wheel-list"), + pytest.param("no-binary", False, id="force-all-source-builds"), + pytest.param("no-binary-package", False, id="force-package-source-build"), + pytest.param("no_binary", False, id="force-all-source-builds-alias"), + pytest.param("no_binary_package", False, id="force-package-source-build-alias"), + pytest.param("uv.toml", False, id="standalone-uv-config-source-override"), + pytest.param(".uv.toml", False, id="hidden-uv-config-source-override"), + ], +) +def test_public_dependencies_require_reviewed_wheels_without_build_overrides( + tmp_path: Path, variant: str, accepted: bool +) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + dependency: dict[str, object] = { + "name": "reviewed-dependency", + "version": "1.0.0", + "source": {"registry": "https://pypi.org/simple"}, + } + overrides: dict[str, object] | None = None + config: str | None = None + if variant == "sdist-only": + dependency["sdist_only"] = True + elif variant == "empty-wheels": + dependency["wheels"] = [] + elif variant in {"no-binary", "no_binary"}: + overrides = {variant: True} + elif variant in {"no-binary-package", "no_binary_package"}: + overrides = {variant: ["reviewed-dependency"]} + elif variant in {"uv.toml", ".uv.toml"}: + config = variant + result = run_dependency_lock_source_check( + tmp_path, + [root, dependency], + uv_overrides=overrides, + extra_uv_config=config, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +def dependency_workflow_jobs() -> dict[str, str]: + path = ROOT / ".github/workflows/ci.yml" + if not path.exists(): + pytest.skip("GitHub workflows are not included in source distributions") + + workflow = path.read_text().split("\njobs:\n", 1)[1] + return { + match.group("name"): match.group("body") + for match in re.finditer( + r"^ (?P[\w-]+):\n(?P.*?)(?=^ [\w-]+:\n|\Z)", + workflow, + re.MULTILINE | re.DOTALL, + ) + } + + +def dependency_installer_jobs(jobs: dict[str, str]) -> set[str]: + return { + name + for name, job in jobs.items() + if name != "dependency-locks" + and ( + "astral-sh/setup-uv@" in job + or "./.github/actions/setup-node-tooling" in job + or re.search( + r"\b(?:uv\s+(?:sync|run)|pip\s+install|(?:npm|pnpm)\s+(?:ci|install|add))\b", + job, + ) + or re.search(r"run:\s*\./scripts/(?:bootstrap|build)\b", job) + ) + } + + +def test_dependency_provenance_runs_before_tool_setup() -> None: + gate = dependency_workflow_jobs()["dependency-locks"] + source = next(line for line in gate.splitlines() if "Use only the public PyPI registry" in line) + assert source.strip().startswith("python -I -c '") + + before = gate.split(source, 1)[0] + actions = re.findall(r"^ - uses:\s*(\S+)", before, re.MULTILINE) + assert len(actions) == 1 + assert re.fullmatch(r"actions/checkout@[0-9a-f]{40}", actions[0]) + assert "persist-credentials: false" in before + assert not re.search( + r"^\s*(?:- )?(?:run:|uses:).*(?:setup-uv|setup-node|uv\s|pip\s|npm\s|pnpm\s|scripts/)", + before, + re.MULTILINE, + ) + + +@pytest.mark.parametrize( + "source", + [ + pytest.param({"git": "https://github.com/unreviewed/package"}, id="git"), + pytest.param({"path": "../unreviewed"}, id="path"), + pytest.param({"url": "https://unreviewed.example/package.whl"}, id="url"), + ], +) +def test_untrusted_provenance_leaves_no_dependency_install_reachable(tmp_path: Path, source: dict[str, str]) -> None: + jobs = dependency_workflow_jobs() + installers = dependency_installer_jobs(jobs) + assert installers == {"lint", "build", "test", "test-httpx2", "examples", "compatibility"} + + needs = {name: re.findall(r"^ needs:\s*([^\s#]+)", jobs[name], re.MULTILINE) for name in installers} + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + root: dict[str, object] = { + "name": project["name"], + "version": project["version"], + "source": {"editable": "."}, + } + dependency: dict[str, object] = { + "name": "reviewed-dependency", + "version": "1.0.0", + "source": source, + } + rejected = run_dependency_lock_source_check(tmp_path, [root, dependency]) + assert rejected.returncode != 0 + reachable = { + name + for name in installers + if not needs[name] or (needs[name] == ["dependency-locks"] and rejected.returncode == 0) + } + assert not reachable + + dependency["source"] = {"registry": "https://pypi.org/simple"} + accepted = run_dependency_lock_source_check(tmp_path, [root, dependency]) + assert accepted.returncode == 0 + assert { + name for name in installers if needs[name] == ["dependency-locks"] and accepted.returncode == 0 + } == installers + + +def test_scheduled_compatibility_keeps_dependency_provenance_gate() -> None: + jobs = dependency_workflow_jobs() + assert not re.search(r"^ if:.*schedule", jobs["dependency-locks"], re.MULTILINE) + assert re.search(r"^ needs:\s*dependency-locks\s*$", jobs["compatibility"], re.MULTILINE) + assert "github.event_name == 'schedule'" in jobs["compatibility"] + assert "github.event_name == 'workflow_dispatch'" in jobs["compatibility"] + + +@pytest.mark.parametrize( + ("event", "experimental", "allowed"), + [ + pytest.param("pull_request", False, False, id="fork-pr-supported-python"), + pytest.param("pull_request", True, False, id="fork-pr-never-gets-prerelease-exception"), + pytest.param("push", True, False, id="push-never-gets-prerelease-exception"), + pytest.param("merge_group", True, False, id="merge-queue-never-gets-prerelease-exception"), + pytest.param("schedule", False, False, id="scheduled-supported-python-wheels-only"), + pytest.param("schedule", True, True, id="trusted-scheduled-prerelease-preserved"), + pytest.param("workflow_dispatch", True, True, id="trusted-manual-prerelease-preserved"), + ], +) +def test_source_builds_only_allowed_in_trusted_experimental_compatibility( + event: str, experimental: bool, allowed: bool +) -> None: + for name in ("ci.yml", "detect-breaking-changes.yml"): + workflow = (ROOT / ".github/workflows" / name).read_text() + global_environment = workflow.split("\njobs:\n", 1)[0].rsplit("\nenv:\n", 1)[1] + assert re.search(r"^ UV_NO_BUILD: ['\"]?1['\"]?\s*$", global_environment, re.MULTILINE) + assert re.search(r"^ UV_NO_BINARY_PACKAGE: ['\"]?openai['\"]?\s*$", global_environment, re.MULTILINE) + assert "openai-agents" not in global_environment + compatibility = dependency_workflow_jobs()["compatibility"] + assert "matrix.experimental" in compatibility + assert "(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')" in compatibility + assert "&& '0' || '1'" in compatibility + assert "environment:" not in compatibility + assert "id-token:" not in compatibility + assert (experimental and event in {"schedule", "workflow_dispatch"}) is allowed + + +def test_editable_project_sync_requires_only_the_reviewed_root_build_exemption() -> None: + uv = shutil.which("uv") + if uv is None: + pytest.skip("uv is not installed") + + command = [uv, "--no-config", "sync", "--frozen", "--all-extras", "--offline", "--dry-run"] + environment = dict(os.environ) + environment["UV_NO_BUILD"] = "1" + environment.pop("UV_NO_BINARY_PACKAGE", None) + + rejected = subprocess.run( + command, + cwd=ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + ) + assert rejected.returncode != 0 + assert "openai" in rejected.stderr + assert "--no-build" in rejected.stderr + + environment["UV_NO_BINARY_PACKAGE"] = "openai" + accepted = subprocess.run( + command, + cwd=ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + ) + assert accepted.returncode == 0, accepted.stdout + accepted.stderr + + +def test_agents_sdk_build_exemption_only_covers_its_trusted_editable_project() -> None: + workflow = (ROOT / ".github/workflows/detect-breaking-changes.yml").read_text() + match = re.search(r"^ agents_sdk:\n(?P.*?)(?=^ [\w-]+:\n|\Z)", workflow, re.MULTILINE | re.DOTALL) + assert match is not None + job = match.group("body") + trusted_checkout = job.index("repository: openai/openai-agents-python") + exception = "UV_NO_BINARY_PACKAGE: 'openai openai-agents'" + reviewed_aiohttp = 'UV_NO_BINARY_PACKAGE="openai openai-agents ${reviewed_sources}" make sync' + assert job.count(exception) == 3 + assert job.count(reviewed_aiohttp) == 1 + + for command in ("uv add --no-sync ../openai-python", "make sync", "make mypy"): + command_index = job.index(command) + assert command_index > trusted_checkout + step_start = job.rfind("\n - ", 0, command_index) + step_end = job.find("\n - ", command_index) + if step_end < 0: + step_end = len(job) + step = job[step_start:step_end] + assert "working-directory: openai-agents-python" in step + assert exception in step + if command == "make sync": + assert reviewed_aiohttp in step + + assert exception not in job[:trusted_checkout] + assert reviewed_aiohttp not in job[:trusted_checkout] + + +def test_agents_source_allowlist_uses_its_immutable_reviewed_checkout() -> None: + workflow = (ROOT / ".github/workflows/detect-breaking-changes.yml").read_text() + job = workflow.split("\n agents_sdk:\n", 1)[1] + checkout = re.search( + r"repository: openai/openai-agents-python\n(?P(?: [^\n]+\n)+)", + job, + ) + assert checkout is not None + assert re.search( + r"^ ref: 7e55afc9500d12937687988f1e91e900dcb4ad09$", checkout.group("inputs"), re.MULTILINE + ) + + +def test_agents_type_checks_reuse_only_the_validated_preinstalled_environment() -> None: + workflow = (ROOT / ".github/workflows/detect-breaking-changes.yml").read_text() + job = workflow.split("\n agents_sdk:\n", 1)[1] + checks = job.split(" - name: Run integration type checks\n", 1)[1] + assert "UV_NO_SYNC: '1'" in checks + assert "UV_NO_BINARY_PACKAGE: 'openai openai-agents'" in checks + assert "reviewed_sources" not in checks + assert checks.index("UV_NO_SYNC") < checks.index("run: make mypy") + assert job.index('${reviewed_sources}" make sync') < job.index("UV_NO_SYNC") + + +def test_agents_link_only_relocks_before_reviewed_source_distributions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workflow = (ROOT / ".github/workflows/detect-breaking-changes.yml").read_text() + job = workflow.split("\n agents_sdk:\n", 1)[1] + link = job.split(" - name: Link to local SDK\n", 1)[1].split("\n - name:", 1)[0] + match = re.search(r"^ run: (.+)$", link, re.MULTILINE) + assert match is not None + command = match.group(1) + assert command == "uv add --no-sync ../openai-python" + assert "UV_NO_BINARY_PACKAGE: 'openai openai-agents'" in link + assert "aiohttp" not in link + assert job.index(command) < job.index("Use only the immutable reviewed Agents source distributions") + assert job.index("Use only the immutable reviewed Agents source distributions") < job.index("make sync") + + executable = tmp_path / "uv" + uv = shutil.which("uv") + if uv is not None: + supported = subprocess.run([uv, "add", "--help"], capture_output=True, text=True, check=False) + assert supported.returncode == 0, supported.stdout + supported.stderr + assert "--no-sync" in supported.stdout + assert "Avoid syncing the virtual environment" in supported.stdout + + executable.write_text( + f"#!{sys.executable}\n" + "import json, os, pathlib, sys\n" + "root = pathlib.Path(os.environ['UV_TEST_ROOT'])\n" + "if '--no-sync' not in sys.argv:\n" + " (root / 'environment-synced').write_text('unreviewed install')\n" + " raise SystemExit('unsafe environment sync before source validation')\n" + "(root / 'relocked.json').write_text(json.dumps({'args': sys.argv[1:], " + "'no_build': os.environ.get('UV_NO_BUILD'), " + "'no_binary': os.environ.get('UV_NO_BINARY_PACKAGE')}))\n" + ) + executable.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ["PATH"]) + monkeypatch.setenv("UV_TEST_ROOT", str(tmp_path)) + monkeypatch.setenv("UV_NO_BUILD", "1") + monkeypatch.setenv("UV_NO_BINARY_PACKAGE", "openai openai-agents") + result = subprocess.run(["bash", "-e", "-c", command], cwd=tmp_path, capture_output=True, text=True, check=False) + assert result.returncode == 0, result.stdout + result.stderr + assert not (tmp_path / "environment-synced").exists() + relocked = cast(dict[str, object], json.loads((tmp_path / "relocked.json").read_text())) + assert relocked["args"] == ["add", "--no-sync", "../openai-python"] + assert relocked["no_build"] == "1" + assert relocked["no_binary"] == "openai openai-agents" + + +def test_explicit_root_build_keeps_every_public_dependency_source_build_disabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + build = dependency_workflow_jobs()["build"] + match = re.search( + r" - name: Run build\n run: \|\n(?P(?: [^\n]*\n)+)", + build, + ) + assert match is not None + script = "\n".join(line[10:] for line in match.group("body").splitlines()) + assert "env -u UV_NO_BUILD UV_NO_BUILD_PACKAGE=" in script + assert "UV_NO_BUILD=0" not in script + + log = tmp_path / "calls.jsonl" + executable = tmp_path / "uv" + executable.write_text( + f"#!{sys.executable}\n" + "import json, os, pathlib, sys\n" + "entry = {'args': sys.argv[1:], 'no_build': os.environ.get('UV_NO_BUILD'), " + "'no_build_packages': os.environ.get('UV_NO_BUILD_PACKAGE')}\n" + "with open(os.environ['UV_TEST_LOG'], 'a') as output:\n" + " output.write(json.dumps(entry) + '\\n')\n" + "if sys.argv[1] == 'export':\n" + " pathlib.Path(sys.argv[sys.argv.index('--output-file') + 1]).write_text('reviewed\\n')\n" + "if sys.argv[1] == 'build':\n" + " assert '--no-sources' in sys.argv and '--require-hashes' in sys.argv\n" + " assert pathlib.Path(sys.argv[sys.argv.index('--build-constraints') + 1]).read_text() == 'reviewed\\n'\n" + ) + executable.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ["PATH"]) + monkeypatch.setenv("UV_TEST_LOG", str(log)) + monkeypatch.setenv("UV_NO_BUILD", "1") + + result = subprocess.run(["bash", "-e", "-c", script], cwd=ROOT, capture_output=True, text=True, check=False) + assert result.returncode == 0, result.stdout + result.stderr + calls: list[dict[str, Any]] = [json.loads(line) for line in log.read_text().splitlines()] + assert [call["args"][0] for call in calls] == ["export", "build"] + lock = tomllib.loads((ROOT / "uv.lock").read_text()) + expected = { + re.sub(r"[-_.]+", "-", cast(str, package["name"])).lower() + for package in cast(list[dict[str, object]], lock["package"]) + if package["source"] == {"registry": "https://pypi.org/simple"} + } + assert expected + assert "openai" not in expected + for call in calls: + assert call["no_build"] is None + assert set(cast(str, call["no_build_packages"]).split()) == expected + + +def test_package_scoped_root_build_policy_rejects_real_external_source_distribution(tmp_path: Path) -> None: + uv = shutil.which("uv") + if uv is None: + pytest.skip("uv is not installed") + + environment = dict(os.environ) + environment.pop("UV_NO_BUILD", None) + environment["UV_NO_BUILD_PACKAGE"] = "aiohttp" + environment["UV_NO_BINARY_PACKAGE"] = "openai aiohttp" + environment["UV_PROJECT_ENVIRONMENT"] = str(tmp_path / "isolated") + result = subprocess.run( + [uv, "--no-config", "sync", "--frozen", "--all-extras", "--offline", "--dry-run"], + cwd=ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode != 0 + assert "aiohttp" in result.stderr + assert "--no-build" in result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("reviewed", True, id="trusted-agents-aiohttp-source"), + pytest.param("version", False, id="agents-aiohttp-version-swapped"), + pytest.param("wheel-upgrade", True, id="changed-reviewed-name-is-wheel-only-without-source-exemption"), + pytest.param("wheel-url", False, id="changed-reviewed-name-rejects-nonpublic-wheel"), + pytest.param("wheel-hash", False, id="changed-reviewed-name-rejects-invalid-wheel-hash"), + pytest.param("wheel-sdist-url", False, id="changed-reviewed-name-rejects-nonpublic-source-artifact"), + pytest.param("wheel-sdist-hash", False, id="changed-reviewed-name-rejects-invalid-source-hash"), + pytest.param("removed", True, id="removed-reviewed-name-receives-no-source-exemption"), + pytest.param("source", False, id="agents-aiohttp-private-registry"), + pytest.param("url", False, id="agents-aiohttp-source-url-swapped"), + pytest.param("hash", False, id="agents-aiohttp-source-hash-swapped"), + pytest.param("duplicate", False, id="agents-aiohttp-canonical-name-collision"), + pytest.param("trusted-hash", False, id="upstream-aiohttp-source-must-be-reviewed"), + pytest.param("origin", False, id="agents-checkout-origin-must-be-trusted"), + ], +) +@pytest.mark.parametrize("package", ["aiohttp", "markupsafe", "pyyaml", "evdev"]) +def test_agents_aiohttp_source_must_match_immutable_trusted_upstream( + tmp_path: Path, variant: str, accepted: bool, package: str +) -> None: + workflow = (ROOT / ".github/workflows/detect-breaking-changes.yml").read_text() + line = next( + entry + for entry in workflow.splitlines() + if "python -c '" in entry and "Use only the immutable reviewed Agents source distributions" in entry + ) + program = line.split("python -c '", 1)[1].rsplit("'", 1)[0] + if sys.version_info < (3, 11): + program = "import sys, tomli; sys.modules['tomllib'] = tomli; " + program + + reviewed = { + "aiohttp": ( + "3.12.15", + "9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", + "4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", + ), + "markupsafe": ( + "3.0.2", + "b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", + "ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", + ), + "pyyaml": ( + "6.0.2", + "54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", + "d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", + ), + "evdev": ( + "1.9.2", + "63/fe/a17c106a1f4061ce83f04d14bcedcfb2c38c7793ea56bfb906a6fadae8cb/evdev-1.9.2.tar.gz", + "5d3278892ce1f92a74d6bf888cc8525d9f68af85dbe336c95d1c87fb8f423069", + ), + } + version, artifact_path, digest = reviewed[package] + url = "https://files.pythonhosted.org/packages/" + artifact_path + current_version = version + current_url = url + current_digest = digest + current_registry = "https://pypi.org/simple" + trusted_digest = digest + origin = "https://github.com/openai/openai-agents-python.git" + current_wheel_url: str | None = None + current_wheel_digest = "c" * 64 + if variant == "version": + current_version = "0.0.1" + elif variant == "source": + current_registry = "https://private.example/simple" + elif variant in {"wheel-upgrade", "wheel-url", "wheel-hash", "wheel-sdist-url", "wheel-sdist-hash"}: + current_version = "3.14.3" if package == "aiohttp" else "9.0.0" + current_url = "https://files.pythonhosted.org/packages/aa/bb/" + package + "-" + current_version + ".tar.gz" + current_digest = "a" * 64 + current_wheel_url = ( + "https://files.pythonhosted.org/packages/aa/bb/" + package + "-" + current_version + "-py3-none-any.whl" + ) + if variant == "wheel-url": + current_wheel_url = "https://private.example/packages/" + package + ".whl" + if variant == "wheel-hash": + current_wheel_digest = "invalid" + if variant == "wheel-sdist-url": + current_url = "https://private.example/packages/" + package + ".tar.gz" + if variant == "wheel-sdist-hash": + current_digest = "invalid" + elif variant == "url": + current_url = "https://unreviewed.example/packages/aiohttp.tar.gz" + elif variant == "hash": + current_digest = "b" * 64 + elif variant == "trusted-hash": + current_digest = trusted_digest = "b" * 64 + elif variant == "origin": + origin = "https://github.com/unreviewed/openai-agents-python.git" + + def lock( + name: str, + version: str, + artifact_url: str, + artifact_digest: str, + registry: str, + wheel_url: str | None = None, + ) -> str: + return ( + "[[package]]\nname = " + + json.dumps(name) + + "\nversion = " + + json.dumps(version) + + "\nsource = { registry = " + + json.dumps(registry) + + " }\nsdist = { url = " + + json.dumps(artifact_url) + + ', hash = "sha256:' + + artifact_digest + + '" }\n' + + ( + "wheels = [{ url = " + json.dumps(wheel_url) + ', hash = "sha256:' + current_wheel_digest + '" }]\n' + if wheel_url is not None + else "" + ) + ) + + current_packages: list[str] = [] + trusted_packages: list[str] = [] + for name, (reviewed_version, reviewed_path, reviewed_digest) in reviewed.items(): + reviewed_url = "https://files.pythonhosted.org/packages/" + reviewed_path + if name == package: + if variant != "removed": + current_packages.append( + lock(name, current_version, current_url, current_digest, current_registry, current_wheel_url) + ) + trusted_packages.append( + lock(name, reviewed_version, reviewed_url, trusted_digest, "https://pypi.org/simple") + ) + else: + current_packages.append( + lock(name, reviewed_version, reviewed_url, reviewed_digest, "https://pypi.org/simple") + ) + trusted_packages.append( + lock(name, reviewed_version, reviewed_url, reviewed_digest, "https://pypi.org/simple") + ) + current = "\n".join(current_packages) + if variant == "duplicate": + current += "\n" + lock(package.upper(), current_version, current_url, current_digest, current_registry) + (tmp_path / "uv.lock").write_text(current) + (tmp_path / "upstream.lock").write_text("\n".join(trusted_packages)) + fake_git = tmp_path / "git" + fake_git.write_text( + f"#!{sys.executable}\n" + "import pathlib, sys\n" + f"root = pathlib.Path({str(tmp_path)!r})\n" + f"origin = {origin!r}\n" + "arguments = sys.argv[1:]\n" + "if arguments == ['remote', 'get-url', 'origin']:\n" + " print(origin)\n" + "elif arguments == ['show', 'HEAD:uv.lock']:\n" + " print((root / 'upstream.lock').read_text(), end='')\n" + "else:\n" + " raise SystemExit('Unexpected Agents checkout operation')\n" + ) + fake_git.chmod(0o755) + environment = dict(os.environ, PATH=str(tmp_path) + os.pathsep + os.environ["PATH"]) + result = subprocess.run( + [sys.executable, "-c", program], cwd=tmp_path, env=environment, capture_output=True, text=True, check=False + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + if accepted: + expected = set(reviewed) + if variant in {"wheel-upgrade", "removed"}: + expected.remove(package) + assert set(result.stdout.split()) == expected + + +@pytest.mark.parametrize("name", ["detect_breaking_changes", "agents_sdk"]) +def test_breaking_change_installers_validate_provenance_first(name: str) -> None: + path = ROOT / ".github/workflows/detect-breaking-changes.yml" + if not path.exists(): + pytest.skip("GitHub workflows are not included in source distributions") + + match = re.search( + rf"^ {name}:\n(?P.*?)(?=^ [\w-]+:\n|\Z)", + path.read_text(), + re.MULTILINE | re.DOTALL, + ) + assert match is not None + job = match.group("body") + steps = re.findall(r"^ - (?:name|uses):\s*(.+)$", job, re.MULTILINE) + assert re.fullmatch(r"actions/checkout@[0-9a-f]{40}.*", steps[0]) + assert steps[1] == "Verify dependency source provenance before installing tools" + source = next(line for line in job.splitlines() if "Use only the public PyPI registry" in line) + command = source.split("python -I -c '", 1)[1].rsplit("'", 1)[0] + workflow = (ROOT / ".github/workflows/ci.yml").read_text() + expected = next(line for line in workflow.splitlines() if "Use only the public PyPI registry" in line) + assert command == expected.split("python -I -c '", 1)[1].rsplit("'", 1)[0] + if name == "agents_sdk": + gate = job.split(" - name: Verify dependency source provenance before installing tools\n", 1)[1] + gate = gate.split("\n - name:", 1)[0] + assert "working-directory: openai-python" in gate + + +def test_security_dependency_policy_is_directly_testable_after_the_trusted_gate() -> None: + workflow = (ROOT / ".github/workflows/ci.yml").read_text() + job = dependency_workflow_jobs()["dependency-locks"] + gate = job.index("Verify dependency source provenance before installing tools") + policy = job.index('git show "$BASE_SHA:scripts/check-dependency-security.py" | python -I -') + assert gate < policy + assert "python - <<'PY'" not in job + script = ROOT / "scripts/check-dependency-security.py" + assert script.is_file() + assert 'import_module("tomllib")' in script.read_text() + assert workflow.count("Use only the public PyPI registry") == 1 + + +def security_dependency_floor_program() -> str: + gate = dependency_workflow_jobs()["dependency-locks"] + match = re.search( + r" - name: Require published minimums for direct security updates\n(?P.*?)(?=\n - name:|\Z)", + gate, + re.DOTALL, + ) + assert match is not None, "Direct Dependabot security updates must validate published dependency floors" + body = match.group("body") + assert "github.actor" not in body, "Maintainer updates must not disable an authored Dependabot security guard" + for condition in ( + "github.event_name == 'pull_request'", + "github.event.pull_request.user.login == 'dependabot[bot]'", + "contains(github.event.pull_request.head.ref, 'python-security')", + ): + assert condition in body + assert "set -euo pipefail" in body + assert 'git fetch --no-tags --depth=1 origin "$BASE_SHA"' in body + assert 'git show "$BASE_SHA:scripts/check-dependency-security.py" | python -I -' in body + program = (ROOT / "scripts/check-dependency-security.py").read_text() + if sys.version_info < (3, 11): + program = program.replace( + "from __future__ import annotations", + "from __future__ import annotations\nimport sys, tomli; sys.modules['tomllib'] = tomli", + 1, + ) + return program + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("trusted-base", True, id="security-checker-runs-only-immutable-base-script"), + pytest.param("tampered-head", True, id="submitted-no-op-cannot-replace-trusted-checker"), + pytest.param("missing-base-script", False, id="missing-trusted-checker-never-falls-back-to-head"), + pytest.param("invalid-base", False, id="checker-rejects-noncanonical-event-base-sha"), + pytest.param("foreign-origin", False, id="checker-rejects-an-attacker-controlled-origin"), + pytest.param("credential-origin", False, id="checker-rejects-credential-bearing-origin"), + pytest.param("stdlib-shadow", True, id="isolated-trusted-checker-ignores-checkout-module-shadow"), + ], +) +def test_security_floor_checker_executes_only_authenticated_base(tmp_path: Path, variant: str, accepted: bool) -> None: + gate = dependency_workflow_jobs()["dependency-locks"] + step = gate.split(" - name: Require published minimums for direct security updates\n", 1)[1] + step = step.split("\n - name:", 1)[0] + match = re.search( + r" run: (?:(?P[^|\n][^\n]*)|\|\n(?P(?: [^\n]*(?:\n|$))+))", + step, + ) + assert match is not None + program = ( + match.group("inline") + if match.group("inline") is not None + else "\n".join(line[10:] for line in match.group("block").splitlines()) + ) + sha = "a" * 40 if variant != "invalid-base" else "a" * 39 + "Z" + origin = "https://github.com/openai/openai-python.git" + if variant == "foreign-origin": + origin = "https://github.com/attacker/openai-python.git" + elif variant == "credential-origin": + origin = "https://token@github.com/openai/openai-python.git" + + scripts = tmp_path / "scripts" + scripts.mkdir() + (scripts / "check-dependency-security.py").write_text( + "import pathlib; pathlib.Path('executed-head').write_text('attacker')\n" + ) + if variant == "stdlib-shadow": + (tmp_path / "subprocess.py").write_text( + "import pathlib; pathlib.Path('shadow-imported').write_text('attacker')\n" + ) + trusted_program = ( + "import subprocess, pathlib\n" + "assert pathlib.Path(subprocess.__file__).resolve().parent != pathlib.Path.cwd()\n" + "pathlib.Path('executed-base').write_text('trusted')\n" + ) + fake_git = tmp_path / "git" + fake_git.write_text( + f"#!{sys.executable}\n" + "import sys\n" + f"sha = {sha!r}\n" + f"origin = {origin!r}\n" + f"missing = {variant == 'missing-base-script'!r}\n" + f"source = {trusted_program!r}\n" + "arguments = sys.argv[1:]\n" + "if arguments == ['remote', 'get-url', 'origin']:\n" + " print(origin)\n" + "elif arguments == ['fetch', '--no-tags', '--depth=1', 'origin', sha]:\n" + " pass\n" + "elif arguments == ['show', sha + ':scripts/check-dependency-security.py'] and not missing:\n" + " print(source, end='')\n" + "else:\n" + " raise SystemExit('Unexpected or unsafe git operation')\n" + ) + fake_git.chmod(0o755) + environment = dict(os.environ, BASE_SHA=sha, PATH=str(tmp_path) + os.pathsep + os.environ["PATH"]) + result = subprocess.run( + ["/bin/bash", "-euo", "pipefail", "-c", program], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + assert (tmp_path / "executed-base").exists() is accepted + assert not (tmp_path / "executed-head").exists() + assert not (tmp_path / "shadow-imported").exists() + + +@pytest.mark.parametrize( + ("actor", "author", "reference", "event", "accepted"), + [ + pytest.param( + "maintainer", + "dependabot[bot]", + "dependabot/uv/python-security-123", + "pull_request", + True, + id="maintainer-updated-security-pr", + ), + pytest.param( + "dependabot[bot]", + "dependabot[bot]", + "dependabot/uv/python-security-123", + "pull_request", + True, + id="dependabot-updated-security-pr", + ), + pytest.param( + "dependabot[bot]", + "untrusted-maintainer", + "dependabot/uv/python-security-123", + "pull_request", + False, + id="spoofed-security-pr-author", + ), + pytest.param( + "maintainer", + "dependabot[bot]", + "dependabot/uv/python-maintenance-123", + "pull_request", + False, + id="routine-dependency-pr", + ), + pytest.param( + "dependabot[bot]", + "dependabot[bot]", + "dependabot/uv/python-security-123", + "push", + False, + id="non-pull-request-event", + ), + ], +) +def test_security_floor_guard_uses_immutable_pr_identity( + actor: str, author: str, reference: str, event: str, accepted: bool +) -> None: + gate = dependency_workflow_jobs()["dependency-locks"] + step = gate.split(" - name: Require published minimums for direct security updates\n", 1)[1] + condition = step.split(" if: >-\n", 1)[1].split(" env:\n", 1)[0] + values = { + "github.event_name == 'pull_request'": event == "pull_request", + "github.actor == 'dependabot[bot]'": actor == "dependabot[bot]", + "github.event.pull_request.user.login == 'dependabot[bot]'": author == "dependabot[bot]", + "contains(github.event.pull_request.head.ref, 'python-security')": "python-security" in reference, + } + clauses = [line.strip().removeprefix("&& ").strip() for line in condition.splitlines() if line.strip()] + assert all(clause in values for clause in clauses), clauses + assert all(values[clause] for clause in clauses) is accepted + + +def run_security_dependency_floor_check( + tmp_path: Path, + *, + base_requirements: list[str], + head_requirements: list[str], + base_packages: list[tuple[str, str]], + head_packages: list[tuple[str, str]], + optional: bool = False, + base_optional_groups: dict[str, list[str]] | None = None, + head_optional_groups: dict[str, list[str]] | None = None, + sha: str = "a" * 40, + base_constraints: list[str] | None = None, + head_constraints: list[str] | None = None, + base_build_constraints: list[str] | None = None, + head_build_constraints: list[str] | None = None, + base_dependency_groups: dict[str, list[str]] | None = None, + head_dependency_groups: dict[str, list[str]] | None = None, + base_resolution_markers: dict[tuple[str, str], list[str]] | None = None, + head_resolution_markers: dict[tuple[str, str], list[str]] | None = None, + origin: str = "https://github.com/openai/openai-python", +) -> subprocess.CompletedProcess[str]: + def project( + requirements: list[str], + groups: dict[str, list[str]] | None, + constraints: list[str] | None, + build_constraints: list[str] | None, + dependency_groups: dict[str, list[str]] | None, + ) -> str: + if optional: + groups = {"feature": requirements} + requirements = [] + result = '[project]\nname = "openai"\nversion = "1.0"\ndependencies = ' + json.dumps(requirements) + "\n" + if groups: + result += "[project.optional-dependencies]\n" + for group, dependencies in groups.items(): + result += group + " = " + json.dumps(dependencies) + "\n" + if dependency_groups: + result += "[dependency-groups]\n" + for group, dependencies in dependency_groups.items(): + result += group + " = " + json.dumps(dependencies) + "\n" + if constraints is not None or build_constraints is not None: + result += "[tool.uv]\n" + if constraints is not None: + result += "constraint-dependencies = " + json.dumps(constraints) + "\n" + if build_constraints is not None: + result += "build-constraint-dependencies = " + json.dumps(build_constraints) + "\n" + return result + + def lock(packages: list[tuple[str, str]], resolutions: dict[tuple[str, str], list[str]] | None) -> str: + result: list[str] = [] + for name, version in packages: + entry = f"[[package]]\nname = {json.dumps(name)}\nversion = {json.dumps(version)}\n" + if resolutions is not None and (name, version) in resolutions: + entry += "resolution-markers = " + json.dumps(resolutions[(name, version)]) + "\n" + result.append(entry) + return "\n".join(result) + + (tmp_path / "pyproject.toml").write_text( + project( + head_requirements, head_optional_groups, head_constraints, head_build_constraints, head_dependency_groups + ) + ) + (tmp_path / "uv.lock").write_text(lock(head_packages, head_resolution_markers)) + (tmp_path / "base-project.toml").write_text( + project( + base_requirements, base_optional_groups, base_constraints, base_build_constraints, base_dependency_groups + ) + ) + (tmp_path / "base-lock.toml").write_text(lock(base_packages, base_resolution_markers)) + fake_git = tmp_path / "git" + fake_git.write_text( + f"#!{sys.executable}\n" + "import pathlib, sys\n" + f"root = pathlib.Path({str(tmp_path)!r})\n" + f"origin = {origin!r}\n" + f"sha = {sha!r}\n" + "arguments = sys.argv[1:]\n" + "if arguments == ['remote', 'get-url', 'origin']:\n" + " print(origin)\n" + "elif arguments == ['fetch', '--no-tags', '--depth=1', 'origin', sha]:\n" + " pass\n" + "elif arguments == ['show', sha + ':pyproject.toml']:\n" + " print((root / 'base-project.toml').read_text(), end='')\n" + "elif arguments == ['show', sha + ':uv.lock']:\n" + " print((root / 'base-lock.toml').read_text(), end='')\n" + "else:\n" + " raise SystemExit('Unexpected or unsafe git operation')\n" + ) + fake_git.chmod(0o755) + environment = dict(os.environ, BASE_SHA=sha, PATH=str(tmp_path) + os.pathsep + os.environ["PATH"]) + return subprocess.run( + [sys.executable, "-c", security_dependency_floor_program()], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + check=False, + ) + + +@pytest.mark.parametrize( + ("base", "head", "before", "after", "optional", "accepted"), + [ + pytest.param( + ["Danger_Pkg>=1.0"], + ["danger-pkg>=1.0"], + [("danger-pkg", "1.0")], + [("danger_pkg", "1.1")], + False, + False, + id="direct-lock-only", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.1"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.1")], + False, + True, + id="direct-floor-raised", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.1"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.5")], + False, + False, + id="direct-floor-below-patched-lock", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.5"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.5")], + False, + True, + id="direct-floor-equals-patched-lock", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.6"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.5")], + False, + True, + id="direct-floor-above-patched-lock", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.5"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.5.0")], + False, + True, + id="patched-lock-trailing-zero-equivalence", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.5"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.5rc1")], + False, + False, + id="patched-lock-prerelease-fails-closed", + ), + pytest.param( + ["danger-pkg>=0!9.0"], + ["danger-pkg>=1!1.0"], + [("danger-pkg", "9.0")], + [("danger-pkg", "1!2.0")], + False, + False, + id="epoch-floor-below-patched-lock", + ), + pytest.param( + ["danger-pkg>=2.0"], + ["danger-pkg>=1.0"], + [("danger-pkg", "2.0")], + [("danger-pkg", "2.1")], + False, + False, + id="direct-floor-lowered", + ), + pytest.param( + ["danger-pkg>=1.10"], + ["danger-pkg>=1.9"], + [("danger-pkg", "1.10")], + [("danger-pkg", "1.11")], + False, + False, + id="numeric-release-floor-lowered", + ), + pytest.param( + ["danger-pkg>=1.9"], + ["danger-pkg>=1.10"], + [("danger-pkg", "1.9")], + [("danger-pkg", "1.10")], + False, + True, + id="numeric-release-floor-raised", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0.0"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.1")], + False, + False, + id="trailing-zero-equivalent-floor", + ), + pytest.param( + ["danger-pkg>=0!9.0"], + ["danger-pkg>=1!1.0"], + [("danger-pkg", "9.0")], + [("danger-pkg", "1!1.0")], + False, + True, + id="epoch-floor-raised", + ), + pytest.param( + ["danger-pkg>=1!1.0"], + ["danger-pkg>=0!9.0"], + [("danger-pkg", "1!1.0")], + [("danger-pkg", "1!1.1")], + False, + False, + id="epoch-floor-lowered", + ), + pytest.param( + ["danger-pkg>=1.0; python_version >= '3.11'"], + ["danger-pkg>=1.1; python_version >= '3.11'"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.1")], + False, + True, + id="python-version-marker-floor-raised", + ), + pytest.param( + ["Danger_Pkg[extra]>=1.0,<3; python_version >= '3.11'"], + ["danger-pkg[extra]>=1.1,<3; python_version >= '3.11'"], + [("danger-pkg", "1.0")], + [("danger_pkg", "1.1")], + True, + True, + id="optional-alias-extra-and-marker-floor-raised", + ), + pytest.param( + ["websockets >= 12"], + ["websockets >= 13"], + [("websockets", "12")], + [("websockets", "13")], + True, + True, + id="repository-whitespace-websockets", + ), + pytest.param( + ["numpy >= 1"], + ["numpy >= 2.1"], + [("numpy", "1")], + [("numpy", "2.1")], + True, + True, + id="repository-whitespace-numpy", + ), + pytest.param( + ["pandas >= 1.2.3"], + ["pandas >= 1.5.0"], + [("pandas", "1.2.3")], + [("pandas", "1.5.0")], + True, + True, + id="repository-whitespace-pandas", + ), + pytest.param( + ["pydantic>=1.10.13,<3"], + ["pydantic>=1.10.26,<3"], + [("pydantic", "1.10.13"), ("pydantic", "2.12.5")], + [("pydantic", "1.10.26"), ("pydantic", "2.12.5")], + False, + True, + id="unchanged-alternate-pydantic-line-preserved", + ), + pytest.param( + ["pydantic>=1.10.13,<3"], + ["pydantic>=1.10.26,<3"], + [("pydantic", "1.10.13"), ("pydantic", "2.12.4")], + [("pydantic", "1.10.26"), ("pydantic", "2.12.5")], + False, + False, + id="multiple-patched-pydantic-lines-fail-closed", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0,<3"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.1")], + False, + False, + id="unchanged-lower-bound", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.1")], + True, + False, + id="optional-lock-only", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.1"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.1")], + True, + True, + id="optional-floor-raised", + ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=2.1", "numpy>=2.1,<3"], + [("numpy", "2.0.2")], + [("numpy", "2.1.0")], + True, + True, + id="optional-multiple-floors-reach-patched-release", + ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=1.1", "numpy>=2.0.2"], + [("numpy", "2.0.2")], + [("numpy", "2.1.0")], + True, + False, + id="optional-multiple-floors-below-patched-release", + ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=2.1", "numpy>=2.1,<3"], + [("numpy", "1.26"), ("numpy", "2.0.2")], + [("numpy", "1.26"), ("numpy", "2.1.0")], + True, + True, + id="unchanged-alternate-numpy-lock-preserved", + ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=1.1", "numpy>=2.0.1"], + [("numpy", "2.0.2")], + [("numpy", "2.1.0")], + True, + False, + id="optional-multiple-floors-one-lowered", + ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=1", "numpy>=2.0.3"], + [("numpy", "2.0.2")], + [("numpy", "2.1.0")], + True, + False, + id="optional-multiple-floors-weakest-unchanged", + ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=1.1"], + [("numpy", "2.0.2")], + [("numpy", "2.1.0")], + True, + False, + id="optional-floor-branch-removed", + ), + pytest.param( + ["other>=2"], + ["other>=1"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-direct-floor-lowered", + ), + pytest.param( + ["other>=2"], + ["other"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-direct-floor-removed", + ), + pytest.param( + ["other>=2,<4"], + ["other>=2,<3"], + [("other", "2")], + [("other", "2")], + False, + True, + id="unchanged-lock-equal-floor-preserved", + ), + pytest.param( + ["other>=1"], + ["other>=2"], + [("other", "2")], + [("other", "2")], + False, + True, + id="unchanged-lock-direct-floor-raised", + ), + pytest.param( + ["other>=2"], + ["other>=2"], + [("other", "2")], + [("other", "2")], + False, + True, + id="unchanged-lock-identical-requirement-preserved", + ), + pytest.param( + ["other >= 1.10"], + ["other >= 1.9"], + [("other", "1.10")], + [("other", "1.10")], + False, + False, + id="unchanged-lock-whitespace-numeric-floor-lowered", + ), + pytest.param( + ["other>=1!1"], + ["other>=0!9"], + [("other", "1!1")], + [("other", "1!1")], + False, + False, + id="unchanged-lock-epoch-floor-lowered", + ), + pytest.param( + ["other>=2.0"], + ["other>=2.0.0,<4"], + [("other", "2")], + [("other", "2")], + False, + True, + id="unchanged-lock-trailing-zero-equivalent-floor", + ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=1", "numpy>=2.0.1"], + [("numpy", "2.1")], + [("numpy", "2.1")], + True, + False, + id="unchanged-lock-optional-floor-lowered", + ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=1"], + [("numpy", "2.1")], + [("numpy", "2.1")], + True, + False, + id="unchanged-lock-optional-floor-branch-removed", + ), + pytest.param( + ["numpy>=2"], + ["numpy>=2", "numpy"], + [("numpy", "2.1")], + [("numpy", "2.1")], + True, + False, + id="unchanged-lock-unbounded-optional-branch-added", + ), + pytest.param( + ["numpy>=1", "numpy>=2.0.2"], + ["numpy>=1.1", "numpy>=2.0.2"], + [("numpy", "2.1")], + [("numpy", "2.1")], + True, + True, + id="unchanged-lock-optional-floor-branches-preserved", + ), + pytest.param( + ["other>=2; python_version >= '3.11'"], + ["other>=1; python_version >= '3.11'"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-marker-floor-lowered", + ), + pytest.param( + ["other>=2"], + ["other>=2rc1"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-unsupported-floor-fails-closed", + ), + pytest.param( + ["other"], + ["other>=2"], + [("other", "2")], + [("other", "2")], + False, + True, + id="unchanged-lock-previously-unbounded-floor-added", + ), + pytest.param( + ["other>=2; python_version < '3.11'", "other>=1; python_version >= '3.11'"], + ["other>=1; python_version < '3.11'", "other>=2; python_version >= '3.11'"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-marker-context-floors-swapped", + ), + pytest.param( + ["other[secure]>=2", "other[compat]>=1"], + ["other[secure]>=1", "other[compat]>=2"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-requested-extra-context-floors-swapped", + ), + pytest.param( + ["other>=2; python_version >= '3.11' and sys_platform == 'Linux'"], + ["other>=2,<4; sys_platform == 'Linux' and python_version >= '3.11'"], + [("other", "2")], + [("other", "2")], + False, + True, + id="unchanged-lock-marker-conjunction-reordered", + ), + pytest.param( + ["other[B,A]>=2"], + ["other[a,b]>=2,<4"], + [("other", "2")], + [("other", "2")], + False, + True, + id="unchanged-lock-requested-extras-reordered", + ), + pytest.param( + ["other>=2; sys_platform == 'Linux'"], + ["other>=2; sys_platform == 'linux'"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-marker-literal-case-preserved", + ), + pytest.param( + ["other>=2; python_version < '3.11' or sys_platform == 'linux'"], + ["other>=2; python_version < '3.11' or sys_platform == 'linux'"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-ambiguous-or-marker-fails-closed", + ), + pytest.param( + ["other>=2; (python_version < '3.11')"], + ["other>=2; (python_version < '3.11')"], + [("other", "2")], + [("other", "2")], + False, + False, + id="unchanged-lock-parenthesized-marker-fails-closed", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.5"], + [("danger-pkg", "2.0")], + [("danger-pkg", "1.5")], + False, + False, + id="downgraded-lock-cannot-be-security-patch", + ), + pytest.param( + ["danger-pkg>=0!1"], + ["danger-pkg>=0!9"], + [("danger-pkg", "1!1")], + [("danger-pkg", "0!9")], + False, + False, + id="downgraded-epoch-lock-cannot-be-security-patch", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=2.4"], + [("danger-pkg", "1.5"), ("danger-pkg", "2.5")], + [("danger-pkg", "1.6"), ("danger-pkg", "2.4")], + False, + False, + id="downgraded-alternate-lock-branch-fails-closed", + ), + pytest.param( + ["danger-pkg>=1"], + ["danger-pkg>=3"], + [("danger-pkg", "2")], + [("danger-pkg", "2"), ("danger-pkg", "3")], + False, + False, + id="unpaired-added-lock-release-fails-closed", + ), + pytest.param( + ["danger-pkg>=1"], + ["danger-pkg>=2"], + [("danger-pkg", "1"), ("danger-pkg", "2")], + [("danger-pkg", "2")], + False, + False, + id="unpaired-removed-lock-release-fails-closed", + ), + pytest.param( + ["danger-pkg>=1"], + ["danger-pkg>=2.0.0"], + [("danger-pkg", "2.0")], + [("danger-pkg", "2.0.0")], + False, + False, + id="equivalent-lock-release-is-not-security-upgrade", + ), + pytest.param( + ["danger-pkg>=1.9"], + ["danger-pkg>=2.0"], + [("danger-pkg", "1.9")], + [("danger-pkg", "2.0")], + False, + True, + id="single-lock-major-upgrade-remains-valid", + ), + pytest.param( + ["pydantic>=1.10.13,<3"], + ["pydantic>=1.10.27,<3"], + [("pydantic", "1.10.26"), ("pydantic", "2.12.5")], + [("pydantic", "1.10.27"), ("pydantic", "2.12.5")], + False, + True, + id="independent-pydantic-v1-lock-upgrade-preserves-v2", + ), + pytest.param( + ["danger-pkg>=1"], + ["danger-pkg>=3"], + [("danger-pkg", "1.5rc1")], + [("danger-pkg", "3")], + False, + False, + id="prerelease-removed-lock-fails-closed", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0.post1"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.0.post1")], + False, + True, + id="stable-post-release-security-fix", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0.post0"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.0.post0")], + False, + True, + id="stable-post-zero-above-base-release", + ), + pytest.param( + ["danger-pkg>=1.0.post0"], + ["danger-pkg>=1.0.post1"], + [("danger-pkg", "1.0.post0")], + [("danger-pkg", "1.0.post1")], + False, + True, + id="stable-post-release-increases-monotonically", + ), + pytest.param( + ["danger-pkg>=1.0.post9"], + ["danger-pkg>=1.1"], + [("danger-pkg", "1.0.post9")], + [("danger-pkg", "1.1")], + False, + True, + id="stable-next-release-above-post-release", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.0.post1")], + False, + False, + id="base-floor-does-not-cover-post-security-fix", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0.post9"], + [("danger-pkg", "1.1")], + [("danger-pkg", "1.0.post9")], + False, + False, + id="post-release-cannot-downgrade-next-release", + ), + pytest.param( + ["danger-pkg>=0!9.0"], + ["danger-pkg>=1!1.0.post1"], + [("danger-pkg", "9.0")], + [("danger-pkg", "1!1.0.post1")], + False, + True, + id="epoch-stable-post-security-fix", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0.0.post1"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.post1")], + False, + True, + id="post-release-normalizes-trailing-zeroes", + ), + pytest.param( + ["safe-direct>=1.0"], + ["safe-direct>=1.0"], + [("safe-direct", "1.0"), ("transitive", "1.0")], + [("safe-direct", "1.0"), ("transitive", "1.1")], + False, + False, + id="unbounded-transitive-security-update-rejected", + ), + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=1.0"], + [("danger-pkg", "1.0"), ("danger-pkg", "2.0")], + [("danger-pkg", "1.0"), ("danger-pkg", "2.1")], + False, + False, + id="multiple-locked-versions", + ), + pytest.param( + ["danger-pkg"], + ["danger-pkg>=1.1"], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.1")], + False, + True, + id="previously-unbounded", + ), + *[ + pytest.param( + ["danger-pkg>=1.0"], + ["danger-pkg>=" + version], + [("danger-pkg", "1.0")], + [("danger-pkg", "1.1")], + False, + False, + id="unsupported-floor-" + label, + ) + for label, version in ( + ("prerelease", "1.1rc1"), + ("development", "1.1.dev1"), + ("local", "1.1+local"), + ) + ], + pytest.param( + ["danger-pkg>=1.0rc1"], + ["danger-pkg>=1.1"], + [("danger-pkg", "1.0rc1")], + [("danger-pkg", "1.1")], + False, + False, + id="unsupported-previous-floor", + ), + ], +) +def test_only_direct_security_updates_must_raise_published_minimums( + tmp_path: Path, + base: list[str], + head: list[str], + before: list[tuple[str, str]], + after: list[tuple[str, str]], + optional: bool, + accepted: bool, +) -> None: + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base, + head_requirements=head, + base_packages=before, + head_packages=after, + optional=optional, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("removed-exclusion", False, id="grouped-update-cannot-remove-runtime-wildcard-exclusion"), + pytest.param("narrowed-exclusion", False, id="exact-exclusion-cannot-replace-entire-vulnerable-prefix"), + pytest.param("removed-upper", False, id="grouped-update-cannot-remove-runtime-upper-bound"), + pytest.param("widened-upper", False, id="grouped-update-cannot-widen-runtime-upper-bound"), + pytest.param("inclusive-upper", False, id="inclusive-bound-cannot-weaken-exclusive-upper"), + pytest.param("strengthened-upper", True, id="narrower-upper-bound-preserves-supported-locks"), + pytest.param("exclusive-upper", True, id="exclusive-upper-may-strengthen-inclusive-bound"), + pytest.param("canonical-reordered", True, id="canonical-reordered-security-bounds-remain-equivalent"), + pytest.param("stronger-wildcard", True, id="broader-exclusion-prefix-may-strengthen-security"), + pytest.param("wildcard-drops-v2", False, id="stronger-wildcard-cannot-drop-supported-v2-lock"), + pytest.param("exact-to-wildcard", True, id="wildcard-may-strengthen-exact-release-exclusion"), + pytest.param("post-removed", False, id="unchanged-lock-must-retain-exact-stable-post-exclusion"), + pytest.param("post-covered", True, id="release-prefix-may-strengthen-stable-post-exclusion"), + pytest.param("floor-covers-exact", True, id="stronger-floor-may-imply-prior-exact-exclusion"), + pytest.param("upper-covers-wildcard", True, id="stronger-upper-may-imply-prior-prefix-exclusion"), + pytest.param("wrong-epoch", False, id="other-epoch-prefix-does-not-preserve-existing-exclusion"), + pytest.param("epoch-canonical", True, id="canonical-same-epoch-prefix-preserves-existing-exclusion"), + pytest.param("marker-preserved", True, id="unchanged-marker-retains-runtime-security-bounds"), + pytest.param("marker-moved", False, id="security-bounds-cannot-move-to-different-marker-context"), + pytest.param("optional-removed", False, id="grouped-update-cannot-remove-optional-security-exclusion"), + pytest.param("optional-preserved", True, id="optional-security-context-and-bounds-remain-supported"), + pytest.param("unaffected-v1-dropped", False, id="security-bounds-cannot-drop-supported-unchanged-v1"), + pytest.param("unaffected-v2-dropped", False, id="security-bounds-cannot-drop-supported-unchanged-v2"), + pytest.param("malformed-exclusion", False, id="ambiguous-unchanged-lock-exclusion-fails-closed"), + pytest.param("no-old-security-bounds", True, id="unbounded-unchanged-dependency-still-accepts-new-floor"), + ], +) +def test_grouped_security_updates_preserve_unchanged_published_bounds( + tmp_path: Path, variant: str, accepted: bool +) -> None: + previous = "pydantic>=1,<3,!=2.12.5.*" + current = previous + v1, v2 = "1.10.26", "2.12.6" + optional = variant.startswith("optional-") + + if variant in {"removed-exclusion", "optional-removed"}: + current = "pydantic>=1,<3" + elif variant == "narrowed-exclusion": + current = "pydantic>=1,<3,!=2.12.5" + elif variant == "removed-upper": + current = "pydantic>=1,!=2.12.5.*" + elif variant == "widened-upper": + current = "pydantic>=1,<4,!=2.12.5.*" + elif variant == "inclusive-upper": + current = "pydantic>=1,<=3,!=2.12.5.*" + elif variant == "strengthened-upper": + current = "pydantic>=1,<2.13,!=2.12.5.*" + elif variant == "exclusive-upper": + previous = "pydantic>=1,<=3,!=2.12.5.*" + current = "pydantic>=1,<3,!=2.12.5.*" + elif variant == "canonical-reordered": + current = "pydantic!=0!2.12.5.*,<3.0,>=1.0.0" + elif variant in {"stronger-wildcard", "wildcard-drops-v2"}: + current = "pydantic>=1,<3,!=2.12.*" + if variant == "stronger-wildcard": + v2 = "2.13.0" + elif variant == "exact-to-wildcard": + previous = "pydantic>=1,<3,!=2.12.5" + elif variant in {"post-removed", "post-covered"}: + previous = "pydantic>=1,<3,!=2.12.5.post1" + current = "pydantic>=1,<3" if variant == "post-removed" else "pydantic>=1,<3,!=2.12.5.*" + elif variant == "floor-covers-exact": + previous = "pydantic>=1,<3,!=1.10.25" + current = "pydantic>=1.10.26,<3" + elif variant == "upper-covers-wildcard": + current = "pydantic>=1,<2.12.5" + v2 = "2.11.0" + elif variant in {"wrong-epoch", "epoch-canonical"}: + previous = "pydantic>=1!1,<1!3,!=1!2.12.5.*" + current = ( + "pydantic>=1!1,<1!3,!=0!2.12.5.*" if variant == "wrong-epoch" else "pydantic>=1!1,<1!3.0,!=01!02.012.005.*" + ) + v1, v2 = "1!1.10.26", "1!2.12.6" + elif variant in {"marker-preserved", "marker-moved"}: + previous += "; python_version >= '3.11'" + current += "; python_version >= '3.12'" if variant == "marker-moved" else "; python_version >= '3.11'" + elif variant == "unaffected-v1-dropped": + current += ",!=1.10.26.*" + elif variant == "unaffected-v2-dropped": + current += ",!=2.12.6.*" + elif variant == "malformed-exclusion": + current = "pydantic>=1,<3,!=2.12.5.post1.*" + elif variant == "no-old-security-bounds": + previous, current = "pydantic", "pydantic>=1" + + base_requirements = ["danger-pkg>=1"] + head_requirements = ["danger-pkg>=2"] + base_optional_groups = {"feature": [previous]} if optional else None + head_optional_groups = {"feature": [current]} if optional else None + if not optional: + base_requirements.append(previous) + head_requirements.append(current) + unchanged = [("pydantic", v1), ("pydantic", v2)] + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=[("danger-pkg", "1"), *unchanged], + head_packages=[("danger-pkg", "2"), *unchanged], + base_optional_groups=base_optional_groups, + head_optional_groups=head_optional_groups, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("constraint-exclusion", False, id="unchanged-uv-constraint-cannot-lose-exact-exclusion"), + pytest.param("constraint-wildcard", False, id="unchanged-uv-constraint-cannot-lose-prefix-exclusion"), + pytest.param("constraint-upper", False, id="unchanged-uv-constraint-cannot-widen-upper-bound"), + pytest.param("build-exclusion", False, id="unchanged-build-constraint-cannot-lose-security-exclusion"), + pytest.param("build-wildcard", False, id="unchanged-build-constraint-cannot-lose-prefix-exclusion"), + pytest.param("group-exclusion", False, id="unchanged-dependency-group-cannot-lose-security-exclusion"), + pytest.param("group-wildcard", False, id="unchanged-dependency-group-cannot-lose-prefix-exclusion"), + pytest.param("group-no-floor", False, id="protected-exclusion-is-checked-before-no-minimum-skip"), + pytest.param("changed-lock-exclusion", False, id="protected-patch-cannot-erase-other-security-exclusion"), + pytest.param("post-removed", False, id="protected-stable-post-exclusion-remains-immutable"), + pytest.param("wrong-epoch", False, id="protected-exclusion-cannot-move-to-different-epoch"), + pytest.param("marker-exclusion", False, id="protected-marker-context-retains-existing-exclusion"), + pytest.param("marker-moved", False, id="protected-exclusion-cannot-move-marker-context"), + pytest.param("drops-supported-lock", False, id="stronger-protected-bound-cannot-drop-current-locked-line"), + pytest.param("stronger-exclusion", True, id="protected-prefix-may-strengthen-exact-exclusion"), + pytest.param("stronger-upper", True, id="protected-upper-may-strengthen-without-dropping-lock"), + pytest.param("canonical-order", True, id="canonical-reordered-protected-bounds-remain-equivalent"), + pytest.param("build-pin-upgrade", True, id="immutable-exact-build-pins-may-take-reviewed-security-patch"), + pytest.param("unbounded-group", True, id="unchanged-unbounded-development-group-remains-supported"), + ], +) +def test_grouped_security_updates_preserve_all_protected_dependency_bounds( + tmp_path: Path, variant: str, accepted: bool +) -> None: + previous = "danger>=1,<3,!=1.5" + current = "danger>=1,<3" + before, after = "2", "2" + scope = "constraint" + + if variant in {"constraint-wildcard", "build-wildcard", "group-wildcard"}: + previous = "danger>=1,<3,!=1.5.*" + elif variant == "constraint-upper": + current = "danger>=1,<4,!=1.5" + elif variant == "group-no-floor": + previous, current = "danger<3,!=1.5", "danger<3" + elif variant == "changed-lock-exclusion": + previous = "danger>=1,<3,!=2.5" + current = "danger>=1.6,<3" + before, after = "1.4", "1.6" + elif variant == "post-removed": + previous = "danger>=1,<3,!=1.5.post2" + elif variant == "wrong-epoch": + previous = "danger>=1!1,<1!3,!=1!1.5.*" + current = "danger>=1!1,<1!3,!=0!1.5.*" + before = after = "1!2" + elif variant in {"marker-exclusion", "marker-moved"}: + previous += "; python_version >= '3.11'" + current += "; python_version >= '3.12'" if variant == "marker-moved" else "; python_version >= '3.11'" + elif variant == "drops-supported-lock": + current = "danger>=1,<2,!=1.5" + elif variant == "stronger-exclusion": + current = "danger>=1,<3,!=1.5.*" + elif variant == "stronger-upper": + current = "danger>=1,<2.5,!=1.5" + elif variant == "canonical-order": + current = "danger!=0!1.5,<3.0,>=1.0" + elif variant == "build-pin-upgrade": + previous, current = "danger==1.5", "danger==1.6" + before, after = "1.5", "1.6" + elif variant == "unbounded-group": + previous = current = "danger" + + if variant.startswith("build-"): + scope = "build" + elif variant.startswith("group-") or variant == "unbounded-group": + scope = "group" + + base_constraints = [previous] if scope == "constraint" else None + head_constraints = [current] if scope == "constraint" else None + base_build = [previous] if scope == "build" else None + head_build = [current] if scope == "build" else None + base_groups = {"reviewed": [previous]} if scope == "group" else None + head_groups = {"reviewed": [current]} if scope == "group" else None + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch-me>=1"], + head_requirements=["patch-me>=1.1"], + base_packages=[("patch-me", "1"), ("danger", before)], + head_packages=[("patch-me", "1.1"), ("danger", after)], + base_constraints=base_constraints, + head_constraints=head_constraints, + base_build_constraints=base_build, + head_build_constraints=head_build, + base_dependency_groups=base_groups, + head_dependency_groups=head_groups, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("runtime-wildcard", False, id="patched-runtime-lock-cannot-remove-prior-prefix-exclusion"), + pytest.param("runtime-exact", False, id="patched-runtime-lock-cannot-remove-prior-exact-exclusion"), + pytest.param("runtime-post", False, id="patched-runtime-lock-cannot-remove-stable-post-exclusion"), + pytest.param("runtime-upper", False, id="patched-runtime-lock-cannot-widen-existing-upper-bound"), + pytest.param("optional-wildcard", False, id="patched-optional-lock-cannot-remove-prior-prefix-exclusion"), + pytest.param("optional-upper", False, id="patched-optional-lock-cannot-remove-existing-upper-bound"), + pytest.param("epoch-wrong", False, id="patched-lock-cannot-move-exclusion-into-another-epoch"), + pytest.param("marker-exclusion", False, id="patched-marker-context-cannot-drop-security-exclusion"), + pytest.param("marker-moved", False, id="patched-security-exclusion-cannot-move-marker-context"), + pytest.param("malformed-exclusion", False, id="patched-lock-security-exclusion-must-remain-unambiguous"), + pytest.param("preserved-wildcard", True, id="patched-lock-preserves-existing-prefix-exclusion"), + pytest.param("stronger-wildcard", True, id="patched-lock-may-strengthen-exact-into-prefix-exclusion"), + pytest.param("stronger-upper", True, id="patched-lock-may-strengthen-upper-without-dropping-support"), + pytest.param("canonical-order", True, id="patched-lock-preserves-canonical-reordered-security-bounds"), + pytest.param("floor-implies-exclusion", True, id="patched-floor-may-safely-imply-prior-exact-exclusion"), + pytest.param("optional-preserved", True, id="patched-optional-context-preserves-existing-security-bounds"), + ], +) +def test_patched_locks_preserve_existing_published_security_bounds( + tmp_path: Path, variant: str, accepted: bool +) -> None: + previous = "danger>=1,<3,!=2.0.*" + current = "danger>=1.1,<3" + before, after = "1", "1.1" + optional = variant.startswith("optional-") + + if variant in {"runtime-exact", "runtime-post"}: + previous = "danger>=1,<3,!=2.0.post1" if variant == "runtime-post" else "danger>=1,<3,!=2.0" + elif variant in {"runtime-upper", "optional-upper"}: + current = "danger>=1.1,<4,!=2.0.*" + elif variant == "epoch-wrong": + previous = "danger>=1!1,<1!3,!=1!2.0.*" + current = "danger>=1!1.1,<1!3,!=0!2.0.*" + before, after = "1!1", "1!1.1" + elif variant in {"marker-exclusion", "marker-moved"}: + previous += "; python_version >= '3.11'" + current += "; python_version >= '3.12'" if variant == "marker-moved" else "; python_version >= '3.11'" + elif variant == "malformed-exclusion": + current += ",!=2.0.post1.*" + elif variant in {"preserved-wildcard", "optional-preserved"}: + current += ",!=2.0.*" + elif variant == "stronger-wildcard": + previous = "danger>=1,<3,!=2.0" + current += ",!=2.0.*" + elif variant == "stronger-upper": + current = "danger>=1.1,<2,!=2.0.*" + elif variant == "canonical-order": + current = "danger!=0!2.0.*,<3.0,>=1.1.0" + elif variant == "floor-implies-exclusion": + previous = "danger>=0,<3,!=0.5" + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[previous], + head_requirements=[current], + base_packages=[("danger", before)], + head_packages=[("danger", after)], + optional=optional, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("runtime-lower", False, id="unchanged-runtime-exact-pin-cannot-widen-to-floor"), + pytest.param("runtime-upper", False, id="unchanged-runtime-exact-pin-cannot-widen-to-ceiling"), + pytest.param("runtime-range", False, id="unchanged-runtime-exact-pin-cannot-widen-to-range"), + pytest.param("runtime-removed", False, id="unchanged-runtime-exact-pin-cannot-be-removed"), + pytest.param("runtime-replaced", False, id="unchanged-runtime-lock-cannot-swap-exact-pin"), + pytest.param("optional-lower", False, id="unchanged-optional-exact-pin-cannot-widen"), + pytest.param("constraint-lower", False, id="unchanged-uv-exact-pin-cannot-widen"), + pytest.param("build-lower", False, id="unchanged-build-exact-pin-cannot-widen"), + pytest.param("group-lower", False, id="unchanged-development-exact-pin-cannot-widen"), + pytest.param("marker-lower", False, id="unchanged-marker-scoped-exact-pin-cannot-widen"), + pytest.param("epoch-lower", False, id="unchanged-epoch-exact-pin-cannot-widen"), + pytest.param("post-lower", False, id="unchanged-stable-post-exact-pin-cannot-widen"), + pytest.param("upgrade-widen", False, id="patched-release-cannot-replace-exact-pin-with-range"), + pytest.param("upgrade-wrong-pin", False, id="patched-release-must-match-replacement-exact-pin"), + pytest.param("upgrade-retained-old", False, id="replacement-pin-cannot-leave-original-release-live"), + pytest.param("upgrade-ambiguous", False, id="replacement-pin-requires-one-for-one-locked-upgrade"), + pytest.param("upgrade-downgrade", False, id="replacement-pin-cannot-follow-a-downgraded-release"), + pytest.param("exact-preserved", True, id="unchanged-original-exact-pin-remains-supported"), + pytest.param("canonical-preserved", True, id="canonical-equivalent-exact-pin-remains-supported"), + pytest.param("redundant-preserved", True, id="exact-pin-with-redundant-safe-bounds-remains-supported"), + pytest.param("runtime-upgrade", True, id="published-exact-pin-may-track-real-security-upgrade"), + pytest.param("optional-upgrade", True, id="optional-exact-pin-may-track-real-security-upgrade"), + pytest.param("constraint-upgrade", True, id="uv-exact-pin-may-track-real-security-upgrade"), + pytest.param("build-upgrade", True, id="reviewed-build-exact-pin-may-track-real-security-upgrade"), + pytest.param("group-upgrade", True, id="development-exact-pin-may-track-real-security-upgrade"), + pytest.param("epoch-upgrade", True, id="epoch-exact-pin-may-track-matching-security-upgrade"), + pytest.param("post-upgrade", True, id="stable-post-exact-pin-may-track-matching-security-upgrade"), + pytest.param("marker-upgrade", True, id="contextual-exact-pin-may-track-matching-security-upgrade"), + ], +) +def test_grouped_security_updates_preserve_exact_dependency_pins(tmp_path: Path, variant: str, accepted: bool) -> None: + previous, current = "danger==1", "danger>=1" + before, after = ["1"], ["1"] + optional = variant.startswith("optional-") + constraints: tuple[list[str], list[str]] | None = None + build: tuple[list[str], list[str]] | None = None + groups: tuple[dict[str, list[str]], dict[str, list[str]]] | None = None + + if variant == "runtime-upper": + current = "danger<=1" + elif variant == "runtime-range": + current = "danger>=1,<2" + elif variant == "runtime-removed": + current = "danger" + elif variant == "runtime-replaced": + current = "danger==2" + elif variant == "constraint-lower": + constraints = ([previous], [current]) + elif variant == "build-lower": + build = ([previous], [current]) + elif variant == "group-lower": + groups = ({"reviewed": [previous]}, {"reviewed": [current]}) + elif variant in {"marker-lower", "marker-upgrade"}: + previous += "; python_version >= '3.11'" + if variant == "marker-upgrade": + current = "danger==2; python_version >= '3.11'" + after = ["2"] + else: + current += "; python_version >= '3.11'" + elif variant in {"epoch-lower", "epoch-upgrade"}: + previous = "danger==1!1" + before = ["1!1"] + if variant == "epoch-upgrade": + current, after = "danger==1!2", ["1!2"] + else: + current, after = "danger>=1!1", ["1!1"] + elif variant in {"post-lower", "post-upgrade"}: + previous = "danger==1.post1" + before = ["1.post1"] + if variant == "post-upgrade": + current, after = "danger==1.post2", ["1.post2"] + else: + current, after = "danger>=1.post1", ["1.post1"] + elif variant == "upgrade-widen": + current, after = "danger>=2", ["2"] + elif variant == "upgrade-wrong-pin": + current, after = "danger==3", ["2"] + elif variant == "upgrade-retained-old": + current, after = "danger==2", ["1", "2"] + elif variant == "upgrade-ambiguous": + current, after = "danger==2", ["2", "3"] + elif variant == "upgrade-downgrade": + previous, current, before, after = "danger==2", "danger==1", ["2"], ["1"] + elif variant == "exact-preserved": + current = previous + elif variant == "canonical-preserved": + previous, current = "danger==1.0", "danger==1" + elif variant == "redundant-preserved": + current = "danger==1,>=1" + elif variant.endswith("-upgrade"): + current, after = "danger==2", ["2"] + if variant == "constraint-upgrade": + constraints = ([previous], [current]) + elif variant == "build-upgrade": + build = ([previous], [current]) + elif variant == "group-upgrade": + groups = ({"reviewed": [previous]}, {"reviewed": [current]}) + + protected = constraints is not None or build is not None or groups is not None + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch-me>=1"] + ([] if protected else [previous]), + head_requirements=["patch-me>=1.1"] + ([] if protected else [current]), + base_packages=[("patch-me", "1"), *[("danger", version) for version in before]], + head_packages=[("patch-me", "1.1"), *[("danger", version) for version in after]], + optional=optional, + base_constraints=None if constraints is None else constraints[0], + head_constraints=None if constraints is None else constraints[1], + base_build_constraints=None if build is None else build[0], + head_build_constraints=None if build is None else build[1], + base_dependency_groups=None if groups is None else groups[0], + head_dependency_groups=None if groups is None else groups[1], + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("runtime-inclusive", False, id="strict-published-runtime-floor-cannot-become-inclusive"), + pytest.param("optional-inclusive", False, id="strict-optional-floor-cannot-become-inclusive"), + pytest.param("constraint-inclusive", False, id="strict-uv-constraint-cannot-become-inclusive"), + pytest.param("build-inclusive", False, id="strict-build-constraint-cannot-become-inclusive"), + pytest.param("group-inclusive", False, id="strict-development-floor-cannot-become-inclusive"), + pytest.param("strict-lowered", False, id="strict-lower-version-cannot-weaken"), + pytest.param("strict-dropped", False, id="strict-floor-cannot-disappear"), + pytest.param("epoch-inclusive", False, id="strict-epoch-floor-cannot-become-inclusive"), + pytest.param("post-inclusive", False, id="strict-post-release-floor-cannot-become-inclusive"), + pytest.param("marker-inclusive", False, id="strict-contextual-floor-cannot-become-inclusive"), + pytest.param("strict-preserved", True, id="unchanged-strict-floor-remains-supported"), + pytest.param("strict-raised", True, id="strict-floor-may-increase"), + pytest.param("inclusive-higher", True, id="higher-inclusive-floor-may-replace-strict-floor"), + pytest.param("inclusive-to-strict", True, id="inclusive-floor-may-strengthen-to-strict"), + pytest.param("canonical-strict", True, id="canonical-equivalent-strict-floor-remains-supported"), + ], +) +def test_grouped_security_updates_preserve_strict_dependency_floors( + tmp_path: Path, variant: str, accepted: bool +) -> None: + previous, current = "danger>1", "danger>=1" + locked = "2" + optional = variant == "optional-inclusive" + constraints: tuple[list[str], list[str]] | None = None + build: tuple[list[str], list[str]] | None = None + groups: tuple[dict[str, list[str]], dict[str, list[str]]] | None = None + + if variant == "constraint-inclusive": + constraints = ([previous], [current]) + elif variant == "build-inclusive": + build = ([previous], [current]) + elif variant == "group-inclusive": + groups = ({"reviewed": [previous]}, {"reviewed": [current]}) + elif variant == "strict-lowered": + previous, current, locked = "danger>2", "danger>1", "3" + elif variant == "strict-dropped": + current = "danger" + elif variant == "epoch-inclusive": + previous, current, locked = "danger>1!1", "danger>=1!1", "1!2" + elif variant == "post-inclusive": + previous, current, locked = "danger>1.post2", "danger>=1.post2", "1.post3" + elif variant == "marker-inclusive": + previous += "; python_version >= '3.11'" + current += "; python_version >= '3.11'" + elif variant == "strict-preserved": + current = previous + elif variant == "strict-raised": + current = "danger>1.5" + elif variant == "inclusive-higher": + current = "danger>=1.5" + elif variant == "inclusive-to-strict": + previous, current = "danger>=1", "danger>1" + elif variant == "canonical-strict": + previous, current = "danger>1.0", "danger>1" + + protected = constraints is not None or build is not None or groups is not None + base_requirements = ["patch-me>=1"] + ([] if protected else [previous]) + head_requirements = ["patch-me>=1.1"] + ([] if protected else [current]) + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=[("patch-me", "1"), ("danger", locked)], + head_packages=[("patch-me", "1.1"), ("danger", locked)], + optional=optional, + base_constraints=None if constraints is None else constraints[0], + head_constraints=None if constraints is None else constraints[1], + base_build_constraints=None if build is None else build[0], + head_build_constraints=None if build is None else build[1], + base_dependency_groups=None if groups is None else groups[0], + head_dependency_groups=None if groups is None else groups[1], + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("pydantic-v2", True, id="published-v1-support-survives-protected-v2-patch"), + pytest.param("pydantic-v1", True, id="published-v2-support-survives-protected-v1-patch"), + pytest.param("unchanged-published", False, id="private-v2-floor-cannot-leave-published-range-vulnerable"), + pytest.param("unchanged-published-v1", False, id="private-v1-floor-cannot-leave-published-range-vulnerable"), + pytest.param("missing-earlier-minor", False, id="published-range-must-exclude-entire-affected-major"), + pytest.param("missing-earlier-patch", False, id="published-range-must-exclude-earlier-minor-patches"), + pytest.param("exact-old-only", False, id="excluding-only-old-lock-does-not-protect-whole-branch"), + pytest.param("removed-still-accepted", False, id="published-range-must-exclude-removed-lock"), + pytest.param("patched-excluded", False, id="published-range-must-accept-patched-lock"), + pytest.param("unaffected-excluded", False, id="published-range-must-preserve-unaffected-major"), + pytest.param("dropped-original-exclusion", False, id="published-original-exclusions-cannot-be-weakened"), + pytest.param("marked-published", True, id="published-exclusions-preserve-original-marker"), + pytest.param("moved-published-marker", False, id="published-exclusions-cannot-move-original-marker"), + pytest.param("epoch-patched", True, id="published-exclusions-match-security-release-epoch"), + pytest.param("wrong-epoch", False, id="other-epoch-exclusions-do-not-secure-published-branch"), + pytest.param("post-patched", True, id="published-exclusions-cover-earlier-stable-post-releases"), + pytest.param("post-missing-intermediate", False, id="published-exclusions-cannot-skip-earlier-post"), + pytest.param("post-wildcard", False, id="published-post-wildcard-cannot-exclude-patched-release"), + pytest.param("unsupported-wildcard", False, id="ambiguous-published-exclusions-fail-closed"), + pytest.param("unbounded-expansion", False, id="published-branch-proof-has-bounded-expansion"), + pytest.param("unchanged-protected-floor", False, id="unchanged-v2-protected-floor-rejected"), + pytest.param("below-patched-release", False, id="protected-v2-floor-must-reach-lock-patch"), + pytest.param("unbounded-branch", False, id="protected-branch-must-retain-upper-bound"), + pytest.param("weakened-unaffected-floor", False, id="unaffected-v1-security-floor-cannot-drop"), + pytest.param("removed-unaffected-group", False, id="supported-v1-protected-context-cannot-disappear"), + pytest.param("removed-unaffected-lock", False, id="supported-v1-locked-branch-cannot-disappear"), + pytest.param("new-protected-context", False, id="protected-v2-context-must-exist-in-immutable-base"), + ], +) +def test_security_updates_preserve_independent_supported_major_branches( + tmp_path: Path, variant: str, accepted: bool +) -> None: + old_exclusions = [f"!=2.{minor}.*" for minor in range(4)] + published = "pydantic>=1.10.13,<3," + ",".join(old_exclusions) + v2_exclusions = [f"!=2.{minor}.*" for minor in range(4, 12)] + [f"!=2.12.{patch}.*" for patch in range(6)] + head_published = published + "," + ",".join(v2_exclusions) + base_groups = { + "pydantic-v1": ["pydantic>=1.10.26,<2"], + "pydantic-v2": ["pydantic>=2,<3"], + } + head_groups = { + "pydantic-v1": ["pydantic>=1.10.26,<2"], + "pydantic-v2": ["pydantic>=2.12.6,<3"], + } + base_packages = [("pydantic", "1.10.26"), ("pydantic", "2.12.5")] + head_packages = [("pydantic", "1.10.26"), ("pydantic", "2.12.6")] + + if variant in {"pydantic-v1", "unchanged-published-v1"}: + head_groups["pydantic-v1"] = ["pydantic>=1.10.27,<2"] + head_groups["pydantic-v2"] = ["pydantic>=2,<3"] + head_packages = [("pydantic", "1.10.27"), ("pydantic", "2.12.5")] + head_published = published + "," + ",".join(f"!=1.10.{patch}.*" for patch in range(13, 27)) + if variant == "unchanged-published-v1": + head_published = published + elif variant == "unchanged-published": + head_published = published + elif variant == "missing-earlier-minor": + head_published = head_published.replace(",!=2.11.*", "") + elif variant == "missing-earlier-patch": + head_published = head_published.replace(",!=2.12.4.*", "") + elif variant == "exact-old-only": + head_published = published + ",!=2.12.5" + elif variant == "removed-still-accepted": + head_published = head_published.replace(",!=2.12.5.*", "") + elif variant == "patched-excluded": + head_published += ",!=2.12.6.*" + elif variant == "unaffected-excluded": + head_published += ",!=1.10.26.*" + elif variant == "dropped-original-exclusion": + head_published = head_published.replace(",!=2.0.*", "") + elif variant in {"marked-published", "moved-published-marker"}: + published += "; python_version >= '3.10'" + head_published += ( + "; python_version >= '3.11'" if variant == "moved-published-marker" else "; python_version >= '3.10'" + ) + elif variant in {"epoch-patched", "wrong-epoch"}: + existing = [f"!=1!2.{minor}.*" for minor in range(4)] + remaining = [f"!=1!2.{minor}.*" for minor in range(4, 12)] + [f"!=1!2.12.{patch}.*" for patch in range(6)] + published = "pydantic>=1!1.10.13,<1!3," + ",".join(existing) + head_published = published + "," + ",".join(remaining) + if variant == "wrong-epoch": + head_published = published + "," + ",".join(value.replace("1!", "0!") for value in remaining) + base_groups = { + "pydantic-v1": ["pydantic>=1!1.10.26,<1!2"], + "pydantic-v2": ["pydantic>=1!2,<1!3"], + } + head_groups = { + "pydantic-v1": ["pydantic>=1!1.10.26,<1!2"], + "pydantic-v2": ["pydantic>=1!2.12.6,<1!3"], + } + base_packages = [("pydantic", "1!1.10.26"), ("pydantic", "1!2.12.5")] + head_packages = [("pydantic", "1!1.10.26"), ("pydantic", "1!2.12.6")] + elif variant in {"post-patched", "post-missing-intermediate", "post-wildcard"}: + head_groups["pydantic-v2"] = ["pydantic>=2.12.5.post3,<3"] + base_packages = [("pydantic", "1.10.26"), ("pydantic", "2.12.5.post1")] + head_packages = [("pydantic", "1.10.26"), ("pydantic", "2.12.5.post3")] + lower = [f"!=2.{minor}.*" for minor in range(4, 12)] + lower += [f"!=2.12.{patch}.*" for patch in range(5)] + lower += ["!=2.12.5", "!=2.12.5.post0", "!=2.12.5.post1", "!=2.12.5.post2"] + head_published = published + "," + ",".join(lower) + if variant == "post-missing-intermediate": + head_published = head_published.replace(",!=2.12.5.post2", "") + elif variant == "post-wildcard": + head_published += ",!=2.12.5.*" + elif variant == "unsupported-wildcard": + head_published += ",!=2.12.5.post1.*" + elif variant == "unbounded-expansion": + head_groups["pydantic-v2"] = ["pydantic>=2.513.1,<3"] + base_packages = [("pydantic", "1.10.26"), ("pydantic", "2.513.0")] + head_packages = [("pydantic", "1.10.26"), ("pydantic", "2.513.1")] + elif variant == "unchanged-protected-floor": + head_groups["pydantic-v2"] = ["pydantic>=2,<3"] + elif variant == "below-patched-release": + head_groups["pydantic-v2"] = ["pydantic>=2.12.5,<3"] + elif variant == "unbounded-branch": + head_groups["pydantic-v2"] = ["pydantic>=2.12.6"] + elif variant == "weakened-unaffected-floor": + head_groups["pydantic-v1"] = ["pydantic>=1.10.13,<2"] + elif variant == "removed-unaffected-group": + head_groups.pop("pydantic-v1") + elif variant == "removed-unaffected-lock": + head_packages = [("pydantic", "2.12.6")] + elif variant == "new-protected-context": + base_groups.pop("pydantic-v2") + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[published], + head_requirements=[head_published], + base_packages=base_packages, + head_packages=head_packages, + base_dependency_groups=base_groups, + head_dependency_groups=head_groups, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("runtime-removed", False, id="runtime-dependency-and-lock-removed"), + pytest.param("runtime-unbounded-removed", False, id="unbounded-runtime-dependency-removed"), + pytest.param("optional-group-removed", False, id="optional-dependency-group-and-lock-removed"), + pytest.param("optional-unbounded-removed", False, id="unbounded-optional-dependency-removed"), + pytest.param("marker-context-removed", False, id="unbounded-marker-context-removed"), + pytest.param("requested-extra-context-removed", False, id="requested-extra-context-removed"), + pytest.param("declaration-removed", False, id="unbounded-same-context-declaration-removed"), + pytest.param("unchanged", True, id="runtime-and-optional-dependencies-preserved"), + pytest.param("transitive-only", True, id="transitive-only-security-update-preserved"), + pytest.param("canonical-group", True, id="canonical-optional-group-spelling-preserved"), + ], +) +def test_security_updates_cannot_remove_published_direct_dependencies( + tmp_path: Path, variant: str, accepted: bool +) -> None: + base_requirements = ["safe-direct>=1", "danger-pkg>=1"] + head_requirements = ["safe-direct>=1"] + base_packages = [("safe-direct", "1"), ("danger-pkg", "1")] + head_packages = [("safe-direct", "1")] + base_groups: dict[str, list[str]] | None = None + head_groups: dict[str, list[str]] | None = None + head_constraints: list[str] | None = None + + if variant == "runtime-unbounded-removed": + base_requirements = ["safe-direct>=1", "danger-pkg"] + elif variant in {"optional-group-removed", "optional-unbounded-removed"}: + base_requirements = head_requirements = ["safe-direct>=1"] + requirement = "danger-pkg" if variant == "optional-unbounded-removed" else "danger-pkg>=1" + base_groups = {"feature": [requirement]} + head_groups = {} + elif variant == "marker-context-removed": + base_requirements = [ + "safe-direct>=1", + "danger-pkg; python_version < '3.11'", + "danger-pkg; python_version >= '3.11'", + ] + head_requirements = ["safe-direct>=1", "danger-pkg; python_version >= '3.11'"] + head_packages = list(base_packages) + elif variant == "requested-extra-context-removed": + base_requirements = ["safe-direct>=1", "danger-pkg[first]", "danger-pkg[second]"] + head_requirements = ["safe-direct>=1", "danger-pkg[first]"] + head_packages = list(base_packages) + elif variant == "declaration-removed": + base_requirements = ["safe-direct>=1", "danger-pkg", "danger-pkg<3"] + head_requirements = ["safe-direct>=1", "danger-pkg<3"] + head_packages = list(base_packages) + elif variant == "unchanged": + head_requirements = list(base_requirements) + head_packages = list(base_packages) + base_groups = head_groups = {"feature": ["danger-pkg>=1"]} + elif variant == "transitive-only": + head_requirements = list(base_requirements) + base_packages = [*base_packages, ("transitive", "1")] + head_packages = [("safe-direct", "1"), ("danger-pkg", "1"), ("transitive", "1.1")] + head_constraints = ["transitive>=1.1"] + elif variant == "canonical-group": + base_requirements = head_requirements = ["safe-direct>=1"] + head_packages = list(base_packages) + base_groups = {"voice_helpers": ["danger-pkg>=1"]} + head_groups = {"voice-helpers": ["danger-pkg>=1"]} + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=base_packages, + head_packages=head_packages, + base_optional_groups=base_groups, + head_optional_groups=head_groups, + head_constraints=head_constraints, + ) + + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + if not accepted: + assert "Do not remove a published direct dependency" in result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("independent-upgrades", True, id="numpy-python-marker-lines-upgrade-independently"), + pytest.param("reordered-marker", True, id="resolution-marker-conjunction-order-preserved"), + pytest.param("high-line-only", True, id="unchanged-old-python-floor-does-not-require-new-line"), + pytest.param("old-line-only", True, id="unchanged-new-python-floor-does-not-require-old-line"), + pytest.param("swapped-lines", False, id="marker-domains-cannot-swap-locked-versions"), + pytest.param("dropped-domain", False, id="resolution-marker-domain-cannot-disappear"), + pytest.param("unmarked-low-floor", False, id="unmarked-floor-must-cover-every-patched-domain"), + pytest.param("ambiguous-or", False, id="ambiguous-resolution-marker-fails-closed"), + ], +) +def test_security_patches_follow_their_original_resolution_marker_domains( + tmp_path: Path, variant: str, accepted: bool +) -> None: + old_requirement = "numpy>=2.2.6; python_version < '3.11'" + new_requirement = "numpy>=2.4.6; python_version >= '3.11'" + old_marker = "python_full_version < '3.11'" + new_markers = [ + "python_full_version >= '3.11' and sys_platform == 'linux'", + "python_full_version >= '3.11' and sys_platform != 'linux'", + ] + base_requirements = [old_requirement, new_requirement] + head_requirements = [ + "numpy>=2.2.7; python_version < '3.11'", + "numpy>=2.4.7; python_version >= '3.11'", + ] + base_packages = [("numpy", "2.2.6"), ("numpy", "2.4.6")] + head_packages = [("numpy", "2.2.7"), ("numpy", "2.4.7")] + base_markers = { + ("numpy", "2.2.6"): [old_marker], + ("numpy", "2.4.6"): new_markers, + } + head_markers = { + ("numpy", "2.2.7"): [old_marker], + ("numpy", "2.4.7"): list(new_markers), + } + if variant == "reordered-marker": + head_markers[("numpy", "2.4.7")] = [ + "sys_platform == 'linux' and python_full_version >= '3.11'", + "sys_platform != 'linux' and python_full_version >= '3.11'", + ] + elif variant == "high-line-only": + head_requirements[0] = old_requirement + head_packages[0] = ("numpy", "2.2.6") + head_markers.pop(("numpy", "2.2.7")) + head_markers[("numpy", "2.2.6")] = [old_marker] + elif variant == "old-line-only": + head_requirements[1] = new_requirement + head_packages[1] = ("numpy", "2.4.6") + head_markers.pop(("numpy", "2.4.7")) + head_markers[("numpy", "2.4.6")] = list(new_markers) + elif variant == "swapped-lines": + head_requirements = [ + "numpy>=2.4.7; python_version < '3.11'", + "numpy>=2.4.7; python_version >= '3.11'", + ] + head_markers = { + ("numpy", "2.2.7"): list(new_markers), + ("numpy", "2.4.7"): [old_marker], + } + elif variant == "dropped-domain": + head_requirements = [ + "numpy>=2.4.7; python_version < '3.11'", + "numpy>=2.4.7; python_version >= '3.11'", + ] + head_markers[("numpy", "2.4.7")] = [] + elif variant == "unmarked-low-floor": + base_requirements = ["numpy>=2.2.6"] + head_requirements = ["numpy>=2.2.7"] + elif variant == "ambiguous-or": + head_markers[("numpy", "2.4.7")] = [ + "python_full_version >= '3.11' or sys_platform == 'linux'", + ] + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=base_packages, + head_packages=head_packages, + base_resolution_markers=base_markers, + head_resolution_markers=head_markers, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("base_groups", "head_groups", "accepted"), + [ + pytest.param( + {"datalib": ["numpy>=1"], "voice_helpers": ["numpy>=2"]}, + {"datalib": ["numpy>=2"], "voice_helpers": ["numpy>=1"]}, + False, + id="actual-numpy-extra-floors-swapped", + ), + pytest.param( + {"datalib": ["numpy>=1"], "voice_helpers": ["numpy>=2"]}, + {"datalib": ["numpy>=1"]}, + False, + id="actual-numpy-bounded-extra-removed", + ), + pytest.param( + {"datalib": ["numpy>=1"], "voice_helpers": ["numpy>=2"]}, + {"voice_helpers": ["numpy>=2"], "datalib": ["numpy>=1"]}, + True, + id="actual-numpy-extra-groups-reordered", + ), + pytest.param( + {"voice_helpers": ["numpy>=2"]}, + {"voice-helpers": ["numpy>=2,<4"]}, + True, + id="canonical-optional-group-spelling-preserved", + ), + pytest.param( + {"voice_helpers": ["numpy>=2"]}, + {"datalib": ["numpy>=2"]}, + False, + id="bounded-optional-context-replaced", + ), + pytest.param( + {"datalib": ["numpy>=1"], "voice_helpers": ["numpy>=2"]}, + {"datalib": ["numpy>=1.1"], "voice_helpers": ["numpy>=2"]}, + True, + id="actual-numpy-extra-floor-raised-in-place", + ), + pytest.param( + {"datalib": ["numpy>=1"], "voice_helpers": ["numpy>=2"]}, + {"datalib": ["numpy>=1"], "voice_helpers": ["numpy>=1.9"]}, + False, + id="actual-numpy-extra-floor-lowered-in-place", + ), + ], +) +def test_security_floors_preserve_original_optional_contexts( + tmp_path: Path, + base_groups: dict[str, list[str]], + head_groups: dict[str, list[str]], + accepted: bool, +) -> None: + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[], + head_requirements=[], + base_packages=[("numpy", "2")], + head_packages=[("numpy", "2")], + base_optional_groups=base_groups, + head_optional_groups=head_groups, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("requirement_marker", "resolution_marker", "direct_accepted", "protected_accepted"), + [ + pytest.param( + "python_version in '3.10, 3.11'", + "python_full_version == '3.10.*'", + True, + True, + id="python-membership-first-release", + ), + pytest.param( + "python_version in '3.10, 3.11'", + "python_full_version == '3.11.*'", + True, + True, + id="python-membership-second-release", + ), + pytest.param( + "python_version in '3.10, 3.11'", + "python_full_version >= '3.12'", + False, + True, + id="python-membership-excludes-other-release", + ), + pytest.param( + "python_version not in '3.10, 3.11'", + "python_full_version == '3.12.*'", + True, + True, + id="python-negative-membership-allows-other-release", + ), + pytest.param( + "python_version not in '3.10, 3.11'", + "python_full_version == '3.10.*'", + False, + True, + id="python-negative-membership-excludes-listed-release", + ), + pytest.param( + "python_full_version in '3.10.4, 3.11.2'", + "python_full_version == '3.10.4'", + True, + True, + id="full-python-version-membership", + ), + pytest.param( + "sys_platform in 'linux, darwin'", + "sys_platform == 'linux'", + True, + True, + id="platform-membership-matches-reviewed-linux", + ), + pytest.param( + "sys_platform not in 'win32, darwin'", + "sys_platform == 'linux'", + True, + True, + id="negative-platform-membership-matches-reviewed-linux", + ), + pytest.param( + "platform_system in 'Linux, Darwin'", + "platform_system == 'linux'", + False, + True, + id="platform-membership-preserves-quoted-case", + ), + pytest.param( + "python_version >= '3.10'", + "python_version in '3.10, 3.11'", + True, + True, + id="resolution-domain-membership-also-supported", + ), + pytest.param( + "python_version in '3.10,,3.11'", + "python_full_version == '3.10.*'", + False, + False, + id="empty-membership-token-fails-closed", + ), + pytest.param( + "python_version in '3.10, 3.10'", + "python_full_version == '3.10.*'", + False, + False, + id="duplicate-membership-token-fails-closed", + ), + pytest.param( + "python_version in '3.1, 3.10'", + "python_full_version == '3.10.*'", + False, + False, + id="substring-ambiguous-membership-token-fails-closed", + ), + pytest.param( + "python_version in '3.10, beta'", + "python_full_version == '3.10.*'", + False, + False, + id="noncanonical-python-membership-token-fails-closed", + ), + pytest.param( + "sys_platform in 'win, win32'", + "sys_platform == 'win32'", + False, + False, + id="substring-ambiguous-platform-membership-fails-closed", + ), + pytest.param( + "unsupported_platform in 'linux'", + "sys_platform == 'linux'", + False, + False, + id="unknown-membership-variable-fails-closed", + ), + pytest.param( + "python_version in '3.10' or sys_platform == 'linux'", + "python_full_version == '3.10.*'", + False, + False, + id="source-level-or-membership-fails-closed", + ), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_security_marker_membership_overlaps_are_safe_and_precise( + tmp_path: Path, + requirement_marker: str, + resolution_marker: str, + direct_accepted: bool, + protected_accepted: bool, + protected: bool, +) -> None: + base = "danger>=1; " + requirement_marker + head = "danger>=2; " + requirement_marker + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [base], + head_requirements=[] if protected else [head], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[base] if protected else None, + head_constraints=[head] if protected else None, + base_resolution_markers={("danger", "1"): [resolution_marker]}, + head_resolution_markers={("danger", "2"): [resolution_marker]}, + ) + accepted = protected_accepted if protected else direct_accepted + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("direct-both", True, id="both-actual-numpy-extra-groups-split-by-python-version"), + pytest.param("direct-high-only", True, id="unchanged-python310-line-does-not-need-artificial-bump"), + pytest.param("direct-membership-split", True, id="published-split-membership-complements-cover-all-domains"), + pytest.param("direct-low-insufficient", False, id="python310-split-floor-must-reach-its-own-patch"), + pytest.param("direct-high-insufficient", False, id="newer-python-split-floor-must-reach-its-own-patch"), + pytest.param("direct-gap", False, id="split-cannot-drop-python310-resolution-domain"), + pytest.param("direct-overlap", False, id="split-cannot-overlap-one-resolution-domain"), + pytest.param("direct-partial-domain", False, id="split-cannot-cover-only-part-of-original-domain"), + pytest.param("direct-moved-group", False, id="split-cannot-move-published-optional-group"), + pytest.param("direct-upper-removed", False, id="split-cannot-remove-original-upper-bound"), + pytest.param("direct-exclusion-removed", False, id="split-cannot-remove-original-excluded-release"), + pytest.param("direct-original-lowered", False, id="split-cannot-lower-original-unchanged-branch"), + pytest.param("protected-constraint", True, id="protected-constraint-splits-by-resolution-domain"), + pytest.param("protected-group", True, id="protected-development-group-splits-by-resolution-domain"), + pytest.param("protected-membership-split", True, id="protected-split-membership-complements-cover-all-domains"), + pytest.param("protected-insufficient", False, id="protected-split-floor-must-reach-its-own-patch"), + pytest.param("protected-gap", False, id="protected-split-cannot-drop-python310-domain"), + pytest.param("protected-upper-removed", False, id="protected-split-preserves-original-upper-bound"), + ], +) +def test_security_floors_can_safely_split_original_unmarked_resolution_domains( + tmp_path: Path, variant: str, accepted: bool +) -> None: + low_marker = "python_version < '3.11'" + high_marker = "python_version >= '3.11'" + base_packages = [("numpy", "2.2.6"), ("numpy", "2.4.6")] + head_packages = [("numpy", "2.2.7"), ("numpy", "2.4.7")] + base_markers = { + ("numpy", "2.2.6"): ["python_full_version < '3.11'"], + ("numpy", "2.4.6"): [ + "python_full_version >= '3.11' and sys_platform == 'linux'", + "python_full_version >= '3.11' and sys_platform != 'linux'", + ], + } + head_markers = { + ("numpy", "2.2.7"): ["python_full_version < '3.11'"], + ("numpy", "2.4.7"): [ + "python_full_version >= '3.11' and sys_platform == 'linux'", + "python_full_version >= '3.11' and sys_platform != 'linux'", + ], + } + original = "numpy>=1,<3" + low = "numpy>=2.2.7,<3; " + low_marker + high = "numpy>=2.4.7,<3; " + high_marker + base_optional = { + "datalib": [original], + "voice_helpers": ["numpy>=2.0.2,<3"], + } + head_optional = { + "datalib": [low, high], + "voice_helpers": [low, high], + } + base_constraints: list[str] | None = None + head_constraints: list[str] | None = None + base_groups: dict[str, list[str]] | None = None + head_groups: dict[str, list[str]] | None = None + + if variant in {"direct-membership-split", "protected-membership-split"}: + base_markers[("numpy", "2.2.6")] = ["python_full_version == '3.10.*'"] + head_markers[("numpy", "2.2.7")] = ["python_full_version == '3.10.*'"] + low = "numpy>=2.2.7,<3; python_version in '3.10'" + high = "numpy>=2.4.7,<3; python_version not in '3.10'" + head_optional = { + "datalib": [low, high], + "voice_helpers": [low, high], + } + + if variant == "direct-high-only": + head_packages[0] = ("numpy", "2.2.6") + head_markers.pop(("numpy", "2.2.7")) + head_markers[("numpy", "2.2.6")] = ["python_full_version < '3.11'"] + head_optional["datalib"][0] = "numpy>=1,<3; " + low_marker + head_optional["voice_helpers"][0] = "numpy>=2.0.2,<3; " + low_marker + elif variant == "direct-low-insufficient": + head_optional["datalib"][0] = "numpy>=2.2.6,<3; " + low_marker + elif variant == "direct-high-insufficient": + head_optional["voice_helpers"][1] = "numpy>=2.4.6,<3; " + high_marker + elif variant == "direct-gap": + head_optional["datalib"] = [high] + elif variant == "direct-overlap": + head_optional["datalib"][0] = "numpy>=2.4.7,<3; python_version < '3.12'" + elif variant == "direct-partial-domain": + head_optional["datalib"][0] = "numpy>=2.2.7,<3; python_version < '3.11' and sys_platform == 'linux'" + elif variant == "direct-moved-group": + head_optional["moved"] = head_optional.pop("datalib") + elif variant == "direct-upper-removed": + head_optional["datalib"][0] = "numpy>=2.2.7; " + low_marker + elif variant == "direct-exclusion-removed": + base_optional["datalib"] = ["numpy>=1,<3,!=2.3"] + elif variant == "direct-original-lowered": + head_packages[0] = ("numpy", "2.2.6") + head_markers.pop(("numpy", "2.2.7")) + head_markers[("numpy", "2.2.6")] = ["python_full_version < '3.11'"] + head_optional["voice_helpers"][0] = "numpy>=1,<3; " + low_marker + elif variant.startswith("protected-"): + base_optional = head_optional = {} + if variant == "protected-group": + base_groups, head_groups = {"dev": [original]}, {"dev": [low, high]} + else: + base_constraints, head_constraints = [original], [low, high] + if variant == "protected-insufficient": + head_constraints[0] = "numpy>=2.2.6,<3; " + low_marker + elif variant == "protected-gap": + head_constraints = [high] + elif variant == "protected-upper-removed": + head_constraints[0] = "numpy>=2.2.7; " + low_marker + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[], + head_requirements=[], + base_packages=base_packages, + head_packages=head_packages, + base_optional_groups=base_optional, + head_optional_groups=head_optional, + base_constraints=base_constraints, + head_constraints=head_constraints, + base_dependency_groups=base_groups, + head_dependency_groups=head_groups, + base_resolution_markers=base_markers, + head_resolution_markers=head_markers, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("constraint-equal", True, id="uv-security-constraint-preserved"), + pytest.param("constraint-higher", True, id="uv-security-constraint-raised"), + pytest.param("constraint-reordered", True, id="uv-security-constraints-reordered"), + pytest.param("constraint-lowered", False, id="uv-security-constraint-lowered"), + pytest.param("constraint-removed", False, id="uv-security-constraint-removed"), + pytest.param("constraint-section-removed", False, id="uv-security-constraint-section-removed"), + pytest.param("constraint-marker-swap", False, id="uv-security-constraint-markers-swapped"), + pytest.param("build-pin-preserved", True, id="uv-build-security-pin-preserved"), + pytest.param("build-pin-lowered", False, id="uv-build-security-pin-lowered"), + pytest.param("build-pin-removed", False, id="uv-build-security-pin-removed"), + pytest.param("group-floor-raised", True, id="dependency-group-security-floor-raised"), + pytest.param("group-floor-lowered", False, id="dependency-group-security-floor-lowered"), + pytest.param("group-floor-removed", False, id="dependency-group-security-floor-removed"), + pytest.param("group-pin-lowered", False, id="dependency-group-exact-security-pin-lowered"), + pytest.param("group-context-swap", False, id="pydantic-dependency-group-contexts-swapped"), + pytest.param("unbounded-group-preserved", True, id="unbounded-dependency-group-preserved"), + ], +) +def test_security_updates_preserve_uv_and_dependency_group_floors(tmp_path: Path, variant: str, accepted: bool) -> None: + base_constraints: list[str] | None = None + head_constraints: list[str] | None = None + base_build_constraints: list[str] | None = None + head_build_constraints: list[str] | None = None + base_groups: dict[str, list[str]] | None = None + head_groups: dict[str, list[str]] | None = None + if variant == "constraint-equal": + base_constraints = head_constraints = ["cryptography>=50.0.0"] + elif variant == "constraint-higher": + base_constraints, head_constraints = ["cryptography>=50.0.0"], ["cryptography>=51.0.0"] + elif variant == "constraint-reordered": + base_constraints = ["cryptography>=50.0.0", "requests>=2.33.0"] + head_constraints = ["requests>=2.33.0", "cryptography>=50.0.0"] + elif variant == "constraint-lowered": + base_constraints, head_constraints = ["cryptography>=50.0.0"], ["cryptography>=49.0.0"] + elif variant == "constraint-removed": + base_constraints = ["cryptography>=50.0.0", "requests>=2.33.0"] + head_constraints = ["requests>=2.33.0"] + elif variant == "constraint-section-removed": + base_constraints = ["cryptography>=50.0.0"] + elif variant == "constraint-marker-swap": + base_constraints = [ + "cryptography>=50; python_version < '3.11'", + "cryptography>=49; python_version >= '3.11'", + ] + head_constraints = [ + "cryptography>=49; python_version < '3.11'", + "cryptography>=50; python_version >= '3.11'", + ] + elif variant == "build-pin-preserved": + base_build_constraints = head_build_constraints = ["hatchling==1.27.0"] + elif variant == "build-pin-lowered": + base_build_constraints, head_build_constraints = ["hatchling==1.27.0"], ["hatchling==1.26.0"] + elif variant == "build-pin-removed": + base_build_constraints, head_build_constraints = ["hatchling==1.27.0"], [] + elif variant == "group-floor-raised": + base_groups, head_groups = {"dev": ["pytest>=9.0.3"]}, {"dev": ["pytest>=9.0.4"]} + elif variant == "group-floor-lowered": + base_groups, head_groups = {"dev": ["pytest>=9.0.3"]}, {"dev": ["pytest>=9.0.2"]} + elif variant == "group-floor-removed": + base_groups = {"dev": ["pytest>=9.0.3"]} + head_groups = {"dev": []} + elif variant == "group-pin-lowered": + base_groups, head_groups = {"build": ["hatchling==1.27.0"]}, {"build": ["hatchling==1.26.0"]} + elif variant == "group-context-swap": + base_groups = {"pydantic-v1": ["pydantic>=1.10"], "pydantic-v2": ["pydantic>=2"]} + head_groups = {"pydantic-v1": ["pydantic>=2"], "pydantic-v2": ["pydantic>=1.10"]} + elif variant == "unbounded-group-preserved": + base_groups = head_groups = {"dev": ["ruff"]} + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[], + head_requirements=[], + base_packages=[ + ("cryptography", "50.0.0"), + ("requests", "2.33.0"), + ("hatchling", "1.27.0"), + ("pytest", "9.0.3"), + ("pydantic", "1.10"), + ("pydantic", "2"), + ("ruff", "1"), + ], + head_packages=[ + ("cryptography", "50.0.0"), + ("requests", "2.33.0"), + ("hatchling", "1.27.0"), + ("pytest", "9.0.3"), + ("pydantic", "1.10"), + ("pydantic", "2"), + ("ruff", "1"), + ], + base_constraints=base_constraints, + head_constraints=head_constraints, + base_build_constraints=base_build_constraints, + head_build_constraints=head_build_constraints, + base_dependency_groups=base_groups, + head_dependency_groups=head_groups, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("constraint-lock-only", False, id="protected-transitive-lock-only-security-update"), + pytest.param("constraint-insufficient", False, id="protected-transitive-floor-below-patched-release"), + pytest.param("constraint-patched", True, id="protected-transitive-floor-reaches-patched-release"), + pytest.param("constraint-higher", True, id="protected-transitive-floor-exceeds-patched-release"), + pytest.param("constraint-upper-blocks", False, id="protected-transitive-upper-bound-excludes-patch"), + pytest.param("constraint-unchanged-lock", True, id="protected-transitive-unchanged-lock-preserved"), + pytest.param("unrelated-transitive", True, id="unrelated-transitive-lock-only-update-preserved"), + pytest.param("unbounded-group", True, id="unbounded-development-group-lock-update-preserved"), + pytest.param("group-lock-only", False, id="development-group-floor-must-reach-patched-release"), + pytest.param("group-patched", True, id="development-group-floor-reaches-patched-release"), + pytest.param("build-pin-lock-only", False, id="build-constraint-pin-must-reach-patched-release"), + pytest.param("build-pin-patched", True, id="build-constraint-pin-reaches-patched-release"), + pytest.param("post-lock-only", False, id="protected-floor-must-reach-stable-post-release"), + pytest.param("post-patched", True, id="protected-floor-reaches-stable-post-release"), + pytest.param("epoch-lock-only", False, id="protected-floor-must-reach-new-epoch-release"), + pytest.param("epoch-patched", True, id="protected-floor-reaches-new-epoch-release"), + pytest.param("downgrade", False, id="protected-locked-release-cannot-downgrade"), + pytest.param("added-release", False, id="protected-added-release-without-prior-line-fails-closed"), + pytest.param("removed-release", False, id="protected-removed-release-without-patch-fails-closed"), + pytest.param("prerelease", False, id="protected-prerelease-patch-fails-closed"), + pytest.param("marker-low-unaffected", True, id="protected-unrelated-marker-line-remains-unchanged"), + pytest.param("marker-high-lock-only", False, id="protected-marker-context-floor-must-reach-patch"), + pytest.param("marker-high-patched", True, id="protected-marker-context-floor-reaches-patch"), + pytest.param("pydantic-v1-lock-only", False, id="protected-pydantic-v1-floor-must-reach-patch"), + pytest.param("pydantic-v1-patched", True, id="protected-pydantic-v1-patch-preserves-v2-line"), + pytest.param("pydantic-v2-patched", True, id="protected-pydantic-v2-patch-preserves-v1-line"), + ], +) +def test_protected_security_floors_must_reach_their_patched_release( + tmp_path: Path, variant: str, accepted: bool +) -> None: + base_constraints: list[str] | None = ["cryptography>=50"] + head_constraints: list[str] | None = ["cryptography>=50"] + base_build_constraints: list[str] | None = None + head_build_constraints: list[str] | None = None + base_groups: dict[str, list[str]] | None = None + head_groups: dict[str, list[str]] | None = None + base_packages = [("cryptography", "50")] + head_packages = [("cryptography", "51")] + base_markers: dict[tuple[str, str], list[str]] | None = None + head_markers: dict[tuple[str, str], list[str]] | None = None + + if variant == "constraint-insufficient": + head_constraints = ["cryptography>=50.1"] + elif variant == "constraint-patched": + head_constraints = ["cryptography>=51"] + elif variant == "constraint-higher": + head_constraints = ["cryptography>=52"] + elif variant == "constraint-upper-blocks": + base_constraints = ["cryptography>=50,<52"] + head_constraints = ["cryptography>=51,<51"] + elif variant == "constraint-unchanged-lock": + head_packages = list(base_packages) + elif variant == "unrelated-transitive": + base_packages.append(("unrelated", "1")) + head_packages = [("cryptography", "50"), ("unrelated", "2")] + head_constraints = ["cryptography>=50", "unrelated>=2"] + elif variant == "unbounded-group": + base_constraints = head_constraints = None + base_groups = {"dev": ["ruff"]} + head_groups = {"dev": ["ruff>=2"]} + base_packages, head_packages = [("ruff", "1")], [("ruff", "2")] + elif variant in {"group-lock-only", "group-patched"}: + base_constraints = head_constraints = None + base_groups = {"dev": ["pytest>=9"]} + head_groups = {"dev": ["pytest>=10" if variant == "group-patched" else "pytest>=9"]} + base_packages, head_packages = [("pytest", "9")], [("pytest", "10")] + elif variant in {"build-pin-lock-only", "build-pin-patched"}: + base_constraints = head_constraints = None + base_build_constraints = ["hatchling==1.27"] + head_build_constraints = ["hatchling==1.28" if variant == "build-pin-patched" else "hatchling==1.27"] + base_packages, head_packages = [("hatchling", "1.27")], [("hatchling", "1.28")] + elif variant in {"post-lock-only", "post-patched"}: + base_constraints = ["cryptography>=50"] + head_constraints = ["cryptography>=50.post1" if variant == "post-patched" else "cryptography>=50"] + base_packages, head_packages = [("cryptography", "50")], [("cryptography", "50.post1")] + elif variant in {"epoch-lock-only", "epoch-patched"}: + base_constraints = ["cryptography>=0!50"] + head_constraints = ["cryptography>=1!1" if variant == "epoch-patched" else "cryptography>=0!50"] + base_packages, head_packages = [("cryptography", "50")], [("cryptography", "1!1")] + elif variant == "downgrade": + base_constraints, head_constraints = ["cryptography>=50"], ["cryptography>=51"] + base_packages, head_packages = [("cryptography", "52")], [("cryptography", "51")] + elif variant == "added-release": + head_constraints = ["cryptography>=51"] + head_packages = [("cryptography", "50"), ("cryptography", "51")] + elif variant == "removed-release": + head_constraints = ["cryptography>=51"] + base_packages = [("cryptography", "50"), ("cryptography", "51")] + head_packages = [("cryptography", "51")] + elif variant == "prerelease": + head_constraints = ["cryptography>=51"] + head_packages = [("cryptography", "51rc1")] + elif variant.startswith("marker-"): + old_requirement = "cryptography>=50; python_version < '3.11'" + new_requirement = "cryptography>=60; python_version >= '3.11'" + base_constraints = [old_requirement] + head_constraints = [old_requirement] + if variant != "marker-low-unaffected": + base_constraints.append(new_requirement) + head_constraints.append( + "cryptography>=61; python_version >= '3.11'" if variant == "marker-high-patched" else new_requirement + ) + base_packages = [("cryptography", "50"), ("cryptography", "60")] + head_packages = [("cryptography", "50"), ("cryptography", "61")] + base_markers = { + ("cryptography", "50"): ["python_full_version < '3.11'"], + ("cryptography", "60"): ["python_full_version >= '3.11'"], + } + head_markers = { + ("cryptography", "50"): ["python_full_version < '3.11'"], + ("cryptography", "61"): ["python_full_version >= '3.11'"], + } + elif variant.startswith("pydantic-"): + base_constraints = head_constraints = None + base_groups = { + "pydantic-v1": ["pydantic>=1.10,<2"], + "pydantic-v2": ["pydantic>=2,<3"], + } + head_groups = { + "pydantic-v1": ["pydantic>=1.11,<2" if variant == "pydantic-v1-patched" else "pydantic>=1.10,<2"], + "pydantic-v2": ["pydantic>=2.13,<3" if variant == "pydantic-v2-patched" else "pydantic>=2,<3"], + } + base_packages = [("pydantic", "1.10"), ("pydantic", "2.12")] + head_packages = ( + [("pydantic", "1.10"), ("pydantic", "2.13")] + if variant == "pydantic-v2-patched" + else [("pydantic", "1.11"), ("pydantic", "2.12")] + ) + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[], + head_requirements=[], + base_packages=base_packages, + head_packages=head_packages, + base_constraints=base_constraints, + head_constraints=head_constraints, + base_build_constraints=base_build_constraints, + head_build_constraints=head_build_constraints, + base_dependency_groups=base_groups, + head_dependency_groups=head_groups, + base_resolution_markers=base_markers, + head_resolution_markers=head_markers, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("lock-only", False, id="newly-patched-transitive-lock-needs-security-boundary"), + pytest.param("grouped-lock-only", False, id="grouped-direct-patch-cannot-hide-transitive-lock-only"), + pytest.param("unbounded-group", False, id="unbounded-development-entry-is-not-a-security-boundary"), + pytest.param("constraint-missing-floor", False, id="new-uv-constraint-without-floor-does-not-protect"), + pytest.param("constraint-too-low", False, id="new-uv-constraint-must-reach-patched-release"), + pytest.param("constraint-old-inclusive", False, id="new-boundary-must-exclude-vulnerable-old-release"), + pytest.param("constraint-excludes-patch", False, id="new-boundary-must-admit-actual-patched-release"), + pytest.param("strict-excludes-patch", False, id="strict-boundary-cannot-exclude-patched-release"), + pytest.param("wrong-marker", False, id="transitive-security-floor-cannot-move-marker-domain"), + pytest.param("widened-marker", False, id="transitive-security-floor-cannot-widen-over-supported-line"), + pytest.param("added-without-removal", False, id="shared-transitive-upgrade-cannot-retain-vulnerable-release"), + pytest.param("ambiguous-upgrade", False, id="shared-transitive-upgrade-must-pair-one-for-one"), + pytest.param("downgrade", False, id="shared-transitive-security-release-cannot-downgrade"), + pytest.param("uv-floor", True, id="new-reviewed-uv-security-floor-covers-transitive-patch"), + pytest.param("uv-exact", True, id="new-reviewed-exact-uv-pin-covers-transitive-patch"), + pytest.param("build-exact", True, id="new-reviewed-build-pin-covers-transitive-patch"), + pytest.param("group-floor", True, id="new-reviewed-development-floor-covers-transitive-patch"), + pytest.param("published-floor", True, id="new-published-floor-covers-former-transitive-patch"), + pytest.param("unchanged", True, id="unchanged-unprotected-transitive-release-remains-supported"), + pytest.param("new-package", True, id="genuinely-new-package-introduction-remains-supported"), + pytest.param("removed-package", True, id="fully-removed-transitive-package-remains-supported"), + pytest.param("canonical-name", True, id="canonical-equivalent-transitive-name-remains-supported"), + pytest.param("marker-floor", True, id="matching-marker-security-floor-preserves-unaffected-line"), + pytest.param("epoch-floor", True, id="matching-epoch-security-floor-covers-transitive-patch"), + pytest.param("post-floor", True, id="matching-stable-post-floor-covers-transitive-patch"), + pytest.param("independent-majors", True, id="new-reviewed-v2-floor-preserves-independent-v1-lock"), + ], +) +def test_newly_patched_transitive_dependencies_require_security_boundaries( + tmp_path: Path, variant: str, accepted: bool +) -> None: + name, old, patched = "transitive", "1", "2" + base_requirements, head_requirements = ["patch-me>=1"], ["patch-me>=1.1"] + before: list[tuple[str, str]] = [(name, old)] + after: list[tuple[str, str]] = [(name, patched)] + base_constraints: list[str] | None = None + head_constraints: list[str] | None = None + base_build: list[str] | None = None + head_build: list[str] | None = None + base_groups: dict[str, list[str]] | None = None + head_groups: dict[str, list[str]] | None = None + base_markers: dict[tuple[str, str], list[str]] | None = None + head_markers: dict[tuple[str, str], list[str]] | None = None + + if variant == "lock-only": + head_requirements = list(base_requirements) + elif variant == "unbounded-group": + base_groups = head_groups = {"dev": [name]} + elif variant == "constraint-missing-floor": + head_constraints = [name] + elif variant == "constraint-too-low": + head_constraints = [name + ">=1.5"] + elif variant == "constraint-old-inclusive": + head_constraints = [name + ">=1"] + elif variant == "constraint-excludes-patch": + head_constraints = [name + ">=2,<2"] + elif variant == "strict-excludes-patch": + head_constraints = [name + ">2"] + elif variant in {"wrong-marker", "widened-marker", "marker-floor"}: + before = [(name, "1"), (name, "2")] + after = [(name, "1"), (name, "3")] + base_markers = { + (name, "1"): ["python_full_version < '3.11'"], + (name, "2"): ["python_full_version >= '3.11'"], + } + head_markers = { + (name, "1"): ["python_full_version < '3.11'"], + (name, "3"): ["python_full_version >= '3.11'"], + } + if variant == "wrong-marker": + head_constraints = [name + ">=3; python_version < '3.11'"] + elif variant == "widened-marker": + head_constraints = [name + ">=3"] + else: + head_constraints = [name + ">=3; python_version >= '3.11'"] + elif variant == "added-without-removal": + after = [(name, old), (name, patched)] + head_constraints = [name + ">=2"] + elif variant == "ambiguous-upgrade": + after = [(name, "2"), (name, "3")] + head_constraints = [name + ">=2"] + elif variant == "downgrade": + before, after = [(name, "2")], [(name, "1")] + head_constraints = [name + ">=1"] + elif variant == "uv-floor": + head_constraints = [name + ">=2"] + elif variant == "uv-exact": + head_constraints = [name + "==2"] + elif variant == "build-exact": + head_build = [name + "==2"] + elif variant == "group-floor": + head_groups = {"reviewed": [name + ">=2"]} + elif variant == "published-floor": + head_requirements.append(name + ">=2") + elif variant == "unchanged": + after = list(before) + elif variant == "new-package": + before = [] + elif variant == "removed-package": + after = [] + elif variant == "canonical-name": + before = [("Transitive_Pkg", old)] + after = [("transitive-pkg", patched)] + head_constraints = ["transitive.pkg>=2"] + elif variant == "epoch-floor": + before, after = [(name, "1!1")], [(name, "1!2")] + head_constraints = [name + ">=1!2"] + elif variant == "post-floor": + before, after = [(name, "1.post1")], [(name, "1.post2")] + head_constraints = [name + ">=1.post2"] + elif variant == "independent-majors": + name = "pydantic" + before, after = [(name, "1.10"), (name, "2.12")], [(name, "1.10"), (name, "2.13")] + head_groups = {"pydantic-v2": [name + ">=2.13,<3"]} + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=[("patch-me", "1"), *before], + head_packages=[("patch-me", "1" if variant == "lock-only" else "1.1"), *after], + base_constraints=base_constraints, + head_constraints=head_constraints, + base_build_constraints=base_build, + head_build_constraints=head_build, + base_dependency_groups=base_groups, + head_dependency_groups=head_groups, + base_resolution_markers=base_markers, + head_resolution_markers=head_markers, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +def test_security_floor_parser_strips_requirement_whitespace() -> None: + assert "stable_version(matches[0].strip())" in security_dependency_floor_program() + + +@pytest.mark.parametrize( + ("sha", "origin"), + [ + pytest.param("invalid", "https://github.com/openai/openai-python", id="untrusted-base"), + pytest.param("a" * 40, "https://github.com/attacker/openai-python", id="untrusted-origin"), + ], +) +def test_security_floor_guard_rejects_untrusted_base(tmp_path: Path, sha: str, origin: str) -> None: + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["danger-pkg>=1.0"], + head_requirements=["danger-pkg>=1.1"], + base_packages=[("danger-pkg", "1.0")], + head_packages=[("danger-pkg", "1.1")], + sha=sha, + origin=origin, + ) + assert result.returncode != 0 + + +def test_routine_dependency_updates_preserve_lock_only_strategy() -> None: + config = (ROOT / ".github/dependabot.yml").read_text() + assert "versioning-strategy: increase-if-necessary" in config + assert re.search(r"python-security:\n\s+applies-to: security-updates", config) + security_dependency_floor_program() + + def test_agents_integration_selects_its_typechecking_runtime() -> None: path = ROOT / ".github/workflows/detect-breaking-changes.yml" if not path.exists(): @@ -189,3 +3980,16 @@ def test_build_uses_hashed_locked_build_group(tmp_path: Path, monkeypatch: pytes "--out-dir", str(tmp_path / "dist"), ] + + +def test_reviewed_root_build_still_runs_with_source_distribution_builds_disabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + log = fake_uv(tmp_path, monkeypatch) + monkeypatch.setenv("UV_NO_BUILD", "1") + result = subprocess.run([str(ROOT / "scripts/build"), "--out-dir", str(tmp_path / "dist")], check=False) + calls = [json.loads(line)["args"] for line in log.read_text().splitlines()] + assert result.returncode == 0 + assert calls[0][0] == "export" + assert calls[1][0] == "build" + assert "--no-sources" in calls[1]