diff --git a/.castiron.stats.yml b/.castiron.stats.yml index a72c5c1efb..881ff0abce 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,8 +1,8 @@ schema_version: 1 -generation_id: b67f6675-ad30-42fd-90ec-33d09485b9a0 -openapi_spec_hash: 32e9f9c8096eae027754def9afb89dc0 -openapi_transformed_spec_hash: a5861d7f7b67f6845c9499fdec7324a5 +generation_id: dd4c6a7b-158a-4b11-9b4a-128435aaa1f0 +openapi_spec_hash: 4af7018ff56446e0398fc7946a92680f +openapi_transformed_spec_hash: b2689771e08c3fdc6ae8f0433dc2c784 config_hash: 5c00fe18ea913ed0186f9894adc02a70 -codegen_sha: 9177ce998eec68cf1d241f1efd84f8786eba92cd -codegen_hash: 9271ebabb8723d763a7a728f3a455fb1ee4ea678e20885f891b50222f68d4def -public_codegen_sha: 2951af18b699be3114ff5e6a3c7f7a669e7d5a76 +codegen_sha: 9bc71a62e18aa941ba073325f46e29ff05e1e90c +codegen_hash: 803a072c019069cce4b17ef21da0980c4d6086b7ba935ff45b9d8b9f00d7acdd +public_codegen_sha: 734497bdc1d37e9709c026b67eaec668fe37f753 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..d85bc1e060 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,11 +18,59 @@ 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; hatch = lambda document: document.get("tool", {}).get("hatch", {}); executable = lambda document: (hatch(document).get("build", {}).get("hooks", {}), tuple(sorted((name, target.get("hooks", {})) for name, target in hatch(document).get("build", {}).get("targets", {}).items() if target.get("hooks", {}))), hatch(document).get("metadata", {}).get("hooks", {})); assert not fork or executable(config) == executable(trusted), "Untrusted fork Hatch executable hook configuration"; sections = executable(config); paths = [definition.get("path", "hatch_build.py") for mapping in [sections[0], *[item[1] for item in sections[1]], sections[2]] for plugin, definition in mapping.items() if plugin == "custom" or "path" in definition] if fork else []; assert not fork or len(paths) <= 32 and all(isinstance(location, str) and bool(location) and len(location) <= 512 and not (candidate := pathlib.Path(location)).is_absolute() and bool(candidate.parts) and ".." not in candidate.parts and candidate.is_file() and all(not pathlib.Path(*candidate.parts[:index]).is_symlink() for index in range(1, len(candidate.parts) + 1)) and candidate.read_bytes() == subprocess.check_output(["git", "show", base + ":" + candidate.as_posix()]) for location in paths), "Untrusted fork Hatch executable hook source"; 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 + # Bash 3.2 does not apply errexit to failed [[ ... ]] guards. + [[ "$BASE_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 1 + origin="$(git remote get-url origin)" + [[ "$origin" == "https://github.com/openai/openai-python" || "$origin" == "https://github.com/openai/openai-python.git" ]] || exit 1 + 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 +92,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 +114,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 +147,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 +184,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 +220,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 +263,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..181e381d02 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; hatch = lambda document: document.get("tool", {}).get("hatch", {}); executable = lambda document: (hatch(document).get("build", {}).get("hooks", {}), tuple(sorted((name, target.get("hooks", {})) for name, target in hatch(document).get("build", {}).get("targets", {}).items() if target.get("hooks", {}))), hatch(document).get("metadata", {}).get("hooks", {})); assert not fork or executable(config) == executable(trusted), "Untrusted fork Hatch executable hook configuration"; sections = executable(config); paths = [definition.get("path", "hatch_build.py") for mapping in [sections[0], *[item[1] for item in sections[1]], sections[2]] for plugin, definition in mapping.items() if plugin == "custom" or "path" in definition] if fork else []; assert not fork or len(paths) <= 32 and all(isinstance(location, str) and bool(location) and len(location) <= 512 and not (candidate := pathlib.Path(location)).is_absolute() and bool(candidate.parts) and ".." not in candidate.parts and candidate.is_file() and all(not pathlib.Path(*candidate.parts[:index]).is_symlink() for index in range(1, len(candidate.parts) + 1)) and candidate.read_bytes() == subprocess.check_output(["git", "show", base + ":" + candidate.as_posix()]) for location in paths), "Untrusted fork Hatch executable hook source"; 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" @@ -45,47 +55,3 @@ jobs: run: | test -f ./scripts/detect-breaking-changes || { echo "Missing scripts/detect-breaking-changes"; exit 1; } ./scripts/detect-breaking-changes "$BASE_SHA" - - agents_sdk: - runs-on: 'ubuntu-latest' - name: Detect Agents SDK regressions - if: github.repository == 'openai/openai-python' && github.event_name == 'pull_request' - steps: - # Setup this sdk - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - path: openai-python - - - name: Set up uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - with: - version: '0.12.5' - enable-cache: true - working-directory: openai-python - # Match the Agents SDK's type-checking runtime, not our minimum. - python-version: '3.14' - - - name: Install dependencies - working-directory: openai-python - run: | - uv sync --locked --all-extras - - # Setup the agents lib - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - repository: openai/openai-agents-python - path: openai-agents-python - - - name: Link to local SDK - working-directory: openai-agents-python - run: uv add ../openai-python - - - name: Install dependencies - working-directory: openai-agents-python - run: make sync - - - name: Run integration type checks - working-directory: openai-agents-python - run: make mypy diff --git a/.github/workflows/python-version-review.yml b/.github/workflows/python-version-review.yml index 6627c43a87..c0508e1d47 100644 --- a/.github/workflows/python-version-review.yml +++ b/.github/workflows/python-version-review.yml @@ -178,7 +178,7 @@ jobs: issues: write steps: - name: Download the Codex assessment - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: monthly-python-version-review path: ${{ runner.temp }}/monthly-python-version-review diff --git a/api_reference/openapi.transformed.yml b/api_reference/openapi.transformed.yml index 556cdd2d9d..1c5a24fad5 100644 --- a/api_reference/openapi.transformed.yml +++ b/api_reference/openapi.transformed.yml @@ -28668,6 +28668,8 @@ components: - tenant.admin_api_key.updated - tenant.admin_api_key.deleted - tenant.project_api_key.created + - tenant.trusted_access.business_verification.started + - tenant.trusted_access.application.submitted - tenant.chatgpt_access_token.revoked - tenant.migration.completed - tenant.sso.migrated @@ -30465,6 +30467,13 @@ components: type: integer default: 0 description: Total number of tokens used in the request (prompt + completion). + compute_units: + anyOf: + - type: integer + minimum: 0 + - type: 'null' + description: | + Compute units for the request. Currently null when available. completion_tokens_details: type: object description: Breakdown of tokens used in a completion. @@ -54949,6 +54958,13 @@ components: total_tokens: type: integer description: The total number of tokens used. + compute_units: + anyOf: + - type: integer + minimum: 0 + - type: 'null' + description: | + Compute units for the request. Currently null when available. required: - input_tokens - input_tokens_details @@ -73838,6 +73854,13 @@ components: total_tokens: type: integer description: The total number of tokens used. + compute_units: + anyOf: + - type: integer + minimum: 0 + - type: 'null' + description: | + Compute units for the request. Currently null when available. required: - input_tokens - input_tokens_details diff --git a/scripts/check-dependency-security.py b/scripts/check-dependency-security.py new file mode 100644 index 0000000000..b6905f5f69 --- /dev/null +++ b/scripts/check-dependency-security.py @@ -0,0 +1,2428 @@ +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] +RequirementSource: TypeAlias = tuple[str, str, tuple[str, ...], str] +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 parse_marker_expression(marker: str) -> ast.expr: + tokens = list(tokenize.generate_tokens(io.StringIO(marker).readline)) + lines = marker.splitlines(keepends=True) + offsets = [0] + for line in lines: + offsets.append(offsets[-1] + len(line)) + replacements: list[tuple[int, int, str]] = [] + for index, token in enumerate(tokens): + if token.type == tokenize.NAME and token.string == "is": + raise ValueError("Unsupported Python security dependency marker operator") + if token.type != tokenize.OP or index + 1 == len(tokens): + continue + following = tokens[index + 1] + if following.type != tokenize.OP or token.end != following.start or following.string != "=": + continue + if token.string == "~": + replacement = "is" + elif token.string == "==": + replacement = "is not" + else: + continue + start = offsets[token.start[0] - 1] + token.start[1] + stop = offsets[following.end[0] - 1] + following.end[1] + replacements.append((start, stop, replacement)) + for start, stop, replacement in reversed(replacements): + marker = marker[:start] + replacement + marker[stop:] + return ast.parse(marker, mode="eval").body + + +def marker_clause(part: ast.expr) -> MarkerClause: + if ( + not isinstance(part, ast.Compare) + or len(part.ops) != 1 + or len(part.comparators) != 1 + or type(part.ops[0]) + not in {ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn, ast.Is, ast.IsNot} + ): + raise SystemExit("Ambiguous direct security dependency marker") + operator = {"Is": "Compatible", "IsNot": "ArbitraryEq"}.get(type(part.ops[0]).__name__, type(part.ops[0]).__name__) + right = part.comparators[0] + if isinstance(part.left, ast.Name) and isinstance(right, ast.Constant) and isinstance(right.value, str): + variable, value = part.left.id, right.value + elif ( + isinstance(part.left, ast.Constant) + and isinstance(part.left.value, str) + and isinstance(right, ast.Name) + and operator in {"Eq", "NotEq", "Lt", "LtE", "Gt", "GtE", "In", "NotIn"} + ): + variable, value = right.id, part.left.value + operator = { + "Eq": "Eq", + "NotEq": "NotEq", + "Lt": "Gt", + "LtE": "GtE", + "Gt": "Lt", + "GtE": "LtE", + "In": "ReverseIn", + "NotIn": "ReverseNotIn", + }[operator] + else: + raise SystemExit("Ambiguous direct security dependency marker") + return variable.lower(), operator, value + + +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 = parse_marker_expression(marker.strip()) + 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] + return tuple(sorted(marker_clause(part) for part in parts)) + + +def direct_marker_contexts(marker: str) -> tuple[MarkerContext, ...]: + if not marker.strip(): + return ((),) + if len(marker) > 1024: + raise SystemExit("Unbounded direct security dependency marker") + try: + expression = parse_marker_expression(marker.strip()) + except (SyntaxError, tokenize.TokenError, ValueError): + raise SystemExit("Ambiguous direct security dependency marker") from None + if sum(1 for _ in ast.walk(expression)) > 256: + raise SystemExit("Unbounded direct security dependency marker") + + def expand(node: ast.expr, depth: int = 0) -> list[MarkerContext]: + if depth > 32: + raise SystemExit("Unbounded direct security dependency marker") + if isinstance(node, ast.BoolOp): + values: list[MarkerContext] + if isinstance(node.op, ast.Or): + values = [] + for child in node.values: + values.extend(expand(child, depth + 1)) + if len(values) > 128: + raise SystemExit("Unbounded direct security dependency marker") + elif isinstance(node.op, ast.And): + values = [()] + for child in node.values: + current = expand(child, depth + 1) + if len(values) * len(current) > 128: + raise SystemExit("Unbounded direct security dependency marker") + values = [tuple(sorted(set(left + right))) for left in values for right in current] + else: + raise SystemExit("Ambiguous direct security dependency marker") + return values + context = (marker_clause(node),) + for option in marker_options(context): + simple_marker_overlap(option, ()) + return [context] + + expanded = expand(expression) + if sum(len(marker_options(context)) for context in expanded) > 128: + raise SystemExit("Unbounded direct security dependency marker") + unique: list[MarkerContext] = [] + for context in sorted(set(expanded), key=lambda value: (len(value), value)): + if not marker_overlap(context, ()): + continue + if any(not uncovered_marker_fragments(context, [previous]) for previous in unique): + continue + unique = [previous for previous in unique if uncovered_marker_fragments(previous, [context])] + unique.append(context) + return tuple(unique) + + +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") + normalized = name + (match.group(2) or "").lower() + re.sub(r"\s+", "", match.group(3)).lower() + result.setdefault(name, set()).add(normalized) + for marker in direct_marker_contexts(match.group(3).partition(";")[2]): + context = (scope, group, requested, marker) + 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 dependency_marker_options(marker: object, extras: tuple[str, ...]) -> list[MarkerContext]: + if marker is None: + return [()] + if not isinstance(marker, str) or not marker.strip() or len(marker) > 1024: + raise SystemExit("Ambiguous locked security dependency edge marker") + try: + expression = parse_marker_expression(marker) + except (SyntaxError, tokenize.TokenError, ValueError): + raise SystemExit("Ambiguous locked security dependency edge marker") from None + + selected = extras or ("",) + if len(selected) > 128: + raise SystemExit("Unbounded locked security dependency edge extras") + work = 0 + + def options(node: ast.expr, extra: str) -> list[MarkerContext]: + nonlocal work + work += 1 + if work > 8192: + raise SystemExit("Unbounded locked security dependency edge marker") + if isinstance(node, ast.BoolOp): + values: list[MarkerContext] + if isinstance(node.op, ast.Or): + values = [context for child in node.values for context in options(child, extra)] + elif isinstance(node.op, ast.And): + values = [()] + for child in node.values: + current = options(child, extra) + if len(values) * len(current) > 128: + raise SystemExit("Unbounded locked security dependency edge marker") + values = [tuple(sorted(set(left + right))) for left in values for right in current] + else: + raise SystemExit("Ambiguous locked security dependency edge marker") + if len(values) > 128: + raise SystemExit("Unbounded locked security dependency edge marker") + return values + context = (marker_clause(node),) + variable, operator, value = context[0] + if variable != "extra": + return [context] + normalized = canonical(value) + if operator in {"Eq", "GtE", "LtE"}: + accepted = extra == normalized + elif operator == "NotEq": + accepted = extra != normalized + elif operator in {"Gt", "Lt"}: + accepted = False + elif operator in {"In", "NotIn", "ReverseIn", "ReverseNotIn"}: + if len(value) > 256: + raise SystemExit("Unbounded locked security dependency edge extra") + if operator in {"ReverseIn", "ReverseNotIn"}: + accepted = (normalized in extra) == (operator == "ReverseIn") + else: + accepted = (extra in normalized) == (operator == "In") + else: + raise SystemExit("Ambiguous locked security dependency edge extra") + return [()] if accepted else [] + + result: list[MarkerContext] = [] + seen: set[MarkerContext] = set() + for extra in selected: + for context in options(expression, extra): + if context in seen: + continue + seen.add(context) + result.append(context) + if len(result) > 128: + raise SystemExit("Unbounded locked security dependency edge marker") + return result + + +def published_reachability( + lock: dict[str, Any], contexts: ContextsByName +) -> dict[str, set[tuple[str, str, MarkerContext]]]: + packages: dict[str, list[dict[str, Any]]] = {} + for item in lock["package"]: + if not isinstance(item, dict): + raise SystemExit("Ambiguous locked security dependency identity") + package = cast(dict[str, Any], item) + if not isinstance(package.get("name"), str): + raise SystemExit("Ambiguous locked security dependency identity") + packages.setdefault(canonical(package["name"]), []).append(package) + + pending: list[tuple[str, tuple[str, ...], str, str, MarkerContext, str | None]] = [ + (name, context[2], context[0], context[1], context[3], None) + for name, requirements in contexts.items() + for context in requirements + ] + visited: set[tuple[str, tuple[str, ...], str, str, MarkerContext, str | None]] = set() + reachable: dict[str, set[tuple[str, str, MarkerContext]]] = {} + while pending: + if len(pending) > 4096 or len(visited) > 8192: + raise SystemExit("Unbounded published security dependency graph") + name, extras, scope, group, context, selected_version = pending.pop() + state = name, extras, scope, group, context, selected_version + if state in visited: + continue + visited.add(state) + candidates = packages.get(name, []) + if not candidates: + raise SystemExit("Missing locked published security dependency identity for " + name) + matched = False + for package in candidates: + version = package.get("version") + if not isinstance(version, str): + raise SystemExit("Ambiguous locked published security dependency version") + if selected_version is not None and version != selected_version: + continue + resolutions = package.get("resolution-markers") + if resolutions is None: + domains: list[MarkerContext] = [()] + elif isinstance(resolutions, list) and resolutions: + values = cast(list[object], resolutions) + if not all(isinstance(value, str) for value in values): + raise SystemExit("Ambiguous locked published security dependency resolution") + domains = [marker_context(value) for value in cast(list[str], resolutions)] + else: + raise SystemExit("Ambiguous locked published security dependency resolution") + for domain in domains: + if not marker_overlap(context, domain): + continue + matched = True + combined = tuple(sorted(set(context + domain))) + reachable.setdefault(name, set()).add((scope, group, combined)) + direct_edges = package.get("dependencies", []) + optional_edges = package.get("optional-dependencies", {}) + if not isinstance(direct_edges, list) or not isinstance(optional_edges, dict): + raise SystemExit("Ambiguous locked published security dependency edges") + groups: dict[str, object] = {} + for optional, edges in cast(dict[object, object], optional_edges).items(): + if not isinstance(optional, str): + raise SystemExit("Ambiguous locked published security dependency extra") + key = canonical(optional) + if key in groups: + raise SystemExit("Ambiguous locked published security dependency extra") + groups[key] = edges + edges_to_follow: list[object] = list(cast(list[object], direct_edges)) + for extra in extras: + requested = groups.get(extra, []) + if not isinstance(requested, list): + raise SystemExit("Ambiguous locked published security dependency extra") + edges_to_follow.extend(cast(list[object], requested)) + for item in edges_to_follow: + if not isinstance(item, dict): + raise SystemExit("Ambiguous locked published security dependency edge") + edge = cast(dict[str, Any], item) + if not isinstance(edge.get("name"), str): + raise SystemExit("Ambiguous locked published security dependency edge") + edge_name = canonical(edge["name"]) + if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", edge_name): + raise SystemExit("Ambiguous locked published security dependency edge") + requested_extras = edge.get("extra", []) + if not isinstance(requested_extras, list) or any( + not isinstance(extra, str) for extra in cast(list[object], requested_extras) + ): + raise SystemExit("Ambiguous locked published security dependency edge extra") + normalized_extras = tuple(sorted(canonical(extra) for extra in cast(list[str], requested_extras))) + if len(normalized_extras) != len(set(normalized_extras)) or any( + not re.fullmatch(r"[a-z0-9][a-z0-9-]*", extra) for extra in normalized_extras + ): + raise SystemExit("Ambiguous locked published security dependency edge extra") + edge_version = edge.get("version") + if edge_version is not None and not isinstance(edge_version, str): + raise SystemExit("Ambiguous locked published security dependency edge version") + for edge_context in dependency_marker_options(edge.get("marker"), extras): + next_context = tuple(sorted(set(combined + edge_context))) + if not marker_overlap(next_context, ()): + continue + pending.append((edge_name, normalized_extras, scope, group, next_context, edge_version)) + if not matched and selected_version is not None: + raise SystemExit("Missing selected locked published security dependency version for " + name) + return reachable + + +def requested_extra_reachability( + lock: dict[str, Any], + previous: ContextsByName, + current: ContextsByName, + requested: list[tuple[str, DependencyContext]], +) -> dict[str, set[tuple[str, str, MarkerContext]]]: + if len(requested) > 64: + raise SystemExit("Unbounded newly requested security dependency extras") + result: dict[str, set[tuple[str, str, MarkerContext]]] = {} + for root, context in requested: + selected = published_reachability(lock, {root: {context: current[root][context]}}) + alternatives: ContextRequirements = {} + for original, requirements in previous.get(root, {}).items(): + if ( + original[:2] != context[:2] + or not set(original[2]).issubset(context[2]) + or not marker_overlap(original[3], context[3]) + ): + continue + shared = tuple(sorted(set(original[3] + context[3]))) + counterfactual = original[0], original[1], original[2], shared + alternatives.setdefault(counterfactual, set()).update(requirements) + without = published_reachability(lock, {root: alternatives}) if alternatives else {} + for name, audiences in selected.items(): + for scope, group, marker in audiences: + prior = [ + previous_marker + for previous_scope, previous_group, previous_marker in without.get(name, set()) + if previous_scope == "runtime" or (previous_scope, previous_group) == (scope, group) + ] + for uncovered in uncovered_marker_fragments(marker, prior): + result.setdefault(name, set()).add((scope, group, uncovered)) + return result + + +def audience_covers(previous: tuple[str, str, MarkerContext], current: tuple[str, str, MarkerContext]) -> bool: + if previous[0] != "runtime" and previous[:2] != current[:2]: + return False + return not uncovered_marker_fragments(current[2], [previous[2]]) + + +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 is_numeric_platform_release(value: str) -> bool: + if len(value) > 256: + raise SystemExit("Unbounded platform security dependency marker") + return ( + re.fullmatch( + r"v?(?:[0-9]+!)?[0-9]+(?:\.[0-9]+)*" + r"(?:[._-]?(?:alpha|a|beta|b|preview|pre|c|rc)[._-]?[0-9]*)?" + r"(?:(?:-[0-9]+)|(?:[._-]?(?:post|rev|r)[._-]?[0-9]*))?" + r"(?:[._-]?dev[._-]?[0-9]*)?" + r"(?:\+[a-z0-9]+(?:[._-][a-z0-9]+)*)?" + r"(?:\.\*)?", + value.strip(), + re.IGNORECASE, + ) + is not None + ) + + +def numeric_platform_release_candidate(value: str) -> str | None: + match = re.fullmatch( + r"v?(?P[0-9]+(?:\.[0-9]+)*)" + r"(?:[._-]?(?Palpha|a|beta|b|preview|pre|c|rc)[._-]?(?P[0-9]*))?" + r"(?:(?:-(?P[0-9]+))|" + r"(?:[._-]?(?Ppost|rev|r)[._-]?(?P[0-9]*)))?" + r"(?:[._-]?dev[._-]?(?P[0-9]*))?", + value.strip(), + re.IGNORECASE, + ) + if match is None: + return None + result = match.group("release") + stage = match.group("stage") + if stage is not None: + result += {"alpha": "a", "a": "a", "beta": "b", "b": "b"}.get(stage.lower(), "rc") + result += match.group("serial") or "0" + if match.group("implicit_post") is not None: + result += ".post" + match.group("implicit_post") + elif match.group("post") is not None: + result += ".post" + (match.group("post_serial") or "0") + if match.group("development") is not None: + result += ".dev" + (match.group("development") or "0") + return result + + +def marker_version_bounds( + variable: str, value: str, *, release_width: int = 3 +) -> tuple[tuple[int, ...], tuple[int, ...], bool, bool]: + match = re.fullmatch( + r"(\d+(?:\.\d+)*)(?:(a|b|rc)(\d+))?(?:\.post(\d+))?(?:\.dev(\d+))?(\.\*)?", + value.strip() if variable == "platform_release" else value, + ) + if match is None: + raise SystemExit("Ambiguous Python security dependency marker") + components = match.group(1).split(".") + if len(components) > 32: + if variable == "platform_release": + raise SystemExit("Unbounded platform security dependency marker") + raise SystemExit("Unbounded Python security dependency marker") + if variable != "platform_release" and len(components) < 2: + raise SystemExit("Ambiguous Python security dependency marker") + if any(len(component) > 9 for component in components) or any( + len(match.group(index)) > 9 for index in (3, 4, 5) if match.group(index) is not None + ): + raise SystemExit("Unbounded Python security dependency marker") + + release = tuple(int(component) for component in components) + release += (0,) * max(0, release_width - len(release)) + prerelease = match.group(2) is not None or match.group(5) is not None + wildcard = match.group(6) is not None + if wildcard and (prerelease or match.group(4) is not None): + raise SystemExit("Ambiguous wildcard security dependency marker") + if variable == "python_version" and ( + any(component != 0 for component in release[2:]) or prerelease or match.group(4) is not None + ): + raise SystemExit("Ambiguous Python security dependency marker") + + phase = ( + -4 + if match.group(5) is not None and match.group(2) is None and match.group(4) is None + else {"a": -3, "b": -2, "rc": -1}.get(match.group(2) or "", 0) + ) + serial = int(match.group(3) or 0) + post = -1 if match.group(4) is None else int(match.group(4)) + development = -1 if match.group(5) is not None else 0 + development_serial = int(match.group(5) or 0) + if variable == "python_version" or wildcard: + start = release + (-5, 0, -1, -1, 0) + prefix = 2 if variable == "python_version" else len(components) + stop = release[: prefix - 1] + (release[prefix - 1] + 1,) + (0,) * (len(release) - prefix) + stop += -5, 0, -1, -1, 0 + else: + start = release + (phase, serial, post, development, development_serial) + stop = start[:-1] + (development_serial + 1,) + return start, stop, wildcard, prerelease + + +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(): + platform_memberships = [ + clause for clause in constraints if family == "platform_release" and clause[1] in {"In", "NotIn"} + ] + platform_raw_equalities = [ + clause for clause in constraints if family == "platform_release" and clause[1] == "ArbitraryEq" + ] + platform_raw_exclusions = [ + clause for clause in constraints if family == "platform_release" and clause[1] == "ArbitraryNotEq" + ] + comparisons = [ + clause + for clause in constraints + if family != "platform_release" or clause[1] not in {"In", "NotIn", "ArbitraryEq", "ArbitraryNotEq"} + ] + numeric_platform_values = [ + family == "platform_release" and is_numeric_platform_release(value) for _, _, value in comparisons + ] + if family == "platform_release" and any(numeric_platform_values) and not all(numeric_platform_values): + if any( + not numeric and operator != "NotEq" + for numeric, (_, operator, _) in zip(numeric_platform_values, comparisons, strict=True) + ): + return False + comparisons = [ + clause for numeric, clause in zip(numeric_platform_values, comparisons, strict=True) if numeric + ] + numeric_platform_values = [True] * len(comparisons) + numeric_platform_release = family == "platform_release" and bool(comparisons) and all(numeric_platform_values) + + def platform_witness_matches( + candidate: str, + memberships: list[MarkerClause] = platform_memberships, + exclusions: list[MarkerClause] = platform_raw_exclusions, + ) -> bool: + return all((candidate in value) == (operator == "In") for _, operator, value in memberships) and all( + candidate.lower() != value.lower() for _, _, value in exclusions + ) + + def anchored_platform_witness() -> str | None: + for context in (resolution, requirement): + equalities = [ + value.strip() + for variable, operator, value in context + if variable == "platform_release" and operator == "Eq" and not value.rstrip().endswith(".*") + ] + if not equalities: + continue + local = [ + clause + for clause in context + if clause[0] == "platform_release" and clause[1] in {"In", "NotIn", "ArbitraryNotEq"} + ] + for candidate in equalities: + if all( + (candidate in value) == (operator == "In") + if operator in {"In", "NotIn"} + else candidate.lower() != value.lower() + for _, operator, value in local + ): + return candidate + return None + return None + + if family == "platform_release" and (platform_raw_equalities or platform_raw_exclusions): + if any(len(value) > 256 for _, _, value in platform_raw_equalities + platform_raw_exclusions): + raise SystemExit("Unbounded platform security dependency marker") + if platform_raw_equalities: + raw_values = {value.lower() for _, _, value in platform_raw_equalities} + if len(raw_values) != 1: + return False + anchor = ( + anchored_platform_witness() + if any(variable == "platform_release" and operator == "Eq" for variable, operator, _ in resolution) + and not any( + variable == "platform_release" and operator == "ArbitraryEq" + for variable, operator, _ in resolution + ) + else None + ) + selected = next( + ( + value + for variable, operator, value in resolution + if variable == "platform_release" and operator == "ArbitraryEq" + ), + platform_raw_equalities[0][2], + ) + if anchor is not None: + if anchor.lower() not in raw_values: + return False + selected = anchor + if not platform_witness_matches(selected): + return False + if not comparisons: + continue + if numeric_platform_release: + normalized = numeric_platform_release_candidate(selected) + if normalized is None: + raise SystemExit("Ambiguous platform security dependency raw equality") + selected = normalized + if not simple_marker_overlap(tuple(comparisons), (("platform_release", "Eq", selected),)): + return False + continue + + if numeric_platform_release and platform_memberships: + if any(len(value) > 256 for _, _, value in platform_memberships): + raise SystemExit("Unbounded platform security dependency marker") + anchor = anchored_platform_witness() + included = [value for _, operator, value in platform_memberships if operator == "In"] + if included: + shortest = min(included, key=len) + if len(shortest) > 128: + raise SystemExit("Unbounded platform security dependency membership") + candidates = { + shortest[start:stop] + for start in range(len(shortest) + 1) + for stop in range(start, len(shortest) + 1) + } + if len(candidates) > 4096: + raise SystemExit("Unbounded platform security dependency membership") + unsupported = False + matched = False + for membership_candidate in sorted(candidates): + if ( + anchor is not None + and membership_candidate != anchor + or not platform_witness_matches(membership_candidate) + or not is_numeric_platform_release(membership_candidate) + ): + continue + normalized = numeric_platform_release_candidate(membership_candidate) + if normalized is None: + unsupported = True + continue + try: + _, _, wildcard, _ = marker_version_bounds("platform_release", normalized) + except SystemExit: + unsupported = True + continue + if wildcard: + unsupported = True + continue + if simple_marker_overlap(tuple(comparisons), (("platform_release", "Eq", normalized),)): + matched = True + break + if not matched: + if unsupported: + raise SystemExit("Ambiguous platform security dependency membership") + return False + continue + if anchor is not None: + if not platform_witness_matches(anchor): + return False + normalized = numeric_platform_release_candidate(anchor) + if normalized is None: + raise SystemExit("Ambiguous platform security dependency membership") + if not simple_marker_overlap(tuple(comparisons), (("platform_release", "Eq", normalized),)): + return False + continue + equalities = [value for _, operator, value in comparisons if operator == "Eq" and not value.endswith(".*")] + if equalities: + if not simple_marker_overlap(tuple(comparisons), ()): + return False + matched = False + for numeric_equality in equalities: + normalized = numeric_platform_release_candidate(numeric_equality) + if normalized is None: + raise SystemExit("Ambiguous platform security dependency membership") + match = re.fullmatch(r"(\d+(?:\.\d+)*)(.*)", normalized) + if match is None: + raise SystemExit("Ambiguous platform security dependency membership") + release, suffix = match.groups() + components = release.split(".") + if len(components) > 32: + raise SystemExit("Unbounded platform security dependency membership") + for trailing in range(33 - len(components)): + for leading in range(10 - len(components[0])): + raw_candidate = "0" * leading + release + ".0" * trailing + suffix + if len(raw_candidate) > 256 or not platform_witness_matches(raw_candidate): + continue + if simple_marker_overlap(tuple(comparisons), (("platform_release", "Eq", normalized),)): + matched = True + break + if matched: + break + if matched: + break + if not matched: + raise SystemExit("Ambiguous platform security dependency membership") + continue + elif numeric_platform_release and platform_raw_exclusions: + anchor = anchored_platform_witness() + if anchor is not None and not platform_witness_matches(anchor): + return False + if family in {"python", "implementation_version"} or numeric_platform_release: + if numeric_platform_release: + constraints = comparisons + release_width = max( + 3, + max( + ( + len(match.group().split(".")) + for _, _, value in constraints + if (match := re.match(r"\d+(?:\.\d+)*", value.strip() if numeric_platform_release else value)) + ), + default=3, + ), + ) + lower: tuple[int, ...] = (0,) * release_width + (-5, 0, -1, -1, 0) + upper: tuple[int, ...] | None = None + excluded: list[tuple[tuple[int, ...], tuple[int, ...]]] = [] + for variable, operator, value in constraints: + start, stop, wildcard, prerelease = marker_version_bounds(variable, value, release_width=release_width) + if wildcard and operator not in {"Eq", "NotEq"}: + raise SystemExit("Ambiguous wildcard security dependency marker") + 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 in {"Lt", "RawLt"}: + ceiling = start + if operator == "Lt" and variable != "python_version" and not prerelease: + if start[-3] == -1: + ceiling = start[:-5] + (-5, 0, -1, -1, 0) + else: + ceiling = start[:-2] + (-1, 0) + upper = ceiling if upper is None else min(upper, ceiling) + elif operator in {"LtE", "RawLtE"}: + ceiling = stop + if operator == "RawLtE" and variable != "python_version" and start[-3] == -1 and start[-2] == 0: + if prerelease: + ceiling = start[:-4] + (start[-4] + 1, -1, -1, 0) + else: + ceiling = start[:-6] + (start[-6] + 1, -5, 0, -1, -1, 0) + upper = ceiling if upper is None else min(upper, ceiling) + elif operator in {"Gt", "RawGt"}: + floor = stop + if operator == "Gt" and variable != "python_version" and start[-3] == -1 and start[-2] == 0: + if prerelease: + floor = start[:-4] + (start[-4] + 1, -1, -1, 0) + else: + floor = start[:-6] + (start[-6] + 1, -5, 0, -1, -1, 0) + lower = max(lower, floor) + elif operator in {"GtE", "RawGtE"}: + floor = start + if operator == "RawGtE" and variable != "python_version" and not prerelease: + if start[-3] == -1: + floor = start[:-5] + (-5, 0, -1, -1, 0) + else: + floor = start[:-2] + (-1, 0) + lower = max(lower, floor) + 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_release", + "platform_version", + "platform_python_implementation", + "implementation_name", + "extra", + }: + raise SystemExit("Unsupported security dependency marker variable") + equality: str | None = None + memberships: list[str] = [] + required_substrings: list[str] = [] + for _, operator, value in constraints: + if len(value) > 256: + raise SystemExit("Unbounded platform security dependency marker") + if operator in {"Eq", "GtE", "LtE"}: + if equality is not None and equality != value: + return False + equality = value + elif operator in {"Gt", "Lt"}: + return False + elif operator == "In": + memberships.append(value) + elif operator == "ReverseIn": + required_substrings.append(value) + elif operator not in {"NotEq", "NotIn", "ReverseNotIn", "ArbitraryNotEq"}: + raise SystemExit("Ambiguous platform security dependency marker") + + def matches(platform_candidate: str, terms: list[MarkerClause] = constraints) -> bool: + for _, operator, value in terms: + if ( + operator in {"Eq", "GtE", "LtE"} + and platform_candidate != value + or operator == "NotEq" + and platform_candidate == value + or operator == "ArbitraryNotEq" + and platform_candidate.lower() == value.lower() + or operator in {"Lt", "Gt"} + or operator == "In" + and platform_candidate not in value + or operator == "NotIn" + and platform_candidate in value + or operator == "ReverseIn" + and value not in platform_candidate + or operator == "ReverseNotIn" + and value in platform_candidate + ): + return False + return True + + if equality is not None: + if not matches(equality): + return False + elif memberships: + shortest = min(memberships, key=len) + if len(shortest) > 128: + raise SystemExit("Unbounded platform security dependency membership") + candidates = { + shortest[start:stop] + for start in range(len(shortest) + 1) + for stop in range(start, len(shortest) + 1) + } + if len(candidates) > 4096: + raise SystemExit("Unbounded platform security dependency membership") + if not any(matches(platform_candidate) for platform_candidate in candidates): + return False + else: + if sum(map(len, required_substrings)) > 4096: + raise SystemExit("Unbounded platform security dependency substring") + if any( + forbidden in required + for _, operator, forbidden in constraints + if operator == "ReverseNotIn" + for required in required_substrings + ) or any(operator == "ReverseNotIn" and not value for _, operator, value in constraints): + return False + platform_candidate = "\x00".join(required_substrings) + for _ in range(258): + if matches(platform_candidate): + break + platform_candidate += "\x00" + else: + raise SystemExit("Ambiguous platform security dependency marker") + return True + + +def marker_options(context: MarkerContext) -> list[MarkerContext]: + options: list[MarkerContext] = [()] + allowed_platforms = { + "sys_platform", + "os_name", + "platform_system", + "platform_machine", + "platform_release", + "platform_version", + "platform_python_implementation", + "implementation_name", + "extra", + } + for variable, operator, value in context: + if operator in {"ReverseIn", "ReverseNotIn"}: + if variable not in allowed_platforms or variable == "platform_release": + raise SystemExit("Unsupported reversed security dependency membership marker") + if len(value) > 256 or value and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", value) is None: + raise SystemExit("Ambiguous reversed security dependency membership marker") + options = [option + ((variable, operator, value),) for option in options] + continue + if operator == "Compatible": + if variable not in {"python_version", "python_full_version", "implementation_version", "platform_release"}: + raise SystemExit("Unsupported compatible security dependency marker") + compatible = re.fullmatch( + r"(\d+(?:\.\d+)+)" + r"((?:(?:a|b|rc)\d+)?(?:\.post\d+)?(?:\.dev\d+)?)", + value, + ) + if compatible is None: + raise SystemExit("Ambiguous compatible security dependency marker") + components = compatible.group(1).split(".") + if len(components) > 32 or any(len(component) > 9 for component in components): + raise SystemExit("Ambiguous compatible security dependency marker") + ceiling = [int(component) for component in components[:-1]] + ceiling[-1] += 1 + if len(ceiling) == 1: + ceiling.append(0) + upper = ".".join(str(component) for component in ceiling) + if variable == "python_version": + minor = int(components[1]) + lower = f"{int(components[0])}.{minor}" + width = max(3, len(components)) + projected = marker_version_bounds("platform_release", lower, release_width=width)[0] + actual = marker_version_bounds("platform_release", value, release_width=width)[0] + if projected < actual: + lower = f"{int(components[0])}.{minor + 1}" + if len(ceiling) > 2 and any(component != 0 for component in ceiling[2:]): + upper = f"{ceiling[0]}.{ceiling[1] + 1}" + else: + upper = f"{ceiling[0]}.{ceiling[1]}" + value = lower + options = [option + ((variable, "GtE", value), (variable, "Lt", upper)) for option in options] + continue + if operator == "ArbitraryEq": + if variable == "python_version": + pattern = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + elif variable in {"python_full_version", "implementation_version"}: + pattern = ( + r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + r"(?:(?:a|b|rc)(?:0|[1-9]\d*))?(?:\.post(?:0|[1-9]\d*))?(?:\.dev(?:0|[1-9]\d*))?" + ) + elif variable == "platform_release": + if len(value) > 256 or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.!+-]*", value.strip()) is None: + raise SystemExit("Ambiguous platform security dependency raw equality") + options = [option + ((variable, operator, value.strip()),) for option in options] + continue + else: + raise SystemExit("Unsupported arbitrary security dependency marker variable") + if re.fullmatch(pattern, value) is None: + return [] + operator = "Eq" + 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*)(?:\.0)?" + elif variable in {"python_full_version", "implementation_version"}: + pattern = ( + r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + r"(?:(?:a|b|rc)(?:0|[1-9]\d*))?(?:\.post(?:0|[1-9]\d*))?(?:\.dev(?: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") + if variable not in allowed_platforms and len(value) > 128: + raise SystemExit("Unbounded Python security dependency membership marker") + 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) + if variable in allowed_platforms + else not any( + re.fullmatch(pattern, item[start:stop]) + for start in range(len(item)) + for stop in range(start + 1, len(item) + 1) + ) + for item in values + ) + ): + raise SystemExit("Ambiguous security dependency membership marker") + if variable in allowed_platforms: + options = [option + ((variable, operator, value),) for option in options] + else: + members = tuple( + sorted( + { + value[start:stop] + for start in range(len(value)) + for stop in range(start + 1, len(value) + 1) + if re.fullmatch(pattern, value[start:stop]) + } + ) + ) + if len(members) > 64: + raise SystemExit("Unbounded Python security dependency membership marker") + if operator == "In": + options = [option + ((variable, "Eq", member),) for option in options for member in members] + else: + exclusions = tuple((variable, "NotEq", member) for member in members) + 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 uncovered_marker_fragments( + domain: MarkerContext, coverings: list[MarkerContext], *, anchor_platform_release: bool = False +) -> tuple[MarkerContext, ...]: + platform_equalities = [ + value.strip() + for variable, operator, value in domain + if variable == "platform_release" and operator == "Eq" and not value.rstrip().endswith(".*") + ] + raw_coverings = [ + covering + for covering in coverings + if any( + variable == "platform_release" and operator in {"In", "NotIn", "ArbitraryEq"} + for variable, operator, _ in covering + ) + ] + if ( + anchor_platform_release + and len(platform_equalities) == 1 + and raw_coverings + and not any( + variable == "platform_release" and operator in {"In", "NotIn", "ArbitraryEq", "ArbitraryNotEq"} + for variable, operator, _ in domain + ) + ): + domain = tuple(sorted(domain + (("platform_release", "ArbitraryEq", platform_equalities[0]),))) + opposite = { + "Eq": "NotEq", + "NotEq": "Eq", + "Lt": "GtE", + "LtE": "Gt", + "Gt": "LtE", + "GtE": "Lt", + "RawLt": "GtE", + "RawLtE": "Gt", + "RawGt": "LtE", + "RawGtE": "Lt", + "In": "NotIn", + "NotIn": "In", + "ReverseIn": "ReverseNotIn", + "ReverseNotIn": "ReverseIn", + "ArbitraryEq": "ArbitraryNotEq", + "ArbitraryNotEq": "ArbitraryEq", + } + fragments = {tuple(sorted(set(option))) for option in marker_options(domain) if simple_marker_overlap(option, ())} + work = 0 + for covering in coverings: + options = marker_options(covering) + if len(fragments) * len(options) > 128: + raise SystemExit("Ambiguous security dependency marker partition") + for option in options: + remaining: set[MarkerContext] = set() + for fragment in fragments: + work += 1 + if work > 2048: + raise SystemExit("Ambiguous security dependency marker partition") + if not simple_marker_overlap(option, fragment): + remaining.add(fragment) + continue + prefix = fragment + for variable, operator, value in option: + if operator not in opposite: + raise SystemExit("Ambiguous security dependency marker partition") + inverse = opposite[operator] + if variable in {"python_full_version", "implementation_version"} or ( + variable == "platform_release" and is_numeric_platform_release(value) + ): + if operator == "GtE": + inverse = "RawLt" + elif operator == "Gt": + inverse = "RawLtE" + elif operator == "Lt": + inverse = "RawGtE" + elif operator == "LtE": + inverse = "RawGt" + elif variable != "python_version" and operator in {"GtE", "LtE"}: + inverse = "NotEq" + excluded = tuple(sorted(set(prefix + ((variable, inverse, value),)))) + if simple_marker_overlap(excluded, ()): + remaining.add(excluded) + prefix = tuple(sorted(set(prefix + ((variable, operator, value),)))) + if len(remaining) > 128 or len(prefix) > 128: + raise SystemExit("Ambiguous security dependency marker partition") + fragments = remaining + if not fragments: + break + return tuple(sorted(fragments)) + + +def reconcile_resolution_domains( + previous: ResolutionDomains, current: ResolutionDomains +) -> tuple[ResolutionDomains, ResolutionDomains]: + if previous == current: + return previous, current + domains = sorted(previous.keys() | current.keys()) + if len(domains) > 128: + raise SystemExit("Unbounded security dependency resolution-domain refinement") + explicit_prerelease = any( + variable in {"python_full_version", "implementation_version", "platform_release"} + and re.search(r"\d+\.\d+(?:\.\d+)?(?:a|b|rc)\d+", value) is not None + for domain in domains + for variable, _, value in domain + ) + fragments: set[MarkerContext] = set() + work = 0 + for domain in domains: + for option in marker_options(domain): + candidate = tuple(sorted(set(option))) + if not simple_marker_overlap(candidate, ()): + raise SystemExit("Ambiguous security dependency resolution-domain refinement") + remaining: set[MarkerContext] = {candidate} + refined: set[MarkerContext] = set() + for fragment in fragments: + work += 1 + if work > 4096: + raise SystemExit("Unbounded security dependency resolution-domain refinement") + if not simple_marker_overlap(fragment, candidate): + refined.add(fragment) + continue + intersection = tuple(sorted(set(fragment + candidate))) + refined.add(intersection) + refined.update(uncovered_marker_fragments(fragment, [candidate])) + remaining = { + uncovered for value in remaining for uncovered in uncovered_marker_fragments(value, [fragment]) + } + if len(refined) + len(remaining) > 128: + raise SystemExit("Unbounded security dependency resolution-domain refinement") + refined.update(remaining) + if len(refined) > 128: + raise SystemExit("Unbounded security dependency resolution-domain refinement") + fragments = refined + aligned_previous: ResolutionDomains = {} + aligned_current: ResolutionDomains = {} + for fragment in fragments: + old = { + release for domain, releases in previous.items() if marker_overlap(domain, fragment) for release in releases + } + new = { + release for domain, releases in current.items() if marker_overlap(domain, fragment) for release in releases + } + if bool(old) != bool(new): + if not explicit_prerelease: + earliest = { + (variable, marker_version_bounds(variable, value)[0][:-5]) + for variable, operator, value in fragment + if operator == "RawGtE" + } + final = { + (variable, marker_version_bounds(variable, value)[0][:-5]) + for variable, operator, value in fragment + if operator == "RawLt" + } + if earliest & final: + continue + raise SystemExit("Do not remove or widen a locked security dependency resolution domain") + if old: + aligned_previous[fragment] = old + aligned_current[fragment] = new + return aligned_previous, aligned_current + + +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 exact: + matches = [value.strip().removesuffix(".*") for value in matches] + else: + matches.extend(re.findall(r"(?=!~])==\s*((?:(?:\d+)!)?\d+(?:\.\d+)*)\.\*(?=\s*(?:,|$))", specifier)) + if not matches: + if allow_missing: + continue + raise SystemExit("Missing or ambiguous direct security dependency minimum") + bounds = published_bounds(requirement) + lower = [bound for bound in bounds if bound[0] in {">=", ">"}] + pinned = [bound for bound in bounds if bound[0] in {"==", "==="}] + for _, epoch, components, post, _ in pinned: + if not allows_published_release(bounds, (epoch, components, post)): + raise SystemExit("Missing or ambiguous direct security dependency minimum") + for operator, epoch, components, post, _ in lower: + release = epoch, components, post + for upper_operator, upper_epoch, upper_components, upper_post, _ in bounds: + if upper_operator not in {"<", "<="}: + continue + upper = upper_epoch, upper_components, upper_post + if ( + release > upper + or release == upper + and (operator == ">" or upper_operator == "<") + or operator == ">" + and post == -1 + and upper_epoch == epoch + and upper_components == components + ): + raise SystemExit("Missing or ambiguous direct security dependency minimum") + floors = lower + pinned if exact else lower + if not floors: + raise SystemExit("Missing or ambiguous direct security dependency minimum") + result.append(max((epoch, components, post) for _, epoch, components, post, _ in floors)) + 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") + if not match.group(3).strip(): + return True + bounds = published_bounds(requirement) + if upper_only: + bounds = tuple(bound for bound in bounds if bound[0] not in {">=", ">"}) + if allows_published_release(bounds, release): + return True + return False + + +def unchanged_nonfloor_bounds(requirement: str) -> tuple[PublishedBound, ...]: + return tuple(sorted(bound for bound in published_bounds(requirement) if bound[0] not in {">=", ">", "==", "==="})) + + +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 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] and marker_overlap(previous_context[3], context[3]) + } + if len(replacements) < 2: + return {} + for context, requirements in replacements.items(): + if len(requirements) != 1 or uncovered_marker_fragments(context[3], [previous_context[3]]): + 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 marker_overlap(previous_context[3], domain) + and any(matches_protected_release(previous_requirements, stable_version(version)) for version in versions) + } + if len(relevant) < 2: + return {} + covered: set[DependencyContext] = set() + for domain in relevant: + original_domain = tuple(sorted(set(previous_context[3] + domain))) + matched = [context for context in replacements if marker_overlap(context[3], original_domain)] + if not matched or uncovered_marker_fragments(original_domain, [context[3] for context in matched]): + return {} + if any( + replacements[first] != replacements[second] + and marker_overlap(tuple(sorted(set(first[3] + second[3]))), original_domain) + for index, first in enumerate(matched) + for second in matched[index + 1 :] + ): + return {} + covered.update(matched) + return replacements if covered == set(replacements) else {} + + +def preserves_requirement_source_markers( + previous_contexts: ContextRequirements, + current_contexts: ContextRequirements, + domains: ResolutionDomains, + current_domains: ResolutionDomains, + *, + extra_review: bool = False, +) -> tuple[bool, bool]: + previous_sources: dict[RequirementSource, list[MarkerContext]] = {} + current_sources: dict[RequirementSource, list[MarkerContext]] = {} + for context, requirements in previous_contexts.items(): + for requirement in requirements: + previous_sources.setdefault((*context[:3], requirement), []).append(context[3]) + for context, requirements in current_contexts.items(): + for requirement in requirements: + current_sources.setdefault((*context[:3], requirement), []).append(context[3]) + previous = list(previous_sources.items()) + current = list(current_sources.items()) + if len(previous) > 64 or len(current) > 64 or len(previous) * len(current) > 2048: + raise SystemExit("Unbounded security dependency source marker assignment") + + def source_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 security dependency source requirement") + return published_bounds(requirement) if match.group(3).strip() else () + + def source_floor(requirement: str) -> StableRelease | None: + floors = [ + (epoch, release, post) + for operator, epoch, release, post, _ in source_bounds(requirement) + if operator in {">=", ">", "==", "==="} + ] + return max(floors, default=None) + + def compatible(original: str, replacement: str) -> bool: + if original == replacement: + return True + before, after = source_floor(original), source_floor(replacement) + if before is not None and (after is None or after < before): + return False + original_bounds = source_bounds(original) + replacement_bounds = source_bounds(replacement) + if any(bound[0] == "===" for bound in replacement_bounds) and not any( + bound[0] == "===" for bound in original_bounds + ): + return False + return all( + preserves_published_security_bound(bound, replacement_bounds) + for bound in original_bounds + if bound[0] not in {">=", ">", "==", "==="} + ) + + edges: dict[int, list[int]] = {} + for index, (identity, markers) in enumerate(previous): + matching: list[int] = [] + for candidate, (updated, updated_markers) in enumerate(current): + if updated[:3] != identity[:3] or not compatible(identity[3], updated[3]): + continue + if any(uncovered_marker_fragments(marker, updated_markers) for marker in markers): + continue + if any(uncovered_marker_fragments(marker, markers) for marker in updated_markers): + continue + matching.append(candidate) + if matching: + edges[index] = matching + + assigned: dict[int, int] = {} + + def claim(index: int, visited: set[int]) -> bool: + for candidate in edges[index]: + if candidate in visited: + continue + visited.add(candidate) + original = assigned.get(candidate) + if original is None or claim(original, visited): + assigned[candidate] = index + return True + return False + + for index in sorted(edges, key=lambda value: len(edges[value])): + if not claim(index, set()): + return False, False + + partitioned = False + for index, (identity, markers) in enumerate(previous): + if index in assigned.values(): + continue + original = identity[3] + if source_floor(original) is None: + return False, False + candidates = [ + (candidate, updated_markers) + for candidate, (updated, updated_markers) in enumerate(current) + if candidate not in assigned + and updated[:3] == identity[:3] + and compatible(original, updated[3]) + and all(not uncovered_marker_fragments(marker, markers) for marker in updated_markers) + ] + if len(candidates) < 2: + return False, False + regions = { + tuple(sorted(set(marker + domain))) + for marker in markers + for domain, releases in domains.items() + if marker_overlap(marker, domain) + and any(matches_protected_release({original}, stable_version(release)) for release in releases) + } + if not regions: + return False, False + covered: set[int] = set() + for region in regions: + applicable = [ + (candidate, marker) + for candidate, updated_markers in candidates + for marker in updated_markers + if marker_overlap(marker, region) + ] + if not applicable or uncovered_marker_fragments(region, [marker for _, marker in applicable]): + return False, False + if any( + first != second and marker_overlap(tuple(sorted(set(left + right))), region) + for position, (first, left) in enumerate(applicable) + for second, right in applicable[position + 1 :] + ): + return False, False + covered.update(candidate for candidate, _ in applicable) + if covered != {candidate for candidate, _ in candidates}: + return False, False + for candidate, _ in candidates: + assigned[candidate] = index + partitioned = True + for candidate, (identity, markers) in enumerate(current): + if candidate in assigned: + continue + if identity[0] in {"runtime", "optional"} and identity[2]: + added_bounds = source_bounds(identity[3]) + for bound in added_bounds: + reviewed_markers = [ + marker + for original, original_markers in previous + if (original[0] == "runtime" or original[:2] == identity[:2]) + and set(original[2]).issubset(identity[2]) + and preserves_published_security_bound(bound, source_bounds(original[3])) + for marker in original_markers + ] + if any(uncovered_marker_fragments(marker, reviewed_markers) for marker in markers): + return False, False + continue + floor = source_floor(identity[3]) + if floor is None: + return False, False + bounds = source_bounds(identity[3]) + reviewed = False + for domain in domains.keys() | current_domains.keys(): + previous_versions = domains.get(domain, set()) + updated_versions = current_domains.get(domain, set()) + if previous_versions == updated_versions or not any(marker_overlap(marker, domain) for marker in markers): + continue + removed = sorted(stable_version(version) for version in previous_versions - updated_versions) + patched = sorted(stable_version(version) for version in updated_versions - previous_versions) + if not removed or len(removed) != len(patched): + continue + if any( + updated > previous + and floor >= updated + and allows_published_release(bounds, updated) + and not allows_published_release(bounds, previous) + for previous, updated in zip(removed, patched, strict=True) + ): + reviewed = True + break + if not reviewed and extra_review: + reviewed = any( + any(marker_overlap(marker, domain) for marker in markers) + and any( + floor >= stable_version(version) and allows_published_release(bounds, stable_version(version)) + for version in releases + ) + for domain, releases in current_domains.items() + ) + if not reviewed: + return False, False + return True, partitioned + + +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 removed or len(removed) != len(introduced): + return False + validated: dict[StableRelease, DependencyContext] = {} + for previous_release, patched_release in zip(removed, introduced, strict=True): + if ( + patched_release <= previous_release + or patched_release[0] != previous_release[0] + or not patched_release[1] + or not previous_release[1] + or patched_release[1][0] != previous_release[1][0] + ): + return False + for context, previous_requirements in previous_contexts.items(): + requirements = current_contexts.get(context, set()) + if ( + context in validated.values() + or 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(bound[0] in {"<", "<="} for bound 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 and len(after) == 1 and after[0] >= patched_release and after[0] > before[0]: + validated[patched_release] = context + break + if patched_release not in validated: + return False + for patched_release, context in validated.items(): + candidates = [ + (release, other) + for release, other in validated.items() + if release != patched_release and other != context + ] + candidates.extend( + (release, other) + for release in unchanged + for other, protected in previous_contexts.items() + if other != context + and marker_overlap(other[3], domain) + and other in current_contexts + and matches_protected_release(protected, release) + and matches_protected_release(current_contexts[other], release) + ) + if not any( + not matches_protected_release(previous_contexts[context], release) + and matches_protected_release(current_contexts[other], release) + for release, other in candidates + ): + return False + observed = True + 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") + name = canonical(match.group(1)) + 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) not in {"!=", "=="} or match.group(5) is not None): + raise SystemExit("Ambiguous published security dependency wildcard") + epoch, release, post = stable_version(match.group(2)) + if match.group(1) == "===" and any( + stable_version(version) == (epoch, release, post) and version != match.group(2) + for version in old_versions.get(name, set()) | new_versions.get(name, set()) + ): + raise SystemExit("Arbitrary-equality security dependency pin does not match its raw locked release") + prefix = tuple(int(component) for component in components) + if match.group(1) == "==" and wildcard: + ceiling = prefix[:-1] + (prefix[-1] + 1,) + upper = stable_version(str(epoch) + "!" + ".".join(str(part) for part in ceiling)) + result.extend(((">=", epoch, release, post, False), ("<", upper[0], upper[1], upper[2], False))) + elif match.group(1) == "~=": + if len(prefix) < 2 or wildcard: + raise SystemExit("Ambiguous compatible published security dependency bound") + ceiling = prefix[:-2] + (prefix[-2] + 1,) + upper = stable_version(str(epoch) + "!" + ".".join(str(part) for part in ceiling)) + result.extend(((">=", epoch, release, post, False), ("<", upper[0], upper[1], upper[2], False))) + else: + 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 post < 0 and release[0] == epoch and release[1] == components and release[2] >= 0) + or operator == "<=" + and release > bound + or operator == "<" + and release >= bound + or operator in {"==", "==="} + 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 ( + operator == ">" + and post < 0 + and candidate_epoch == epoch + and candidate == components + and candidate_post >= 0 + ): + continue + 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 in {"==", "==="}: + 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 in {"==", "==="}: + 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 not in {"==", "==="} 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( + candidate_operator == operator and not candidate_wildcard and (epoch, components, post) == patched + for candidate_operator, epoch, components, post, candidate_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] in {"==", "==="} + 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 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))) + preserved = retained | {release for release in patched if release != new} + if not excludes_affected_published_branch(before, after, old, new, preserved): + return False + covered = True + if not covered: + return False + observed = True + return observed + + +def covers_transitive_security_release( + requirements: ContextRequirements, + domain: MarkerContext, + fragments: tuple[MarkerContext, ...], + removed: StableRelease, + patched: StableRelease, + current_domains: ResolutionDomains, +) -> bool: + covered: list[MarkerContext] = [] + for context, declarations in requirements.items(): + if not any(marker_overlap(context[3], fragment) for fragment in fragments): + continue + for requirement in declarations: + bounds = published_bounds(requirement) + floors = minimums({requirement}, allow_missing=True, exact=True) + reviewed_floor = len(floors) == 1 and floors[0] >= patched + reviewed_series = any( + operator == "!=" + and wildcard + and epoch == removed[0] + and len(prefix) <= len(removed[1]) + and removed[1][: len(prefix)] == prefix + for operator, epoch, prefix, _, wildcard in bounds + ) + if not reviewed_floor and not reviewed_series: + continue + if not allows_published_release(bounds, patched) or allows_published_release(bounds, removed): + continue + if any( + (other != domain or not reviewed_floor) + 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 + covered.append(context[3]) + return bool(covered) and all( + not uncovered_marker_fragments(fragment, covered, anchor_platform_release=True) for fragment in fragments + ) + + +def preserves_additive_supported_releases( + context: DependencyContext, + bounds: tuple[PublishedBound, ...], + previous_contexts: ContextRequirements, + previous_domains: ResolutionDomains, + current_domains: ResolutionDomains, +) -> bool: + global_scope = context[0] in {"uv-constraint", "uv-build-constraint"} + for current_domain, versions in current_domains.items(): + if not marker_overlap(context[3], current_domain): + continue + for version in versions: + release = stable_version(version) + if allows_published_release(bounds, release): + continue + for previous_domain, previous_versions in previous_domains.items(): + if not any(stable_version(previous) == release for previous in previous_versions): + continue + shared = tuple(sorted(context[3] + previous_domain)) + if not marker_overlap(shared, current_domain): + continue + if global_scope: + return False + for original, declarations in previous_contexts.items(): + if original[:2] != context[:2] or not matches_protected_release(declarations, release): + continue + if marker_overlap(tuple(sorted(shared + original[3])), current_domain): + return False + return True + + +def reviewed_additive_protected_contexts( + previous_contexts: ContextRequirements, + current_contexts: ContextRequirements, + previous_domains: ResolutionDomains, + current_domains: ResolutionDomains, +) -> bool: + additions = set(current_contexts) - set(previous_contexts) + if not additions: + return False + for context in additions: + for requirement in current_contexts[context]: + floors = minimums({requirement}, allow_missing=True, exact=True) + if len(floors) != 1: + return False + bounds = published_bounds(requirement) + reviewed = 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 or not marker_overlap(context[3], domain): + continue + removed = sorted(stable_version(version) for version in previous_versions - current_versions) + patched = sorted(stable_version(version) for version in current_versions - previous_versions) + if len(removed) != len(patched): + continue + for previous, current in zip(removed, patched, strict=True): + if ( + current <= previous + or floors[0] < current + or not allows_published_release(bounds, current) + or allows_published_release(bounds, previous) + ): + continue + prior = [ + original[3] + for original, declarations in previous_contexts.items() + for declaration in declarations + if minimums({declaration}, allow_missing=True, exact=True) + and matches_protected_release({declaration}, previous) + ] + fragments = uncovered_marker_fragments(domain, prior) + if not any(marker_overlap(context[3], fragment) for fragment in fragments): + 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() + ) or not preserves_additive_supported_releases( + context, bounds, previous_contexts, previous_domains, current_domains + ): + continue + reviewed = True + break + if reviewed: + break + if not reviewed: + return False + return True + + +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) +for name in old_resolution_contexts.keys() & new_resolution_contexts.keys(): + old_resolution_contexts[name], new_resolution_contexts[name] = reconcile_resolution_domains( + old_resolution_contexts[name], new_resolution_contexts[name] + ) +old_protected, old_protected_contexts = direct(old_project, protected=True) +new_protected, new_protected_contexts = direct(new_project, protected=True) +new_requested_extra_contexts: list[tuple[str, DependencyContext]] = [] +for name, contexts in new_contexts.items(): + previous_contexts = old_contexts.get(name, {}) + for context in contexts: + if context[2] and not any( + previous[:2] == context[:2] and set(context[2]).issubset(previous[2]) and previous[3] == context[3] + for previous in previous_contexts + ): + new_requested_extra_contexts.append((name, context)) +previous_reachable: dict[str, set[tuple[str, str, MarkerContext]]] = {} +extra_reachable: dict[str, set[tuple[str, str, MarkerContext]]] = {} +newly_exposed: set[str] = set() +if new_requested_extra_contexts: + previous_reachable = published_reachability(old_lock, old_contexts) + extra_reachable = requested_extra_reachability(new_lock, old_contexts, new_contexts, new_requested_extra_contexts) + newly_exposed = { + name + for name, audiences in extra_reachable.items() + for audience in audiences + if not any(audience_covers(previous, audience) for previous in previous_reachable.get(name, set())) + and not any( + root == name and audience_covers((context[0], context[1], context[3]), audience) + for root, context in new_requested_extra_contexts + ) + } +partitioned_security_sources: set[tuple[bool, str]] = set() +for is_protected, previous_by_name, current_by_name in ( + (False, old_contexts, new_contexts), + (True, old_protected_contexts, new_protected_contexts), +): + for name, previous_contexts in previous_by_name.items(): + preserved, partitioned = preserves_requirement_source_markers( + previous_contexts, + current_by_name.get(name, {}), + old_resolution_contexts.get(name, {}), + new_resolution_contexts.get(name, {}), + extra_review=is_protected and name in newly_exposed, + ) + if not preserved: + message = ( + "Do not widen or narrow an existing security dependency marker for " + if is_protected + else "Do not remove a published direct dependency or its original context for " + ) + raise SystemExit(message + name) + if partitioned: + partitioned_security_sources.add((is_protected, name)) +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()) + or (True, name) in partitioned_security_sources + ) + 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) + ): + if ( + name in old_direct + or name in new_direct + or not reviewed_additive_protected_contexts( + previous_contexts, current_contexts, previous_domains, current_domains + ) + ): + 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, {}) + 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): + protected = [ + context[3] + for context, declarations in previous_contexts.items() + for declaration in declarations + if minimums({declaration}, allow_missing=True, exact=True) + and matches_protected_release({declaration}, removed_release) + ] + fragments = uncovered_marker_fragments(domain, protected, anchor_platform_release=True) + if fragments and not covers_transitive_security_release( + new_protected_contexts.get(name, {}), + domain, + fragments, + removed_release, + patched_release, + current_domains, + ): + raise SystemExit("Add a reviewed contextual transitive security dependency boundary for " + name) + +if new_requested_extra_contexts: + for name in newly_exposed: + reviewed_contexts = new_protected_contexts.get(name, {}) | new_contexts.get(name, {}) + for domain, domain_versions in new_resolution_contexts.get(name, {}).items(): + exposed: set[tuple[str, str, MarkerContext]] = set() + for scope, group, marker in extra_reachable.get(name, set()): + if not marker_overlap(marker, domain): + continue + shared = tuple(sorted(set(marker + domain))) + prior = [ + previous_marker + for previous_scope, previous_group, previous_marker in previous_reachable.get(name, set()) + if previous_scope == "runtime" or (previous_scope, previous_group) == (scope, group) + ] + exposed.update((scope, group, fragment) for fragment in uncovered_marker_fragments(shared, prior)) + if not exposed: + continue + for version in domain_versions: + release = stable_version(version) + for scope, group, fragment in exposed: + extra_coverings: list[MarkerContext] = [] + for context, requirements in reviewed_contexts.items(): + if context[0] == "optional" and context[:2] != (scope, group): + continue + if not marker_overlap(context[3], fragment): + continue + for requirement in requirements: + floors = minimums({requirement}, allow_missing=True, exact=True) + if ( + len(floors) == 1 + and floors[0] >= release + and allows_published_release(published_bounds(requirement), release) + ): + extra_coverings.append(context[3]) + if not extra_coverings or uncovered_marker_fragments( + fragment, extra_coverings, anchor_platform_release=True + ): + raise SystemExit( + "Review the contextual dependency introduced by a newly requested extra 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: + mapped_contexts = direct_replacements.get(name, {}) + retained_requirements = { + requirement + for replacements in mapped_contexts.values() + for updated in replacements.values() + for requirement in updated + } + updated_minimums = minimums(retained_requirements or requirements) + split = ( + any(context not in replacements for context, replacements in mapped_contexts.items()) + or (False, name) in partitioned_security_sources + ) + 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/src/openai/_client.py b/src/openai/_client.py index 9cf48b5d28..7e1daecae8 100644 --- a/src/openai/_client.py +++ b/src/openai/_client.py @@ -40,7 +40,9 @@ AsyncX509WorkloadIdentityAuth, validate_x509_api_url, is_x509_workload_identity, + x509_data_residency_base_url, validate_x509_api_credentials, + x509_safe_environment_headers, validate_x509_request_authority, ) from ._exceptions import OpenAIError, APIStatusError @@ -127,6 +129,8 @@ class OpenAI(SyncAPIClient): _provider: _Provider | None _provider_runtime: _ProviderRuntime | None _base_url_was_default: bool + _data_residency: DataResidency | None + _ambient_authorizations: frozenset[str] websocket_base_url: str | httpx2.URL | None """Base URL for WebSocket connections. @@ -148,6 +152,7 @@ def base_url(self, url: httpx2.URL | str) -> None: validate_x509_api_url(normalized_url) self._base_url = self._enforce_trailing_slash(normalized_url) self._base_url_was_default = False + self._data_residency = None def __init__( self, @@ -197,6 +202,7 @@ def __init__( base_url = resolve_data_residency( data_residency, base_url, provider=provider, websocket_base_url=websocket_base_url ) + base_url = x509_data_residency_base_url(base_url, data_residency, workload_identity) provider_runtime: _ProviderRuntime | None = None if provider is not None: provider_name = _provider_name(provider) @@ -294,11 +300,13 @@ def __init__( elif base_url is None: base_url = os.environ.get("OPENAI_BASE_URL") self._base_url_was_default = provider_runtime is None and base_url is None + self._data_residency = data_residency if base_url is None: base_url = MTLS_API_BASE_URL if x509_identity is not None else "https://api.openai.com/v1" if x509_identity is not None: validate_x509_api_url(base_url) + self._ambient_authorizations = frozenset() custom_headers_env = os.environ.get("OPENAI_CUSTOM_HEADERS") if provider_runtime is None else None if custom_headers_env is not None: parsed: dict[str, str] = {} @@ -306,7 +314,18 @@ def __init__( colon = line.find(":") if colon >= 0: parsed[line[:colon].strip()] = line[colon + 1 :].strip() - default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + explicit_headers: Mapping[str, str] = default_headers if is_mapping_t(default_headers) else {} + explicit_authorization = any(name.lower() == "authorization" for name in explicit_headers) + if explicit_authorization: + parsed = {name: value for name, value in parsed.items() if name.lower() != "authorization"} + elif x509_identity is None: + self._ambient_authorizations = frozenset( + value for name, value in parsed.items() if name.lower() == "authorization" + ) + default_headers = { + **x509_safe_environment_headers(parsed, x509_identity), + **explicit_headers, + } super().__init__( version=__version__, @@ -523,7 +542,11 @@ def _send_with_auth_retry( kwargs["follow_redirects"] = x509_auth._follow_redirects authorization = request.headers.get("Authorization") if authorization == f"Bearer {WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER}": - used_access_token = x509_auth.get_token() + used_access_token = ( + x509_auth.get_token_for_request(request) + if isinstance(x509_auth, SyncX509WorkloadIdentityAuth) + else x509_auth.get_token() + ) request.headers["Authorization"] = f"Bearer {used_access_token}" request_is_replayable = x509_auth._can_retry_request(request) @@ -690,7 +713,19 @@ def copy( inherited_project = None if provider_changed else self.project headers: Mapping[str, str] = {} if provider_changed else self._custom_headers + if ( + is_x509_workload_identity(workload_identity) + and not is_x509_workload_identity(self.workload_identity) + and self._ambient_authorizations + ): + headers = { + name: value + for name, value in headers.items() + if name.lower() != "authorization" or value not in self._ambient_authorizations + } if default_headers is not None: + if any(name.lower() == "authorization" for name in default_headers): + headers = {name: value for name, value in headers.items() if name.lower() != "authorization"} headers = {**headers, **default_headers} elif set_default_headers is not None: headers = set_default_headers @@ -704,9 +739,23 @@ def copy( http_client = http_client or self._client next_provider = self._provider if isinstance(provider, NotGiven) else provider + explicit_base_url = base_url is not None and not isinstance(base_url, NotGiven) + next_workload_identity = workload_identity if workload_identity is not None else self.workload_identity + if api_key is not None and workload_identity is None: + next_workload_identity = None + current_x509 = is_x509_workload_identity(self.workload_identity) + next_x509 = is_x509_workload_identity(next_workload_identity) + mode_changed = current_x509 != next_x509 + effective_data_residency = data_residency + if effective_data_residency is None and mode_changed and not explicit_base_url: + effective_data_residency = self._data_residency base_url = resolve_data_residency( - data_residency, base_url, provider=next_provider, websocket_base_url=websocket_base_url + effective_data_residency, + not_given if base_url is None and data_residency is None else base_url, + provider=next_provider, + websocket_base_url=websocket_base_url, ) + base_url = x509_data_residency_base_url(base_url, effective_data_residency, next_workload_identity) preserve_default_base_url = False auth_options: dict[str, Any] if next_provider is not None: @@ -725,12 +774,6 @@ def copy( "base_url": base_url, } else: - next_workload_identity = workload_identity if workload_identity is not None else self.workload_identity - if api_key is not None and workload_identity is None: - next_workload_identity = None - current_x509 = is_x509_workload_identity(self.workload_identity) - next_x509 = is_x509_workload_identity(next_workload_identity) - mode_changed = current_x509 != next_x509 inherited_base_url = None if mode_changed and self._base_url_was_default else self.base_url preserve_default_base_url = base_url is None and not mode_changed and self._base_url_was_default auth_options = { @@ -758,6 +801,23 @@ def copy( ) if preserve_default_base_url: copied._base_url_was_default = True + overridden_authorizations = default_headers if default_headers is not None else set_default_headers + explicit_authorization_override = overridden_authorizations is not None and any( + name.lower() == "authorization" for name in overridden_authorizations + ) + if ( + self._ambient_authorizations + and not explicit_authorization_override + and any( + name.lower() == "authorization" and value in self._ambient_authorizations + for name, value in copied._custom_headers.items() + ) + ): + copied._ambient_authorizations = self._ambient_authorizations + if data_residency is not None: + copied._data_residency = data_residency + elif not explicit_base_url and not provider_changed: + copied._data_residency = self._data_residency return copied # Alias for `copy` for nicer inline usage, e.g. @@ -811,6 +871,8 @@ class AsyncOpenAI(AsyncAPIClient): _provider: _Provider | None _provider_runtime: _ProviderRuntime | None _base_url_was_default: bool + _data_residency: DataResidency | None + _ambient_authorizations: frozenset[str] websocket_base_url: str | httpx2.URL | None """Base URL for WebSocket connections. @@ -832,6 +894,7 @@ def base_url(self, url: httpx2.URL | str) -> None: validate_x509_api_url(normalized_url) self._base_url = self._enforce_trailing_slash(normalized_url) self._base_url_was_default = False + self._data_residency = None def __init__( self, @@ -881,6 +944,7 @@ def __init__( base_url = resolve_data_residency( data_residency, base_url, provider=provider, websocket_base_url=websocket_base_url ) + base_url = x509_data_residency_base_url(base_url, data_residency, workload_identity) provider_runtime: _ProviderRuntime | None = None if provider is not None: provider_name = _provider_name(provider) @@ -978,11 +1042,13 @@ def __init__( elif base_url is None: base_url = os.environ.get("OPENAI_BASE_URL") self._base_url_was_default = provider_runtime is None and base_url is None + self._data_residency = data_residency if base_url is None: base_url = MTLS_API_BASE_URL if x509_identity is not None else "https://api.openai.com/v1" if x509_identity is not None: validate_x509_api_url(base_url) + self._ambient_authorizations = frozenset() custom_headers_env = os.environ.get("OPENAI_CUSTOM_HEADERS") if provider_runtime is None else None if custom_headers_env is not None: parsed: dict[str, str] = {} @@ -990,7 +1056,18 @@ def __init__( colon = line.find(":") if colon >= 0: parsed[line[:colon].strip()] = line[colon + 1 :].strip() - default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + explicit_headers: Mapping[str, str] = default_headers if is_mapping_t(default_headers) else {} + explicit_authorization = any(name.lower() == "authorization" for name in explicit_headers) + if explicit_authorization: + parsed = {name: value for name, value in parsed.items() if name.lower() != "authorization"} + elif x509_identity is None: + self._ambient_authorizations = frozenset( + value for name, value in parsed.items() if name.lower() == "authorization" + ) + default_headers = { + **x509_safe_environment_headers(parsed, x509_identity), + **explicit_headers, + } super().__init__( version=__version__, @@ -1207,7 +1284,11 @@ async def _send_with_auth_retry( kwargs["follow_redirects"] = x509_auth._follow_redirects authorization = request.headers.get("Authorization") if authorization == f"Bearer {WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER}": - used_access_token = await x509_auth.get_token_async() + used_access_token = ( + await x509_auth.get_token_for_request(request) + if isinstance(x509_auth, AsyncX509WorkloadIdentityAuth) + else await x509_auth.get_token_async() + ) request.headers["Authorization"] = f"Bearer {used_access_token}" request_is_replayable = x509_auth._can_retry_request(request) @@ -1387,7 +1468,19 @@ def copy( inherited_project = None if provider_changed else self.project headers: Mapping[str, str] = {} if provider_changed else self._custom_headers + if ( + is_x509_workload_identity(workload_identity) + and not is_x509_workload_identity(self.workload_identity) + and self._ambient_authorizations + ): + headers = { + name: value + for name, value in headers.items() + if name.lower() != "authorization" or value not in self._ambient_authorizations + } if default_headers is not None: + if any(name.lower() == "authorization" for name in default_headers): + headers = {name: value for name, value in headers.items() if name.lower() != "authorization"} headers = {**headers, **default_headers} elif set_default_headers is not None: headers = set_default_headers @@ -1400,9 +1493,23 @@ def copy( http_client = http_client or self._client next_provider = self._provider if isinstance(provider, NotGiven) else provider + explicit_base_url = base_url is not None and not isinstance(base_url, NotGiven) + next_workload_identity = workload_identity if workload_identity is not None else self.workload_identity + if api_key is not None and workload_identity is None: + next_workload_identity = None + current_x509 = is_x509_workload_identity(self.workload_identity) + next_x509 = is_x509_workload_identity(next_workload_identity) + mode_changed = current_x509 != next_x509 + effective_data_residency = data_residency + if effective_data_residency is None and mode_changed and not explicit_base_url: + effective_data_residency = self._data_residency base_url = resolve_data_residency( - data_residency, base_url, provider=next_provider, websocket_base_url=websocket_base_url + effective_data_residency, + not_given if base_url is None and data_residency is None else base_url, + provider=next_provider, + websocket_base_url=websocket_base_url, ) + base_url = x509_data_residency_base_url(base_url, effective_data_residency, next_workload_identity) preserve_default_base_url = False auth_options: dict[str, Any] if next_provider is not None: @@ -1421,12 +1528,6 @@ def copy( "base_url": base_url, } else: - next_workload_identity = workload_identity if workload_identity is not None else self.workload_identity - if api_key is not None and workload_identity is None: - next_workload_identity = None - current_x509 = is_x509_workload_identity(self.workload_identity) - next_x509 = is_x509_workload_identity(next_workload_identity) - mode_changed = current_x509 != next_x509 inherited_base_url = None if mode_changed and self._base_url_was_default else self.base_url preserve_default_base_url = base_url is None and not mode_changed and self._base_url_was_default auth_options = { @@ -1454,6 +1555,23 @@ def copy( ) if preserve_default_base_url: copied._base_url_was_default = True + overridden_authorizations = default_headers if default_headers is not None else set_default_headers + explicit_authorization_override = overridden_authorizations is not None and any( + name.lower() == "authorization" for name in overridden_authorizations + ) + if ( + self._ambient_authorizations + and not explicit_authorization_override + and any( + name.lower() == "authorization" and value in self._ambient_authorizations + for name, value in copied._custom_headers.items() + ) + ): + copied._ambient_authorizations = self._ambient_authorizations + if data_residency is not None: + copied._data_residency = data_residency + elif not explicit_base_url and not provider_changed: + copied._data_residency = self._data_residency return copied # Alias for `copy` for nicer inline usage, e.g. diff --git a/src/openai/auth/_x509.py b/src/openai/auth/_x509.py index 622f2b89ec..f87e9f50df 100644 --- a/src/openai/auth/_x509.py +++ b/src/openai/auth/_x509.py @@ -5,6 +5,7 @@ import time import email.utils from typing import Any, NoReturn, cast +from contextvars import ContextVar from typing_extensions import TypeIs, override import anyio @@ -29,6 +30,20 @@ _REPLAY_FILE_POSITIONS_EXTENSION = "openai_x509_replay_file_positions" _ALLOWED_IDENTITY_FIELDS = {"type", "identity_provider_id", "service_account_id", "refresh_buffer_seconds"} _BEARER_ACCESS_TOKEN = re.compile(r"[A-Za-z0-9._~+/-]+=*") +_MTLS_REGIONAL_BASE_URLS = { + "global": MTLS_API_BASE_URL, + "us": "https://mtls-us.api.openai.com/v1", + "eu": "https://mtls-eu.api.openai.com/v1", +} +_OPENAI_MTLS_HOSTS = {httpx2.URL(url).host for url in _MTLS_REGIONAL_BASE_URLS.values()} +_EXCHANGE_REQUEST_TIMEOUT: ContextVar[dict[str, float | None] | None] = ContextVar( + "openai_x509_exchange_request_timeout", default=None +) + + +class _TransientTokenExchangeError(Exception): + def __init__(self, error: OpenAIError) -> None: + self.error = error def validate_x509_api_url(url: httpx2.URL | str, *, expected_origin: httpx2.URL | None = None) -> None: @@ -89,6 +104,15 @@ def _validate_transport_request( validate_x509_api_url(request.url, expected_origin=expected_origin) validate_x509_request_authority(request) + target = request.extensions.get("target") + if target is not None and target != request.url.raw_path: + raise OpenAIError("X.509 workload identity request target must match the request URL") + + sni_hostname = request.extensions.get("sni_hostname") + if request.url.host in _OPENAI_MTLS_HOSTS and sni_hostname is not None: + if not isinstance(sni_hostname, str) or sni_hostname.lower() != expected_origin.host.lower(): + raise OpenAIError("X.509 workload identity TLS hostname must match the configured origin") + if token_exchange: if str(request.url) != _X509_TOKEN_EXCHANGE_URL: raise OpenAIError("X.509 token exchange requests must use the pinned authentication URL") @@ -189,6 +213,7 @@ def _scoped_sync_client( transport=transport, timeout=http_client.timeout, event_hooks=None if token_exchange else http_client.event_hooks, + default_encoding=http_client._default_encoding, trust_env=False, ) if not token_exchange: @@ -217,6 +242,7 @@ def _scoped_async_client( transport=transport, timeout=http_client.timeout, event_hooks=None if token_exchange else http_client.event_hooks, + default_encoding=http_client._default_encoding, trust_env=False, ) if not token_exchange: @@ -240,6 +266,26 @@ def is_x509_workload_identity( return identity is not None and identity.get("type") == "x509" +def x509_data_residency_base_url( + base_url: httpx2.URL | str | None, + data_residency: str | None, + workload_identity: WorkloadIdentity | X509WorkloadIdentity | None, +) -> httpx2.URL | str | None: + if data_residency is None or not is_x509_workload_identity(workload_identity): + return base_url + if data_residency not in _MTLS_REGIONAL_BASE_URLS: + raise OpenAIError("X.509 workload identity requires a supported regional mTLS endpoint") + return _MTLS_REGIONAL_BASE_URLS[data_residency] + + +def x509_safe_environment_headers( + headers: dict[str, str], workload_identity: X509WorkloadIdentity | None +) -> dict[str, str]: + if workload_identity is None: + return headers + return {name: value for name, value in headers.items() if name.lower() != "authorization"} + + def _validate_identity(identity: X509WorkloadIdentity) -> None: if "provider" in identity or "client_id" in identity: raise OpenAIError("X.509 workload identity does not accept a subject-token provider or client ID") @@ -247,7 +293,13 @@ def _validate_identity(identity: X509WorkloadIdentity) -> None: if set(identity) - _ALLOWED_IDENTITY_FIELDS: raise OpenAIError("X.509 workload identity accepts only identity IDs and an optional refresh buffer") - if not identity.get("identity_provider_id") or not identity.get("service_account_id"): + if any( + not isinstance(identity.get(field), str) or not identity.get(field) + for field in ( + "identity_provider_id", + "service_account_id", + ) + ): raise OpenAIError("X.509 workload identity requires identity-provider and service-account IDs") refresh_buffer = cast(object, identity.get("refresh_buffer_seconds")) @@ -276,20 +328,39 @@ def _token_exchange_request( if legacy_httpx is not None and not isinstance(cast(object, http_client), (httpx2.Client, httpx2.AsyncClient)): request_type = cast(type[httpx2.Request], cast(Any, legacy_httpx).Request) + configured_timeout = _EXCHANGE_REQUEST_TIMEOUT.get() + timeout = { + phase: min(value, 10.0) if value is not None else 10.0 + for phase, value in (configured_timeout or httpx2.Timeout(10.0).as_dict()).items() + } return request_type( "POST", _X509_TOKEN_EXCHANGE_URL, json=_exchange_payload(identity), - extensions={"timeout": httpx2.Timeout(10.0).as_dict()}, + extensions={"timeout": timeout}, ) def _retry_delay(response: httpx2.Response | None, attempt: int) -> float | None: if response is not None: - if response.status_code not in (408, 409, 429) and response.status_code < 500: + should_retry = response.headers.get("x-should-retry") + if response.status_code in (400, 401, 403) or should_retry == "false": + return None + if should_retry != "true" and response.status_code not in (408, 409, 429) and response.status_code < 500: return None + retry_after_ms = response.headers.get("retry-after-ms") retry_after = response.headers.get("retry-after") + if retry_after_ms is not None: + try: + millisecond_delay = float(retry_after_ms) / 1000 + except ValueError: + pass + else: + if math.isfinite(millisecond_delay) and 0 <= millisecond_delay <= MAX_RETRY_AFTER_DELAY: + return millisecond_delay + if millisecond_delay > MAX_RETRY_AFTER_DELAY: + return None if retry_after is not None: try: delay = float(retry_after) @@ -323,9 +394,12 @@ def _is_replayable_request(request: httpx2.Request) -> bool: seekable = getattr(file, "seekable", None) seek = getattr(file, "seek", None) tell = getattr(file, "tell", None) - if not callable(seekable) or not seekable() or not callable(seek) or not callable(tell): + try: + if not callable(seekable) or not seekable() or not callable(seek) or not callable(tell): + return False + position = tell() + except (OSError, ValueError): return False - position = tell() if not isinstance(position, int): return False file_positions.append((file, position)) @@ -336,9 +410,12 @@ def _is_replayable_request(request: httpx2.Request) -> bool: seekable = getattr(source, "seekable", None) seek = getattr(source, "seek", None) tell = getattr(source, "tell", None) - if not callable(seekable) or not seekable() or not callable(seek) or not callable(tell): + try: + if not callable(seekable) or not seekable() or not callable(seek) or not callable(tell): + return False + request.extensions[_REPLAY_POSITION_EXTENSION] = tell() + except (OSError, ValueError): return False - request.extensions[_REPLAY_POSITION_EXTENSION] = tell() return True @@ -350,10 +427,7 @@ def _transport_errors() -> tuple[type[Exception], ...]: return (httpx2.TransportError, legacy_transport_error) -def _raise_transport_error(error: Exception) -> NoReturn: - request = cast(httpx2.Request | None, getattr(error, "request", None)) - if request is None: - raise OpenAIError("X.509 token exchange connection failed") from error +def _raise_transport_error(error: Exception, *, request: httpx2.Request) -> NoReturn: if isinstance(error, timeout_exceptions()): raise APITimeoutError(request=request) from error raise APIConnectionError(request=request) from error @@ -416,8 +490,37 @@ def _prepare_retry_request(self, request: httpx2.Request) -> None: if callable(seek): seek(position) + def _usable_token_after_transient_failure(self) -> str | None: + with self._lock: + if self._token_unusable(): + return None + self._cached_token_refresh_at_monotonic = time.monotonic() + INITIAL_RETRY_DELAY + return self._cached_token + + @override + def _perform_refresh(self) -> None: + try: + super()._perform_refresh() + except (APIConnectionError, _TransientTokenExchangeError): + if self._usable_token_after_transient_failure() is None: + raise + + def _handle_exchange_response(self, response: httpx2.Response) -> dict[str, Any]: + try: + return self._handle_token_response(response) + except OpenAIError as error: + if ( + response.status_code in (408, 409, 429) + or response.status_code >= 500 + or (response.status_code not in (400, 401, 403) and response.headers.get("x-should-retry") == "true") + ): + raise _TransientTokenExchangeError(error) from error + raise + class SyncX509WorkloadIdentityAuth(_X509WorkloadIdentityAuth): + _http_client: httpx2.Client + def __init__( self, *, workload_identity: X509WorkloadIdentity, http_client: httpx2.Client, max_retries: int ) -> None: @@ -434,15 +537,30 @@ def send_api_request( **kwargs: Any, ) -> httpx2.Response: with _scoped_sync_client( - self._http_client, - expected_origin=expected_origin, - expected_authorization=expected_authorization, + self._http_client, expected_origin=expected_origin, expected_authorization=expected_authorization ) as scoped_client: + kwargs.setdefault("auth", None) return scoped_client.send(request, stream=stream, **kwargs) + def get_token_for_request(self, request: httpx2.Request) -> str: + timeout_token = _EXCHANGE_REQUEST_TIMEOUT.set(request.extensions.get("timeout")) + try: + try: + return self.get_token() + except (APIConnectionError, _TransientTokenExchangeError) as error: + token = self._usable_token_after_transient_failure() + if token is None: + if isinstance(error, _TransientTokenExchangeError): + raise error.error from None + raise + return token + finally: + _EXCHANGE_REQUEST_TIMEOUT.reset(timeout_token) + @override def _fetch_token_from_exchange(self) -> dict[str, Any]: for attempt in range(self._max_exchange_retries + 1): + exchange_request = _token_exchange_request(self.workload_identity, http_client=self._http_client) try: with _scoped_sync_client( self._http_client, @@ -450,18 +568,18 @@ def _fetch_token_from_exchange(self) -> dict[str, Any]: token_exchange=True, ) as scoped_client: response = scoped_client.send( - _token_exchange_request(self.workload_identity, http_client=self._http_client), + exchange_request, auth=None, follow_redirects=False, ) except _transport_errors() as error: if attempt >= self._max_exchange_retries: - _raise_transport_error(error) + _raise_transport_error(error, request=exchange_request) delay = _retry_delay(None, attempt) else: delay = _retry_delay(response, attempt) if attempt >= self._max_exchange_retries or delay is None: - return self._handle_token_response(response) + return self._handle_exchange_response(response) if delay is not None: time.sleep(delay) @@ -470,6 +588,8 @@ def _fetch_token_from_exchange(self) -> dict[str, Any]: class AsyncX509WorkloadIdentityAuth(_X509WorkloadIdentityAuth): + _http_client: httpx2.AsyncClient + def __init__( self, *, workload_identity: X509WorkloadIdentity, http_client: httpx2.AsyncClient, max_retries: int ) -> None: @@ -487,12 +607,26 @@ async def send_api_request( **kwargs: Any, ) -> httpx2.Response: async with _scoped_async_client( - self._http_client, - expected_origin=expected_origin, - expected_authorization=expected_authorization, + self._http_client, expected_origin=expected_origin, expected_authorization=expected_authorization ) as scoped_client: + kwargs.setdefault("auth", None) return await scoped_client.send(request, stream=stream, **kwargs) + async def get_token_for_request(self, request: httpx2.Request) -> str: + timeout_token = _EXCHANGE_REQUEST_TIMEOUT.set(request.extensions.get("timeout")) + try: + try: + return await self.get_token_async() + except (APIConnectionError, _TransientTokenExchangeError) as error: + token = self._usable_token_after_transient_failure() + if token is None: + if isinstance(error, _TransientTokenExchangeError): + raise error.error from None + raise + return token + finally: + _EXCHANGE_REQUEST_TIMEOUT.reset(timeout_token) + @override async def get_token_async(self) -> str: async with self._async_lock: @@ -500,13 +634,20 @@ async def get_token_async(self) -> str: if not self._token_unusable() and not self._needs_refresh(): return cast(str, self._cached_token) - token_data = await self._fetch_token_from_exchange_async() + try: + token_data = await self._fetch_token_from_exchange_async() + except (APIConnectionError, _TransientTokenExchangeError): + token = self._usable_token_after_transient_failure() + if token is None: + raise + return token self._store_token(token_data) with self._lock: return cast(str, self._cached_token) async def _fetch_token_from_exchange_async(self) -> dict[str, Any]: for attempt in range(self._max_exchange_retries + 1): + exchange_request = _token_exchange_request(self.workload_identity, http_client=self._http_client) try: async with _scoped_async_client( self._http_client, @@ -514,18 +655,18 @@ async def _fetch_token_from_exchange_async(self) -> dict[str, Any]: token_exchange=True, ) as scoped_client: response = await scoped_client.send( - _token_exchange_request(self.workload_identity, http_client=self._http_client), + exchange_request, auth=None, follow_redirects=False, ) except _transport_errors() as error: if attempt >= self._max_exchange_retries: - _raise_transport_error(error) + _raise_transport_error(error, request=exchange_request) delay = _retry_delay(None, attempt) else: delay = _retry_delay(response, attempt) if attempt >= self._max_exchange_retries or delay is None: - return self._handle_token_response(response) + return self._handle_exchange_response(response) if delay is not None: await anyio.sleep(delay) diff --git a/src/openai/resources/admin/organization/audit_logs.py b/src/openai/resources/admin/organization/audit_logs.py index d90442a107..51f4bed101 100644 --- a/src/openai/resources/admin/organization/audit_logs.py +++ b/src/openai/resources/admin/organization/audit_logs.py @@ -128,6 +128,8 @@ def list( "tenant.admin_api_key.updated", "tenant.admin_api_key.deleted", "tenant.project_api_key.created", + "tenant.trusted_access.business_verification.started", + "tenant.trusted_access.application.submitted", "tenant.chatgpt_access_token.revoked", "tenant.migration.completed", "tenant.sso.migrated", @@ -395,6 +397,8 @@ def list( "tenant.admin_api_key.updated", "tenant.admin_api_key.deleted", "tenant.project_api_key.created", + "tenant.trusted_access.business_verification.started", + "tenant.trusted_access.application.submitted", "tenant.chatgpt_access_token.revoked", "tenant.migration.completed", "tenant.sso.migrated", diff --git a/src/openai/types/admin/organization/audit_log_list_params.py b/src/openai/types/admin/organization/audit_log_list_params.py index 23e343eec7..528eb9d9d8 100644 --- a/src/openai/types/admin/organization/audit_log_list_params.py +++ b/src/openai/types/admin/organization/audit_log_list_params.py @@ -118,6 +118,8 @@ class AuditLogListParams(TypedDict, total=False): "tenant.admin_api_key.updated", "tenant.admin_api_key.deleted", "tenant.project_api_key.created", + "tenant.trusted_access.business_verification.started", + "tenant.trusted_access.application.submitted", "tenant.chatgpt_access_token.revoked", "tenant.migration.completed", "tenant.sso.migrated", diff --git a/src/openai/types/admin/organization/audit_log_list_response.py b/src/openai/types/admin/organization/audit_log_list_response.py index d9f0712b9a..c78aaf27bd 100644 --- a/src/openai/types/admin/organization/audit_log_list_response.py +++ b/src/openai/types/admin/organization/audit_log_list_response.py @@ -1053,6 +1053,8 @@ class AuditLogListResponse(BaseModel): "tenant.admin_api_key.updated", "tenant.admin_api_key.deleted", "tenant.project_api_key.created", + "tenant.trusted_access.business_verification.started", + "tenant.trusted_access.application.submitted", "tenant.chatgpt_access_token.revoked", "tenant.migration.completed", "tenant.sso.migrated", diff --git a/src/openai/types/beta/beta_response_usage.py b/src/openai/types/beta/beta_response_usage.py index b96bde525d..f86c1839cf 100644 --- a/src/openai/types/beta/beta_response_usage.py +++ b/src/openai/types/beta/beta_response_usage.py @@ -1,5 +1,7 @@ # File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. +from typing import Optional + from ..._models import BaseModel __all__ = ["BetaResponseUsage", "InputTokensDetails", "OutputTokensDetails"] @@ -45,3 +47,6 @@ class BetaResponseUsage(BaseModel): total_tokens: int """The total number of tokens used.""" + + compute_units: Optional[int] = None + """Compute units for the request. Currently null when available.""" diff --git a/src/openai/types/completion_usage.py b/src/openai/types/completion_usage.py index 73bd2bccaa..c69cc90cfb 100644 --- a/src/openai/types/completion_usage.py +++ b/src/openai/types/completion_usage.py @@ -68,5 +68,8 @@ class CompletionUsage(BaseModel): completion_tokens_details: Optional[CompletionTokensDetails] = None """Breakdown of tokens used in a completion.""" + compute_units: Optional[int] = None + """Compute units for the request. Currently null when available.""" + prompt_tokens_details: Optional[PromptTokensDetails] = None """Breakdown of tokens used in the prompt.""" diff --git a/src/openai/types/responses/response_usage.py b/src/openai/types/responses/response_usage.py index 355dd5d735..9ebaaeafaa 100644 --- a/src/openai/types/responses/response_usage.py +++ b/src/openai/types/responses/response_usage.py @@ -1,5 +1,7 @@ # File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. +from typing import Optional + from ..._models import BaseModel __all__ = ["ResponseUsage", "InputTokensDetails", "OutputTokensDetails"] @@ -45,3 +47,6 @@ class ResponseUsage(BaseModel): total_tokens: int """The total number of tokens used.""" + + compute_units: Optional[int] = None + """Compute units for the request. Currently null when available.""" diff --git a/tests/lib/chat/test_completions.py b/tests/lib/chat/test_completions.py index e2447310f8..0d783b2ce9 100644 --- a/tests/lib/chat/test_completions.py +++ b/tests/lib/chat/test_completions.py @@ -86,6 +86,7 @@ def test_parse_nothing(client: OpenAI, respx2_mock: MockRouter, monkeypatch: pyt rejected_prediction_tokens=None, text_tokens=None ), + compute_units=None, prompt_tokens=14, prompt_tokens_details=None, total_tokens=51 @@ -158,6 +159,7 @@ class Location(BaseModel): rejected_prediction_tokens=None, text_tokens=None ), + compute_units=None, prompt_tokens=79, prompt_tokens_details=None, total_tokens=93 @@ -232,6 +234,7 @@ class Location(BaseModel): rejected_prediction_tokens=None, text_tokens=None ), + compute_units=None, prompt_tokens=88, prompt_tokens_details=None, total_tokens=102 @@ -441,6 +444,7 @@ class CalendarEvent: rejected_prediction_tokens=None, text_tokens=None ), + compute_units=None, prompt_tokens=92, prompt_tokens_details=None, total_tokens=109 @@ -917,6 +921,7 @@ class Location(BaseModel): rejected_prediction_tokens=None, text_tokens=None ), + compute_units=None, prompt_tokens=79, prompt_tokens_details=None, total_tokens=93 @@ -997,6 +1002,7 @@ class Location(BaseModel): rejected_prediction_tokens=None, text_tokens=None ), + compute_units=None, prompt_tokens=79, prompt_tokens_details=None, total_tokens=93 diff --git a/tests/lib/chat/test_completions_streaming.py b/tests/lib/chat/test_completions_streaming.py index 25d2942639..40b5a7a47c 100644 --- a/tests/lib/chat/test_completions_streaming.py +++ b/tests/lib/chat/test_completions_streaming.py @@ -175,6 +175,7 @@ def on_event(stream: ChatCompletionStream[Location], event: ChatCompletionStream rejected_prediction_tokens=None, text_tokens=None ), + compute_units=None, prompt_tokens=79, prompt_tokens_details=None, total_tokens=93 diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index ed9636f93a..779777dabd 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -4,11 +4,15 @@ import re import sys import json +import shutil import subprocess from typing import Any, cast from pathlib import Path import pytest +from packaging.markers import Marker +from packaging.version import Version +from packaging.requirements import Requirement if sys.version_info >= (3, 11): import tomllib @@ -71,14 +75,4581 @@ def test_dependabot_delays_only_ordinary_version_updates() -> None: assert "open-pull-requests-limit: 0" not in entry -def test_agents_integration_selects_its_typechecking_runtime() -> None: +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", + hatch_configuration: str = "", + trusted_hatch_configuration: str = "", + hatch_files: dict[str, str] | None = None, + trusted_hatch_files: dict[str, str] | None = None, + hatch_symlinks: dict[str, str] | None = None, +) -> 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, configured_source in uv_sources.items(): + values = ", ".join(key + " = " + json.dumps(value) for key, value in configured_source.items()) + configuration += name + " = { " + values + " }\n" + configuration += hatch_configuration + (tmp_path / "pyproject.toml").write_text(configuration) + for name, contents in (hatch_files or {}).items(): + destination = tmp_path / name + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(contents) + for name, target in (hatch_symlinks or {}).items(): + destination = tmp_path / name + destination.parent.mkdir(parents=True, exist_ok=True) + destination.symlink_to(target) + 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" + ) + trusted_configuration += trusted_hatch_configuration + (tmp_path / "trusted-base.toml").write_text(trusted_configuration) + for name, contents in (trusted_hatch_files or {}).items(): + destination = tmp_path / ".trusted-hooks" / name + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(contents) + (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" + "elif len(arguments) == 2 and arguments[0] == 'show' and arguments[1].startswith(sha + ':'):\n" + " path = root / '.trusted-hooks' / arguments[1].split(':', 1)[1]\n" + " sys.stdout.buffer.write(path.read_bytes())\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("fork-added-global", False, id="fork-cannot-add-executable-global-hatch-hook"), + pytest.param("fork-added-target", False, id="fork-cannot-add-executable-target-hatch-hook"), + pytest.param("fork-added-metadata", False, id="fork-cannot-add-executable-metadata-hatch-hook"), + pytest.param("fork-changed-path", False, id="fork-cannot-redirect-reviewed-custom-hook"), + pytest.param("fork-modified-source", False, id="fork-cannot-modify-reviewed-custom-hook-source"), + pytest.param("fork-missing-source", False, id="fork-cannot-remove-reviewed-custom-hook-source"), + pytest.param("fork-symlink-source", False, id="fork-cannot-use-symlinked-custom-hook-source"), + pytest.param("fork-traversal", False, id="fork-cannot-escape-checkout-through-custom-hook-path"), + pytest.param("fork-reviewed-metadata", True, id="fork-preserves-immutable-reviewed-metadata-hook"), + pytest.param("fork-reviewed-default", True, id="fork-preserves-immutable-reviewed-default-hook"), + pytest.param("fork-packaging-change", True, id="fork-can-change-nonexecutable-hatch-build-metadata"), + pytest.param("same-repo-maintainer", True, id="trusted-maintainer-can-change-hatch-hooks"), + pytest.param("same-repo-dependabot", True, id="trusted-dependabot-can-change-hatch-hooks"), + ], +) +def test_fork_hatch_hooks_and_sources_must_match_immutable_reviewed_base( + tmp_path: Path, variant: str, accepted: bool +) -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + packages: list[dict[str, object]] = [ + {"name": project["name"], "version": project["version"], "source": {"editable": "."}} + ] + reviewed = '[tool.hatch.metadata.hooks.custom]\npath = "scripts/hatch_metadata.py"\n' + head = reviewed + base = reviewed + files = {"scripts/hatch_metadata.py": "reviewed = True\n"} + trusted_files = dict(files) + symlinks: dict[str, str] = {} + + if variant == "fork-added-global": + head += '[tool.hatch.build.hooks.custom]\npath = "attacker.py"\n' + files["attacker.py"] = "raise RuntimeError('unreviewed global hook')\n" + elif variant == "fork-added-target": + head += '[tool.hatch.build.targets.wheel.hooks.custom]\npath = "attacker.py"\n' + files["attacker.py"] = "raise RuntimeError('unreviewed target hook')\n" + elif variant == "fork-added-metadata": + base = "" + elif variant == "fork-changed-path": + head = '[tool.hatch.metadata.hooks.custom]\npath = "attacker.py"\n' + files["attacker.py"] = "raise RuntimeError('unreviewed metadata hook')\n" + elif variant == "fork-modified-source": + files["scripts/hatch_metadata.py"] = "raise RuntimeError('modified reviewed hook')\n" + elif variant == "fork-missing-source": + files.clear() + elif variant == "fork-symlink-source": + files = {"reviewed.py": "reviewed = True\n"} + symlinks = {"scripts/hatch_metadata.py": "../reviewed.py"} + elif variant == "fork-traversal": + head = base = '[tool.hatch.metadata.hooks.custom]\npath = "../outside.py"\n' + files.clear() + trusted_files.clear() + elif variant == "fork-reviewed-default": + head = base = "[tool.hatch.build.hooks.custom]\n" + files = trusted_files = {"hatch_build.py": "reviewed = True\n"} + elif variant == "fork-packaging-change": + head += '[tool.hatch.build]\ninclude = ["different/*"]\n' + base += '[tool.hatch.build]\ninclude = ["src/*"]\n' + elif variant.startswith("same-repo-"): + head += '[tool.hatch.build.hooks.custom]\npath = "new-maintainer-hook.py"\n' + files["new-maintainer-hook.py"] = "reviewed_maintainer_update = True\n" + + result = run_dependency_lock_source_check( + tmp_path, + packages, + trusted_fork=not variant.startswith("same-repo-"), + hatch_configuration=head, + trusted_hatch_configuration=base, + hatch_files=files, + trusted_hatch_files=trusted_files, + hatch_symlinks=symlinks, + ) + 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) + 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_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 + + +def test_breaking_change_installer_validates_provenance_first() -> None: path = ROOT / ".github/workflows/detect-breaking-changes.yml" if not path.exists(): pytest.skip("GitHub workflows are not included in source distributions") - integration = path.read_text().split("\n agents_sdk:\n", 1)[1] - setup = integration.split(" - name: Set up uv\n", 1)[1].split("\n - name:", 1)[0] - assert "working-directory: openai-python" in setup - assert "python-version: '3.14'" in setup + + match = re.search( + r"^ detect_breaking_changes:\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] + + +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, + base_lock_dependencies: dict[tuple[str, str], list[dict[str, object]]] | None = None, + head_lock_dependencies: dict[tuple[str, str], list[dict[str, object]]] | None = None, + base_lock_optional_dependencies: dict[tuple[str, str], dict[str, list[dict[str, object]]]] | None = None, + head_lock_optional_dependencies: dict[tuple[str, str], dict[str, list[dict[str, object]]]] | 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, + dependencies: dict[tuple[str, str], list[dict[str, object]]] | None, + optional_dependencies: dict[tuple[str, str], dict[str, list[dict[str, object]]]] | None, + ) -> str: + def edges(values: list[dict[str, object]]) -> str: + return ( + "[" + + ", ".join( + "{ " + ", ".join(key + " = " + json.dumps(value) for key, value in item.items()) + " }" + for item in values + ) + + "]" + ) + + result: list[str] = [] + for name, version in packages: + identity = name, version + entry = f"[[package]]\nname = {json.dumps(name)}\nversion = {json.dumps(version)}\n" + if resolutions is not None and identity in resolutions: + entry += "resolution-markers = " + json.dumps(resolutions[identity]) + "\n" + if dependencies is not None and identity in dependencies: + entry += "dependencies = " + edges(dependencies[identity]) + "\n" + if optional_dependencies is not None and identity in optional_dependencies: + entry += "[package.optional-dependencies]\n" + for extra, values in optional_dependencies[identity].items(): + entry += json.dumps(extra) + " = " + edges(values) + "\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, head_lock_dependencies, head_lock_optional_dependencies) + ) + (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, base_lock_dependencies, base_lock_optional_dependencies) + ) + 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, + True, + id="unchanged-lock-valid-or-marker-remains-supported", + ), + pytest.param( + ["other>=2; (python_version < '3.11')"], + ["other>=2; (python_version < '3.11')"], + [("other", "2")], + [("other", "2")], + False, + True, + id="unchanged-lock-parenthesized-marker-remains-supported", + ), + 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("scope", ["runtime", "optional", "protected"]) +@pytest.mark.parametrize( + ("previous", "current", "before", "after", "accepted"), + [ + pytest.param(">=1,>=1.5", ">=1,>=2", "1.5", "2", True, id="strongest-redundant-floor-reaches-patched-release"), + pytest.param(">=1.5,>=1", ">=2,>=1", "1.5", "2", True, id="strongest-redundant-floor-is-order-independent"), + pytest.param(">=1,>=2", ">=1,>=1.5", "2", "2", False, id="strongest-redundant-floor-cannot-be-lowered"), + pytest.param( + ">=1,>=1.5", ">=1.1,>=1.5", "1.5", "2", False, id="weaker-redundant-floor-cannot-mask-patched-floor" + ), + pytest.param( + ">=1.9,>=1.10", ">=1.9,>=1.11", "1.10", "1.11", True, id="redundant-floors-use-numeric-release-order" + ), + pytest.param(">=0!9,>=1!1", ">=0!9,>=1!2", "1!1", "1!2", True, id="redundant-floors-preserve-epoch-order"), + pytest.param( + ">=1,>=1.post1", + ">=1,>=1.post2", + "1.post1", + "1.post2", + True, + id="redundant-floors-preserve-post-release-order", + ), + pytest.param(">1,>=1.5", ">1,>=2", "1.5", "2", True, id="strict-and-inclusive-floors-select-strongest"), + pytest.param(">=1,>1.5", ">=1,>=2", "1.6", "2", True, id="stronger-inclusive-floor-preserves-strict-floor"), + pytest.param(">1,>=1.post1", ">1,>=1.2", "1.1", "1.2", True, id="strict-final-floor-still-excludes-base-posts"), + pytest.param("~=1.4,>=1.5", "~=1.4,>=1.7", "1.5", "1.7", True, id="compatible-floor-retains-implicit-ceiling"), + pytest.param( + "~=1.4,>=1.5", ">=1.4,>=1.7", "1.5", "1.7", False, id="redundant-floor-cannot-drop-compatible-ceiling" + ), + pytest.param("==1.*,>=1.5", "==1.*,>=1.7", "1.5", "1.7", True, id="wildcard-floor-retains-implicit-ceiling"), + pytest.param( + "==1.*,>=1.5", ">=1,>=1.7,<3", "1.5", "1.7", False, id="redundant-floor-cannot-widen-wildcard-ceiling" + ), + pytest.param( + ">=1,>=1.5,!=1.6", + ">=1,>=1.7,!=1.6", + "1.5", + "1.7", + True, + id="redundant-floor-preserves-published-exclusion", + ), + pytest.param( + ">=1,>=1.5,!=1.8", + ">=1,>=1.7", + "1.5", + "1.7", + False, + id="redundant-floor-cannot-remove-published-exclusion", + ), + pytest.param( + ">=1,>=1.5,<3", ">=1,>=2,<3", "1.5", "2", True, id="redundant-floor-preserves-published-upper-bound" + ), + pytest.param( + ">=1,>=1.5,<3", ">=1,>=2,<4", "1.5", "2", False, id="redundant-floor-cannot-widen-published-upper-bound" + ), + pytest.param("==1.5,>=1", "==2,>=1", "1.5", "2", True, id="exact-pin-and-redundant-floor-upgrade"), + pytest.param( + "==1.5,>=1", "==1.5,>=2", "1.5", "2", False, id="exact-pin-contradicting-strongest-floor-fails-closed" + ), + pytest.param("===1.5,>=1", "===2,>=1", "1.5", "2", True, id="arbitrary-pin-and-redundant-floor-upgrade"), + pytest.param("===1.5,>=1", "===2.0,>=1", "1.5", "2", False, id="arbitrary-pin-retains-raw-lock-identity"), + pytest.param( + ">=1,>=1.5", ">=2rc1,>=2", "1.5", "2", False, id="unsupported-redundant-prerelease-floor-fails-closed" + ), + pytest.param(">=1,>=1.5", ">=2,>=", "1.5", "2", False, id="malformed-redundant-floor-fails-closed"), + pytest.param( + ">=1,>=1.5", ">=2,>=2.0", "1.5", "2", False, id="ambiguous-equivalent-redundant-floor-fails-closed" + ), + pytest.param(">=1,>=1.5,<3", ">=1,>=2,<2", "1.5", "2", False, id="contradictory-floor-and-ceiling-fail-closed"), + ], +) +def test_security_dependency_minimum_uses_strongest_effective_floor( + tmp_path: Path, scope: str, previous: str, current: str, before: str, after: str, accepted: bool +) -> None: + base = "danger-pkg" + previous + head = "danger-pkg" + current + protected = scope == "protected" + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["safe-direct>=1"] if protected else [base], + head_requirements=["safe-direct>=1"] if protected else [head], + base_packages=[("danger-pkg", before), *([("safe-direct", "1")] if protected else [])], + head_packages=[("danger-pkg", after), *([("safe-direct", "1")] if protected else [])], + optional=scope == "optional", + base_constraints=[base] if protected else None, + head_constraints=[head] if protected else None, + ) + 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( + ("specifier", "locked"), + [ + pytest.param(">=1.0a1", "1.0", id="unchanged-alpha-lower-bound"), + pytest.param(">=1.0b2", "1.0", id="unchanged-beta-lower-bound"), + pytest.param(">=1.0rc1", "1.0", id="unchanged-release-candidate-lower-bound"), + pytest.param(">=1.0.dev1", "1.0", id="unchanged-development-lower-bound"), + pytest.param(">=1.0rc1.post2.dev3", "1.0", id="unchanged-combined-prerelease-post-development-bound"), + pytest.param("<2.0rc1", "1.0", id="unchanged-prerelease-upper-bound"), + pytest.param("!=1.0rc1", "1.0", id="unchanged-prerelease-exclusion"), + pytest.param("~=1.0rc1", "1.0", id="unchanged-compatible-prerelease-bound"), + pytest.param("==1.0rc1", "1.0rc1", id="unchanged-exact-prerelease-pin"), + pytest.param("===1.0rc1", "1.0rc1", id="unchanged-arbitrary-prerelease-pin"), + pytest.param("==1.0+linux", "1.0+linux", id="unchanged-local-version-pin"), + pytest.param(">=1!1.0rc1", "1!1.0", id="unchanged-epoch-prerelease-bound"), + ], +) +@pytest.mark.parametrize("scope", ["runtime", "optional", "constraint", "build", "group"]) +def test_grouped_security_updates_preserve_unchanged_pep440_prerelease_requirements( + tmp_path: Path, specifier: str, locked: str, scope: str +) -> None: + requirement = "beta" + specifier + assert Requirement(requirement).specifier.contains(locked, prereleases=True) + base_requirements = ["patch>=1"] + head_requirements = ["patch>=2"] + if scope == "runtime": + base_requirements.append(requirement) + head_requirements.append(requirement) + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=[("patch", "1"), ("beta", locked)], + head_packages=[("patch", "2"), ("beta", locked)], + base_optional_groups={"feature": [requirement]} if scope == "optional" else None, + head_optional_groups={"feature": [requirement]} if scope == "optional" else None, + base_constraints=[requirement] if scope == "constraint" else None, + head_constraints=[requirement] if scope == "constraint" else None, + base_build_constraints=[requirement] if scope == "build" else None, + head_build_constraints=[requirement] if scope == "build" else None, + base_dependency_groups={"development": [requirement]} if scope == "group" else None, + head_dependency_groups={"development": [requirement]} if scope == "group" else None, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("scope", ["runtime", "optional", "constraint", "build", "group"]) +@pytest.mark.parametrize( + ("previous", "current"), + [ + pytest.param("beta>=1.0rc1", "beta>=1", id="changed-prerelease-floor-is-not-silently-reinterpreted"), + pytest.param("beta>=1.0.dev1", "beta>=1", id="changed-development-floor-is-not-silently-reinterpreted"), + pytest.param("beta<2.0rc1", "beta<3", id="changed-prerelease-ceiling-is-not-silently-reinterpreted"), + pytest.param("beta!=1.0rc1", "beta", id="removed-prerelease-exclusion-is-not-silently-reinterpreted"), + ], +) +def test_grouped_security_updates_do_not_bypass_changed_prerelease_requirements( + tmp_path: Path, previous: str, current: str, scope: str +) -> None: + Requirement(previous) + Requirement(current) + base_requirements = ["patch>=1"] + head_requirements = ["patch>=2"] + if scope == "runtime": + base_requirements.append(previous) + head_requirements.append(current) + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=[("patch", "1"), ("beta", "1.5")], + head_packages=[("patch", "2"), ("beta", "1.5")], + base_optional_groups={"feature": [previous]} if scope == "optional" else None, + head_optional_groups={"feature": [current]} if scope == "optional" else None, + base_constraints=[previous] if scope == "constraint" else None, + head_constraints=[current] if scope == "constraint" else None, + base_build_constraints=[previous] if scope == "build" else None, + head_build_constraints=[current] if scope == "build" else None, + base_dependency_groups={"development": [previous]} if scope == "group" else None, + head_dependency_groups={"development": [current]} if scope == "group" else None, + ) + assert result.returncode == 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("runtime-final-post", False, id="exclusive-final-runtime-floor-cannot-admit-same-release-post"), + pytest.param("optional-final-post", False, id="exclusive-final-optional-floor-cannot-admit-same-release-post"), + pytest.param("constraint-final-post", False, id="exclusive-final-constraint-cannot-admit-same-release-post"), + pytest.param("build-final-post", False, id="exclusive-final-build-floor-cannot-admit-same-release-post"), + pytest.param("group-final-post", False, id="exclusive-final-group-floor-cannot-admit-same-release-post"), + pytest.param("final-exclusive-post", False, id="exclusive-final-floor-cannot-admit-later-post-releases"), + pytest.param("epoch-final-post", False, id="exclusive-epoch-final-floor-cannot-admit-same-release-post"), + pytest.param("zero-final-post", False, id="exclusive-zero-normalized-final-floor-cannot-admit-post-release"), + 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"), + pytest.param("post-exclusive-higher", True, id="exclusive-post-floor-may-increase-within-same-release"), + pytest.param("post-inclusive-higher", True, id="inclusive-later-post-may-strengthen-exclusive-post-floor"), + ], +) +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 in {"optional-inclusive", "optional-final-post"} + 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 in { + "runtime-final-post", + "optional-final-post", + "constraint-final-post", + "build-final-post", + "group-final-post", + }: + previous, current = "danger>1.0", "danger>=1.0.post1" + if variant == "constraint-final-post": + constraints = ([previous], [current]) + elif variant == "build-final-post": + build = ([previous], [current]) + elif variant == "group-final-post": + groups = ({"reviewed": [previous]}, {"reviewed": [current]}) + elif variant == "final-exclusive-post": + previous, current = "danger>1.0", "danger>1.0.post1" + elif variant == "epoch-final-post": + previous, current, locked = "danger>1!1.0", "danger>=1!1.0.post1", "1!2" + elif variant == "zero-final-post": + previous, current = "danger>1.0.0", "danger>=1.0.post1" + elif variant == "post-exclusive-higher": + previous, current, locked = "danger>1.0.post1", "danger>1.0.post2", "1.0.post3" + elif variant == "post-inclusive-higher": + previous, current, locked = "danger>1.0.post1", "danger>=1.0.post2", "1.0.post2" + elif 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( + ("previous", "current", "locked", "accepted"), + [ + pytest.param( + "danger>=1,<2", + "danger>1,<2", + "1.post1", + False, + id="exclusive-final-floor-cannot-drop-retained-post-release", + ), + pytest.param( + "danger>=1.0,<2", + "danger>1.0.0,<2", + "1.0.post2", + False, + id="canonical-exclusive-final-floor-cannot-drop-retained-post-release", + ), + pytest.param( + "danger>=1!1,<1!2", + "danger>1!1,<1!2", + "1!1.post1", + False, + id="exclusive-epoch-final-floor-cannot-drop-retained-post-release", + ), + pytest.param( + "danger>=1.post1,<2", + "danger>1.post1,<2", + "1.post2", + True, + id="exclusive-post-floor-retains-later-same-release-post", + ), + pytest.param( + "danger>=1,<2", + "danger>1,<2", + "1.1", + True, + id="exclusive-final-floor-retains-later-base-release", + ), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_exclusive_final_dependency_floors_preserve_retained_post_releases( + tmp_path: Path, previous: str, current: str, locked: str, accepted: bool, protected: bool +) -> 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", locked)], + head_packages=[("patch-me", "1.1"), ("danger", locked)], + base_constraints=[previous] if protected else None, + head_constraints=[current] if protected else None, + ) + 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, + False, + 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, + False, + 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, + False, + id="platform-membership-preserves-quoted-case", + ), + pytest.param( + "python_version >= '3.1'", + "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.*'", + True, + True, + id="overlapping-python-membership-tokens-preserve-pep508-substrings", + ), + 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'", + True, + True, + id="overlapping-platform-membership-tokens-preserve-pep508-substrings", + ), + 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.*'", + True, + True, + id="source-level-or-membership-remains-supported", + ), + ], +) +@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", False, id="unprotected-marker-line-upgrade-requires-security-boundary"), + 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 + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("disjoint-unreviewed", False, id="protected-high-domain-cannot-hide-unreviewed-low-upgrade"), + pytest.param("disjoint-reviewed", True, id="new-disjoint-reviewed-floor-protects-low-domain"), + pytest.param("disjoint-insufficient", False, id="new-disjoint-floor-must-reach-low-domain-patch"), + pytest.param("disjoint-drops-retained", False, id="new-overlapping-floor-cannot-drop-retained-high-domain"), + pytest.param("broad-prior-partial", False, id="prior-partial-marker-cannot-cover-broad-resolution-domain"), + pytest.param("broad-prior-complement", True, id="complementary-prior-markers-cover-broad-resolution-domain"), + pytest.param("broad-prior-additive-global", True, id="reviewed-global-floor-may-overlap-protected-domain"), + pytest.param("broad-prior-additive-broad", True, id="reviewed-broader-floor-may-overlap-protected-domain"), + pytest.param("broad-prior-additive-insufficient", False, id="overlapping-additive-floor-must-reach-patch"), + pytest.param("broad-prior-old-weakened", False, id="overlapping-addition-cannot-weaken-original-context"), + pytest.param("broad-prior-old-dropped", False, id="overlapping-addition-cannot-remove-original-context"), + pytest.param("broad-current-partial", False, id="reviewed-partial-marker-cannot-cover-broad-resolution-domain"), + pytest.param("broad-current-complement", True, id="complementary-reviewed-markers-cover-resolution-domain"), + pytest.param("platform-prior-partial", False, id="python-platform-conjunction-cannot-cover-other-platforms"), + pytest.param("platform-prior-complement", True, id="complementary-platform-markers-cover-every-platform"), + pytest.param("membership-prior-partial", False, id="membership-protection-cannot-hide-unlisted-python-lines"), + pytest.param("membership-prior-complement", True, id="membership-complements-cover-all-python-lines"), + pytest.param("full-version-prior-partial", False, id="full-python-version-protection-cannot-hide-other-lines"), + pytest.param("independent-major-unreviewed", False, id="protected-v1-cannot-hide-unreviewed-v2-upgrade"), + pytest.param("independent-major-reviewed", True, id="new-reviewed-v2-group-preserves-existing-v1-group"), + pytest.param("independent-major-insufficient", False, id="new-v2-group-must-exclude-vulnerable-release"), + pytest.param( + "independent-major-global-drops-retained", + False, + id="global-additive-floor-cannot-drop-same-domain-protected-v1-release", + ), + pytest.param( + "independent-major-same-group-drops-retained", + False, + id="same-group-additive-floor-cannot-drop-same-domain-protected-v1-release", + ), + pytest.param("mixed-declarations", False, id="same-context-floor-must-protect-the-actual-removed-release"), + pytest.param("unchanged-uncovered", True, id="unchanged-unprotected-resolution-domain-remains-supported"), + pytest.param("extra-platform-uncovered", False, id="selected-extra-platform-cannot-borrow-linux-protection"), + pytest.param("malformed-membership", False, id="ambiguous-protected-domain-membership-fails-closed"), + pytest.param("fragment-limit", False, id="overcomplex-security-domain-partition-fails-closed"), + ], +) +def test_transitive_security_boundaries_cover_every_resolution_fragment( + tmp_path: Path, variant: str, accepted: bool +) -> None: + name = "transitive" + before = [(name, "4")] + after = [(name, "5")] + base_requirements = ["patch-me>=1"] + head_requirements = ["patch-me>=1.1"] + 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 + base_optional_edges: dict[tuple[str, str], dict[str, list[dict[str, object]]]] | None = None + head_optional_edges: dict[tuple[str, str], dict[str, list[dict[str, object]]]] | None = None + broad = "python_full_version >= '3.10'" + base_markers: dict[tuple[str, str], list[str]] = {(name, "4"): [broad]} + head_markers: dict[tuple[str, str], list[str]] = {(name, "5"): [broad]} + + if variant.startswith("disjoint-"): + before = [(name, "1"), (name, "3")] + after = [(name, "2"), (name, "3")] + base_markers = { + (name, "1"): ["python_full_version < '3.11'"], + (name, "3"): ["python_full_version >= '3.11'"], + } + head_markers = { + (name, "2"): ["python_full_version < '3.11'"], + (name, "3"): ["python_full_version >= '3.11'"], + } + high = name + ">=3; python_version >= '3.11'" + base_constraints = [high] + head_constraints = [high] + if variant == "disjoint-reviewed": + head_constraints.append(name + ">=2; python_version < '3.11'") + elif variant == "disjoint-insufficient": + head_constraints.append(name + ">=1; python_version < '3.11'") + elif variant == "disjoint-drops-retained": + head_constraints.append(name + ">=2,<3") + elif variant.startswith("broad-prior-"): + high = "; python_version >= '3.11'" + base_constraints = [name + ">=4" + high] + head_constraints = [name + ">=5" + high] + if variant == "broad-prior-complement": + low = "; python_version < '3.11'" + base_constraints.append(name + ">=4" + low) + head_constraints.append(name + ">=5" + low) + elif variant == "broad-prior-additive-global": + head_constraints.append(name + ">=5") + elif variant == "broad-prior-additive-broad": + head_constraints.append(name + ">=5; python_version >= '3.10'") + elif variant == "broad-prior-additive-insufficient": + head_constraints.append(name + ">=4") + elif variant == "broad-prior-old-weakened": + head_constraints = [name + ">=3" + high, name + ">=5"] + elif variant == "broad-prior-old-dropped": + head_constraints = [name + ">=5"] + elif variant in {"broad-current-partial", "broad-current-complement"}: + head_constraints = [name + ">=5; python_version >= '3.11'"] + if variant == "broad-current-complement": + head_constraints.append(name + ">=5; python_version < '3.11'") + elif variant in {"platform-prior-partial", "platform-prior-complement"}: + marker = "; sys_platform == 'linux'" + if variant == "platform-prior-partial": + marker = "; python_version >= '3.11' and sys_platform == 'linux'" + base_constraints = [name + ">=4" + marker] + head_constraints = [name + ">=5" + marker] + if variant == "platform-prior-complement": + other = "; sys_platform != 'linux'" + base_constraints.append(name + ">=4" + other) + head_constraints.append(name + ">=5" + other) + elif variant in {"membership-prior-partial", "membership-prior-complement"}: + marker = "; python_version in '3.11, 3.12'" + base_constraints = [name + ">=4" + marker] + head_constraints = [name + ">=5" + marker] + if variant == "membership-prior-complement": + other = "; python_version not in '3.11, 3.12'" + base_constraints.append(name + ">=4" + other) + head_constraints.append(name + ">=5" + other) + elif variant == "full-version-prior-partial": + marker = "; python_full_version == '3.11.*'" + base_constraints = [name + ">=4" + marker] + head_constraints = [name + ">=5" + marker] + elif variant.startswith("independent-major-"): + name = "pydantic" + before = [(name, "1.5"), (name, "2.4")] + after = [(name, "1.5"), (name, "2.5")] + base_markers = {} + head_markers = {} + base_groups = {"pydantic-v1": [name + ">=1,<2"]} + head_groups = {"pydantic-v1": [name + ">=1,<2"]} + if variant == "independent-major-reviewed": + head_groups["pydantic-v2"] = [name + ">=2.5,<3"] + elif variant == "independent-major-insufficient": + head_groups["pydantic-v2"] = [name + ">=2.4,<3"] + elif variant == "independent-major-global-drops-retained": + head_constraints = [name + ">=2.5,<3"] + elif variant == "independent-major-same-group-drops-retained": + base_markers = {(name, release): [broad] for _, release in before} + head_markers = {(name, release): [broad] for _, release in after} + head_groups["pydantic-v1"].append(name + ">=2.5,<3; python_version >= '3.10'") + elif variant == "mixed-declarations": + before = [(name, "5")] + after = [(name, "6")] + base_markers = {} + head_markers = {} + base_constraints = [name + ">=10", name + "<8"] + head_constraints = list(base_constraints) + elif variant == "unchanged-uncovered": + after = list(before) + head_markers = dict(base_markers) + marker = "; python_version >= '3.11'" + base_constraints = [name + ">=4" + marker] + head_constraints = list(base_constraints) + elif variant == "extra-platform-uncovered": + base_requirements.append("parent[feature]") + head_requirements.append("parent[feature]") + before.insert(0, ("parent", "1")) + after.insert(0, ("parent", "1")) + edges: dict[tuple[str, str], dict[str, list[dict[str, object]]]] = { + ("parent", "1"): {"feature": [{"name": name, "marker": "extra == 'feature' and sys_platform == 'win32'"}]} + } + base_optional_edges = edges + head_optional_edges = edges + marker = "; python_version >= '3.11' and sys_platform == 'linux'" + base_constraints = [name + ">=4" + marker] + head_constraints = [name + ">=5" + marker] + elif variant == "malformed-membership": + marker = "; sys_platform in 'linux, linux'" + base_constraints = [name + ">=4" + marker] + head_constraints = [name + ">=5" + marker] + elif variant == "fragment-limit": + membership = ", ".join("3." + str(minor) for minor in range(16)) + platforms = "linux, darwin, win32, freebsd, openbsd" + broad = "python_version in '" + membership + "' and sys_platform in '" + platforms + "'" + base_markers = {(name, "4"): [broad]} + head_markers = {(name, "5"): [broad]} + marker = "; python_version >= '3.11'" + base_constraints = [name + ">=4" + marker] + head_constraints = [name + ">=5" + marker] + + 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.1"), *after], + 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, + base_lock_optional_dependencies=base_optional_edges, + head_lock_optional_dependencies=head_optional_edges, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("wildcard", True, id="transitive-vulnerable-series-exclusion-preserves-independent-major"), + pytest.param("canonical-wildcard", True, id="canonical-series-exclusion-preserves-independent-major"), + pytest.param("no-global-floor", True, id="series-exclusion-does-not-require-destructive-global-floor"), + pytest.param("group-wildcard", True, id="reviewed-development-series-exclusion-protects-transitive-patch"), + pytest.param("post-wildcard", True, id="wildcard-excludes-old-stable-post-release-series"), + pytest.param("extra-exclusion", True, id="independent-reviewed-series-exclusions-remain-supported"), + pytest.param("exact-only", False, id="single-exact-exclusion-cannot-protect-whole-vulnerable-series"), + pytest.param("narrow-wildcard", False, id="narrow-wildcard-must-cover-actual-removed-release"), + pytest.param("narrow-zero-wildcard", False, id="zero-padded-wildcard-must-cover-whole-vulnerable-series"), + pytest.param("missing-exclusion", False, id="low-global-floor-without-series-exclusion-is-unsafe"), + pytest.param("wrong-epoch", False, id="series-exclusion-must-match-removed-release-epoch"), + pytest.param("drops-retained", False, id="series-boundary-must-preserve-independent-supported-major"), + pytest.param("drops-patched", False, id="series-boundary-must-admit-actual-patched-release"), + pytest.param("overbroad-wildcard", False, id="series-exclusion-cannot-cover-actual-patched-release"), + ], +) +def test_transitive_security_boundaries_accept_reviewed_vulnerable_series_exclusions( + tmp_path: Path, variant: str, accepted: bool +) -> None: + old, patched = "2.5", "2.6" + requirement = "danger>=1,<3,!=2.5.*" + groups: dict[str, list[str]] | None = None + + if variant == "canonical-wildcard": + requirement = "danger>=1,<3,!=02.05.*" + elif variant == "no-global-floor": + requirement = "danger<3,!=2.5.*" + elif variant == "group-wildcard": + groups = {"pydantic-v2": [requirement]} + elif variant == "post-wildcard": + old = "2.5.post1" + elif variant == "extra-exclusion": + requirement += ",!=2.4.*" + elif variant == "exact-only": + requirement = "danger>=1,<3,!=2.5" + elif variant == "narrow-wildcard": + requirement = "danger>=1,<3,!=2.5.1.*" + elif variant == "narrow-zero-wildcard": + requirement = "danger>=1,<3,!=2.5.0.*" + elif variant == "missing-exclusion": + requirement = "danger>=1,<3" + elif variant == "wrong-epoch": + requirement = "danger>=1,<3,!=1!2.5.*" + elif variant == "drops-retained": + requirement = "danger>=2,<3,!=2.5.*" + elif variant == "drops-patched": + requirement = "danger>=1,<2.6,!=2.5.*" + elif variant == "overbroad-wildcard": + requirement = "danger>=1,<3,!=2.*" + + 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", "1.5"), ("danger", old)], + head_packages=[("patch-me", "1.1"), ("danger", "1.5"), ("danger", patched)], + head_constraints=None if groups is not None else [requirement], + head_dependency_groups=groups, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("runtime-extra", False, id="new-runtime-extra-cannot-introduce-unreviewed-wheel"), + pytest.param("optional-extra", False, id="new-optional-extra-cannot-introduce-unreviewed-wheel"), + pytest.param("two-new-packages", False, id="every-new-extra-dependency-identity-must-be-reviewed"), + pytest.param("unbounded-review", False, id="unbounded-new-extra-dependency-is-not-review-boundary"), + pytest.param("low-review", False, id="reviewed-new-extra-floor-must-reach-actual-release"), + pytest.param("wrong-marker", False, id="new-extra-package-review-must-cover-actual-marker-domain"), + pytest.param("reviewed-floor", True, id="reviewed-floor-can-approve-new-extra-package-identity"), + pytest.param("reviewed-pin", True, id="reviewed-exact-pin-can-approve-new-extra-package-identity"), + pytest.param("reviewed-group", True, id="reviewed-development-bound-can-approve-new-extra-package"), + pytest.param("reviewed-marker", True, id="reviewed-matching-marker-can-approve-new-extra-package"), + pytest.param("no-new-package", True, id="new-extra-without-new-package-identity-remains-supported"), + pytest.param("unchanged-extra", True, id="unchanged-existing-extra-does-not-block-unrelated-new-package"), + pytest.param("widened-extra", False, id="widened-existing-extra-cannot-introduce-unreviewed-package"), + pytest.param("reviewed-widened-extra", True, id="reviewed-boundary-can-approve-widened-existing-extra"), + pytest.param("no-extra-change", True, id="ordinary-unrelated-new-package-remains-supported"), + pytest.param("canonical-extra", True, id="canonical-existing-extra-name-is-not-a-new-context"), + ], +) +def test_new_requested_extras_cannot_introduce_unreviewed_dependency_identities( + tmp_path: Path, variant: str, accepted: bool +) -> None: + base_requirements = ["patch-me>=1", "parent"] + head_requirements = ["patch-me>=1.1", "parent", "parent[new-extra]"] + base_optional: dict[str, list[str]] | None = None + head_optional: dict[str, list[str]] | None = None + constraints: list[str] | None = None + groups: dict[str, list[str]] | None = None + new_packages: list[tuple[str, str]] = [("unreviewed-plugin", "1")] + head_markers: dict[tuple[str, str], list[str]] | None = None + + if variant == "optional-extra": + base_requirements, head_requirements = ["patch-me>=1"], ["patch-me>=1.1"] + base_optional = {"feature": ["parent"]} + head_optional = {"feature": ["parent", "parent[new-extra]"]} + elif variant == "two-new-packages": + new_packages.append(("second-unreviewed-plugin", "1")) + constraints = ["unreviewed-plugin>=1"] + elif variant == "unbounded-review": + constraints = ["unreviewed-plugin"] + elif variant == "low-review": + constraints = ["unreviewed-plugin>=0"] + elif variant in {"wrong-marker", "reviewed-marker"}: + head_requirements[-1] += "; python_version >= '3.11'" + head_markers = {("unreviewed-plugin", "1"): ["python_full_version >= '3.11'"]} + suffix = "< '3.11'" if variant == "wrong-marker" else ">= '3.11'" + constraints = ["unreviewed-plugin>=1; python_version " + suffix] + elif variant == "reviewed-floor": + constraints = ["unreviewed-plugin>=1"] + elif variant == "reviewed-pin": + constraints = ["unreviewed-plugin==1"] + elif variant == "reviewed-group": + groups = {"reviewed": ["unreviewed-plugin>=1"]} + elif variant == "no-new-package": + new_packages = [] + elif variant == "unchanged-extra": + base_requirements[-1] = "parent[new-extra]" + head_requirements = ["patch-me>=1.1", "parent[new-extra]"] + elif variant in {"widened-extra", "reviewed-widened-extra"}: + base_requirements[-1] = "parent[new-extra]; python_version < '3.11'" + head_requirements = ["patch-me>=1.1", base_requirements[-1], "parent[new-extra]"] + if variant == "reviewed-widened-extra": + constraints = ["unreviewed-plugin>=1"] + elif variant == "no-extra-change": + head_requirements = ["patch-me>=1.1", "parent"] + elif variant == "canonical-extra": + base_requirements[-1] = "parent[New_Extra]" + head_requirements = ["patch-me>=1.1", "parent[new-extra]"] + + optional_edges: dict[tuple[str, str], dict[str, list[dict[str, object]]]] = { + ("parent", "1"): {"new-extra": [{"name": name} for name, _ in new_packages]} + } + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=[("patch-me", "1"), ("parent", "1")], + head_packages=[("patch-me", "1.1"), ("parent", "1"), *new_packages], + base_optional_groups=base_optional, + head_optional_groups=head_optional, + head_constraints=constraints, + head_dependency_groups=groups, + head_resolution_markers=head_markers, + head_lock_optional_dependencies=optional_edges, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("existing-dev", False, id="new-extra-cannot-publish-previously-dev-locked-package"), + pytest.param("reviewed-existing-dev", True, id="reviewed-floor-allows-previously-dev-locked-package"), + pytest.param("already-runtime", True, id="already-published-runtime-package-needs-no-extra-review"), + pytest.param("transitive-existing", False, id="new-extra-cannot-publish-transitive-dev-locked-package"), + pytest.param("reviewed-transitive", True, id="reviewed-contextual-floors-allow-transitive-existing-packages"), + pytest.param("selected-nested-extra", False, id="nested-selected-extra-cannot-publish-dev-locked-package"), + pytest.param("wrong-marker", False, id="reviewed-existing-package-bound-must-cover-extra-domain"), + pytest.param("matching-marker", True, id="reviewed-existing-package-marker-can-cover-extra-domain"), + pytest.param("unchanged-extra", True, id="unchanged-requested-extra-keeps-prior-published-reachability"), + pytest.param("cyclic-extra", False, id="cyclic-extra-cannot-hide-an-unreviewed-dependency"), + pytest.param("reviewed-cyclic-extra", True, id="reviewed-cyclic-extra-dependency-graph-is-supported"), + pytest.param("composed-extra", False, id="composed-extra-cannot-hide-an-unreviewed-dependency"), + pytest.param("reviewed-composed-extra", True, id="reviewed-self-referential-composed-extra-is-supported"), + pytest.param("mutual-cycle", False, id="mutual-package-cycle-cannot-hide-an-unreviewed-dependency"), + pytest.param("reviewed-mutual-cycle", True, id="reviewed-mutual-package-dependency-cycle-is-supported"), + pytest.param("ambiguous-edge", False, id="ambiguous-extra-dependency-edge-fails-closed"), + ], +) +def test_new_extras_cannot_publish_previously_locked_transitive_packages( + tmp_path: Path, variant: str, accepted: bool +) -> None: + base_requirements = ["patch-me>=1", "parent"] + head_requirements = ["patch-me>=1.1", "parent", "parent[new-extra]"] + packages = [("parent", "1"), ("existing-plugin", "1")] + base_groups = {"dev": ["existing-plugin"]} + head_groups = {"dev": ["existing-plugin"]} + base_edges: dict[tuple[str, str], list[dict[str, object]]] = {} + head_edges: dict[tuple[str, str], list[dict[str, object]]] = {} + parent_optional: dict[str, list[dict[str, object]]] = {"new-extra": [{"name": "existing-plugin"}]} + base_optional = {("parent", "1"): dict(parent_optional)} + head_optional = {("parent", "1"): dict(parent_optional)} + constraints: list[str] | None = None + markers: dict[tuple[str, str], list[str]] | None = None + + if variant == "reviewed-existing-dev": + constraints = ["existing-plugin>=1"] + elif variant == "already-runtime": + base_requirements.append("existing-plugin>=1") + head_requirements.append("existing-plugin>=1") + elif variant in { + "transitive-existing", + "reviewed-transitive", + "selected-nested-extra", + "cyclic-extra", + "reviewed-cyclic-extra", + "mutual-cycle", + "reviewed-mutual-cycle", + }: + packages.append(("bridge", "1")) + parent_optional["new-extra"] = [{"name": "bridge"}] + if variant == "selected-nested-extra": + parent_optional["new-extra"] = [{"name": "bridge", "extra": ["nested"]}] + head_optional[("bridge", "1")] = {"nested": [{"name": "existing-plugin"}]} + base_optional[("bridge", "1")] = {"nested": [{"name": "existing-plugin"}]} + elif variant in {"cyclic-extra", "reviewed-cyclic-extra"}: + head_edges[("bridge", "1")] = [{"name": "parent", "extra": ["new-extra"]}] + base_edges[("bridge", "1")] = list(head_edges[("bridge", "1")]) + elif variant in {"mutual-cycle", "reviewed-mutual-cycle"}: + head_edges[("bridge", "1")] = [{"name": "existing-plugin"}] + head_edges[("existing-plugin", "1")] = [{"name": "bridge"}] + base_edges.update({identity: list(edges) for identity, edges in head_edges.items()}) + else: + head_edges[("bridge", "1")] = [{"name": "existing-plugin"}] + base_edges[("bridge", "1")] = [{"name": "existing-plugin"}] + base_optional[("parent", "1")]["new-extra"] = list(parent_optional["new-extra"]) + head_optional[("parent", "1")]["new-extra"] = list(parent_optional["new-extra"]) + if variant in {"reviewed-transitive", "reviewed-mutual-cycle"}: + constraints = ["bridge>=1", "existing-plugin>=1"] + elif variant in {"transitive-existing", "selected-nested-extra", "reviewed-cyclic-extra", "mutual-cycle"}: + constraints = ["bridge>=1"] + elif variant in {"composed-extra", "reviewed-composed-extra"}: + parent_optional["new-extra"] = [{"name": "parent", "extra": ["nested"]}] + parent_optional["nested"] = [{"name": "existing-plugin"}] + base_optional[("parent", "1")] = dict(parent_optional) + head_optional[("parent", "1")] = dict(parent_optional) + if variant == "reviewed-composed-extra": + constraints = ["existing-plugin>=1"] + elif variant in {"wrong-marker", "matching-marker"}: + head_requirements[-1] += "; python_version >= '3.11'" + markers = {("existing-plugin", "1"): ["python_full_version >= '3.11'"]} + suffix = "< '3.11'" if variant == "wrong-marker" else ">= '3.11'" + constraints = ["existing-plugin>=1; python_version " + suffix] + elif variant == "unchanged-extra": + base_requirements[-1] = "parent[new-extra]" + head_requirements = ["patch-me>=1.1", "parent[new-extra]"] + elif variant == "ambiguous-edge": + parent_optional["new-extra"] = [{"name": "existing-plugin", "extra": "not-a-list"}] + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=[("patch-me", "1"), *packages], + head_packages=[("patch-me", "1.1"), *packages], + base_dependency_groups=base_groups, + head_dependency_groups=head_groups, + head_constraints=constraints, + base_resolution_markers=markers, + head_resolution_markers=markers, + base_lock_dependencies=base_edges, + head_lock_dependencies=head_edges, + base_lock_optional_dependencies=base_optional, + head_lock_optional_dependencies=head_optional, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("optional-to-runtime", False, id="optional-direct-floor-cannot-review-new-runtime-audience"), + pytest.param("runtime-reviewed", True, id="reviewed-runtime-floor-covers-new-runtime-audience"), + pytest.param("protected-reviewed", True, id="protected-floor-can-review-new-runtime-audience"), + pytest.param("protected-too-low", False, id="protected-review-must-reach-the-actual-locked-release"), + pytest.param("different-optional", False, id="one-optional-group-cannot-review-another-group-audience"), + pytest.param("same-optional", True, id="same-optional-group-retains-its-reviewed-direct-audience"), + ], +) +def test_new_extra_direct_dependency_boundaries_cover_their_actual_audience( + tmp_path: Path, variant: str, accepted: bool +) -> None: + base_requirements = ["patch-me>=1", "parent"] + head_requirements = ["patch-me>=1.1", "parent", "parent[feature]"] + base_optional = {"private": ["plugin>=1"]} + head_optional = {"private": ["plugin>=1"]} + constraints: list[str] | None = None + + if variant == "runtime-reviewed": + base_requirements.append("plugin>=2") + head_requirements.append("plugin>=2") + elif variant == "protected-reviewed": + constraints = ["plugin>=2"] + elif variant == "protected-too-low": + constraints = ["plugin>=1"] + elif variant == "different-optional": + base_requirements, head_requirements = ["patch-me>=1"], ["patch-me>=1.1"] + base_optional = {"private": ["plugin>=1"], "public": ["parent"]} + head_optional = {"private": ["plugin>=1"], "public": ["parent", "parent[feature]"]} + elif variant == "same-optional": + base_requirements, head_requirements = ["patch-me>=1"], ["patch-me>=1.1"] + base_optional = {"public": ["parent", "plugin>=2"]} + head_optional = {"public": ["parent", "parent[feature]", "plugin>=2"]} + + edges: dict[tuple[str, str], dict[str, list[dict[str, object]]]] = { + ("parent", "1"): {"feature": [{"name": "plugin"}]} + } + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=[("patch-me", "1"), ("parent", "1"), ("plugin", "2")], + head_packages=[("patch-me", "1.1"), ("parent", "1"), ("plugin", "2")], + base_optional_groups=base_optional, + head_optional_groups=head_optional, + head_constraints=constraints, + base_lock_optional_dependencies=edges, + head_lock_optional_dependencies=edges, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +def test_cyclic_extra_dependency_graph_retains_bounded_pending_states(tmp_path: Path) -> None: + edges: list[dict[str, object]] = [{"name": "parent", "extra": ["new-extra"]} for _ in range(4097)] + optional: dict[tuple[str, str], dict[str, list[dict[str, object]]]] = {("parent", "1"): {"new-extra": edges}} + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch-me>=1", "parent"], + head_requirements=["patch-me>=1.1", "parent", "parent[new-extra]"], + base_packages=[("patch-me", "1"), ("parent", "1")], + head_packages=[("patch-me", "1.1"), ("parent", "1")], + base_lock_optional_dependencies=optional, + head_lock_optional_dependencies=optional, + ) + assert result.returncode != 0 + assert "Unbounded published security dependency graph" in result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("published-wildcard", True, id="published-marker-split-preserves-existing-vulnerable-series"), + pytest.param("protected-wildcard", True, id="protected-marker-split-preserves-existing-vulnerable-series"), + pytest.param("canonical-wildcard", True, id="split-accepts-canonical-equivalent-wildcard-prefix"), + pytest.param("reordered-wildcard", True, id="split-accepts-reordered-identical-wildcard-exclusions"), + pytest.param("published-removed", False, id="published-split-cannot-drop-vulnerable-series-exclusion"), + pytest.param("protected-removed", False, id="protected-split-cannot-drop-vulnerable-series-exclusion"), + pytest.param("narrowed-wildcard", False, id="split-cannot-narrow-existing-vulnerable-series-exclusion"), + pytest.param("wrong-epoch", False, id="split-cannot-move-wildcard-exclusion-to-another-epoch"), + pytest.param("upper-removed", False, id="wildcard-marker-split-still-preserves-existing-upper-bound"), + pytest.param("post-wildcard", False, id="split-rejects-ambiguous-post-release-wildcard"), + ], +) +def test_marker_context_splits_preserve_canonical_wildcard_security_exclusions( + tmp_path: Path, variant: str, accepted: bool +) -> None: + old = "danger>=1,<3,!=2.0.*" + low = "danger>=1.6,<3,!=2.0.*; python_version < '3.11'" + high = "danger>=2.6,<3,!=2.0.*; python_version >= '3.11'" + protected = variant.startswith("protected-") + + if variant == "canonical-wildcard": + low = "danger>=1.6,<3,!=02.00.*; python_version < '3.11'" + elif variant == "reordered-wildcard": + low = "danger!=2.0.*,<3,>=1.6; python_version < '3.11'" + elif variant in {"published-removed", "protected-removed"}: + low = "danger>=1.6,<3; python_version < '3.11'" + elif variant == "narrowed-wildcard": + low = "danger>=1.6,<3,!=2.0.1.*; python_version < '3.11'" + elif variant == "wrong-epoch": + low = "danger>=1.6,<3,!=1!2.0.*; python_version < '3.11'" + elif variant == "upper-removed": + low = "danger>=1.6,!=2.0.*; python_version < '3.11'" + elif variant == "post-wildcard": + low = "danger>=1.6,<3,!=2.0.post1.*; python_version < '3.11'" + + base_optional = None if protected else {"feature": [old]} + head_optional = None if protected else {"feature": [low, high]} + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[], + head_requirements=[], + base_packages=[("danger", "1.5"), ("danger", "2.5")], + head_packages=[("danger", "1.6"), ("danger", "2.6")], + base_optional_groups=base_optional, + head_optional_groups=head_optional, + base_constraints=[old] if protected else None, + head_constraints=[low, high] if protected else None, + base_resolution_markers={ + ("danger", "1.5"): ["python_full_version < '3.11'"], + ("danger", "2.5"): ["python_full_version >= '3.11'"], + }, + head_resolution_markers={ + ("danger", "1.6"): ["python_full_version < '3.11'"], + ("danger", "2.6"): ["python_full_version >= '3.11'"], + }, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("published-lower-dropped", False, id="published-compatible-release-cannot-drop-lower-floor"), + pytest.param("published-upper-dropped", False, id="published-compatible-release-cannot-drop-series-ceiling"), + pytest.param("protected-upper-dropped", False, id="protected-compatible-release-cannot-drop-series-ceiling"), + pytest.param("equivalent-expanded", True, id="compatible-release-can-expand-to-equivalent-explicit-bounds"), + pytest.param("tighter-series", True, id="compatible-release-can-tighten-without-dropping-locked-release"), + pytest.param("zero-precision", False, id="compatible-release-trailing-zero-preserves-precision-ceiling"), + pytest.param("epoch-ceiling", False, id="compatible-release-preserves-epoch-aware-series-ceiling"), + pytest.param("post-floor", False, id="compatible-release-preserves-post-release-lower-floor"), + pytest.param("single-component", False, id="single-component-compatible-release-fails-closed"), + pytest.param("wildcard", False, id="wildcard-compatible-release-fails-closed"), + ], +) +def test_compatible_release_requirements_preserve_reviewed_security_bounds( + tmp_path: Path, variant: str, accepted: bool +) -> None: + original = "safe~=1.4" + replacement = "safe>=1.4,<2" + locked = "1.5" + protected = variant.startswith("protected-") + + if variant == "published-lower-dropped": + replacement = "safe>=1,<2" + elif variant in {"published-upper-dropped", "protected-upper-dropped"}: + replacement = "safe>=1.4" + elif variant == "tighter-series": + replacement = "safe~=1.5" + elif variant == "zero-precision": + original = "safe~=1.4.0" + replacement = "safe>=1.4,<2" + locked = "1.4.5" + elif variant == "epoch-ceiling": + original = "safe~=1!1.4" + replacement = "safe>=1!1.4" + locked = "1!1.5" + elif variant == "post-floor": + original = "safe~=1.4.post1" + replacement = "safe>=1.4,<2" + elif variant == "single-component": + original = "safe~=1" + elif variant == "wildcard": + original = "safe~=1.4.*" + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch-me>=1"] + ([] if protected else [original]), + head_requirements=["patch-me>=1.1"] + ([] if protected else [replacement]), + base_packages=[("patch-me", "1"), ("safe", locked)], + head_packages=[("patch-me", "1.1"), ("safe", locked)], + base_constraints=[original] if protected else None, + head_constraints=[replacement] if protected else None, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("previous", "updated"), + [ + pytest.param("danger >= 1.0 ", "danger >= 2.0 ", id="whitespace-padded-security-floor"), + pytest.param( + "danger >= 1.0 , >= 1.5 ", + "danger >= 1.0 , >= 2.0 ", + id="whitespace-padded-redundant-security-floors", + ), + ], +) +def test_security_floor_parser_strips_requirement_whitespace(tmp_path: Path, previous: str, updated: str) -> None: + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[previous], + head_requirements=[updated], + base_packages=[("danger", "1.5")], + head_packages=[("danger", "2.0")], + ) + assert result.returncode == 0, result.stdout + result.stderr + + +@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() @pytest.mark.parametrize("name", ["create-releases.yml", "publish-pypi.yml"]) @@ -189,3 +4760,3563 @@ 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] + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("runtime", True, id="published-runtime-or-marker-receives-security-patch"), + pytest.param("optional", True, id="published-optional-or-marker-receives-security-patch"), + pytest.param("constraint", True, id="reviewed-uv-constraint-or-marker-receives-security-patch"), + pytest.param("build", True, id="reviewed-build-constraint-or-marker-receives-security-patch"), + pytest.param("group", True, id="reviewed-development-group-or-marker-receives-security-patch"), + pytest.param("grouped", True, id="grouped-marker-disjunction-preserves-safe-precedence"), + pytest.param("precedence", True, id="ungrouped-marker-disjunction-preserves-and-precedence"), + pytest.param("reordered", True, id="reordered-marker-disjunction-preserves-original-source-scope"), + pytest.param("duplicate", True, id="duplicate-marker-disjunction-arm-is-idempotent"), + pytest.param("subsumed", True, id="subsumed-marker-disjunction-arm-preserves-source-scope"), + pytest.param("membership", True, id="membership-marker-disjunction-remains-bounded"), + pytest.param("widen-single", False, id="single-marker-source-cannot-gain-unreviewed-or-arm"), + pytest.param("widen-existing", False, id="existing-disjunction-cannot-gain-unreviewed-or-arm"), + pytest.param("dropped-arm", False, id="existing-disjunction-cannot-drop-supported-source-arm"), + pytest.param("lowered-arm", False, id="split-disjunction-arm-cannot-lower-original-security-floor"), + pytest.param("unsafe-call", False, id="disjunction-cannot-hide-executable-marker-expression"), + pytest.param("unsafe-constant", False, id="disjunction-cannot-hide-constant-marker-expression"), + pytest.param("unsafe-unary", False, id="disjunction-cannot-hide-unary-marker-expression"), + pytest.param("unsafe-chained", False, id="disjunction-cannot-hide-chained-marker-comparison"), + pytest.param("malformed-membership", False, id="disjunction-cannot-hide-ambiguous-membership"), + pytest.param("unbounded", False, id="unbounded-marker-disjunction-fails-closed"), + ], +) +def test_security_updates_preserve_bounded_disjunctive_requirement_markers( + tmp_path: Path, variant: str, accepted: bool +) -> None: + low = "python_version < '3.11'" + windows = "sys_platform == 'win32'" + marker = low + " or " + windows + head_marker = marker + base_floor, head_floor = "1", "2" + before, after = "1", "2" + head_entries: list[str] | None = None + scope = variant if variant in {"optional", "constraint", "build", "group"} else "runtime" + + if variant == "grouped": + marker = "(" + low + " or " + windows + ") and python_version >= '3.10'" + head_marker = marker + elif variant == "precedence": + marker = low + " or " + windows + " and python_version >= '3.10'" + head_marker = marker + elif variant == "reordered": + head_marker = windows + " or " + low + elif variant == "duplicate": + marker = low + " or " + low + head_marker = marker + elif variant == "subsumed": + marker = low + " or (" + low + " and " + windows + ")" + head_marker = marker + elif variant == "membership": + marker = "python_version in '3.10, 3.11' or " + windows + head_marker = marker + elif variant in {"widen-single", "widen-existing", "dropped-arm", "lowered-arm"}: + base_floor = head_floor = before = after = "2" + if variant == "widen-single": + marker, head_marker = low, marker + elif variant == "widen-existing": + head_marker = marker + " or sys_platform == 'darwin'" + elif variant == "dropped-arm": + head_marker = low + else: + head_entries = ["danger>=1; " + low, "danger>=2; " + windows] + elif variant == "unsafe-call": + head_marker = low + " or __import__('os').system('true')" + elif variant == "unsafe-constant": + head_marker = low + " or True" + elif variant == "unsafe-unary": + head_marker = low + " or not " + windows + elif variant == "unsafe-chained": + head_marker = low + " or python_version < '3.11' < '3.12'" + elif variant == "malformed-membership": + head_marker = low + " or python_version in '3.10,,3.11'" + elif variant == "unbounded": + head_marker = " or ".join("sys_platform == 'platform" + str(index) + "'" for index in range(129)) + + previous = "danger>=" + base_floor + "; " + marker + current = "danger>=" + head_floor + "; " + head_marker + head_entries = [current] if head_entries is None else head_entries + base_requirements = [previous] if scope == "runtime" else [] + head_requirements = head_entries if scope == "runtime" else [] + base_optional = {"feature": [previous]} if scope == "optional" else None + head_optional = {"feature": head_entries} if scope == "optional" else None + base_constraints = [previous] if scope == "constraint" else None + head_constraints = head_entries if scope == "constraint" else None + base_build = [previous] if scope == "build" else None + head_build = head_entries if scope == "build" else None + base_groups = {"reviewed": [previous]} if scope == "group" else None + head_groups = {"reviewed": head_entries} if scope == "group" else None + resolution = "python_full_version == '3.10.*'" + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=[("danger", before)], + head_packages=[("danger", after)], + base_optional_groups=base_optional, + head_optional_groups=head_optional, + 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={("danger", before): [resolution]}, + head_resolution_markers={("danger", after): [resolution]}, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("runtime", True, id="existing-published-marker-can-split-into-supported-domains"), + pytest.param("optional", True, id="existing-optional-marker-can-split-within-original-group"), + pytest.param("constraint", True, id="existing-uv-constraint-marker-can-split-within-original-scope"), + pytest.param("group", True, id="existing-development-marker-can-split-within-original-group"), + pytest.param("implicit-lower", True, id="marked-split-can-use-implicit-supported-python-lower-bound"), + pytest.param("gap", False, id="marked-split-cannot-drop-supported-original-resolution-domain"), + pytest.param("widened", False, id="marked-split-cannot-widen-outside-original-marker"), + pytest.param("overlap", False, id="marked-split-cannot-overlap-supported-resolution-subdomains"), + pytest.param("partial", False, id="marked-split-cannot-cover-only-one-supported-platform"), + pytest.param("weakened", False, id="marked-split-cannot-lower-original-security-floor"), + pytest.param("upper-removed", False, id="marked-split-cannot-remove-existing-upper-bound"), + pytest.param("exclusion-removed", False, id="marked-split-cannot-remove-existing-wildcard-exclusion"), + pytest.param("moved-group", False, id="marked-split-cannot-move-existing-optional-group"), + pytest.param("moved-extra", False, id="marked-split-cannot-replace-original-requested-extra"), + ], +) +def test_security_floors_can_safely_split_existing_marked_resolution_domains( + tmp_path: Path, variant: str, accepted: bool +) -> None: + original = "danger>=1,<3,!=2.0.*; python_version < '3.14'" + low = "danger>=1.6,<3,!=2.0.*; python_version >= '3.10' and python_version < '3.11'" + high = "danger>=2.6,<3,!=2.0.*; python_version >= '3.11' and python_version < '3.14'" + scope = variant if variant in {"optional", "constraint", "group"} else "runtime" + + if variant == "implicit-lower": + low = "danger>=1.6,<3,!=2.0.*; python_version < '3.11'" + elif variant == "gap": + low = "" + elif variant == "widened": + high = "danger>=2.6,<3,!=2.0.*; python_version >= '3.11'" + elif variant == "overlap": + low = "danger>=1.6,<3,!=2.0.*; python_version >= '3.10' and python_version < '3.12'" + elif variant == "partial": + low += " and sys_platform == 'linux'" + elif variant == "weakened": + low = "danger>=0.9,<3,!=2.0.*; python_version >= '3.10' and python_version < '3.11'" + elif variant == "upper-removed": + low = low.replace(",<3", "") + elif variant == "exclusion-removed": + low = low.replace(",!=2.0.*", "") + elif variant == "moved-extra": + original = original.replace("danger>=", "danger[secure]>=") + low = low.replace("danger>=", "danger[other]>=") + high = high.replace("danger>=", "danger[other]>=") + + replacements = [value for value in (low, high) if value] + base_requirements = [original] if scope == "runtime" else [] + head_requirements = replacements if scope == "runtime" else [] + base_optional = {"feature": [original]} if scope == "optional" or variant == "moved-group" else None + head_optional = ( + {"different": replacements} + if variant == "moved-group" + else {"feature": replacements} + if scope == "optional" + else None + ) + if variant == "moved-group": + base_requirements = head_requirements = [] + base_constraints = [original] if scope == "constraint" else None + head_constraints = replacements if scope == "constraint" else None + base_groups = {"reviewed": [original]} if scope == "group" else None + head_groups = {"reviewed": replacements} if scope == "group" else None + low_domain = "python_full_version >= '3.10' and python_full_version < '3.11'" + high_domain = "python_full_version >= '3.11'" + if scope in {"constraint", "group"}: + high_domain += " and python_full_version < '3.14'" + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=[("danger", "1.5"), ("danger", "2.5")], + head_packages=[("danger", "1.6"), ("danger", "2.6")], + 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={("danger", "1.5"): [low_domain], ("danger", "2.5"): [high_domain]}, + head_resolution_markers={("danger", "1.6"): [low_domain], ("danger", "2.6"): [high_domain]}, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("both", True, id="every-supported-major-can-receive-security-patch-together"), + pytest.param("missing-v1-exclusion", False, id="joint-patch-must-exclude-entire-vulnerable-v1-series"), + pytest.param("missing-v2-exclusion", False, id="joint-patch-must-exclude-entire-vulnerable-v2-series"), + pytest.param("drops-patched-v1", False, id="joint-patch-cannot-exclude-other-patched-supported-major"), + pytest.param("unchanged-v1-floor", False, id="joint-patch-must-raise-preexisting-v1-security-floor"), + pytest.param("unchanged-v2-floor", False, id="joint-patch-must-raise-preexisting-v2-security-floor"), + pytest.param("missing-old-group", False, id="joint-patch-cannot-trust-newly-added-protected-context"), + pytest.param("removed-group", False, id="joint-patch-cannot-remove-existing-supported-major-context"), + pytest.param("missing-upper", False, id="joint-patch-must-preserve-independent-branch-upper-bound"), + pytest.param("downgrade", False, id="joint-patch-cannot-downgrade-one-supported-major"), + pytest.param("removed-branch", False, id="joint-patch-cannot-remove-one-supported-locked-major"), + pytest.param("single-branch", False, id="published-exclusion-path-requires-distinct-supported-peer"), + ], +) +def test_security_updates_can_patch_all_independent_supported_major_branches( + tmp_path: Path, variant: str, accepted: bool +) -> None: + published = "danger>=1.5,<3" + exclusions = ["!=1.5.*", *("!=2." + str(minor) + ".*" for minor in range(6))] + updated_published = published + "," + ",".join(exclusions) + base_groups = { + "danger-v1": ["danger>=1.5,<2"], + "danger-v2": ["danger>=2.5,<3"], + } + head_groups = { + "danger-v1": ["danger>=1.6,<2"], + "danger-v2": ["danger>=2.6,<3"], + } + previous = [("danger", "1.5"), ("danger", "2.5")] + current = [("danger", "1.6"), ("danger", "2.6")] + + if variant == "missing-v1-exclusion": + updated_published = updated_published.replace(",!=1.5.*", "") + elif variant == "missing-v2-exclusion": + updated_published = updated_published.replace(",!=2.4.*", "") + elif variant == "drops-patched-v1": + updated_published += ",!=1.6.*" + elif variant == "unchanged-v1-floor": + head_groups["danger-v1"] = ["danger>=1.5,<2"] + elif variant == "unchanged-v2-floor": + head_groups["danger-v2"] = ["danger>=2.5,<3"] + elif variant == "missing-old-group": + base_groups.pop("danger-v2") + elif variant == "removed-group": + head_groups.pop("danger-v2") + elif variant == "missing-upper": + head_groups["danger-v2"] = ["danger>=2.6"] + elif variant == "downgrade": + current[1] = ("danger", "2.4") + elif variant == "removed-branch": + current = [("danger", "2.6")] + elif variant == "single-branch": + base_groups.pop("danger-v2") + head_groups.pop("danger-v2") + previous = [("danger", "1.5")] + current = [("danger", "1.6")] + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[published], + head_requirements=[updated_published], + base_packages=previous, + head_packages=current, + 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("unchanged", True, id="independent-overlapping-or-sources-can-remain-unchanged"), + pytest.param("raised", True, id="independent-overlapping-or-sources-can-both-raise-their-floor"), + pytest.param("widened", False, id="overlapping-source-cannot-borrow-another-source-to-widen"), + pytest.param("dropped", False, id="overlapping-source-cannot-drop-its-own-original-marker-arm"), + pytest.param("swapped-floor", False, id="overlapping-sources-cannot-swap-a-lower-security-floor"), + pytest.param("removed-source", False, id="overlapping-sources-cannot-remove-independent-source"), + ], +) +def test_security_marker_source_lineage_preserves_independent_overlapping_declarations( + tmp_path: Path, variant: str, accepted: bool +) -> None: + shared = "sys_platform == 'linux'" + low = "python_version < '3.11'" + high = "python_version >= '3.11'" + base = ["danger>=1; " + shared + " or " + low, "danger>=2; " + shared + " or " + high] + head = ["danger>=3; " + shared + " or " + low, "danger>=3; " + shared + " or " + high] + before, after = "2", "3" + if variant == "unchanged": + head = list(base) + after = before + elif variant == "widened": + head[0] += " or os_name == 'nt'" + elif variant == "dropped": + head[0] = "danger>=3; " + shared + elif variant == "swapped-floor": + base = ["danger>=2; " + shared + " or " + low, "danger>=3; " + shared + " or " + high] + head = ["danger>=3; " + shared + " or " + low, "danger>=2; " + shared + " or " + high] + before = after = "3" + elif variant == "removed-source": + head.pop() + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base, + head_requirements=head, + base_packages=[("danger", before)], + head_packages=[("danger", after)], + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + "variant", + [ + pytest.param("disjoint", id="unbounded-protected-source-cannot-move-to-disjoint-domain"), + pytest.param("removed", id="unbounded-protected-source-cannot-disappear-entirely"), + pytest.param("widened", id="unbounded-protected-source-cannot-gain-unreviewed-domain"), + pytest.param("add-protected", id="retained-protected-source-cannot-add-unbounded-disjoint-source"), + pytest.param("add-runtime", id="retained-runtime-source-cannot-add-unbounded-disjoint-source"), + ], +) +def test_unbounded_protected_marker_sources_preserve_their_reviewed_domain(tmp_path: Path, variant: str) -> None: + original = "danger; python_version < '3.11' or sys_platform == 'linux'" + if variant == "disjoint": + updated = ["danger; python_version >= '3.11' and sys_platform != 'linux'"] + elif variant == "removed": + updated = [] + elif variant in {"add-protected", "add-runtime"}: + updated = [original, "danger; os_name == 'nt'"] + else: + updated = [original + " or os_name == 'nt'"] + runtime = variant == "add-runtime" + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[original] if runtime else [], + head_requirements=updated if runtime else [], + base_packages=[("danger", "2")], + head_packages=[("danger", "2")], + base_dependency_groups=None if runtime else {"reviewed": [original]}, + head_dependency_groups=None if runtime else {"reviewed": updated}, + ) + assert result.returncode == 1, result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("runtime", True, id="published-or-source-can-split-into-independently-raised-arms"), + pytest.param("group", True, id="protected-or-source-can-split-into-independently-raised-arms"), + pytest.param("drop", False, id="or-source-partition-cannot-drop-original-supported-arm"), + pytest.param("widen", False, id="or-source-partition-cannot-widen-beyond-original-union"), + pytest.param("lower", False, id="or-source-partition-cannot-lower-any-original-floor"), + pytest.param("overlap", False, id="or-source-partition-cannot-overlap-independent-replacements"), + ], +) +def test_security_marker_disjunction_can_split_into_reviewed_source_declarations( + tmp_path: Path, variant: str, accepted: bool +) -> None: + low = "python_version < '3.11'" + high = "python_version >= '3.11' and python_version < '3.14'" + original = "danger>=1,<3; " + low + " or " + high + first = "danger>=1.6,<3; " + low + second = "danger>=2.6,<3; " + high + if variant == "drop": + second = "" + elif variant == "widen": + second = "danger>=2.6,<3; python_version >= '3.11'" + elif variant == "lower": + second = "danger>=0.5,<3; " + high + elif variant == "overlap": + first = "danger>=1.6,<3; python_version < '3.12'" + updated = [value for value in (first, second) if value] + protected = variant == "group" + low_domain = "python_full_version < '3.11'" + high_domain = "python_full_version >= '3.11' and python_full_version < '3.14'" + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [original], + head_requirements=[] if protected else updated, + base_packages=[("danger", "1.5"), ("danger", "2.5")], + head_packages=[("danger", "1.6"), ("danger", "2.6")], + base_dependency_groups={"reviewed": [original]} if protected else None, + head_dependency_groups={"reviewed": updated} if protected else None, + base_resolution_markers={("danger", "1.5"): [low_domain], ("danger", "2.5"): [high_domain]}, + head_resolution_markers={("danger", "1.6"): [low_domain], ("danger", "2.6"): [high_domain]}, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +def test_direct_disjunction_preserves_symbolic_extra_markers(tmp_path: Path) -> None: + marker = "extra == 'feature' or sys_platform == 'win32'" + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["danger[secure]>=1; " + marker], + head_requirements=["danger[secure]>=2; " + marker], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + ) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("reviewed", True, id="new-extra-can-review-exact-newly-exposed-locked-package"), + pytest.param("fake-extra", False, id="unrelated-new-extra-cannot-approve-different-protected-source"), + pytest.param("wrong-marker", False, id="new-extra-review-boundary-must-cover-actual-exposed-marker"), + pytest.param("low-floor", False, id="new-extra-review-boundary-must-reach-actual-locked-release"), + pytest.param("floorless", False, id="new-extra-cannot-approve-unbounded-protected-source"), + ], +) +def test_requested_extra_source_exceptions_validate_the_exact_exposed_package( + tmp_path: Path, variant: str, accepted: bool +) -> None: + marker = "python_version >= '3.11'" + requirement = "danger>=2; " + marker + target = "danger" + packages = [("parent", "1"), ("danger", "2")] + groups = {"reviewed": ["danger"]} + constraints: list[str] + + if variant == "wrong-marker": + requirement = "danger>=2; python_version < '3.11'" + elif variant == "low-floor": + requirement = "danger>=1; " + marker + elif variant == "floorless": + requirement = "danger; " + marker + elif variant == "fake-extra": + target = "safe-plugin" + packages.append(("safe-plugin", "1")) + groups["reviewed"].append("safe-plugin") + + constraints = [requirement] + if variant == "fake-extra": + constraints.append("safe-plugin>=1") + optional: dict[tuple[str, str], dict[str, list[dict[str, object]]]] = { + ("parent", "1"): {"feature": [{"name": target}]} + } + domains = {("danger", "2"): ["python_full_version >= '3.11'"]} + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["parent"], + head_requirements=["parent", "parent[feature]; " + marker], + base_packages=packages, + head_packages=packages, + base_dependency_groups=groups, + head_dependency_groups=groups, + head_constraints=constraints, + base_resolution_markers=domains, + head_resolution_markers=domains, + base_lock_optional_dependencies=optional, + head_lock_optional_dependencies=optional, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("transitive-refined", True, id="unchanged-transitive-broad-domain-can-refine-semantically"), + pytest.param("transitive-coalesced", True, id="unchanged-transitive-split-domains-can-coalesce-semantically"), + pytest.param("direct-refined", True, id="unchanged-direct-release-can-refine-its-lock-domain"), + pytest.param("protected-refined", True, id="unchanged-protected-release-can-refine-its-lock-domain"), + pytest.param("protected-coalesced", True, id="unchanged-protected-release-can-coalesce-lock-domains"), + pytest.param("platform-refined", True, id="equivalent-platform-complements-preserve-original-domain"), + pytest.param("membership-refined", True, id="equivalent-membership-alternatives-preserve-domain"), + pytest.param("independent-majors", True, id="domain-refinement-preserves-coexisting-supported-majors"), + pytest.param("reviewed-upgrade", True, id="refined-upgrade-remains-covered-by-reviewed-security-floor"), + pytest.param("complementary-upgrade", True, id="refined-upgrade-accepts-complementary-reviewed-floors"), + pytest.param("unreviewed-upgrade", False, id="domain-refinement-cannot-hide-unreviewed-upgraded-release"), + pytest.param("partial-upgrade", False, id="reviewed-floor-cannot-cover-only-one-refined-domain"), + pytest.param("gap", False, id="semantic-refinement-cannot-drop-an-original-resolution-region"), + pytest.param("widened", False, id="semantic-refinement-cannot-add-an-unreviewed-resolution-region"), + pytest.param("swapped", False, id="semantic-refinement-cannot-swap-releases-across-domains"), + pytest.param("unbounded", False, id="unbounded-common-resolution-refinement-fails-closed"), + ], +) +def test_security_resolution_domains_compare_semantic_release_coverage( + tmp_path: Path, variant: str, accepted: bool +) -> None: + broad = "python_full_version < '3.12'" + low = "python_full_version < '3.11'" + high = "python_full_version >= '3.11' and python_full_version < '3.12'" + previous = [("danger", "2")] + current = [("danger", "2")] + base_markers: dict[tuple[str, str], list[str]] = {("danger", "2"): [broad]} + head_markers: dict[tuple[str, str], list[str]] = {("danger", "2"): [low, high]} + base_requirements = ["patch-me>=1"] + head_requirements = ["patch-me>=1.1"] + base_constraints: list[str] | None = None + head_constraints: list[str] | None = None + + if variant in {"transitive-coalesced", "protected-coalesced"}: + base_markers, head_markers = head_markers, base_markers + if variant == "direct-refined": + base_requirements.append("danger>=2") + head_requirements.append("danger>=2") + elif variant in {"protected-refined", "protected-coalesced"}: + base_constraints = ["danger>=2"] + head_constraints = ["danger>=2"] + elif variant == "platform-refined": + head_markers = { + ("danger", "2"): [broad + " and sys_platform == 'linux'", broad + " and sys_platform != 'linux'"] + } + elif variant == "membership-refined": + base_markers = {("danger", "2"): ["python_version in '3.10, 3.11'"]} + head_markers = { + ("danger", "2"): ["python_version == '3.1'", "python_version == '3.10'", "python_version == '3.11'"] + } + elif variant == "independent-majors": + previous = current = [("danger", "1.5"), ("danger", "2.5")] + base_markers = {(name, release): [broad] for name, release in previous} + head_markers = {(name, release): [low, high] for name, release in current} + elif variant in {"reviewed-upgrade", "complementary-upgrade", "unreviewed-upgrade", "partial-upgrade"}: + current = [("danger", "3")] + head_markers = {("danger", "3"): [low, high]} + if variant == "reviewed-upgrade": + head_constraints = ["danger>=3"] + elif variant == "complementary-upgrade": + head_constraints = ["danger>=3; python_version < '3.11'", "danger>=3; python_version >= '3.11'"] + elif variant == "partial-upgrade": + head_constraints = ["danger>=3; python_version < '3.11'"] + elif variant == "gap": + head_markers = {("danger", "2"): [low]} + elif variant == "widened": + head_markers = {("danger", "2"): [low, high, "python_full_version >= '3.12'"]} + elif variant == "swapped": + previous = [("danger", "1"), ("danger", "2")] + current = [("danger", "1"), ("danger", "2")] + base_markers = {("danger", "1"): [low], ("danger", "2"): [high]} + head_markers = {("danger", "1"): [high], ("danger", "2"): [low]} + elif variant == "unbounded": + head_markers = { + ("danger", "2"): [broad + " and sys_platform == 'platform" + str(index) + "'" for index in range(129)] + } + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=[("patch-me", "1"), *previous], + head_packages=[("patch-me", "1.1"), *current], + base_constraints=base_constraints, + head_constraints=head_constraints, + 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("unrelated-root", True, id="new-extra-does-not-review-unrelated-runtime-root-transitive"), + pytest.param("same-parent-normal", True, id="new-extra-does-not-review-parent-normal-dependency"), + pytest.param("related-unreviewed", False, id="package-actually-reachable-from-new-extra-needs-review"), + pytest.param("related-reviewed", True, id="reviewed-extra-package-does-not-impose-unrelated-bound"), + pytest.param("wrong-audience", False, id="extra-review-must-intersect-actual-requested-audience"), + pytest.param("partial-platform", False, id="extra-review-cannot-cover-only-one-exposed-platform"), + pytest.param("complementary-platform", True, id="complementary-extra-reviews-cover-all-exposed-platforms"), + pytest.param("wrong-root", False, id="review-on-unrelated-root-cannot-secure-actual-extra-package"), + pytest.param("low-floor", False, id="actual-extra-review-floor-must-reach-selected-release"), + ], +) +def test_requested_extra_review_only_covers_packages_reachable_through_that_extra( + tmp_path: Path, variant: str, accepted: bool +) -> None: + base_requirements = ["patch-me>=1", "parent"] + head_requirements = ["patch-me>=1.1", "parent", "parent[feature]"] + previous = [("patch-me", "1"), ("parent", "1")] + current = [("patch-me", "1.1"), ("parent", "1"), ("unrelated", "1")] + head_edges: dict[tuple[str, str], list[dict[str, object]]] = {("patch-me", "1.1"): [{"name": "unrelated"}]} + optional: dict[tuple[str, str], dict[str, list[dict[str, object]]]] = {("parent", "1"): {"feature": []}} + constraints: list[str] | None = None + markers: dict[tuple[str, str], list[str]] | None = None + + if variant == "same-parent-normal": + head_edges = {("parent", "1"): [{"name": "unrelated"}]} + elif variant not in {"unrelated-root", "same-parent-normal"}: + current.append(("extra-package", "2")) + optional[("parent", "1")]["feature"].append({"name": "extra-package"}) + markers = {("extra-package", "2"): ["python_full_version >= '3.10'"]} + if variant == "related-reviewed": + constraints = ["extra-package>=2"] + elif variant == "wrong-audience": + head_requirements[-1] += "; python_version >= '3.11'" + constraints = ["extra-package>=2; python_version < '3.11'"] + elif variant == "partial-platform": + constraints = ["extra-package>=2; sys_platform == 'linux'"] + elif variant == "complementary-platform": + constraints = [ + "extra-package>=2; sys_platform == 'linux'", + "extra-package>=2; sys_platform != 'linux'", + ] + elif variant == "wrong-root": + constraints = ["unrelated>=1"] + elif variant == "low-floor": + constraints = ["extra-package>=1"] + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=base_requirements, + head_requirements=head_requirements, + base_packages=previous, + head_packages=current, + head_constraints=constraints, + head_resolution_markers=markers, + head_lock_dependencies=head_edges, + base_lock_optional_dependencies=optional, + head_lock_optional_dependencies=optional, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("substring", True, id="platform-membership-covers-actual-arm-substring"), + pytest.param("complete-token", True, id="platform-membership-still-covers-complete-token"), + pytest.param("overlapping-prefix", True, id="overlapping-platform-membership-covers-shorter-token"), + pytest.param("overlapping-long", True, id="overlapping-platform-membership-covers-longer-token"), + pytest.param("overlapping-short", True, id="overlapping-platform-membership-covers-hidden-short-substring"), + pytest.param("overlapping-negative", False, id="negative-overlapping-membership-excludes-shared-prefix"), + pytest.param("overlapping-negative-outside", True, id="negative-overlapping-membership-retains-outside-value"), + pytest.param("overlapping-case-sensitive", False, id="overlapping-platform-membership-preserves-quoted-case"), + pytest.param("overlapping-resolution", True, id="overlapping-platform-resolution-membership-remains-supported"), + pytest.param("overlapping-complement", True, id="overlapping-membership-and-complement-cover-every-substring"), + pytest.param( + "overlapping-dropped-complement", False, id="overlapping-membership-cannot-drop-complement-substrings" + ), + pytest.param( + "overlapping-dropped-substrings", False, id="overlapping-token-split-cannot-drop-hidden-substrings" + ), + pytest.param("duplicate-token", False, id="platform-membership-still-rejects-duplicate-tokens"), + pytest.param("empty-token", False, id="platform-membership-still-rejects-empty-tokens"), + pytest.param("unbounded-tokens", False, id="platform-membership-still-rejects-too-many-tokens"), + pytest.param("case-sensitive", False, id="platform-membership-keeps-quoted-case"), + pytest.param("negative-substring", False, id="negative-membership-rejects-an-actual-substring"), + pytest.param("negative-outside", True, id="negative-membership-keeps-values-outside-the-string"), + pytest.param("mixed-complement", True, id="membership-and-negative-complement-intersect-exactly"), + pytest.param("mixed-complement-rejected", False, id="negative-membership-removes-an-actual-substring"), + pytest.param("complement-partition", True, id="membership-and-complement-fully-partition-original-domain"), + pytest.param("dropped-complement", False, id="missing-membership-complement-cannot-drop-original-contexts"), + pytest.param("dropped-substrings", False, id="equality-split-cannot-drop-original-membership-substrings"), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_platform_membership_markers_preserve_pep508_substring_domains( + tmp_path: Path, variant: str, accepted: bool, protected: bool +) -> None: + expression = "platform_machine in 'arm64, x86_64'" + original = "danger>=1; " + expression + updated = ["danger>=2; " + expression] + old_domains = ["platform_machine == 'arm'"] + new_domains = list(old_domains) + + if variant == "complete-token": + old_domains = new_domains = ["platform_machine == 'arm64'"] + elif variant in { + "overlapping-prefix", + "overlapping-long", + "overlapping-short", + "overlapping-negative", + "overlapping-negative-outside", + "overlapping-case-sensitive", + }: + negative = variant in {"overlapping-negative", "overlapping-negative-outside"} + expression = "platform_machine " + ("not in" if negative else "in") + " 'arm, arm64'" + original = "danger>=1; " + expression + updated = ["danger>=2; " + expression] + if variant == "overlapping-long": + old_domains = new_domains = ["platform_machine == 'arm64'"] + elif variant == "overlapping-short": + old_domains = new_domains = ["platform_machine == 'a'"] + elif variant == "overlapping-negative-outside": + old_domains = new_domains = ["platform_machine == 'aarch64'"] + elif variant == "overlapping-case-sensitive": + old_domains = new_domains = ["platform_machine == 'ARM'"] + elif variant == "overlapping-resolution": + expression = "platform_machine != 'aarch64'" + original = "danger>=1; " + expression + updated = ["danger>=2; " + expression] + old_domains = new_domains = ["platform_machine in 'arm, arm64'"] + elif variant in {"overlapping-complement", "overlapping-dropped-complement"}: + expression = "platform_machine in 'arm, arm64'" + original = "danger>=1; " + expression + included = expression + " and platform_machine in 'arm64'" + excluded = expression + " and platform_machine not in 'arm64'" + updated = ["danger>=2; " + excluded] + old_domains = [expression] + new_domains = [excluded] + if variant == "overlapping-complement": + updated.append("danger>=2; " + included) + new_domains.append(included) + elif variant == "overlapping-dropped-substrings": + expression = "platform_machine in 'arm, arm64'" + original = "danger>=1; " + expression + updated = ["danger>=2; platform_machine == 'arm'", "danger>=2; platform_machine == 'arm64'"] + old_domains = [expression] + new_domains = ["platform_machine == 'arm'", "platform_machine == 'arm64'"] + elif variant in {"duplicate-token", "empty-token", "unbounded-tokens"}: + values = "arm, arm" if variant == "duplicate-token" else "arm,,arm64" + if variant == "unbounded-tokens": + values = ", ".join("arm" + str(index) for index in range(17)) + expression = "platform_machine in '" + values + "'" + original = "danger>=1; " + expression + updated = ["danger>=2; " + expression] + elif variant == "case-sensitive": + old_domains = new_domains = ["platform_machine == 'ARM'"] + elif variant in {"negative-substring", "negative-outside"}: + expression = "platform_machine not in 'arm64, x86_64'" + original = "danger>=1; " + expression + updated = ["danger>=2; " + expression] + if variant == "negative-outside": + old_domains = new_domains = ["platform_machine == 'aarch64'"] + elif variant in {"mixed-complement", "mixed-complement-rejected"}: + expression = "platform_machine in 'arm64, x86_64' and platform_machine not in 'x86_64'" + original = "danger>=1; " + expression + updated = ["danger>=2; " + expression] + if variant == "mixed-complement-rejected": + old_domains = new_domains = ["platform_machine == 'x86'"] + elif variant in {"complement-partition", "dropped-complement"}: + included = expression + " and platform_machine in 'x86_64'" + excluded = expression + " and platform_machine not in 'x86_64'" + updated = ["danger>=2; " + excluded] + old_domains = [expression] + new_domains = [excluded] + if variant == "complement-partition": + updated.append("danger>=2; " + included) + new_domains.append(included) + elif variant == "dropped-substrings": + updated = [ + "danger>=2; platform_machine == 'arm64'", + "danger>=2; platform_machine == 'x86_64'", + ] + old_domains = [expression] + new_domains = ["platform_machine == 'arm64'", "platform_machine == 'x86_64'"] + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [original], + head_requirements=[] if protected else updated, + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[original] if protected else None, + head_constraints=updated if protected else None, + base_resolution_markers={("danger", "1"): old_domains}, + head_resolution_markers={("danger", "2"): new_domains}, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("requirement_marker", "release", "membership"), + [ + pytest.param("platform_release in 'arm64'", "6", "arm64", id="string-membership-keeps-numeric-substring"), + pytest.param( + "platform_release not in 'arm64'", "6", "arm64", id="negative-membership-rejects-numeric-substring" + ), + pytest.param("platform_release in 'arm64, x86_64'", "6", "arm64", id="membership-preserves-entire-raw-string"), + pytest.param( + "platform_release == '6.0' and platform_release in 'arm64'", + "6", + "arm64", + id="normalized-equality-keeps-raw-substring-witness", + ), + pytest.param( + "platform_release == '6' and platform_release in 'v6' and platform_release not in '6'", + "v6", + "v6", + id="v-prefixed-raw-alias-survives-negative-membership", + ), + pytest.param( + "platform_release == '6a0' and platform_release in '6a'", + "6a", + "6a", + id="missing-prerelease-serial-normalizes-without-losing-raw-spelling", + ), + pytest.param( + "platform_release == '6a1' and platform_release in '6alpha1'", + "6alpha1", + "6alpha1", + id="prerelease-stage-alias-normalizes-without-losing-raw-spelling", + ), + pytest.param( + "platform_release == '6.post0' and platform_release in '6.post'", + "6.post", + "6.post", + id="missing-postrelease-serial-normalizes-without-losing-raw-spelling", + ), + pytest.param( + "platform_release == '6.post1' and platform_release in '6-1'", + "6-1", + "6-1", + id="implicit-postrelease-normalizes-without-losing-raw-spelling", + ), + pytest.param( + "platform_release == '6.dev0' and platform_release in '6.dev'", + "6.dev", + "6.dev", + id="missing-development-serial-normalizes-without-losing-raw-spelling", + ), + pytest.param( + "platform_release != '6.0' and platform_release in 'arm64'", + "6", + "arm64", + id="normalized-inequality-removes-raw-substring-witness", + ), + pytest.param( + "platform_release != 'linux' and platform_release in 'arm64'", + "6", + "arm64", + id="string-exclusion-does-not-reject-numeric-membership-domain", + ), + pytest.param( + "platform_release < '7' and platform_release in 'arm64'", + "6", + "arm64", + id="exclusive-upper-bound-keeps-numeric-substring", + ), + pytest.param( + "platform_release <= '6' and platform_release in 'arm64'", + "6", + "arm64", + id="inclusive-upper-bound-keeps-numeric-substring", + ), + pytest.param( + "platform_release > '5' and platform_release in 'arm64'", + "6", + "arm64", + id="exclusive-lower-bound-keeps-numeric-substring", + ), + pytest.param( + "platform_release >= '6' and platform_release in 'arm64'", + "6", + "arm64", + id="inclusive-lower-bound-keeps-numeric-substring", + ), + pytest.param( + "platform_release > '6' and platform_release in 'arm64'", + "6", + "arm64", + id="exclusive-lower-bound-rejects-equal-substring", + ), + pytest.param( + "platform_release >= '5' and platform_release not in 'arm64'", + "6", + "arm64", + id="negative-membership-still-limits-numeric-witness", + ), + pytest.param( + "platform_release == '6.*' and platform_release in 'arm64'", + "6", + "arm64", + id="wildcard-equality-keeps-numeric-substring", + ), + pytest.param( + "platform_release >= '6.8.0.1a1' and platform_release in 'kernel-6.8.0.1a2'", + "6.8.0.1a2", + "kernel-6.8.0.1a2", + id="prerelease-membership-preserves-numeric-ordering", + ), + pytest.param( + "platform_release > '6.8.0.1a1' and platform_release in 'kernel-6.8.0.1a1.post1'", + "6.8.0.1a1.post1", + "kernel-6.8.0.1a1.post1", + id="strict-prerelease-floor-excludes-its-post-release", + ), + pytest.param( + "platform_release > '6.8.0.1.post1' and platform_release in 'kernel-6.8.0.1.post2'", + "6.8.0.1.post2", + "kernel-6.8.0.1.post2", + id="post-release-membership-preserves-numeric-ordering", + ), + pytest.param( + "platform_release >= '6.8.0.1.dev1' and platform_release in 'kernel-6.8.0.1.dev2'", + "6.8.0.1.dev2", + "kernel-6.8.0.1.dev2", + id="development-membership-preserves-numeric-ordering", + ), + pytest.param( + "platform_release ~= '6.8.0.1' and platform_release in 'kernel-6.8.0.2'", + "6.8.0.2", + "kernel-6.8.0.2", + id="wide-compatible-range-preserves-raw-membership", + ), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_platform_release_membership_matches_packaging_version_domains( + tmp_path: Path, requirement_marker: str, release: str, membership: str, protected: bool +) -> None: + expected = Marker(requirement_marker).evaluate(environment={"platform_release": release}) + resolution_marker = f"platform_release == '{Version(release)}' and platform_release in '{membership}'" + if release == "v6": + resolution_marker += " and platform_release not in '6'" + previous = "danger>=1; " + requirement_marker + updated = "danger>=2; " + requirement_marker + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [previous], + head_requirements=[] if protected else [updated], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[previous] if protected else None, + head_constraints=[updated] if protected else None, + base_resolution_markers={("danger", "1"): [resolution_marker]}, + head_resolution_markers={("danger", "2"): [resolution_marker]}, + ) + assert result.returncode == (0 if expected else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize("missing", [None, "included", "excluded"], ids=["complete", "missing-in", "missing-not-in"]) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_platform_release_membership_complements_preserve_every_numeric_alias( + tmp_path: Path, missing: str | None, protected: bool +) -> None: + domain = "platform_release == '6'" + partitions = { + "included": domain + " and platform_release in 'arm64'", + "excluded": domain + " and platform_release not in 'arm64'", + } + current = [marker for name, marker in partitions.items() if name != missing] + previous = "danger>=1; " + domain + updated = ["danger>=2; " + marker for marker in current] + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [previous], + head_requirements=[] if protected else updated, + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[previous] if protected else None, + head_constraints=updated if protected else None, + base_resolution_markers={("danger", "1"): [domain]}, + head_resolution_markers={("danger", "2"): current}, + ) + assert result.returncode == (0 if missing is None else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_platform_release_negative_membership_retains_equivalent_numeric_aliases( + tmp_path: Path, protected: bool +) -> None: + marker = "platform_release == '6' and platform_release not in '6, 6.0, 06, 6.00'" + assert Marker(marker).evaluate(environment={"platform_release": "6.0.0"}) + previous, updated = "danger>=1; " + marker, "danger>=2; " + marker + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [previous], + head_requirements=[] if protected else [updated], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[previous] if protected else None, + head_constraints=[updated] if protected else None, + base_resolution_markers={("danger", "1"): [marker]}, + head_resolution_markers={("danger", "2"): [marker]}, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("requirement_marker", "release"), + [ + pytest.param("platform_release not in '6'", "6", id="standalone-negative-excludes-exact-release"), + pytest.param("platform_release not in '7'", "6", id="standalone-negative-preserves-other-release"), + pytest.param("platform_release not in 'kernel-6'", "6", id="negative-preserves-raw-substring-semantics"), + pytest.param("platform_release not in 'linux'", "6", id="negative-preserves-nonnumeric-operand"), + pytest.param("platform_release not in '6'", "6.0", id="negative-preserves-dotted-raw-alias"), + pytest.param("platform_release not in '6.0'", "6.0", id="negative-excludes-dotted-raw-release"), + pytest.param("platform_release not in '06'", "6", id="negative-excludes-leading-zero-containing-string"), + pytest.param("platform_release not in '6'", "06", id="negative-preserves-leading-zero-raw-alias"), + pytest.param( + "platform_release not in '7' and platform_release not in 'kernel-6'", + "6", + id="every-negative-clause-is-preserved", + ), + pytest.param( + "platform_release not in '7' and platform_release not in 'linux'", + "6", + id="multiple-negative-clauses-can-share-a-witness", + ), + pytest.param( + "platform_release >= '5' and platform_release not in '6'", + "6", + id="numeric-lower-bound-cannot-drop-negative-membership", + ), + pytest.param( + "platform_release == '6' and platform_release not in '6'", + "6", + id="numeric-equality-cannot-manufacture-an-alias-to-evade-negative-membership", + ), + pytest.param( + "platform_release == '6' and platform_release not in '6'", + "6.0", + id="numeric-equality-retains-a-real-dotted-raw-alias", + ), + pytest.param( + "platform_release == '6' and platform_release in '6'", + "6.0", + id="numeric-equality-cannot-manufacture-a-positive-membership-alias", + ), + pytest.param( + "platform_release == '6' or platform_release in 'arm64'", + "6.0", + id="numeric-equality-disjunction-preserves-its-dotted-alias-alternative", + ), + pytest.param( + "platform_release < '7' and platform_release not in '5'", + "6", + id="numeric-upper-bound-retains-valid-negative-membership", + ), + pytest.param( + "platform_release != 'linux' and platform_release not in '6'", + "6", + id="mixed-raw-and-numeric-domains-retain-negative-membership", + ), + pytest.param( + "platform_release in 'kernel-6' and platform_release not in '6'", + "6", + id="positive-and-negative-memberships-share-the-same-witness", + ), + pytest.param( + "platform_release not in 'kernel-6.8.0.1'", + "6.8.0.1", + id="negative-membership-preserves-arbitrary-release-width", + ), + pytest.param( + "platform_release not in 'kernel-6.8.0.1a1'", + "6.8.0.1a1", + id="negative-membership-preserves-prerelease-spelling", + ), + pytest.param( + "platform_release not in 'kernel-6.8.0.1.post2'", + "6.8.0.1.post2", + id="negative-membership-preserves-postrelease-spelling", + ), + pytest.param( + "platform_release not in 'kernel-6.8.0.1.dev3'", + "6.8.0.1.dev3", + id="negative-membership-preserves-development-spelling", + ), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_platform_release_negative_membership_matches_packaging_without_positive_membership( + tmp_path: Path, requirement_marker: str, release: str, protected: bool +) -> None: + expected = Marker(requirement_marker).evaluate(environment={"platform_release": release}) + domain = f"platform_release == '{release}'" + previous, updated = "danger>=1; " + requirement_marker, "danger>=2; " + requirement_marker + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [previous], + head_requirements=[] if protected else [updated], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[previous] if protected else None, + head_constraints=[updated] if protected else None, + base_resolution_markers={("danger", "1"): [domain]}, + head_resolution_markers={("danger", "2"): [domain]}, + ) + assert result.returncode == (0 if expected else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("requirement_marker", "resolution_marker", "release"), + [ + pytest.param( + "platform_release === '6.8.0'", + "platform_release == '6.8.0'", + "6.8.0", + id="arbitrary-equality-preserves-exact-raw-platform-release", + ), + pytest.param( + "platform_release === '6.8.0'", + "platform_release == '6.8'", + "6.8", + id="arbitrary-equality-rejects-shorter-normalized-alias", + ), + pytest.param( + "platform_release === '6.8'", + "platform_release == '6.8.0'", + "6.8.0", + id="arbitrary-equality-rejects-longer-normalized-alias", + ), + pytest.param( + "platform_release === '06.008.000'", + "platform_release == '06.008.000'", + "06.008.000", + id="arbitrary-equality-retains-leading-zero-spelling", + ), + pytest.param( + "platform_release === '6.0' and platform_release == '6'", + "platform_release == '6.0'", + "6.0", + id="arbitrary-equality-can-intersect-an-equivalent-numeric-alias", + ), + pytest.param( + "platform_release === '06' and platform_release == '6'", + "platform_release == '06'", + "06", + id="arbitrary-equality-can-intersect-a-leading-zero-numeric-alias", + ), + pytest.param( + "platform_release === '6.8.0.1.2'", + "platform_release == '6.8.0.1.2'", + "6.8.0.1.2", + id="arbitrary-equality-supports-arbitrary-release-width", + ), + pytest.param( + "platform_release === '6.8.0.1a1'", + "platform_release == '6.8.0.1a1'", + "6.8.0.1a1", + id="arbitrary-equality-retains-prerelease-spelling", + ), + pytest.param( + "platform_release === '6.8.0.1.post2'", + "platform_release == '6.8.0.1.post2'", + "6.8.0.1.post2", + id="arbitrary-equality-retains-postrelease-spelling", + ), + pytest.param( + "platform_release === '6.8.0.1.dev3'", + "platform_release == '6.8.0.1.dev3'", + "6.8.0.1.dev3", + id="arbitrary-equality-retains-development-spelling", + ), + pytest.param( + "platform_release === 'Linux'", + "platform_release == 'linux'", + "linux", + id="arbitrary-equality-matches-packaging-case-insensitivity", + ), + pytest.param( + "platform_release === 'v6.8'", + "platform_release === 'v6.8'", + "v6.8", + id="arbitrary-equality-retains-v-prefixed-raw-domains", + ), + pytest.param( + "platform_release === '6+LOCAL'", + "platform_release === '6+local'", + "6+local", + id="arbitrary-equality-retains-case-insensitive-local-domains", + ), + pytest.param( + "platform_release === '6' and platform_release not in '6'", + "platform_release == '6'", + "6", + id="raw-equality-cannot-evade-negative-membership", + ), + pytest.param( + "platform_release === '6' and platform_release in 'kernel-6'", + "platform_release == '6'", + "6", + id="raw-equality-can-intersect-positive-membership", + ), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_platform_release_arbitrary_equality_matches_packaging_raw_domains( + tmp_path: Path, requirement_marker: str, resolution_marker: str, release: str, protected: bool +) -> None: + expected = Marker(requirement_marker).evaluate(environment={"platform_release": release}) + assert Marker(resolution_marker).evaluate(environment={"platform_release": release}) + previous, updated = "danger>=1; " + requirement_marker, "danger>=2; " + requirement_marker + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [previous], + head_requirements=[] if protected else [updated], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[previous] if protected else None, + head_constraints=[updated] if protected else None, + base_resolution_markers={("danger", "1"): [resolution_marker]}, + head_resolution_markers={("danger", "2"): [resolution_marker]}, + ) + assert result.returncode == (0 if expected else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize("scope", ["runtime", "optional", "constraint", "build", "group"]) +@pytest.mark.parametrize( + ("marker", "release"), + [ + pytest.param("platform_release not in '6'", "6", id="inactive-negative-membership"), + pytest.param("platform_release not in '7'", "6", id="active-negative-membership"), + pytest.param("platform_release === '6.0'", "6", id="inactive-normalized-raw-equality-alias"), + pytest.param("platform_release === '6'", "6", id="active-exact-raw-equality"), + ], +) +def test_platform_release_raw_security_boundaries_are_consistent_across_dependency_groups( + tmp_path: Path, scope: str, marker: str, release: str +) -> None: + expected = Marker(marker).evaluate(environment={"platform_release": release}) + previous, updated = "danger>=1; " + marker, "danger>=2; " + marker + runtime = scope in {"runtime", "optional"} + domain = f"platform_release == '{release}'" + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[previous] if runtime else [], + head_requirements=[updated] if runtime else [], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + optional=scope == "optional", + base_constraints=[previous] if scope == "constraint" else None, + head_constraints=[updated] if scope == "constraint" else None, + base_build_constraints=[previous] if scope == "build" else None, + head_build_constraints=[updated] if scope == "build" else None, + base_dependency_groups={"development": [previous]} if scope == "group" else None, + head_dependency_groups={"development": [updated]} if scope == "group" else None, + base_resolution_markers={("danger", "1"): [domain]}, + head_resolution_markers={("danger", "2"): [domain]}, + ) + assert result.returncode == (0 if expected else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize("scope", ["constraint", "build", "group"]) +@pytest.mark.parametrize("exposure", ["transitive-upgrade", "new-extra"]) +@pytest.mark.parametrize( + ("marker", "release"), + [ + pytest.param("platform_release not in '6'", "6", id="inactive-negative-boundary"), + pytest.param("platform_release not in '7'", "6", id="active-negative-boundary"), + pytest.param("platform_release not in '6'", "6.0", id="active-normalized-numeric-alias"), + pytest.param("platform_release === '6'", "6", id="active-exact-raw-boundary"), + pytest.param("platform_release === '6.0'", "6", id="inactive-normalized-raw-boundary"), + ], +) +def test_new_platform_release_security_boundaries_cover_actual_exposure_domains( + tmp_path: Path, scope: str, exposure: str, marker: str, release: str +) -> None: + expected = Marker(marker).evaluate(environment={"platform_release": release}) + requirement = "danger>=2; " + marker + domain = f"platform_release == '{release}'" + extra = exposure == "new-extra" + optional: dict[tuple[str, str], dict[str, list[dict[str, object]]]] | None = ( + {("parent", "1"): {"feature": [{"name": "danger"}]}} if extra else None + ) + base_packages = [("patch", "1"), ("parent", "1") if extra else ("danger", "1")] + head_packages = [("patch", "2"), *([("parent", "1")] if extra else []), ("danger", "2")] + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch>=1", *(["parent"] if extra else [])], + head_requirements=["patch>=2", *(["parent", "parent[feature]"] if extra else [])], + base_packages=base_packages, + head_packages=head_packages, + head_constraints=[requirement] if scope == "constraint" else None, + head_build_constraints=[requirement] if scope == "build" else None, + head_dependency_groups={"reviewed": [requirement]} if scope == "group" else None, + base_lock_optional_dependencies=optional, + head_lock_optional_dependencies=optional, + base_resolution_markers=None if extra else {("danger", "1"): [domain]}, + head_resolution_markers={("danger", "2"): [domain]}, + ) + assert result.returncode == (0 if expected else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("requirement_marker", "resolution_marker", "accepted"), + [ + pytest.param("'lin' in sys_platform", "sys_platform == 'linux'", True, id="reversed-platform-substring"), + pytest.param( + "'linux' in sys_platform", "sys_platform == 'lin'", False, id="reversed-membership-keeps-direction" + ), + pytest.param("'lin' in sys_platform", "sys_platform == 'win32'", False, id="reversed-platform-missing-needle"), + pytest.param("'win' not in sys_platform", "sys_platform == 'linux'", True, id="reversed-negative-outside"), + pytest.param("'lin' not in sys_platform", "sys_platform == 'linux'", False, id="reversed-negative-substring"), + pytest.param( + "sys_platform == 'linux'", + "'lin' in sys_platform and sys_platform == 'linux'", + True, + id="reversed-resolution-substring", + ), + pytest.param("sys_platform == 'linux'", "'lin' not in sys_platform", False, id="reversed-resolution-negative"), + pytest.param( + "'lin' in sys_platform and 'ux' in sys_platform", + "sys_platform == 'linux'", + True, + id="reversed-platform-keeps-every-required-needle", + ), + pytest.param( + "'lin' in sys_platform and 'in' not in sys_platform", + "sys_platform == 'linux'", + False, + id="required-needle-cannot-contain-a-forbidden-needle", + ), + pytest.param( + "'lin' in sys_platform and sys_platform in 'linux,win32'", + "sys_platform == 'linux'", + True, + id="forward-and-reversed-membership-share-one-platform-witness", + ), + pytest.param( + "'inu' in sys_platform and sys_platform in 'linux,win32'", + "sys_platform == 'win32'", + False, + id="forward-and-reversed-membership-reject-distinct-witnesses", + ), + pytest.param("'' in sys_platform", "sys_platform == 'linux'", True, id="empty-reversed-needle-is-universal"), + pytest.param("'' not in sys_platform", "sys_platform == 'linux'", False, id="empty-reversed-negative-is-empty"), + pytest.param("'LIN' in sys_platform", "sys_platform == 'linux'", False, id="reversed-platform-keeps-case"), + pytest.param( + "'3.1' in platform_version", + "platform_version == '3.10'", + True, + id="reversed-platform-version-remains-string-containment", + ), + pytest.param( + "'6.1' in platform_release", + "platform_release == '6.10'", + False, + id="reversed-numeric-platform-release-fails-closed", + ), + pytest.param( + "'3.1' in python_version", + "python_full_version == '3.10.4'", + False, + id="unbounded-reversed-python-version-space-fails-closed", + ), + pytest.param( + "'" + "a" * 257 + "' in sys_platform", + "sys_platform == 'linux'", + False, + id="unbounded-reversed-platform-needle-fails-closed", + ), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_reversed_platform_membership_preserves_operand_orientation( + tmp_path: Path, requirement_marker: str, resolution_marker: str, accepted: bool, protected: bool +) -> None: + previous = "danger>=1; " + requirement_marker + updated = "danger>=2; " + requirement_marker + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [previous], + head_requirements=[] if protected else [updated], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[previous] if protected else None, + head_constraints=[updated] if protected else None, + base_resolution_markers={("danger", "1"): [resolution_marker]}, + head_resolution_markers={("danger", "2"): [resolution_marker]}, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize("complete", [False, True], ids=["missing-complement", "complete-complement"]) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_reversed_platform_membership_complements_preserve_every_security_domain( + tmp_path: Path, complete: bool, protected: bool +) -> None: + domain = "sys_platform != 'darwin'" + included = domain + " and 'lin' in sys_platform" + excluded = domain + " and 'lin' not in sys_platform" + updated = ["danger>=2; " + included] + new_domains = [included] + if complete: + updated.append("danger>=2; " + excluded) + new_domains.append(excluded) + previous = "danger>=1; " + domain + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [previous], + head_requirements=[] if protected else updated, + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[previous] if protected else None, + head_constraints=updated if protected else None, + base_resolution_markers={("danger", "1"): [domain]}, + head_resolution_markers={("danger", "2"): new_domains}, + ) + assert result.returncode == (0 if complete else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("requirement_marker", "resolution_marker", "accepted"), + [ + pytest.param( + "sys_platform >= 'linux'", + "sys_platform == 'linux'", + True, + id="inclusive-lower-includes-only-the-equal-platform", + ), + pytest.param( + "sys_platform > 'linux'", + "sys_platform == 'linux'", + False, + id="exclusive-lower-rejects-equal-platform", + ), + pytest.param( + "sys_platform > 'linux'", + "sys_platform == 'win32'", + False, + id="exclusive-lower-does-not-use-lexical-platform-ordering", + ), + pytest.param( + "sys_platform >= 'linux'", + "sys_platform == 'win32'", + False, + id="inclusive-lower-does-not-cover-a-lexically-greater-platform", + ), + pytest.param( + "sys_platform <= 'linux'", + "sys_platform == 'linux'", + True, + id="inclusive-upper-includes-only-the-equal-platform", + ), + pytest.param( + "sys_platform <= 'linux'", + "sys_platform == 'darwin'", + False, + id="inclusive-upper-does-not-cover-a-lexically-smaller-platform", + ), + pytest.param( + "sys_platform < 'linux'", + "sys_platform == 'linux'", + False, + id="exclusive-upper-rejects-equal-platform", + ), + pytest.param( + "sys_platform < 'ab'", + "sys_platform == 'a'", + False, + id="exclusive-upper-does-not-use-lexical-platform-ordering", + ), + pytest.param( + "'ab' > sys_platform", + "sys_platform == 'a'", + False, + id="reversed-exclusive-comparison-does-not-use-lexical-ordering", + ), + pytest.param( + "'a' <= sys_platform", + "sys_platform == 'a'", + True, + id="reversed-inclusive-comparison-preserves-equal-platform", + ), + pytest.param( + "'a' <= sys_platform", + "sys_platform == 'b'", + False, + id="reversed-inclusive-comparison-rejects-unequal-platform", + ), + pytest.param( + "sys_platform >= 'linux' and sys_platform < 'win32'", + "sys_platform == 'linux'", + False, + id="exclusive-upper-makes-a-platform-window-unsatisfiable", + ), + pytest.param( + "sys_platform >= 'linux' and sys_platform < 'win32'", + "sys_platform == 'win32'", + False, + id="exclusive-platform-window-rejects-every-platform", + ), + pytest.param( + "sys_platform >= 'linux'", + "sys_platform >= 'linux' and sys_platform < 'win32'", + False, + id="exclusive-resolution-marker-has-no-installer-domain", + ), + pytest.param( + "sys_platform > 'linux'", + "sys_platform <= 'linux'", + False, + id="strict-platform-comparison-cannot-cover-an-inclusive-domain", + ), + pytest.param( + "sys_platform >= 'linux' and sys_platform != 'linux'", + "sys_platform == 'linux'", + False, + id="inclusive-platform-exclusion-cannot-be-lost", + ), + pytest.param( + "sys_platform >= 'linux' and sys_platform != 'linux'", + "sys_platform > 'linux'", + False, + id="inclusive-platform-exclusion-leaves-no-other-witness", + ), + pytest.param( + "platform_machine > 'ar' and platform_machine in 'arm64, x86_64'", + "platform_machine == 'arm'", + False, + id="strict-platform-comparison-cannot-gain-a-substring-witness", + ), + pytest.param( + "platform_machine >= 'arm' and platform_machine in 'arm64, x86_64'", + "platform_machine == 'arm'", + True, + id="inclusive-platform-comparison-retains-an-equal-substring-witness", + ), + pytest.param( + "platform_version > '3.9'", + "platform_version == '3.10'", + False, + id="platform-version-does-not-enable-version-ordering", + ), + pytest.param( + "platform_version >= '3.9'", + "platform_version == '3.10'", + False, + id="inclusive-platform-version-comparison-still-requires-equality", + ), + pytest.param( + "platform_release < 'build-42'", + "platform_release == 'build-4'", + False, + id="nonnumeric-platform-release-does-not-use-lexical-ordering", + ), + pytest.param( + "platform_release >= 'build-42'", + "platform_release == 'build-42'", + True, + id="nonnumeric-platform-release-inclusive-comparison-preserves-equality", + ), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_ordered_platform_markers_preserve_installer_security_domains( + tmp_path: Path, + requirement_marker: str, + resolution_marker: str, + accepted: bool, + protected: bool, +) -> None: + before = "danger>=1; " + requirement_marker + after = "danger>=2; " + requirement_marker + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [before], + head_requirements=[] if protected else [after], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[before] if protected else None, + head_constraints=[after] if protected else None, + base_resolution_markers={("danger", "1"): [resolution_marker]}, + head_resolution_markers={("danger", "2"): [resolution_marker]}, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("requirement_marker", "resolution_marker", "accepted"), + [ + pytest.param( + "sys_platform < 'ab'", + "sys_platform == 'a'", + False, + id="strict-less-than-never-protects-a-transitive-platform", + ), + pytest.param( + "sys_platform > 'a'", + "sys_platform == 'ab'", + False, + id="strict-greater-than-never-protects-a-transitive-platform", + ), + pytest.param( + "sys_platform >= 'a'", + "sys_platform in 'a,b'", + False, + id="inclusive-lower-cannot-hide-an-unprotected-platform-fragment", + ), + pytest.param( + "sys_platform <= 'a'", + "sys_platform in 'a,b'", + False, + id="inclusive-upper-cannot-hide-an-unprotected-platform-fragment", + ), + pytest.param( + "sys_platform >= 'a'", + "sys_platform == 'a'", + True, + id="inclusive-lower-protects-its-equal-platform", + ), + pytest.param( + "sys_platform <= 'a'", + "sys_platform == 'a'", + True, + id="inclusive-upper-protects-its-equal-platform", + ), + ], +) +def test_ordered_platform_markers_cannot_fake_transitive_security_coverage( + tmp_path: Path, requirement_marker: str, resolution_marker: str, accepted: bool +) -> 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", "1")], + head_packages=[("patch-me", "1.1"), ("danger", "2")], + base_constraints=["danger>=1; " + (requirement_marker if accepted else resolution_marker)], + head_constraints=["danger>=2; " + requirement_marker], + base_resolution_markers={("danger", "1"): [resolution_marker]}, + head_resolution_markers={("danger", "2"): [resolution_marker]}, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("patched-range", True, id="wildcard-equality-normalizes-to-inclusive-series-range"), + pytest.param("subsumed-exclusion", True, id="patched-floor-subsumes-the-original-wildcard-exclusion"), + pytest.param("preserved-exclusion", True, id="wildcard-equality-retains-an-independent-prefix-exclusion"), + pytest.param("removed-exclusion", False, id="wildcard-equality-cannot-drop-an-uncovered-exclusion"), + pytest.param("widened-series", False, id="wildcard-equality-cannot-widen-its-original-major-series"), + pytest.param("removed-upper", False, id="wildcard-equality-cannot-drop-its-implicit-upper-bound"), + pytest.param("nested-prefix", True, id="minor-wildcard-equality-keeps-the-correct-next-prefix-ceiling"), + pytest.param("minor-zero-prefix", True, id="one-zero-wildcard-equality-has-one-one-exclusive-ceiling"), + pytest.param("patch-zero-prefix", True, id="one-zero-zero-wildcard-equality-has-one-zero-one-ceiling"), + pytest.param("canonical-zeroes", True, id="wildcard-equality-normalizes-release-prefix-zeroes"), + pytest.param("epoch-zeroes", True, id="wildcard-equality-normalizes-epoch-and-release-zeroes"), + pytest.param("epoch", True, id="wildcard-equality-preserves-its-explicit-epoch"), + pytest.param("wrong-epoch", False, id="wildcard-equality-cannot-move-its-epoch"), + pytest.param("malformed-post", False, id="post-release-wildcard-equality-remains-invalid"), + pytest.param("retained-release", False, id="wildcard-equality-cannot-drop-an-unchanged-supported-lock"), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_wildcard_equality_series_preserve_reviewed_security_bounds( + tmp_path: Path, variant: str, accepted: bool, protected: bool +) -> None: + previous, current = "danger==1.*", "danger>=1.3,<2" + before, after = ["1.2"], ["1.3"] + + if variant in {"preserved-exclusion", "removed-exclusion"}: + previous = "danger==1.*,!=1.8.*" + current = "danger>=1.3,<2" + (",!=1.8.*" if variant == "preserved-exclusion" else "") + elif variant == "subsumed-exclusion": + previous = "danger==1.*,!=1.2.*" + before = ["1.1"] + elif variant == "widened-series": + current = "danger>=1.3,<3" + elif variant == "removed-upper": + current = "danger>=1.3" + elif variant == "nested-prefix": + previous, current = "danger==1.2.*", "danger>=1.2.4,<1.3" + before, after = ["1.2.3"], ["1.2.4"] + elif variant == "minor-zero-prefix": + previous, current = "danger==1.0.*", "danger>=1.0.3,<1.1" + before, after = ["1.0.2"], ["1.0.3"] + elif variant == "patch-zero-prefix": + previous, current = "danger==1.0.0.*", "danger>=1.0.0.4,<1.0.1" + before, after = ["1.0.0.3"], ["1.0.0.4"] + elif variant == "canonical-zeroes": + previous, current = "danger==01.00.*", "danger>=1.0.3,<1.1" + before, after = ["1.0.2"], ["1.0.3"] + elif variant == "epoch-zeroes": + previous, current = "danger==01!01.00.*", "danger>=1!1.0.3,<1!1.1" + before, after = ["1!1.0.2"], ["1!1.0.3"] + elif variant in {"epoch", "wrong-epoch"}: + previous = "danger==1!1.*" + current = "danger>=1!1.3,<1!2" if variant == "epoch" else "danger>=0!1.3,<0!2" + before = ["1!1.2"] + after = ["1!1.3"] if variant == "epoch" else ["1.3"] + elif variant == "malformed-post": + previous = "danger==1.post1.*" + before, after = ["1.post1"], ["1.3"] + elif variant == "retained-release": + current = "danger>=1.3,<1.5" + before, after = ["1.2", "1.8"], ["1.3", "1.8"] + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [previous], + head_requirements=[] if protected else [current], + base_packages=[("danger", version) for version in before], + head_packages=[("danger", version) for version in after], + base_constraints=[previous] if protected else None, + head_constraints=[current] if protected else None, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variant", "accepted"), + [ + pytest.param("full-substring", True, id="full-python-membership-covers-partial-final-digits"), + pytest.param("full-token", True, id="full-python-membership-keeps-complete-token"), + pytest.param("full-outside", False, id="full-python-membership-rejects-values-outside-literal"), + pytest.param("full-negative-substring", False, id="full-python-negative-membership-rejects-real-substring"), + pytest.param("full-negative-outside", True, id="full-python-negative-membership-keeps-outside-release"), + pytest.param("minor-substring", True, id="python-minor-membership-covers-partial-minor-digits"), + pytest.param( + "minor-containing-full-version", True, id="python-minor-membership-allows-containing-full-version" + ), + pytest.param( + "minor-containing-full-versions", True, id="python-minor-membership-allows-containing-full-version-list" + ), + pytest.param("minor-overlapping-prefix", True, id="python-minor-membership-allows-overlapping-prefix"), + pytest.param("minor-overlapping-short", True, id="python-minor-membership-preserves-short-overlapping-prefix"), + pytest.param( + "minor-overlapping-negative", False, id="negative-python-minor-membership-excludes-overlapping-prefix" + ), + pytest.param( + "minor-overlapping-negative-outside", True, id="negative-python-minor-membership-keeps-outside-release" + ), + pytest.param("full-overlapping-prefix", True, id="full-python-membership-allows-overlapping-prefix"), + pytest.param("full-overlapping-short", True, id="full-python-membership-preserves-short-overlapping-prefix"), + pytest.param( + "full-overlapping-prerelease", True, id="full-python-membership-allows-overlapping-prerelease-serials" + ), + pytest.param("minor-negative-substring", False, id="python-minor-negative-membership-excludes-partial-minor"), + pytest.param("minor-dropped-substrings", False, id="python-minor-equalities-cannot-drop-hidden-three-one"), + pytest.param( + "minor-overlapping-dropped-substrings", False, id="overlapping-python-membership-cannot-drop-short-prefix" + ), + pytest.param("resolution-projection", True, id="full-python-membership-resolution-projects-into-minor"), + pytest.param("complete-partition", True, id="python-membership-and-complement-cover-entire-domain"), + pytest.param("missing-partition", False, id="missing-python-membership-complement-cannot-drop-domain"), + pytest.param("dropped-substrings", False, id="python-token-equalities-cannot-drop-original-substrings"), + pytest.param("unbounded-substrings", False, id="unbounded-python-membership-substrings-fail-closed"), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_python_membership_markers_preserve_exact_pep508_substrings( + tmp_path: Path, variant: str, accepted: bool, protected: bool +) -> None: + expression = "python_full_version in '3.10.10, 3.10.11'" + original = "danger>=1; " + expression + updated = ["danger>=2; " + expression] + old_domains = ["python_full_version == '3.10.1'"] + new_domains = list(old_domains) + + if variant == "full-token": + old_domains = new_domains = ["python_full_version == '3.10.10'"] + elif variant == "full-outside": + old_domains = new_domains = ["python_full_version == '3.10.12'"] + elif variant in {"full-negative-substring", "full-negative-outside"}: + expression = "python_full_version not in '3.10.10, 3.10.11'" + original = "danger>=1; " + expression + updated = ["danger>=2; " + expression] + if variant == "full-negative-outside": + old_domains = new_domains = ["python_full_version == '3.10.12'"] + elif variant in {"minor-substring", "minor-negative-substring"}: + operator = "not in" if variant == "minor-negative-substring" else "in" + expression = "python_version " + operator + " '3.10, 3.11'" + original = "danger>=1; " + expression + updated = ["danger>=2; " + expression] + old_domains = new_domains = ["python_full_version == '3.1.7'"] + elif variant in {"minor-containing-full-version", "minor-containing-full-versions"}: + versions = "3.10.1" if variant == "minor-containing-full-version" else "3.10.1, 3.11.2" + release = "3.10.7" if variant == "minor-containing-full-version" else "3.11.7" + expression = "python_version in '" + versions + "'" + original = "danger>=1; " + expression + updated = ["danger>=2; " + expression] + old_domains = new_domains = ["python_full_version == '" + release + "'"] + elif variant in { + "minor-overlapping-prefix", + "minor-overlapping-short", + "minor-overlapping-negative", + "minor-overlapping-negative-outside", + }: + negative = variant in {"minor-overlapping-negative", "minor-overlapping-negative-outside"} + operator = "not in" if negative else "in" + expression = "python_version " + operator + " '3.1, 3.10'" + original = "danger>=1; " + expression + updated = ["danger>=2; " + expression] + if variant == "minor-overlapping-short": + release = "3.1.7" + elif variant == "minor-overlapping-negative-outside": + release = "3.11.7" + else: + release = "3.10.7" + old_domains = new_domains = ["python_full_version == '" + release + "'"] + elif variant in {"full-overlapping-prefix", "full-overlapping-short", "full-overlapping-prerelease"}: + versions = "3.15.0a1, 3.15.0a10" if variant == "full-overlapping-prerelease" else "3.10.1, 3.10.10" + if variant == "full-overlapping-short": + release = "3.10.1" + elif variant == "full-overlapping-prerelease": + release = "3.15.0a10" + else: + release = "3.10.10" + expression = "python_full_version in '" + versions + "'" + original = "danger>=1; " + expression + updated = ["danger>=2; " + expression] + old_domains = new_domains = ["python_full_version == '" + release + "'"] + elif variant == "minor-dropped-substrings": + expression = "python_version in '3.10, 3.11'" + original = "danger>=1; " + expression + updated = [ + "danger>=2; python_version == '3.10'", + "danger>=2; python_version == '3.11'", + ] + old_domains = [expression] + new_domains = ["python_version == '3.10'", "python_version == '3.11'"] + elif variant == "minor-overlapping-dropped-substrings": + expression = "python_version in '3.1, 3.10'" + original = "danger>=1; " + expression + updated = ["danger>=2; python_version == '3.10'"] + old_domains = [expression] + new_domains = ["python_version == '3.10'"] + elif variant == "resolution-projection": + expression = "python_version == '3.10'" + original = "danger>=1; " + expression + updated = ["danger>=2; " + expression] + old_domains = new_domains = ["python_full_version in '3.10.10, 3.10.11'"] + elif variant in {"complete-partition", "missing-partition"}: + included = expression + " and python_full_version in '3.10.11'" + excluded = expression + " and python_full_version not in '3.10.11'" + updated = ["danger>=2; " + excluded] + old_domains = new_domains = [ + "python_full_version == '3.10.1'", + "python_full_version == '3.10.10'", + "python_full_version == '3.10.11'", + ] + if variant == "complete-partition": + updated.append("danger>=2; " + included) + elif variant == "dropped-substrings": + updated = [ + "danger>=2; python_full_version == '3.10.10'", + "danger>=2; python_full_version == '3.10.11'", + ] + old_domains = [expression] + new_domains = ["python_full_version == '3.10.10'", "python_full_version == '3.10.11'"] + elif variant == "unbounded-substrings": + members = ", ".join("3.10." + str(1000 + index) for index in range(16)) + expression = "python_full_version in '" + members + "'" + original = "danger>=1; " + expression + updated = ["danger>=2; " + expression] + old_domains = new_domains = ["python_full_version == '3.10.1000'"] + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [original], + head_requirements=[] if protected else updated, + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[original] if protected else None, + head_constraints=updated if protected else None, + base_resolution_markers={("danger", "1"): old_domains}, + head_resolution_markers={("danger", "2"): new_domains}, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("requirement_marker", "resolution_marker", "accepted"), + [ + pytest.param("'3.10' == python_version", "python_full_version == '3.10.4'", True, id="reversed-equality"), + pytest.param("'3.10' != python_version", "python_full_version == '3.11.4'", True, id="reversed-inequality"), + pytest.param("'3.10' != python_version", "python_full_version == '3.10.4'", False, id="reversed-excluded"), + pytest.param("'3.10' < python_version", "python_full_version == '3.11.0'", True, id="reversed-exclusive-lower"), + pytest.param("'3.10' < python_version", "python_full_version == '3.10.4'", False, id="reversed-lower-equal"), + pytest.param( + "'3.10' <= python_version", "python_full_version == '3.10.4'", True, id="reversed-inclusive-lower" + ), + pytest.param("'3.10' <= python_version", "python_full_version == '3.9.4'", False, id="reversed-lower-outside"), + pytest.param("'3.10' > python_version", "python_full_version == '3.9.4'", True, id="reversed-exclusive-upper"), + pytest.param("'3.10' > python_version", "python_full_version == '3.10.4'", False, id="reversed-upper-equal"), + pytest.param( + "'3.10' >= python_version", "python_full_version == '3.10.4'", True, id="reversed-inclusive-upper" + ), + pytest.param("'3.10' >= python_version", "python_full_version == '3.11.4'", False, id="reversed-upper-outside"), + pytest.param("'3.1' == python_version", "python_full_version == '3.1.7'", True, id="reversed-minor-projection"), + pytest.param("'3.10.1' <= python_full_version", "python_full_version == '3.10.1'", True, id="reversed-full"), + pytest.param("'linux' <= sys_platform", "sys_platform == 'win32'", False, id="reversed-platform-unequal"), + pytest.param("'linux' <= sys_platform", "sys_platform == 'darwin'", False, id="reversed-platform-outside"), + pytest.param( + "python_version >= '3.10'", + "'3.10.4' <= python_full_version", + True, + id="reversed-resolution-comparison", + ), + pytest.param( + "sys_platform >= 'linux'", + "'linux' <= sys_platform", + True, + id="reversed-platform-resolution", + ), + pytest.param("'3.10' in python_version", "python_full_version == '3.10.4'", False, id="reversed-in-rejected"), + pytest.param( + "'3.10' not in python_version", + "python_full_version == '3.11.4'", + False, + id="reversed-not-in-rejected", + ), + pytest.param( + "'3.10' <= python_version <= '3.11'", + "python_full_version == '3.10.4'", + False, + id="reversed-chained-comparison-rejected", + ), + pytest.param( + "3.10 <= python_version", "python_full_version == '3.10.4'", False, id="nonstring-literal-rejected" + ), + pytest.param( + "python_full_version <= python_version", + "python_full_version == '3.10.4'", + False, + id="two-marker-variables-rejected", + ), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_reversed_literal_marker_comparisons_preserve_semantic_security_domains( + tmp_path: Path, requirement_marker: str, resolution_marker: str, accepted: bool, protected: bool +) -> None: + previous = "danger>=1; " + requirement_marker + updated = "danger>=2; " + requirement_marker + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [previous], + head_requirements=[] if protected else [updated], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[previous] if protected else None, + head_constraints=[updated] if protected else None, + base_resolution_markers={("danger", "1"): [resolution_marker]}, + head_resolution_markers={("danger", "2"): [resolution_marker]}, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("requested", "marker", "reviewed", "accepted"), + [ + pytest.param("foo", "extra in 'foobar'", False, False, id="substring-extra-cannot-hide-unreviewed-package"), + pytest.param("foo", "extra in 'foobar'", True, True, id="substring-extra-can-use-reviewed-package"), + pytest.param( + "foo", "extra not in 'foobar'", False, True, id="negative-substring-extra-does-not-expose-package" + ), + pytest.param( + "foo-bar", "extra in 'FOO_BAR-baz'", False, False, id="extra-substring-normalizes-both-pep508-operands" + ), + pytest.param( + "foo-bar", "'FOO_BAR' in extra", False, False, id="reversed-extra-containment-normalizes-the-needle" + ), + pytest.param("foo-bar", "'foo' in extra", False, False, id="reversed-extra-containment-keeps-direction"), + pytest.param("foo", "'foobar' in extra", False, True, id="reversed-extra-longer-needle-cannot-match"), + pytest.param("foo", "'foo' not in extra", False, True, id="reversed-extra-negative-excludes-a-real-needle"), + pytest.param("bar", "'foo' not in extra", False, False, id="reversed-extra-negative-keeps-an-outside-needle"), + ], +) +def test_locked_extra_membership_uses_pep508_substring_containment( + tmp_path: Path, requested: str, marker: str, reviewed: bool, accepted: bool +) -> None: + optional: dict[tuple[str, str], dict[str, list[dict[str, object]]]] = { + ("parent", "1"): {requested: [{"name": "plugin", "marker": marker}]} + } + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch-me>=1", "parent"], + head_requirements=["patch-me>=1.1", "parent", "parent[" + requested + "]"], + base_packages=[("patch-me", "1"), ("parent", "1")], + head_packages=[("patch-me", "1.1"), ("parent", "1"), ("plugin", "1")], + head_constraints=["plugin>=1"] if reviewed else None, + base_lock_optional_dependencies=optional, + head_lock_optional_dependencies=optional, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("requested", "marker"), + [ + pytest.param("new-extra", "extra >= 'new-extra'", id="inclusive-lower-order-matches-selected-extra"), + pytest.param("new-extra", "extra <= 'new-extra'", id="inclusive-upper-order-matches-selected-extra"), + pytest.param("new-extra", "extra > 'new-extra'", id="strict-lower-order-does-not-match-selected-extra"), + pytest.param("new-extra", "extra < 'new-extra'", id="strict-upper-order-does-not-match-selected-extra"), + pytest.param("new-extra", "extra >= 'other-extra'", id="inclusive-lower-order-rejects-different-extra"), + pytest.param("new-extra", "extra <= 'other-extra'", id="inclusive-upper-order-rejects-different-extra"), + pytest.param("new-extra", "extra > 'aaa'", id="strict-lower-order-does-not-fall-back-to-lexical-order"), + pytest.param("new-extra", "extra < 'zzz'", id="strict-upper-order-does-not-fall-back-to-lexical-order"), + pytest.param("new-extra", "extra >= 'NEW_EXTRA'", id="inclusive-order-normalizes-pep508-extra-alias"), + pytest.param("new-extra", "'NEW_EXTRA' <= extra", id="reversed-inclusive-order-normalizes-extra-alias"), + pytest.param("new-extra", "'new-extra' >= extra", id="reversed-inclusive-upper-order-matches-equality"), + pytest.param("new-extra", "'new-extra' < extra", id="reversed-strict-order-remains-unsatisfied"), + pytest.param( + "new-extra", + "extra >= 'new-extra' and extra <= 'NEW_EXTRA'", + id="inclusive-extra-conjunction-shares-one-normalized-witness", + ), + pytest.param( + "new-extra", + "extra >= 'new-extra' and extra < 'new-extra'", + id="strict-and-inclusive-extra-conjunction-remains-unsatisfied", + ), + pytest.param( + "new-extra", + "extra > 'new-extra' or extra <= 'NEW_EXTRA'", + id="inclusive-extra-disjunction-retains-its-active-alternative", + ), + ], +) +@pytest.mark.parametrize("reviewed", [False, True], ids=["unreviewed-package", "reviewed-package"]) +def test_selected_extra_ordered_markers_match_packaging_without_hiding_dependencies( + tmp_path: Path, requested: str, marker: str, reviewed: bool +) -> None: + active = Marker(marker).evaluate(environment={"extra": requested}) + optional: dict[tuple[str, str], dict[str, list[dict[str, object]]]] = { + ("parent", "1"): {requested: [{"name": "plugin", "marker": marker}]} + } + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch-me>=1", "parent"], + head_requirements=["patch-me>=1.1", "parent", "parent[" + requested + "]"], + base_packages=[("patch-me", "1"), ("parent", "1")], + head_packages=[("patch-me", "1.1"), ("parent", "1"), ("plugin", "1")], + head_constraints=["plugin>=1"] if reviewed else None, + base_lock_optional_dependencies=optional, + head_lock_optional_dependencies=optional, + ) + assert result.returncode == (0 if not active or reviewed else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("marker", "reviewed", "accepted"), + [ + pytest.param( + "extra != 'foo' and extra != 'bar'", + None, + True, + id="different-selected-extras-cannot-witness-separate-negative-clauses", + ), + pytest.param( + "extra == 'foo' and extra == 'bar'", + None, + True, + id="different-selected-extras-cannot-witness-separate-equality-clauses", + ), + pytest.param( + "extra == 'foo' and extra != 'bar'", + None, + False, + id="one-selected-extra-satisfying-both-clauses-exposes-the-package", + ), + pytest.param( + "extra == 'foo' and extra != 'bar'", + ["plugin>=1"], + True, + id="one-selected-extra-satisfying-both-clauses-can-use-a-reviewed-package", + ), + pytest.param( + "extra in 'foobar' and extra not in 'foobar'", + None, + True, + id="membership-and-its-complement-cannot-use-different-selected-extras", + ), + pytest.param( + "extra in 'foo' and extra not in 'bar'", + None, + False, + id="membership-conjunction-retains-a-single-valid-selected-extra", + ), + pytest.param( + "(extra == 'foo' and sys_platform == 'linux') or (extra == 'bar' and sys_platform == 'win32')", + ["plugin>=1; sys_platform == 'linux'"], + False, + id="distinct-selected-extras-retain-both-platform-security-domains", + ), + pytest.param( + "(extra == 'foo' and sys_platform == 'linux') or (extra == 'bar' and sys_platform == 'win32')", + ["plugin>=1; sys_platform == 'linux'", "plugin>=1; sys_platform == 'win32'"], + True, + id="distinct-selected-extras-can-each-use-their-reviewed-platform-domain", + ), + pytest.param( + "(extra != 'foo' and extra != 'bar') or (extra == 'foo' and sys_platform == 'linux')", + ["plugin>=1; sys_platform == 'linux'"], + True, + id="nested-boolean-marker-discards-cross-extra-witnesses-and-keeps-platform-context", + ), + ], +) +def test_locked_extra_conjunctions_share_one_selected_extra_witness( + tmp_path: Path, marker: str, reviewed: list[str] | None, accepted: bool +) -> None: + optional: dict[tuple[str, str], dict[str, list[dict[str, object]]]] = { + ("parent", "1"): {"foo": [{"name": "plugin", "marker": marker}], "bar": []} + } + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch-me>=1", "parent"], + head_requirements=["patch-me>=1.1", "parent", "parent[foo,bar]"], + base_packages=[("patch-me", "1"), ("parent", "1")], + head_packages=[("patch-me", "1.1"), ("parent", "1"), ("plugin", "1")], + head_constraints=reviewed, + base_lock_optional_dependencies=optional, + head_lock_optional_dependencies=optional, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("marker", "reviewed", "accepted"), + [ + pytest.param("extra == ''", False, False, id="missing-extra-uses-its-empty-selected-value"), + pytest.param("extra == ''", True, True, id="matching-empty-extra-can-use-a-reviewed-package"), + pytest.param("extra != ''", False, True, id="missing-extra-rejects-a-nonempty-only-edge"), + pytest.param("extra in ''", False, False, id="empty-selected-extra-preserves-substring-containment"), + pytest.param("extra in 'foobar'", False, False, id="empty-selected-extra-is-a-substring-of-other-values"), + pytest.param("extra not in ''", False, True, id="empty-selected-extra-preserves-negative-containment"), + ], +) +def test_locked_extra_marker_defaults_to_one_empty_selected_extra( + tmp_path: Path, marker: str, reviewed: bool, accepted: bool +) -> None: + optional: dict[tuple[str, str], dict[str, list[dict[str, object]]]] = { + ("parent", "1"): {"feature": [{"name": "nested"}]} + } + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch-me>=1", "parent"], + head_requirements=["patch-me>=1.1", "parent", "parent[feature]"], + base_packages=[("patch-me", "1"), ("parent", "1"), ("nested", "1")], + head_packages=[("patch-me", "1.1"), ("parent", "1"), ("nested", "1"), *([("plugin", "1")] if reviewed else [])], + head_constraints=["nested>=1", *(["plugin>=1"] if reviewed else [])], + head_lock_dependencies={("nested", "1"): [{"name": "plugin", "marker": marker}]}, + base_lock_optional_dependencies=optional, + head_lock_optional_dependencies=optional, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("requirement_marker", "resolution_marker", "accepted"), + [ + pytest.param( + "implementation_version >= '3.10'", + "implementation_version == '3.11.0'", + True, + id="implementation-version-uses-pep440-ordering", + ), + pytest.param( + "implementation_version >= '3.10'", + "implementation_version == '3.9.9'", + False, + id="implementation-version-rejects-lower-numeric-release", + ), + pytest.param( + "implementation_version < '3.10'", + "implementation_version == '3.9.9'", + True, + id="implementation-version-does-not-use-lexical-ordering", + ), + pytest.param( + "platform_release == '6.8.0'", + "platform_release == '6.8.0'", + True, + id="platform-release-marker-is-supported", + ), + pytest.param( + "platform_release >= '6.10'", + "platform_release == '6.11.0'", + True, + id="platform-release-uses-pep440-ordering", + ), + pytest.param( + "platform_release < '6.10'", + "platform_release == '6.9.0'", + True, + id="platform-release-does-not-use-lexical-ordering", + ), + pytest.param( + "platform_release >= '6.10'", + "platform_release == '6.9.0'", + False, + id="platform-release-rejects-lower-numeric-release", + ), + pytest.param( + "platform_release >= '6.8.0.1'", + "platform_release == '6.8.0.2'", + True, + id="platform-release-preserves-four-component-numeric-ordering", + ), + pytest.param( + "platform_release >= '6.8.0.2'", + "platform_release == '6.8.0.1'", + False, + id="platform-release-rejects-lower-four-component-release", + ), + pytest.param( + "platform_release < '6.8.0.10'", + "platform_release == '6.8.0.2'", + True, + id="platform-release-orders-fourth-component-numerically", + ), + pytest.param( + "platform_release >= '6.8'", + "platform_release == '6.8.0.1'", + True, + id="platform-release-compares-short-and-long-release-segments", + ), + pytest.param( + "platform_release >= '6.8.0.1'", + "platform_release == '6.8'", + False, + id="platform-release-rejects-shorter-lower-release-segments", + ), + pytest.param( + "platform_release == '6.8.0.1'", + "platform_release == '6.8.0.1.0'", + True, + id="platform-release-normalizes-trailing-zero-components", + ), + pytest.param( + "platform_release > '6.8.0'", + "platform_release == '6.8.0.1'", + True, + id="platform-release-preserves-strict-longer-release-ordering", + ), + pytest.param( + "platform_release == '6.8.0.*'", + "platform_release == '6.8.0.2'", + True, + id="platform-release-wildcard-covers-longer-release-segments", + ), + pytest.param( + "platform_release == '6.8.0.*'", + "platform_release == '6.8.1.0'", + False, + id="platform-release-wildcard-does-not-cover-another-prefix", + ), + pytest.param( + "platform_release >= '6.8.0.1.2'", + "platform_release == '6.8.0.1.3'", + True, + id="platform-release-preserves-five-component-numeric-ordering", + ), + pytest.param( + "platform_release >= '6'", + "platform_release == '6.0.0.1'", + True, + id="platform-release-supports-single-component-numeric-floor", + ), + pytest.param( + "platform_release >= ' 6.8.0.1 '", + "platform_release == '6.8.0.2'", + True, + id="platform-release-normalizes-surrounding-version-whitespace", + ), + pytest.param( + "platform_release >= '06.008.000.001'", + "platform_release == '6.8.0.2'", + True, + id="platform-release-normalizes-component-leading-zeroes", + ), + pytest.param( + "platform_release >= '6.8.1234567890.1'", + "platform_release == '6.8.1234567890.2'", + False, + id="platform-release-rejects-unbounded-numeric-components", + ), + pytest.param( + "platform_release >= '" + ".".join(["6"] * 33) + "'", + "platform_release == '" + ".".join(["6"] * 33) + "'", + False, + id="platform-release-rejects-unbounded-release-component-count", + ), + pytest.param( + "platform_release >= 'v6.8.0.1'", + "platform_release == 'v6.8.0.2'", + False, + id="platform-release-unsupported-v-prefix-fails-closed", + ), + pytest.param( + "platform_release >= '1!6.8.0.1'", + "platform_release == '1!6.8.0.2'", + False, + id="platform-release-unsupported-epoch-fails-closed", + ), + pytest.param( + "platform_release == '6.8.0.1+linux'", + "platform_release == '6.8.0.1+linux'", + False, + id="platform-release-unsupported-local-label-fails-closed", + ), + pytest.param( + "platform_release >= '6.10'", + "platform_release == 'build-42'", + False, + id="platform-release-rejects-mixed-numeric-and-nonversion-domains", + ), + pytest.param( + "platform_release != '6.8.0.1'", + "platform_release == 'linux'", + False, + id="platform-release-rejects-four-component-version-against-nonversion-domain", + ), + pytest.param( + "platform_release != ' 6.8.0.1 '", + "platform_release == 'linux'", + False, + id="platform-release-rejects-whitespace-padded-version-against-nonversion-domain", + ), + pytest.param( + "platform_release != '6'", + "platform_release == 'linux'", + False, + id="platform-release-rejects-single-component-version-against-nonversion-domain", + ), + pytest.param( + "platform_release != '1!6.8.0'", + "platform_release == 'linux'", + False, + id="platform-release-rejects-epoch-version-against-nonversion-domain", + ), + pytest.param( + "platform_release != '6.8.0.1rc1'", + "platform_release == 'linux'", + False, + id="platform-release-rejects-prerelease-version-against-nonversion-domain", + ), + pytest.param( + "platform_release != '6.8.0.1.post1'", + "platform_release == 'linux'", + False, + id="platform-release-rejects-post-release-version-against-nonversion-domain", + ), + pytest.param( + "platform_release != '6.8.0.1.dev1'", + "platform_release == 'linux'", + False, + id="platform-release-rejects-development-version-against-nonversion-domain", + ), + pytest.param( + "platform_release != '6.8.0.1+local'", + "platform_release == 'linux'", + False, + id="platform-release-rejects-local-version-against-nonversion-domain", + ), + pytest.param( + "platform_release != 'v6.8alpha1'", + "platform_release == 'linux'", + False, + id="platform-release-rejects-normalized-version-against-nonversion-domain", + ), + pytest.param( + "platform_release >= '6.8.0a1'", + "platform_release == '6.8.0a2'", + True, + id="platform-release-preserves-numeric-prerelease-ordering", + ), + pytest.param( + "platform_release >= '6.8.0.1a1'", + "platform_release == '6.8.0.1a2'", + True, + id="platform-release-preserves-four-component-prerelease-ordering", + ), + pytest.param( + "platform_release >= '6.8.0.post1'", + "platform_release == '6.8.0.post2'", + True, + id="platform-release-preserves-numeric-post-release-ordering", + ), + pytest.param( + "platform_release >= '6.8.0.1.post1'", + "platform_release == '6.8.0.1.post2'", + True, + id="platform-release-preserves-four-component-post-release-ordering", + ), + pytest.param( + "platform_release >= '6.8.0.dev1'", + "platform_release == '6.8.0.dev2'", + True, + id="platform-release-preserves-numeric-development-ordering", + ), + pytest.param( + "platform_release >= '6.8.0.1.dev1'", + "platform_release == '6.8.0.1.dev2'", + True, + id="platform-release-preserves-four-component-development-ordering", + ), + pytest.param( + "platform_release != 'build-42'", + "platform_release == '6.9.0'", + True, + id="platform-release-string-exclusions-preserve-numeric-domains", + ), + pytest.param( + "platform_release === '6.10.0'", + "platform_release == '6.10'", + False, + id="platform-release-arbitrary-equality-cannot-cover-normalized-domain", + ), + pytest.param( + "platform_version == 'build-42'", + "platform_version == 'build-42'", + True, + id="platform-version-marker-is-supported", + ), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_additional_pep508_marker_variables_preserve_security_domains( + tmp_path: Path, requirement_marker: str, resolution_marker: str, accepted: bool, protected: bool +) -> None: + previous = "danger>=1; " + requirement_marker + updated = "danger>=2; " + requirement_marker + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [previous], + head_requirements=[] if protected else [updated], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[previous] if protected else None, + head_constraints=[updated] if protected else None, + base_resolution_markers={("danger", "1"): [resolution_marker]}, + head_resolution_markers={("danger", "2"): [resolution_marker]}, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("original", "addition", "accepted"), + [ + pytest.param("sniffio", "sniffio[feature]", True, id="new-unbounded-extra-retains-unbounded-parent"), + pytest.param("sniffio", "sniffio[feature]<2", False, id="new-extra-cannot-add-unreviewed-upper-bound"), + pytest.param("sniffio", "sniffio[feature]>=1", False, id="new-extra-cannot-add-unreviewed-lower-bound"), + pytest.param( + "sniffio>=1,<3", "sniffio[feature]>=1,<3", True, id="new-extra-may-repeat-existing-reviewed-bounds" + ), + pytest.param( + "sniffio>=1,<3", "sniffio[feature]>=1,<2", False, id="new-extra-cannot-narrow-existing-reviewed-bounds" + ), + ], +) +def test_new_requested_extras_cannot_narrow_existing_published_bounds( + tmp_path: Path, original: str, addition: str, accepted: bool +) -> None: + optional: dict[tuple[str, str], dict[str, list[dict[str, object]]]] = {("sniffio", "1.5"): {"feature": []}} + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch-me>=1", original], + head_requirements=["patch-me>=1.1", original, addition], + base_packages=[("patch-me", "1"), ("sniffio", "1.5")], + head_packages=[("patch-me", "1.1"), ("sniffio", "1.5")], + base_lock_optional_dependencies=optional, + head_lock_optional_dependencies=optional, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("addition", "accepted"), + [ + pytest.param("sniffio[feature]>=1,<3", True, id="optional-extra-may-repeat-reviewed-runtime-bounds"), + pytest.param("sniffio[feature]>=1,<2", False, id="optional-extra-cannot-narrow-reviewed-runtime-bounds"), + ], +) +def test_optional_requested_extras_preserve_existing_runtime_bounds( + tmp_path: Path, addition: str, accepted: bool +) -> None: + optional: dict[tuple[str, str], dict[str, list[dict[str, object]]]] = {("sniffio", "1.5"): {"feature": []}} + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch-me>=1", "sniffio>=1,<3"], + head_requirements=["patch-me>=1.1", "sniffio>=1,<3"], + base_packages=[("patch-me", "1"), ("sniffio", "1.5")], + head_packages=[("patch-me", "1.1"), ("sniffio", "1.5")], + head_optional_groups={"feature": [addition]}, + base_lock_optional_dependencies=optional, + head_lock_optional_dependencies=optional, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("previous_marker", "requested_marker", "accepted"), + [ + pytest.param( + "python_version ~= '3.10'", + "python_version ~= '3.10'", + True, + id="compatible-marker-preserves-previously-published-extra-audience", + ), + pytest.param( + "python_version === '3.10'", + "python_version === '3.10'", + True, + id="arbitrary-equality-preserves-previously-published-extra-audience", + ), + pytest.param( + "python_version ~= '3.10'", + "python_version ~= '4.0'", + False, + id="compatible-extra-marker-cannot-expose-unreviewed-major", + ), + pytest.param( + "python_version === '3.10'", + "python_version === '3.11'", + False, + id="arbitrary-equality-extra-marker-cannot-expose-unreviewed-minor", + ), + ], +) +def test_new_extra_reachability_compares_pep508_version_marker_audiences( + tmp_path: Path, previous_marker: str, requested_marker: str, accepted: bool +) -> None: + previous = [("existing-parent", "1"), ("parent", "1"), ("plugin", "1")] + edges: dict[tuple[str, str], list[dict[str, object]]] = { + ("existing-parent", "1"): [{"name": "plugin", "marker": previous_marker}] + } + optional: dict[tuple[str, str], dict[str, list[dict[str, object]]]] = { + ("parent", "1"): {"feature": [{"name": "plugin"}]} + } + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch-me>=1", "existing-parent", "parent"], + head_requirements=[ + "patch-me>=1.1", + "existing-parent", + "parent", + "parent[feature]; " + requested_marker, + ], + base_packages=[("patch-me", "1"), *previous], + head_packages=[("patch-me", "1.1"), *previous], + base_lock_dependencies=edges, + head_lock_dependencies=edges, + base_lock_optional_dependencies=optional, + head_lock_optional_dependencies=optional, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variable", "compatible", "release"), + [ + pytest.param("platform_release", "6.8", "6.9", id="two-component-compatible-range-keeps-next-minor"), + pytest.param("platform_release", "6.8", "7.0", id="two-component-compatible-range-rejects-next-major"), + pytest.param("platform_release", "6.8.0", "6.8.9", id="three-component-compatible-range-keeps-next-patch"), + pytest.param("platform_release", "6.8.0", "6.9", id="three-component-compatible-range-rejects-next-minor"), + pytest.param("platform_release", "6.8.0.1", "6.8.0.1", id="four-component-compatible-range-keeps-lower-bound"), + pytest.param("platform_release", "6.8.0.1", "6.8.0.2", id="four-component-compatible-range-keeps-next-build"), + pytest.param("platform_release", "6.8.0.1", "6.8.1", id="four-component-compatible-range-rejects-next-patch"), + pytest.param( + "platform_release", "6.8.0.1", "6.8.1a1", id="four-component-compatible-range-rejects-next-patch-alpha" + ), + pytest.param( + "platform_release", "6.8.0.1", "6.8.1.dev1", id="four-component-compatible-range-rejects-next-patch-dev" + ), + pytest.param( + "platform_release", "6.8.0.1.2", "6.8.0.1.3", id="five-component-compatible-range-keeps-next-build" + ), + pytest.param( + "platform_release", "6.8.0.1.2", "6.8.0.2", id="five-component-compatible-range-rejects-next-build-series" + ), + pytest.param( + "platform_release", "6.8.0.1.0", "6.8.0.1.9", id="trailing-zero-compatible-range-preserves-precision" + ), + pytest.param( + "platform_release", "6.8.0.1.0", "6.8.0.2", id="trailing-zero-compatible-range-rejects-next-series" + ), + pytest.param( + "platform_release", "06.008.000.001", "6.8.0.2", id="compatible-range-normalizes-release-leading-zeroes" + ), + pytest.param("platform_release", "6.8.0.1a1", "6.8.0.1a2", id="compatible-alpha-range-keeps-later-alpha"), + pytest.param("platform_release", "6.8.0.1a1", "6.8.0.1b1", id="compatible-alpha-range-keeps-later-beta"), + pytest.param("platform_release", "6.8.0.1a1", "6.8.0.2", id="compatible-alpha-range-keeps-next-build"), + pytest.param("platform_release", "6.8.0.1a1", "6.8.1a1", id="compatible-alpha-range-rejects-next-series"), + pytest.param("platform_release", "6.8.0.1.post2", "6.8.0.1.post1", id="compatible-post-range-rejects-old-post"), + pytest.param("platform_release", "6.8.0.1.post2", "6.8.0.1.post3", id="compatible-post-range-keeps-later-post"), + pytest.param("platform_release", "6.8.0.1.post2", "6.8.0.2", id="compatible-post-range-keeps-next-build"), + pytest.param("platform_release", "6.8.0.1.dev2", "6.8.0.1.dev1", id="compatible-dev-range-rejects-earlier-dev"), + pytest.param("platform_release", "6.8.0.1.dev2", "6.8.0.1a1", id="compatible-dev-range-keeps-alpha"), + pytest.param("platform_release", "6.8.0.1.dev2", "6.8.0.1", id="compatible-dev-range-keeps-final"), + pytest.param( + "platform_release", "6.8.0.1a1.post2.dev1", "6.8.0.1a1.post2", id="compatible-combined-suffix-range" + ), + pytest.param( + "python_full_version", "3.10.0.1", "3.10.0.2", id="full-python-version-supports-four-component-range" + ), + pytest.param( + "python_full_version", "3.10.0.1", "3.10.1", id="full-python-version-preserves-four-component-ceiling" + ), + pytest.param( + "python_full_version", "3.10.0.1.2", "3.10.0.1.3", id="full-python-version-supports-five-component-range" + ), + pytest.param( + "implementation_version", "3.10.0.1", "3.10.0.2", id="implementation-version-supports-four-component-range" + ), + pytest.param( + "implementation_version", "3.10.0.1", "3.10.1", id="implementation-version-preserves-four-component-ceiling" + ), + pytest.param("python_version", "3.10.0", "3.10", id="python-minor-compatible-range-projects-zero-micro"), + pytest.param("python_version", "3.10.0", "3.11", id="python-minor-compatible-range-preserves-micro-ceiling"), + pytest.param("python_version", "3.10.0.0", "3.10", id="python-minor-compatible-range-projects-zero-build"), + pytest.param("python_version", "3.10.0.1", "3.10", id="python-minor-compatible-range-rejects-nonzero-build"), + pytest.param("python_version", "3.10.1", "3.10", id="python-minor-compatible-range-rejects-nonzero-micro"), + pytest.param("python_version", "3.10.0a1", "3.10", id="python-minor-compatible-range-keeps-prerelease-floor"), + pytest.param("python_version", "3.10.0.post1", "3.10", id="python-minor-compatible-range-rejects-post-floor"), + pytest.param("python_version", "3.10.post1", "3.11", id="two-component-python-post-floor-keeps-next-minor"), + pytest.param("python_version", "3.10.post1", "3.10", id="two-component-python-post-floor-rejects-same-minor"), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_arbitrary_width_compatible_markers_match_packaging( + tmp_path: Path, variable: str, compatible: str, release: str, protected: bool +) -> None: + marker = f"{variable} ~= '{compatible}'" + expected = Marker(marker).evaluate(environment={variable: release}) + resolution = f"{variable} == '{release}'" + previous, updated = "danger>=1; " + marker, "danger>=2; " + marker + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [previous], + head_requirements=[] if protected else [updated], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[previous] if protected else None, + head_constraints=[updated] if protected else None, + base_resolution_markers={("danger", "1"): [resolution]}, + head_resolution_markers={("danger", "2"): [resolution]}, + ) + assert result.returncode == (0 if expected else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize("variable", ["python_full_version", "implementation_version"]) +@pytest.mark.parametrize( + ("operator", "operand", "release"), + [ + pytest.param("==", "3.10.0.1", "3.10.0.1", id="wide-pep440-equality"), + pytest.param("!=", "3.10.0.1", "3.10.0.1", id="wide-pep440-inequality"), + pytest.param("<", "3.10.0.2", "3.10.0.1", id="wide-exclusive-ceiling"), + pytest.param("<=", "3.10.0.1", "3.10.0.1", id="wide-inclusive-ceiling"), + pytest.param(">", "3.10.0.1", "3.10.0.2", id="wide-exclusive-floor"), + pytest.param(">=", "3.10.0.1", "3.10.0.1", id="wide-inclusive-floor"), + pytest.param("~=", "3.10.0.1", "3.10.0.2", id="wide-compatible-range"), + pytest.param("===", "3.10.0", "3.10.0", id="canonical-raw-equality"), + pytest.param("===", "3.10.0", "3.10.1", id="canonical-raw-equality-rejects-other-patch"), + pytest.param("in", "3.10.0.1, 3.10.0.2", "3.10.0", id="wide-membership-retains-real-python-version"), + pytest.param("in", "3.10.0.1, 3.10.0.2", "3.10.3", id="wide-membership-rejects-other-python-version"), + pytest.param("not in", "3.10.0.1, 3.10.0.2", "3.10.0", id="wide-negative-membership-excludes-real-version"), + pytest.param("not in", "3.10.0.1, 3.10.0.2", "3.10.3", id="wide-negative-membership-keeps-other-version"), + pytest.param("in", "3.10.0.10", "3.10.0", id="wide-membership-preserves-canonical-raw-prefix"), + pytest.param("not in", "3.10.0.10", "3.10.0", id="wide-negative-membership-preserves-canonical-prefix"), + pytest.param("===", "3.10.0a1", "3.10.0a1", id="canonical-raw-prerelease-equality"), + pytest.param("in", "3.10.0.post1", "3.10.0.post1", id="canonical-postrelease-membership"), + pytest.param("not in", "3.10.0.dev1", "3.10.0.dev1", id="canonical-development-negative-membership"), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_arbitrary_width_python_marker_operators_match_packaging( + tmp_path: Path, variable: str, operator: str, operand: str, release: str, protected: bool +) -> None: + marker = f"{variable} {operator} '{operand}'" + expected = Marker(marker).evaluate(environment={variable: release}) + resolution = f"{variable} == '{release}'" + previous, updated = "danger>=1; " + marker, "danger>=2; " + marker + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [previous], + head_requirements=[] if protected else [updated], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[previous] if protected else None, + head_constraints=[updated] if protected else None, + base_resolution_markers={("danger", "1"): [resolution]}, + head_resolution_markers={("danger", "2"): [resolution]}, + ) + assert result.returncode == (0 if expected else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize("variable", ["python_full_version", "implementation_version"]) +@pytest.mark.parametrize( + "marker", + [ + pytest.param("{variable} === '3.10.0.1'", id="wide-raw-equality-fails-closed"), + pytest.param( + "{variable} === '3.10.0' and {variable} === '3.10.0.0'", + id="distinct-raw-equality-aliases-cannot-share-a-normalized-witness", + ), + pytest.param( + "{variable} == '3.10.0a1' and {variable} in '3.10.0.0a1' and {variable} in '3.10.0a1'", + id="distinct-prerelease-membership-aliases-cannot-share-a-normalized-witness", + ), + pytest.param( + "{variable} === '3.10.0a1' and {variable} in '3.10.0.0a1'", + id="raw-prerelease-equality-cannot-use-a-normalized-membership-alias", + ), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_python_raw_marker_operators_cannot_merge_distinct_release_aliases( + tmp_path: Path, variable: str, marker: str, protected: bool +) -> None: + expression = marker.format(variable=variable) + previous, updated = "danger>=1; " + expression, "danger>=2; " + expression + release = "3.10.0a1" if "a1" in expression else "3.10.0.1" if "3.10.0.1" in expression else "3.10.0" + resolution = f"{variable} == '{release}'" + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [previous], + head_requirements=[] if protected else [updated], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[previous] if protected else None, + head_constraints=[updated] if protected else None, + base_resolution_markers={("danger", "1"): [resolution]}, + head_resolution_markers={("danger", "2"): [resolution]}, + ) + assert result.returncode == 1, result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variable", "compatible"), + [ + pytest.param("platform_release", "6", id="single-component-compatible-operand-remains-invalid"), + pytest.param("platform_release", "1!6.8.0.1", id="unsupported-compatible-epoch-fails-closed"), + pytest.param("platform_release", "6.8.0.1+local", id="unsupported-compatible-local-version-fails-closed"), + pytest.param("platform_release", "6.8.1234567890.1", id="unbounded-compatible-release-component-fails-closed"), + pytest.param("platform_release", "6.8.0.1a1234567890", id="unbounded-compatible-alpha-serial-fails-closed"), + pytest.param( + "platform_release", "6.8.0.1.post1234567890", id="unbounded-compatible-postrelease-serial-fails-closed" + ), + pytest.param("platform_release", ".".join(["6"] * 33), id="unbounded-platform-compatible-width-fails-closed"), + pytest.param( + "python_full_version", + ".".join(["3", "10", *(["0"] * 31)]), + id="unbounded-python-compatible-width-fails-closed", + ), + pytest.param( + "implementation_version", + ".".join(["3", "10", *(["0"] * 31)]), + id="unbounded-implementation-compatible-width-fails-closed", + ), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_arbitrary_width_compatible_markers_preserve_fail_closed_bounds( + tmp_path: Path, variable: str, compatible: str, protected: bool +) -> None: + marker = f"{variable} ~= '{compatible}'" + resolution = f"{variable} == '6.8.0.2'" if variable == "platform_release" else f"{variable} == '3.10.0'" + previous, updated = "danger>=1; " + marker, "danger>=2; " + marker + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [previous], + head_requirements=[] if protected else [updated], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[previous] if protected else None, + head_constraints=[updated] if protected else None, + base_resolution_markers={("danger", "1"): [resolution]}, + head_resolution_markers={("danger", "2"): [resolution]}, + ) + assert result.returncode == 1, result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("requirement_marker", "resolution_marker", "accepted"), + [ + pytest.param( + "python_version ~= '3.10'", + "python_full_version == '3.11.4'", + True, + id="compatible-python-marker-includes-next-supported-minor", + ), + pytest.param( + "python_version ~= '3.10'", + "python_full_version == '4.0.1'", + False, + id="compatible-python-marker-rejects-next-major", + ), + pytest.param( + "python_version ~= '3.10'", + "python_full_version == '3.9.9'", + False, + id="compatible-python-marker-rejects-lower-minor", + ), + pytest.param( + "python_version === '3.10'", + "python_full_version == '3.10.4'", + True, + id="arbitrary-equality-python-marker-includes-exact-minor", + ), + pytest.param( + "python_version === '3.10'", + "python_full_version == '3.11.4'", + False, + id="arbitrary-equality-python-marker-excludes-other-minor", + ), + pytest.param( + "implementation_version ~= '3.10'", + "implementation_version == '3.11.4'", + True, + id="compatible-implementation-marker-includes-next-minor", + ), + pytest.param( + "implementation_version ~= '3.10'", + "implementation_version == '4.0.1'", + False, + id="compatible-implementation-marker-rejects-next-major", + ), + pytest.param( + "python_version >= '3.10'", + "python_full_version ~= '3.10.0'", + True, + id="compatible-resolution-marker-is-supported", + ), + pytest.param( + "python_version is '3.10'", + "python_full_version == '3.10.4'", + False, + id="raw-python-is-operator-is-not-a-pep508-marker", + ), + pytest.param( + "python_version is not '3.10'", + "python_full_version == '3.10.4'", + False, + id="raw-python-is-not-operator-is-not-a-pep508-marker", + ), + pytest.param( + "python_version >= '3.10'", + "python_full_version is '3.10.4'", + False, + id="raw-python-is-operator-is-rejected-in-resolution-marker", + ), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_pep508_compatible_and_arbitrary_equality_marker_operators( + tmp_path: Path, requirement_marker: str, resolution_marker: str, accepted: bool, protected: bool +) -> None: + previous = "danger>=1; " + requirement_marker + updated = "danger>=2; " + requirement_marker + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [previous], + head_requirements=[] if protected else [updated], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[previous] if protected else None, + head_constraints=[updated] if protected else None, + base_resolution_markers={("danger", "1"): [resolution_marker]}, + head_resolution_markers={("danger", "2"): [resolution_marker]}, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("requirement_marker", "resolution_marker", "accepted"), + [ + pytest.param( + "python_full_version >= '3.15.0a1'", + "python_full_version == '3.15.0a1'", + True, + id="alpha-bound-includes-exact-prerelease", + ), + pytest.param( + "python_full_version >= '3.15.0a2'", + "python_full_version == '3.15.0a1'", + False, + id="alpha-bound-rejects-earlier-alpha", + ), + pytest.param( + "python_full_version >= '3.15.0a2'", + "python_full_version == '3.15.0b1'", + True, + id="alpha-precedes-beta", + ), + pytest.param( + "python_full_version >= '3.15.0b2'", + "python_full_version == '3.15.0a9'", + False, + id="beta-bound-rejects-alpha", + ), + pytest.param( + "python_full_version >= '3.15.0rc1'", + "python_full_version == '3.15.0b9'", + False, + id="release-candidate-bound-rejects-beta", + ), + pytest.param( + "python_full_version >= '3.15.0rc1'", + "python_full_version == '3.15.0'", + True, + id="release-candidate-precedes-final", + ), + pytest.param( + "python_full_version >= '3.15.0'", + "python_full_version == '3.15.0rc1'", + False, + id="final-bound-rejects-release-candidate", + ), + pytest.param( + "python_full_version < '3.15.0'", + "python_full_version == '3.15.0rc1'", + False, + id="exclusive-final-ceiling-excludes-matching-release-prereleases", + ), + pytest.param( + "python_full_version <= '3.15.0'", + "python_full_version == '3.15.0rc1'", + True, + id="inclusive-final-ceiling-includes-release-candidate", + ), + pytest.param( + "python_full_version > '3.15.0a1'", + "python_full_version == '3.15.0a2'", + True, + id="exclusive-alpha-bound-includes-later-alpha", + ), + pytest.param( + "python_full_version > '3.15.0a1'", + "python_full_version == '3.15.0a1.post1'", + False, + id="exclusive-alpha-bound-excludes-post-release-of-same-alpha", + ), + pytest.param( + "python_full_version > '3.15.0a1'", + "python_full_version == '3.15.0a1.post1.dev1'", + False, + id="exclusive-alpha-bound-excludes-development-build-of-same-alpha-post-release", + ), + pytest.param( + "python_full_version > '3.15.0b1'", + "python_full_version == '3.15.0b1.post2'", + False, + id="exclusive-beta-bound-excludes-post-release-of-same-beta", + ), + pytest.param( + "python_full_version > '3.15.0rc1'", + "python_full_version == '3.15.0rc1.post1'", + False, + id="exclusive-release-candidate-bound-excludes-post-release-of-same-candidate", + ), + pytest.param( + "implementation_version > '3.15.0a1'", + "implementation_version == '3.15.0a1.post1'", + False, + id="exclusive-implementation-alpha-bound-excludes-post-release-of-same-alpha", + ), + pytest.param( + "python_full_version > '3.15.0a1'", + "python_full_version == '3.15.0a2.dev1'", + True, + id="exclusive-alpha-bound-includes-development-build-of-next-alpha", + ), + pytest.param( + "python_full_version > '3.15.0a1.dev1'", + "python_full_version == '3.15.0a1.post1'", + True, + id="exclusive-alpha-development-bound-includes-post-release-of-later-alpha", + ), + pytest.param( + "python_full_version > '3.15.0a1.post1'", + "python_full_version == '3.15.0a1.post2'", + True, + id="exclusive-alpha-post-release-bound-includes-later-post-releases", + ), + pytest.param( + "python_full_version > '3.15.0a1.post1.dev1'", + "python_full_version == '3.15.0a1.post1'", + True, + id="exclusive-alpha-post-development-bound-includes-its-post-release", + ), + pytest.param( + "python_full_version < '3.15.0.post1'", + "python_full_version == '3.15.0.post1.dev1'", + False, + id="exclusive-post-release-ceiling-excludes-its-own-development-build", + ), + pytest.param( + "python_full_version < '3.15.0.post0'", + "python_full_version == '3.15.0.post0.dev0'", + False, + id="exclusive-first-post-release-ceiling-excludes-its-own-development-build", + ), + pytest.param( + "implementation_version < '3.15.0.post1'", + "implementation_version == '3.15.0.post1.dev1'", + False, + id="exclusive-implementation-post-ceiling-excludes-its-own-development-build", + ), + pytest.param( + "python_full_version <= '3.15.0.post1'", + "python_full_version == '3.15.0.post1.dev1'", + True, + id="inclusive-post-release-ceiling-retains-its-own-development-build", + ), + pytest.param( + "python_full_version < '3.15.0.post1'", + "python_full_version == '3.15.0.post0.dev1'", + True, + id="exclusive-post-release-ceiling-retains-earlier-post-development-builds", + ), + pytest.param( + "python_full_version < '3.15.0a1.post1'", + "python_full_version == '3.15.0a1.post1.dev1'", + True, + id="exclusive-prerelease-post-ceiling-retains-its-own-development-build", + ), + pytest.param( + "python_full_version < '3.15.0.post1.dev1'", + "python_full_version == '3.15.0.post1.dev0'", + True, + id="exclusive-post-development-ceiling-retains-earlier-development-build", + ), + pytest.param( + "python_full_version != '3.15.0a1'", + "python_full_version == '3.15.0a1'", + False, + id="alpha-exclusion-removes-exact-prerelease", + ), + pytest.param( + "python_full_version == '3.15.0a1'", + "python_full_version == '3.15.0b1'", + False, + id="exact-alpha-excludes-later-prerelease-stages", + ), + pytest.param( + "python_full_version == '3.15.0'", + "python_full_version == '3.15.0a1'", + False, + id="exact-final-excludes-alpha", + ), + pytest.param( + "python_full_version == '3.15.*'", + "python_full_version == '3.15.0a1'", + True, + id="wildcard-minor-includes-alpha", + ), + pytest.param( + "python_version == '3.15'", + "python_full_version == '3.15.0a1'", + True, + id="python-minor-projection-includes-alpha", + ), + pytest.param( + "python_version >= '3.15'", + "python_full_version == '3.15.0a1'", + True, + id="python-minor-floor-includes-alpha", + ), + pytest.param( + "python_version < '3.15'", + "python_full_version == '3.15.0a1'", + False, + id="python-minor-ceiling-excludes-alpha-from-that-minor", + ), + pytest.param( + "implementation_version >= '3.15.0a1'", + "implementation_version == '3.15.0b2'", + True, + id="implementation-version-preserves-prerelease-ordering", + ), + pytest.param( + "python_full_version ~= '3.15.0a1'", + "python_full_version == '3.15.0b2'", + True, + id="compatible-alpha-bound-includes-beta", + ), + pytest.param( + "python_full_version ~= '3.15.0a1'", + "python_full_version == '3.16.0a1'", + False, + id="compatible-alpha-bound-excludes-next-minor-alpha", + ), + pytest.param( + "python_full_version === '3.15.0a1'", + "python_full_version == '3.15.0a1'", + True, + id="arbitrary-equality-preserves-alpha", + ), + pytest.param( + "python_full_version in '3.15.0a1, 3.15.0b2'", + "python_full_version == '3.15.0a1'", + True, + id="membership-preserves-alpha", + ), + pytest.param( + "python_full_version not in '3.15.0a1, 3.15.0b2'", + "python_full_version == '3.15.0a1'", + False, + id="negative-membership-excludes-alpha", + ), + pytest.param( + "python_full_version in '3.15.0a1'", + "python_full_version == '3.15.0'", + True, + id="membership-retains-final-substring-of-alpha", + ), + pytest.param( + "python_full_version not in '3.15.0a1'", + "python_full_version == '3.15.0'", + False, + id="negative-membership-excludes-final-substring-of-alpha", + ), + pytest.param( + "python_full_version in '3.15.0a10'", + "python_full_version == '3.15.0a1'", + True, + id="membership-retains-shorter-alpha-serial-substring", + ), + pytest.param( + "python_full_version not in '3.15.0a10'", + "python_full_version == '3.15.0a1'", + False, + id="negative-membership-excludes-shorter-alpha-serial-substring", + ), + pytest.param( + "python_full_version >= '3.15.0z1'", + "python_full_version == '3.15.0a1'", + False, + id="unknown-prerelease-stage-fails-closed", + ), + pytest.param( + "python_full_version >= '3.15.0a'", + "python_full_version == '3.15.0a1'", + False, + id="missing-prerelease-serial-fails-closed", + ), + pytest.param( + "python_full_version >= '3.15.0.dev1'", + "python_full_version == '3.15.0a1'", + True, + id="development-prerelease-precedes-alpha", + ), + pytest.param( + "python_full_version >= '3.15.0.dev1'", + "python_full_version == '3.15.0.dev2'", + True, + id="development-prerelease-ordering-preserves-later-builds", + ), + pytest.param( + "python_full_version >= '3.15.0.dev2'", + "python_full_version == '3.15.0.dev1'", + False, + id="development-prerelease-floor-rejects-earlier-builds", + ), + pytest.param( + "python_full_version >= '3.15.0a1.dev2'", + "python_full_version == '3.15.0a1.dev3'", + True, + id="alpha-development-builds-retain-their-own-ordering", + ), + pytest.param( + "python_full_version >= '3.15.0.post1'", + "python_full_version == '3.15.0.post2'", + True, + id="post-release-floor-preserves-later-post-releases", + ), + pytest.param( + "python_full_version >= '3.15.0.post2'", + "python_full_version == '3.15.0.post1'", + False, + id="post-release-floor-rejects-earlier-post-releases", + ), + pytest.param( + "python_full_version >= '3.15.0.post1.dev2'", + "python_full_version == '3.15.0.post1'", + True, + id="post-development-build-precedes-its-post-release", + ), + pytest.param( + "python_full_version == '3.15.0'", + "python_full_version == '3.15.0.post1'", + False, + id="exact-final-release-cannot-admit-a-post-release", + ), + pytest.param( + "python_full_version > '3.15.0'", + "python_full_version == '3.15.0.post1'", + False, + id="exclusive-final-floor-cannot-admit-same-release-post-versions", + ), + pytest.param( + "implementation_version >= '3.15.0.post1'", + "implementation_version == '3.15.0.post2'", + True, + id="implementation-version-preserves-post-release-ordering", + ), + pytest.param( + "implementation_version >= '3.15.0.dev1'", + "implementation_version == '3.15.0a1'", + True, + id="implementation-development-release-precedes-alpha", + ), + pytest.param( + "python_version == '3.10.0'", + "python_full_version == '3.10.4'", + True, + id="three-component-python-version-projects-zero-micro-to-minor", + ), + pytest.param( + "python_version === '3.10'", + "python_version == '3.10'", + True, + id="raw-python-version-equality-retains-exact-two-component-value", + ), + pytest.param( + "python_version === '3.10.0'", + "python_version == '3.10'", + False, + id="raw-python-version-equality-rejects-extra-zero-component", + ), + pytest.param( + "python_version === '3.10.0'", + "python_full_version == '3.10.4'", + False, + id="raw-python-version-equality-cannot-use-normalized-full-version", + ), + pytest.param( + "python_version === '03.10'", + "python_version == '3.10'", + False, + id="raw-python-version-equality-rejects-zero-padded-components", + ), + pytest.param( + "python_version == '3.10.1'", + "python_full_version == '3.10.1'", + False, + id="python-version-nonzero-micro-does-not-falsely-match-minor", + ), + pytest.param( + "python_full_version in '3.15.0.dev1, 3.15.0.post2'", + "python_full_version == '3.15.0.dev1'", + True, + id="membership-retains-development-and-post-release-operands", + ), + pytest.param( + "python_full_version === '3.15.0.post2'", + "python_full_version == '3.15.0.post2'", + True, + id="arbitrary-equality-preserves-post-release-identities", + ), + pytest.param( + "python_full_version >= '3.15.0.post1'", + "python_full_version == '3.15.0a1'", + False, + id="post-release-floor-rejects-earlier-prerelease-stages", + ), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_prerelease_python_markers_preserve_pep440_security_domains( + tmp_path: Path, requirement_marker: str, resolution_marker: str, accepted: bool, protected: bool +) -> None: + previous = "danger>=1; " + requirement_marker + updated = "danger>=2; " + requirement_marker + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[] if protected else [previous], + head_requirements=[] if protected else [updated], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_constraints=[previous] if protected else None, + head_constraints=[updated] if protected else None, + base_resolution_markers={("danger", "1"): [resolution_marker]}, + head_resolution_markers={("danger", "2"): [resolution_marker]}, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("resolution_marker", "protected_markers", "accepted"), + [ + pytest.param( + "python_full_version >= '3.15.0a1'", + ["python_full_version >= '3.15.0'"], + False, + id="final-only-protection-cannot-hide-unprotected-prerelease-domain", + ), + pytest.param( + "python_version == '3.15'", + ["python_full_version >= '3.15.0'"], + False, + id="final-only-protection-cannot-hide-prerelease-minor-projection", + ), + pytest.param( + "python_full_version >= '3.14.0'", + ["python_full_version < '3.15.0'", "python_full_version >= '3.15.0'"], + False, + id="ordered-final-complements-cannot-hide-prerelease-gap", + ), + pytest.param( + "python_full_version >= '3.15.0a1'", + ["python_full_version >= '3.15.0a1'"], + True, + id="reviewed-prerelease-floor-covers-complete-prerelease-domain", + ), + ], +) +def test_transitive_prerelease_security_boundaries_cannot_hide_unprotected_domains( + tmp_path: Path, resolution_marker: str, protected_markers: list[str], accepted: bool +) -> 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", "1")], + head_packages=[("patch-me", "1.1"), ("danger", "2")], + base_constraints=["danger>=1; " + marker for marker in protected_markers], + head_constraints=["danger>=2; " + marker for marker in protected_markers], + base_resolution_markers={("danger", "1"): [resolution_marker]}, + head_resolution_markers={("danger", "2"): [resolution_marker]}, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("marker", "accepted"), + [ + pytest.param("python_version == '3.10.0'", True, id="pep440-equality-normalizes-zero-component"), + pytest.param("python_version === '3.10'", True, id="raw-equality-covers-exact-python-minor"), + pytest.param("python_version === '3.10.0'", False, id="raw-equality-cannot-fake-python-minor-coverage"), + pytest.param("python_version === '3.10.00'", False, id="raw-equality-rejects-noncanonical-zero-component"), + pytest.param("python_version === '03.10'", False, id="raw-equality-rejects-zero-padded-python-major"), + ], +) +@pytest.mark.parametrize("scope", ["constraint", "build", "group"]) +def test_transitive_python_security_boundaries_preserve_raw_arbitrary_equality( + tmp_path: Path, marker: str, accepted: bool, scope: str +) -> None: + previous, updated = "danger>=1; " + marker, "danger>=2; " + marker + resolution = "python_version == '3.10'" + 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", "1")], + head_packages=[("patch-me", "1.1"), ("danger", "2")], + base_constraints=[previous] if scope == "constraint" else None, + head_constraints=[updated] if scope == "constraint" else None, + base_build_constraints=[previous] if scope == "build" else None, + head_build_constraints=[updated] if scope == "build" else None, + base_dependency_groups={"reviewed": [previous]} if scope == "group" else None, + head_dependency_groups={"reviewed": [updated]} if scope == "group" else None, + base_resolution_markers={("danger", "1"): [resolution]}, + head_resolution_markers={("danger", "2"): [resolution]}, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("previous_markers", "updated_markers", "accepted"), + [ + pytest.param( + ["python_full_version < '3.12'"], + [ + "python_full_version < '3.11'", + "python_full_version >= '3.11' and python_full_version < '3.12'", + ], + True, + id="stable-only-lock-refinement-keeps-existing-complement-semantics", + ), + pytest.param( + ["python_full_version >= '3.11.0a1' and python_full_version < '3.12'"], + ["python_full_version >= '3.11' and python_full_version < '3.12'"], + False, + id="explicit-prerelease-lock-domain-cannot-be-dropped-during-refinement", + ), + ], +) +def test_resolution_refinement_preserves_explicit_prerelease_domains( + tmp_path: Path, previous_markers: list[str], updated_markers: list[str], accepted: bool +) -> 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", "2")], + head_packages=[("patch-me", "1.1"), ("danger", "2")], + base_resolution_markers={("danger", "2"): previous_markers}, + head_resolution_markers={("danger", "2"): updated_markers}, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("variable", "bound", "next_release"), + [ + pytest.param("python_full_version", "3.15.0", "3.15.1", id="python-full-final-release"), + pytest.param("python_full_version", "3.15.0a1", "3.15.0a2", id="python-full-prerelease"), + pytest.param("implementation_version", "3.15.0", "3.15.1", id="implementation-final-release"), + pytest.param("platform_release", "6.8.0", "6.8.1", id="platform-final-release"), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +@pytest.mark.parametrize("missing_posts", [False, True], ids=["complete-inclusive-domain", "missing-post-release-gap"]) +@pytest.mark.parametrize("strict_floor", [False, True], ids=["inclusive-ceiling", "strict-floor"]) +def test_inclusive_version_marker_partitions_preserve_post_release_contexts( + tmp_path: Path, + variable: str, + bound: str, + next_release: str, + protected: bool, + missing_posts: bool, + strict_floor: bool, +) -> None: + if strict_floor: + original_marker = f"{variable} >= '{bound}.post0'" if missing_posts else f"{variable} > '{bound}'" + replacement_marker = f"{variable} > '{bound}'" + else: + original_marker = f"{variable} < '{next_release}'" if missing_posts else f"{variable} <= '{bound}'" + replacement_marker = f"{variable} <= '{bound}'" + original = "danger>=1,<3; " + original_marker + replacements = [ + f"danger>=1,<3; {replacement_marker} and sys_platform == 'linux'", + f"danger>=1,<3; {replacement_marker} and sys_platform != 'linux'", + ] + resolutions = { + ("danger", "1"): ["sys_platform == 'linux'"], + ("danger", "2"): ["sys_platform != 'linux'"], + } + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch-me>=1"] + ([] if protected else [original]), + head_requirements=["patch-me>=1.1"] + ([] if protected else replacements), + base_packages=[("patch-me", "1"), ("danger", "1"), ("danger", "2")], + head_packages=[("patch-me", "1.1"), ("danger", "1"), ("danger", "2")], + base_constraints=[original] if protected else None, + head_constraints=replacements if protected else None, + base_resolution_markers=resolutions, + head_resolution_markers=resolutions, + ) + assert result.returncode == (1 if missing_posts else 0), result.stdout + result.stderr + + +@pytest.mark.parametrize("scope", ["runtime", "optional", "constraint", "build", "group"]) +@pytest.mark.parametrize("marked", [False, True], ids=["unmarked", "marker-scoped"]) +def test_arbitrary_equality_dependency_pins_can_track_reviewed_security_upgrades( + tmp_path: Path, scope: str, marked: bool +) -> None: + marker = "; python_version >= '3.11'" if marked else "" + previous, current = "danger===1" + marker, "danger===2" + marker + protected = scope in {"constraint", "build", "group"} + resolutions = ["python_full_version >= '3.11'"] if marked else 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", "1")], + head_packages=[("patch-me", "1.1"), ("danger", "2")], + optional=scope == "optional", + base_constraints=[previous] if scope == "constraint" else None, + head_constraints=[current] if scope == "constraint" else None, + base_build_constraints=[previous] if scope == "build" else None, + head_build_constraints=[current] if scope == "build" else None, + base_dependency_groups={"reviewed": [previous]} if scope == "group" else None, + head_dependency_groups={"reviewed": [current]} if scope == "group" else None, + base_resolution_markers={("danger", "1"): resolutions} if resolutions is not None else None, + head_resolution_markers={("danger", "2"): resolutions} if resolutions is not None else None, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("previous", "current", "before", "after", "accepted"), + [ + pytest.param("danger===1", "danger===1", ["1"], ["1"], True, id="unchanged-canonical-arbitrary-pin"), + pytest.param("danger===1.0", "danger===2.0", ["1.0"], ["2.0"], True, id="raw-dotted-arbitrary-pin-upgrade"), + pytest.param("danger===1!1", "danger===1!2", ["1!1"], ["1!2"], True, id="raw-epoch-arbitrary-pin-upgrade"), + pytest.param( + "danger===1.post1", + "danger===1.post2", + ["1.post1"], + ["1.post2"], + True, + id="raw-post-release-arbitrary-pin-upgrade", + ), + pytest.param("danger===1", "danger>=1", ["1"], ["1"], False, id="arbitrary-pin-cannot-widen-to-floor"), + pytest.param("danger===1", "danger==1", ["1"], ["1"], False, id="arbitrary-pin-cannot-widen-to-pep440-pin"), + pytest.param("danger==1", "danger===1", ["1"], ["1"], False, id="pep440-pin-cannot-narrow-to-arbitrary-pin"), + pytest.param("danger===1", "danger===3", ["1"], ["2"], False, id="arbitrary-pin-must-match-patched-lock"), + pytest.param("danger===2", "danger===1", ["2"], ["1"], False, id="arbitrary-pin-cannot-follow-downgrade"), + pytest.param("danger===1", "danger===2", ["1", "2"], ["2"], False, id="arbitrary-pin-cannot-hide-old-alias"), + pytest.param( + "danger===1.0", "danger===2", ["1"], ["2"], False, id="trailing-zero-arbitrary-pin-is-not-raw-lock" + ), + pytest.param("danger===1", "danger===2", ["1.0"], ["2"], False, id="old-lock-alias-cannot-match-raw-pin"), + pytest.param("danger===1", "danger===2", ["1"], ["2.0"], False, id="new-lock-alias-cannot-match-raw-pin"), + pytest.param("danger===01", "danger===2", ["1"], ["2"], False, id="zero-padded-arbitrary-pin-fails-closed"), + pytest.param("danger===0!1", "danger===2", ["1"], ["2"], False, id="epoch-arbitrary-pin-fails-closed"), + pytest.param( + "danger===1.post1", + "danger===2", + ["1.post01"], + ["2"], + False, + id="post-release-arbitrary-pin-rejects-nonraw-serial", + ), + pytest.param("danger===1.*", "danger===2", ["1"], ["2"], False, id="wildcard-arbitrary-pin-fails-closed"), + ], +) +@pytest.mark.parametrize("protected", [False, True], ids=["published-direct", "protected-constraint"]) +def test_arbitrary_equality_dependency_pins_preserve_raw_security_boundaries( + tmp_path: Path, previous: str, current: str, before: list[str], after: list[str], accepted: bool, protected: bool +) -> 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", release) for release in before]], + head_packages=[("patch-me", "1.1"), *[("danger", release) for release in after]], + base_constraints=[previous] if protected else None, + head_constraints=[current] if protected else None, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("pin", "locked", "accepted"), + [ + pytest.param("plugin===1", "1", True, id="reviewed-arbitrary-pin-can-approve-new-extra-package"), + pytest.param("plugin===1", "1.0", False, id="arbitrary-pin-cannot-approve-nonraw-extra-package-version"), + pytest.param("plugin===1", "2", False, id="arbitrary-pin-cannot-approve-another-extra-package-version"), + ], +) +def test_arbitrary_equality_pin_reviews_exact_new_extra_release( + tmp_path: Path, pin: str, locked: str, accepted: bool +) -> None: + optional: dict[tuple[str, str], dict[str, list[dict[str, object]]]] = { + ("parent", "1"): {"feature": [{"name": "plugin"}]} + } + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch-me>=1", "parent"], + head_requirements=["patch-me>=1.1", "parent", "parent[feature]"], + base_packages=[("patch-me", "1"), ("parent", "1")], + head_packages=[("patch-me", "1.1"), ("parent", "1"), ("plugin", locked)], + head_constraints=[pin], + base_lock_optional_dependencies=optional, + head_lock_optional_dependencies=optional, + ) + assert result.returncode == (0 if accepted else 1), result.stdout + result.stderr + + +@pytest.mark.parametrize("optional", [False, True], ids=["published-runtime", "published-optional"]) +@pytest.mark.parametrize( + ("previous", "current"), + [ + pytest.param("danger==1", "danger==1,===1", id="raw-pin-cannot-narrow-existing-pep440-equality"), + pytest.param("danger>=1,<2", "danger>=1,<2,===1", id="raw-pin-cannot-narrow-existing-published-range"), + ], +) +def test_new_arbitrary_equality_bound_cannot_narrow_existing_published_source( + tmp_path: Path, previous: str, current: str, optional: bool +) -> None: + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=["patch-me>=1", previous], + head_requirements=["patch-me>=1.1", current], + base_packages=[("patch-me", "1"), ("danger", "1")], + head_packages=[("patch-me", "1.1"), ("danger", "1")], + optional=optional, + ) + assert result.returncode != 0 diff --git a/tests/test_x509_workload_identity_hardening.py b/tests/test_x509_workload_identity_hardening.py new file mode 100644 index 0000000000..46c5496751 --- /dev/null +++ b/tests/test_x509_workload_identity_hardening.py @@ -0,0 +1,961 @@ +from __future__ import annotations + +import io +import json +import time +import asyncio +import threading +from typing import Any, cast +from contextvars import Context +from typing_extensions import override +from concurrent.futures import ThreadPoolExecutor + +import httpx2 +import pytest + +import openai.auth._x509 as x509_auth +from openai import OpenAI, OAuthError, AsyncOpenAI, OpenAIError, APIConnectionError +from openai.auth import X509WorkloadIdentity, x509_workload_identity +from openai.providers import bedrock + +_TOKEN_URL = "https://mtls.auth.openai.com/oauth/token" +_API_URL = "https://mtls.api.openai.com/v1/models" +_REGIONAL_MTLS_URLS = { + "global": "https://mtls.api.openai.com/v1/", + "us": "https://mtls-us.api.openai.com/v1/", + "eu": "https://mtls-eu.api.openai.com/v1/", +} + + +class _RequestlessConnectError(httpx2.ConnectError): + @property + @override + def request(self) -> httpx2.Request: + raise RuntimeError("The .request property has not been set.") + + @request.setter + def request(self, request: httpx2.Request) -> None: + del request + return None + + +def _identity() -> X509WorkloadIdentity: + return x509_workload_identity(identity_provider_id="idp_example", service_account_id="svc_example") + + +def _response(request: httpx2.Request, *, token: str = "access-token") -> httpx2.Response: + if str(request.url) == _TOKEN_URL: + return httpx2.Response(200, request=request, json={"access_token": token, "expires_in": 3600}) + return httpx2.Response(200, request=request, json={"object": "list", "data": []}) + + +def test_sync_x509_ignores_ambient_authorization_without_changing_explicit_overrides( + monkeypatch: pytest.MonkeyPatch, +) -> None: + requests: list[httpx2.Request] = [] + monkeypatch.setenv("OPENAI_CUSTOM_HEADERS", "Authorization: Bearer ambient-secret\nX-Custom: retained") + http_client = httpx2.Client(transport=httpx2.MockTransport(lambda request: _record(requests, request))) + + with OpenAI(workload_identity=_identity(), http_client=http_client, max_retries=0) as client: + assert client.models.list().object == "list" + + assert [str(request.url) for request in requests] == [_TOKEN_URL, _API_URL] + assert requests[-1].headers["Authorization"] == "Bearer access-token" + assert requests[-1].headers["X-Custom"] == "retained" + + +@pytest.mark.parametrize("header_name", ["Authorization", "aUtHoRiZaTiOn"]) +def test_sync_switch_to_x509_discards_inherited_ambient_authorization( + monkeypatch: pytest.MonkeyPatch, header_name: str +) -> None: + requests: list[httpx2.Request] = [] + monkeypatch.setenv("OPENAI_CUSTOM_HEADERS", f"{header_name}: Bearer ambient-secret") + http_client = httpx2.Client(transport=httpx2.MockTransport(lambda request: _record(requests, request))) + + with OpenAI(api_key="original-api-key", http_client=http_client, max_retries=0) as original: + original.with_options(workload_identity=_identity()).models.list() + + assert [str(request.url) for request in requests] == [_TOKEN_URL, _API_URL] + assert requests[-1].headers["Authorization"] == "Bearer access-token" + + +@pytest.mark.parametrize("header_name", ["Authorization", "aUtHoRiZaTiOn"]) +async def test_async_switch_to_x509_discards_inherited_ambient_authorization( + monkeypatch: pytest.MonkeyPatch, header_name: str +) -> None: + requests: list[httpx2.Request] = [] + monkeypatch.setenv("OPENAI_CUSTOM_HEADERS", f"{header_name}: Bearer ambient-secret") + http_client = httpx2.AsyncClient(transport=httpx2.MockTransport(lambda request: _record(requests, request))) + + async with AsyncOpenAI(api_key="original-api-key", http_client=http_client, max_retries=0) as original: + await original.with_options(workload_identity=_identity()).models.list() + + assert [str(request.url) for request in requests] == [_TOKEN_URL, _API_URL] + assert requests[-1].headers["Authorization"] == "Bearer access-token" + + +@pytest.mark.parametrize("ambient_header", ["authorization", "aUtHoRiZaTiOn"]) +def test_sync_switch_to_x509_discards_ambient_authorization_from_an_explicit_intermediate_copy( + monkeypatch: pytest.MonkeyPatch, ambient_header: str +) -> None: + requests: list[httpx2.Request] = [] + monkeypatch.setenv("OPENAI_CUSTOM_HEADERS", f"{ambient_header}: Bearer ambient-secret") + http_client = httpx2.Client(transport=httpx2.MockTransport(lambda request: _record(requests, request))) + + with OpenAI(api_key="original-api-key", http_client=http_client, max_retries=0) as original: + intermediate = original.with_options(default_headers={"Authorization": "Bearer workload-identity-auth"}) + assert httpx2.Headers(intermediate._custom_headers).get_list("Authorization") == [ + "Bearer workload-identity-auth" + ] + copied = intermediate.with_options(workload_identity=_identity()) + assert httpx2.Headers(copied._custom_headers).get_list("Authorization") == ["Bearer workload-identity-auth"] + assert copied.models.list().object == "list" + + assert [str(request.url) for request in requests] == [_TOKEN_URL, _API_URL] + assert requests[-1].headers.get_list("Authorization") == ["Bearer access-token"] + + +@pytest.mark.parametrize("ambient_header", ["authorization", "aUtHoRiZaTiOn"]) +async def test_async_switch_to_x509_discards_ambient_authorization_from_an_explicit_intermediate_copy( + monkeypatch: pytest.MonkeyPatch, ambient_header: str +) -> None: + requests: list[httpx2.Request] = [] + monkeypatch.setenv("OPENAI_CUSTOM_HEADERS", f"{ambient_header}: Bearer ambient-secret") + http_client = httpx2.AsyncClient(transport=httpx2.MockTransport(lambda request: _record(requests, request))) + + async with AsyncOpenAI(api_key="original-api-key", http_client=http_client, max_retries=0) as original: + intermediate = original.with_options(default_headers={"Authorization": "Bearer workload-identity-auth"}) + assert httpx2.Headers(intermediate._custom_headers).get_list("Authorization") == [ + "Bearer workload-identity-auth" + ] + copied = intermediate.with_options(workload_identity=_identity()) + assert httpx2.Headers(copied._custom_headers).get_list("Authorization") == ["Bearer workload-identity-auth"] + assert (await copied.models.list()).object == "list" + + assert [str(request.url) for request in requests] == [_TOKEN_URL, _API_URL] + assert requests[-1].headers.get_list("Authorization") == ["Bearer access-token"] + + +def test_sync_switch_to_x509_discards_every_mixed_case_ambient_authorization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + requests: list[httpx2.Request] = [] + monkeypatch.setenv( + "OPENAI_CUSTOM_HEADERS", "Authorization: Bearer first-ambient\nAUTHORIZATION: Bearer second-ambient" + ) + http_client = httpx2.Client(transport=httpx2.MockTransport(lambda request: _record(requests, request))) + + with OpenAI(api_key="original-api-key", http_client=http_client, max_retries=0) as original: + original.with_options(workload_identity=_identity()).models.list() + + assert [str(request.url) for request in requests] == [_TOKEN_URL, _API_URL] + assert requests[-1].headers.get_list("Authorization") == ["Bearer access-token"] + + +async def test_async_switch_to_x509_discards_every_mixed_case_ambient_authorization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + requests: list[httpx2.Request] = [] + monkeypatch.setenv( + "OPENAI_CUSTOM_HEADERS", "Authorization: Bearer first-ambient\nAUTHORIZATION: Bearer second-ambient" + ) + http_client = httpx2.AsyncClient(transport=httpx2.MockTransport(lambda request: _record(requests, request))) + + async with AsyncOpenAI(api_key="original-api-key", http_client=http_client, max_retries=0) as original: + await original.with_options(workload_identity=_identity()).models.list() + + assert [str(request.url) for request in requests] == [_TOKEN_URL, _API_URL] + assert requests[-1].headers.get_list("Authorization") == ["Bearer access-token"] + + +@pytest.mark.parametrize("client_type", [OpenAI, AsyncOpenAI]) +def test_x509_mode_switch_preserves_explicit_authorization_override( + client_type: type[OpenAI] | type[AsyncOpenAI], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("OPENAI_CUSTOM_HEADERS", "Authorization: Bearer ambient-secret") + original = client_type(api_key="original-api-key") + copied = original.with_options( + workload_identity=_identity(), default_headers={"Authorization": "Bearer intentional-override"} + ) + assert copied.default_headers["Authorization"] == "Bearer intentional-override" + + +@pytest.mark.parametrize("client_type", [OpenAI, AsyncOpenAI]) +@pytest.mark.parametrize("header_name", ["Authorization", "authorization", "AUTHORIZATION"]) +def test_x509_mode_switch_preserves_inherited_explicit_authorization_override( + client_type: type[OpenAI] | type[AsyncOpenAI], monkeypatch: pytest.MonkeyPatch, header_name: str +) -> None: + monkeypatch.setenv("OPENAI_CUSTOM_HEADERS", "Authorization: Bearer ambient-secret") + original = client_type(api_key="original-api-key", default_headers={header_name: "Bearer intentional-override"}) + copied = original.with_options(workload_identity=_identity()) + assert httpx2.Headers(copied._custom_headers).get_list("authorization") == ["Bearer intentional-override"] + + +@pytest.mark.parametrize("client_type", [OpenAI, AsyncOpenAI]) +@pytest.mark.parametrize("header_option", ["default_headers", "set_default_headers"]) +def test_x509_mode_switch_preserves_explicit_override_matching_ambient_authorization( + client_type: type[OpenAI] | type[AsyncOpenAI], monkeypatch: pytest.MonkeyPatch, header_option: str +) -> None: + monkeypatch.setenv("OPENAI_CUSTOM_HEADERS", "Authorization: Bearer ambient-secret") + original = client_type(api_key="original-api-key") + headers = {"Authorization": "Bearer ambient-secret"} + explicitly_overridden = ( + original.with_options(default_headers=headers) + if header_option == "default_headers" + else original.with_options(set_default_headers=headers) + ) + + copied = explicitly_overridden.with_options(workload_identity=_identity()) + + assert httpx2.Headers(copied._custom_headers).get_list("authorization") == ["Bearer ambient-secret"] + + +@pytest.mark.parametrize("client_type", [OpenAI, AsyncOpenAI]) +def test_x509_mode_switch_discards_ambient_authorization_after_intermediate_copy( + client_type: type[OpenAI] | type[AsyncOpenAI], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("OPENAI_CUSTOM_HEADERS", "Authorization: Bearer ambient-secret") + original = client_type(api_key="original-api-key") + copied = original.with_options(timeout=2).with_options(workload_identity=_identity()) + assert not any(name.lower() == "authorization" for name in copied._custom_headers) + + +async def test_async_x509_ignores_ambient_authorization_without_changing_explicit_overrides( + monkeypatch: pytest.MonkeyPatch, +) -> None: + requests: list[httpx2.Request] = [] + monkeypatch.setenv("OPENAI_CUSTOM_HEADERS", "aUtHoRiZaTiOn: Bearer ambient-secret\nX-Custom: retained") + http_client = httpx2.AsyncClient(transport=httpx2.MockTransport(lambda request: _record(requests, request))) + + async with AsyncOpenAI(workload_identity=_identity(), http_client=http_client, max_retries=0) as client: + assert (await client.models.list()).object == "list" + + assert [str(request.url) for request in requests] == [_TOKEN_URL, _API_URL] + assert requests[-1].headers["Authorization"] == "Bearer access-token" + assert requests[-1].headers["X-Custom"] == "retained" + + +@pytest.mark.parametrize("client_type", [OpenAI, AsyncOpenAI]) +@pytest.mark.parametrize(("region", "expected_url"), _REGIONAL_MTLS_URLS.items()) +def test_x509_data_residency_uses_confirmed_regional_mtls_endpoints( + client_type: type[OpenAI] | type[AsyncOpenAI], region: str, expected_url: str +) -> None: + client = client_type(workload_identity=_identity(), data_residency=cast(Any, region)) + assert str(client.base_url) == expected_url + + +@pytest.mark.parametrize("client_type", [OpenAI, AsyncOpenAI]) +@pytest.mark.parametrize(("region", "expected_url"), _REGIONAL_MTLS_URLS.items()) +def test_x509_copy_uses_confirmed_regional_mtls_endpoints( + client_type: type[OpenAI] | type[AsyncOpenAI], region: str, expected_url: str +) -> None: + client = client_type(workload_identity=_identity()) + copied = client.with_options(data_residency=cast(Any, region)) + assert str(copied.base_url) == expected_url + assert str(client.base_url) == _REGIONAL_MTLS_URLS["global"] + + +@pytest.mark.parametrize("client_type", [OpenAI, AsyncOpenAI]) +def test_switching_from_provider_to_regional_x509_uses_the_mtls_endpoint( + client_type: type[OpenAI] | type[AsyncOpenAI], +) -> None: + client = client_type(provider=bedrock(region="us-east-1", api_key="bedrock-token")) + copied = client.with_options(provider=None, workload_identity=_identity(), data_residency="eu") + assert str(copied.base_url) == _REGIONAL_MTLS_URLS["eu"] + + +@pytest.mark.parametrize("client_type", [OpenAI, AsyncOpenAI]) +@pytest.mark.parametrize("region", ["global", "us", "eu"]) +@pytest.mark.parametrize("base_url_mode", ["omitted", "none", "intermediate_none"]) +def test_switching_regional_api_key_client_to_x509_preserves_residency( + client_type: type[OpenAI] | type[AsyncOpenAI], region: str, base_url_mode: str +) -> None: + original = client_type(api_key="original-api-key", data_residency=cast(Any, region)) + if base_url_mode == "intermediate_none": + original = original.with_options(base_url=None) + copied = ( + original.with_options(workload_identity=_identity(), base_url=None) + if base_url_mode == "none" + else original.with_options(workload_identity=_identity()) + ) + assert str(copied.base_url) == _REGIONAL_MTLS_URLS[region] + assert str(copied.with_options(timeout=1).base_url) == _REGIONAL_MTLS_URLS[region] + + +@pytest.mark.parametrize("client_type", [OpenAI, AsyncOpenAI]) +@pytest.mark.parametrize("region", ["global", "us", "eu"]) +@pytest.mark.parametrize("base_url_mode", ["omitted", "none", "intermediate_none"]) +def test_switching_regional_x509_client_to_api_key_preserves_residency( + client_type: type[OpenAI] | type[AsyncOpenAI], region: str, base_url_mode: str +) -> None: + original = client_type(workload_identity=_identity(), data_residency=cast(Any, region)) + if base_url_mode == "intermediate_none": + original = original.with_options(base_url=None) + copied = ( + original.with_options(api_key="replacement-api-key", base_url=None) + if base_url_mode == "none" + else original.with_options(api_key="replacement-api-key") + ) + expected_host = "api.openai.com" if region == "global" else f"{region}.api.openai.com" + assert str(copied.base_url) == f"https://{expected_host}/v1/" + assert str(copied.with_options(timeout=1).base_url) == f"https://{expected_host}/v1/" + + +@pytest.mark.parametrize("client_type", [OpenAI, AsyncOpenAI]) +def test_authentication_switch_preserves_explicit_custom_origin( + client_type: type[OpenAI] | type[AsyncOpenAI], +) -> None: + original = client_type(api_key="original-api-key", base_url="https://private.example/v1") + copied = original.with_options(workload_identity=_identity()) + assert str(copied.base_url) == "https://private.example/v1/" + + +@pytest.mark.parametrize("client_type", [OpenAI, AsyncOpenAI]) +def test_x509_rejects_data_residency_without_a_confirmed_mtls_endpoint( + client_type: type[OpenAI] | type[AsyncOpenAI], +) -> None: + with pytest.raises(OpenAIError, match="mTLS endpoint"): + client_type(workload_identity=_identity(), data_residency="ae") + + client = client_type(workload_identity=_identity()) + with pytest.raises(OpenAIError, match="mTLS endpoint"): + client.with_options(data_residency="ae") + + +@pytest.mark.parametrize("headers", [{"x-should-retry": "false"}, {"retry-after-ms": "120001"}]) +def test_sync_x509_token_exchange_honors_server_retry_refusals(headers: dict[str, str]) -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(503, request=request, headers=headers) + + with OpenAI( + workload_identity=_identity(), http_client=httpx2.Client(transport=httpx2.MockTransport(handler)), max_retries=2 + ) as client: + with pytest.raises(OpenAIError, match="503"): + client.models.list() + + assert len(requests) == 1 + + +@pytest.mark.parametrize("headers", [{"x-should-retry": "false"}, {"retry-after-ms": "120001"}]) +async def test_async_x509_token_exchange_honors_server_retry_refusals(headers: dict[str, str]) -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(503, request=request, headers=headers) + + async with AsyncOpenAI( + workload_identity=_identity(), + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), + max_retries=2, + ) as client: + with pytest.raises(OpenAIError, match="503"): + await client.models.list() + + assert len(requests) == 1 + + +def test_sync_x509_honors_millisecond_retry_delay(monkeypatch: pytest.MonkeyPatch) -> None: + delays: list[float] = [] + attempts = 0 + monkeypatch.setattr(x509_auth.time, "sleep", delays.append) + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal attempts + if str(request.url) == _TOKEN_URL: + attempts += 1 + if attempts == 1: + return httpx2.Response(429, request=request, headers={"retry-after-ms": "250"}) + return _response(request) + + with OpenAI( + workload_identity=_identity(), http_client=httpx2.Client(transport=httpx2.MockTransport(handler)) + ) as client: + assert client.models.list().object == "list" + + assert delays == [0.25] + + +async def test_async_x509_honors_millisecond_retry_delay(monkeypatch: pytest.MonkeyPatch) -> None: + delays: list[float] = [] + attempts = 0 + + async def record_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr(x509_auth.anyio, "sleep", record_sleep) + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal attempts + if str(request.url) == _TOKEN_URL: + attempts += 1 + if attempts == 1: + return httpx2.Response(429, request=request, headers={"retry-after-ms": "250"}) + return _response(request) + + async with AsyncOpenAI( + workload_identity=_identity(), http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) + ) as client: + assert (await client.models.list()).object == "list" + + assert delays == [0.25] + + +@pytest.mark.parametrize("status_code", [418, 425]) +def test_sync_x509_honors_explicit_server_retry_requests(monkeypatch: pytest.MonkeyPatch, status_code: int) -> None: + def no_sleep(_delay: float) -> None: + return None + + monkeypatch.setattr(x509_auth.time, "sleep", no_sleep) + attempts = 0 + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal attempts + if str(request.url) == _TOKEN_URL: + attempts += 1 + if attempts == 1: + return httpx2.Response(status_code, request=request, headers={"x-should-retry": "true"}) + return _response(request) + + with OpenAI( + workload_identity=_identity(), http_client=httpx2.Client(transport=httpx2.MockTransport(handler)) + ) as client: + assert client.models.list().object == "list" + + assert attempts == 2 + + +@pytest.mark.parametrize("status_code", [418, 425]) +async def test_async_x509_honors_explicit_server_retry_requests( + monkeypatch: pytest.MonkeyPatch, status_code: int +) -> None: + async def no_sleep(_delay: float) -> None: + return None + + monkeypatch.setattr(x509_auth.anyio, "sleep", no_sleep) + attempts = 0 + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal attempts + if str(request.url) == _TOKEN_URL: + attempts += 1 + if attempts == 1: + return httpx2.Response(status_code, request=request, headers={"x-should-retry": "true"}) + return _response(request) + + async with AsyncOpenAI( + workload_identity=_identity(), http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) + ) as client: + assert (await client.models.list()).object == "list" + + assert attempts == 2 + + +def test_sync_x509_client_copies_keep_authentication_caches_independent() -> None: + requests: list[httpx2.Request] = [] + http_client = httpx2.Client(transport=httpx2.MockTransport(lambda request: _record(requests, request))) + + with OpenAI(workload_identity=_identity(), http_client=http_client, max_retries=0) as client: + client.models.list() + copied = client.with_options(timeout=1) + sibling = client.with_options(timeout=2) + assert client._workload_identity_auth is not None + assert copied._workload_identity_auth is not None + assert sibling._workload_identity_auth is not None + assert copied._workload_identity_auth is not client._workload_identity_auth + assert sibling._workload_identity_auth is not client._workload_identity_auth + assert sibling._workload_identity_auth is not copied._workload_identity_auth + copied.models.list() + sibling.models.list() + + copied._workload_identity_auth.invalidate_token("access-token") + assert copied._workload_identity_auth._cached_token is None + assert client._workload_identity_auth._cached_token == "access-token" + assert sibling._workload_identity_auth._cached_token == "access-token" + copied.models.list() + + changed_identity = x509_workload_identity(identity_provider_id="other", service_account_id="svc_example") + client.with_options(workload_identity=changed_identity).models.list() + + exchanges = [request for request in requests if str(request.url) == _TOKEN_URL] + assert len(exchanges) == 5 + + +async def test_async_x509_client_copies_keep_authentication_caches_independent() -> None: + requests: list[httpx2.Request] = [] + http_client = httpx2.AsyncClient(transport=httpx2.MockTransport(lambda request: _record(requests, request))) + + async with AsyncOpenAI(workload_identity=_identity(), http_client=http_client, max_retries=0) as client: + await client.models.list() + copied = client.with_options(timeout=1) + sibling = client.with_options(timeout=2) + assert client._workload_identity_auth is not None + assert copied._workload_identity_auth is not None + assert sibling._workload_identity_auth is not None + assert copied._workload_identity_auth is not client._workload_identity_auth + assert sibling._workload_identity_auth is not client._workload_identity_auth + assert sibling._workload_identity_auth is not copied._workload_identity_auth + await copied.models.list() + await sibling.models.list() + + copied._workload_identity_auth.invalidate_token("access-token") + assert copied._workload_identity_auth._cached_token is None + assert client._workload_identity_auth._cached_token == "access-token" + assert sibling._workload_identity_auth._cached_token == "access-token" + await copied.models.list() + + changed_identity = x509_workload_identity(identity_provider_id="other", service_account_id="svc_example") + await client.with_options(workload_identity=changed_identity).models.list() + + exchanges = [request for request in requests if str(request.url) == _TOKEN_URL] + assert len(exchanges) == 5 + + +@pytest.mark.parametrize("requestless", [False, True]) +def test_sync_x509_uses_unexpired_token_when_proactive_refresh_temporarily_fails(requestless: bool) -> None: + requests: list[httpx2.Request] = [] + exchange_count = 0 + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal exchange_count + requests.append(request) + if str(request.url) == _TOKEN_URL: + exchange_count += 1 + if exchange_count > 1: + if requestless: + raise _RequestlessConnectError("temporary failure") + raise httpx2.ConnectError("temporary failure", request=request) + return _response(request) + + with OpenAI( + workload_identity=_identity(), http_client=httpx2.Client(transport=httpx2.MockTransport(handler)), max_retries=0 + ) as client: + client.models.list() + assert client._workload_identity_auth is not None + client._workload_identity_auth._cached_token_refresh_at_monotonic = time.monotonic() - 1 + assert client.models.list().object == "list" + client._workload_identity_auth._cached_token_expires_at_monotonic = time.monotonic() - 1 + with pytest.raises(APIConnectionError): + client.models.list() + + +@pytest.mark.parametrize("requestless", [False, True]) +async def test_async_x509_uses_unexpired_token_when_proactive_refresh_temporarily_fails(requestless: bool) -> None: + requests: list[httpx2.Request] = [] + exchange_count = 0 + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal exchange_count + requests.append(request) + if str(request.url) == _TOKEN_URL: + exchange_count += 1 + if exchange_count > 1: + if requestless: + raise _RequestlessConnectError("temporary failure") + raise httpx2.ConnectError("temporary failure", request=request) + return _response(request) + + async with AsyncOpenAI( + workload_identity=_identity(), + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), + max_retries=0, + ) as client: + await client.models.list() + assert client._workload_identity_auth is not None + client._workload_identity_auth._cached_token_refresh_at_monotonic = time.monotonic() - 1 + assert (await client.models.list()).object == "list" + client._workload_identity_auth._cached_token_expires_at_monotonic = time.monotonic() - 1 + with pytest.raises(APIConnectionError): + await client.models.list() + + +def test_sync_x509_shares_failed_proactive_refresh_across_concurrent_requests( + monkeypatch: pytest.MonkeyPatch, +) -> None: + exchange_count = 0 + count_lock = threading.Lock() + fallback_started = threading.Event() + release_fallback = threading.Event() + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal exchange_count + if str(request.url) == _TOKEN_URL: + with count_lock: + exchange_count += 1 + current_count = exchange_count + if current_count > 1: + time.sleep(0.025) + raise httpx2.ConnectError("temporary failure", request=request) + return _response(request) + + with OpenAI( + workload_identity=_identity(), http_client=httpx2.Client(transport=httpx2.MockTransport(handler)), max_retries=0 + ) as client: + client.models.list() + auth = client._workload_identity_auth + assert isinstance(auth, x509_auth.SyncX509WorkloadIdentityAuth) + auth._cached_token_refresh_at_monotonic = time.monotonic() - 1 + fallback = auth._usable_token_after_transient_failure + + def delayed_fallback() -> str | None: + fallback_started.set() + assert release_fallback.wait(timeout=5) + return fallback() + + monkeypatch.setattr(auth, "_usable_token_after_transient_failure", delayed_fallback) + + with ThreadPoolExecutor(max_workers=6) as executor: + first = executor.submit(client.models.list) + assert fallback_started.wait(timeout=5) + waiters = [executor.submit(client.models.list) for _ in range(5)] + time.sleep(0.05) + release_fallback.set() + assert [result.result(timeout=5).object for result in [first, *waiters]] == ["list"] * 6 + + assert exchange_count == 2 + + +async def test_async_x509_shares_failed_proactive_refresh_across_concurrent_requests() -> None: + exchange_count = 0 + + async def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal exchange_count + if str(request.url) == _TOKEN_URL: + exchange_count += 1 + if exchange_count > 1: + await asyncio.sleep(0.025) + raise httpx2.ConnectError("temporary failure", request=request) + return _response(request) + + async with AsyncOpenAI( + workload_identity=_identity(), + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), + max_retries=0, + ) as client: + await client.models.list() + auth = client._workload_identity_auth + assert auth is not None + auth._cached_token_refresh_at_monotonic = time.monotonic() - 1 + + async def list_models() -> str: + return (await client.models.list()).object + + assert await asyncio.gather(*(list_models() for _ in range(6))) == ["list"] * 6 + + assert exchange_count == 2 + + +@pytest.mark.parametrize( + ("status_code", "headers"), + [(429, {}), (500, {}), (503, {}), (418, {"x-should-retry": "true"}), (425, {"x-should-retry": "true"})], +) +def test_sync_x509_uses_unexpired_token_when_proactive_refresh_gets_transient_status( + monkeypatch: pytest.MonkeyPatch, status_code: int, headers: dict[str, str] +) -> None: + def no_sleep(_delay: float) -> None: + return None + + monkeypatch.setattr(x509_auth.time, "sleep", no_sleep) + exchange_count = 0 + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal exchange_count + if str(request.url) == _TOKEN_URL: + exchange_count += 1 + if exchange_count > 1: + return httpx2.Response(status_code, request=request, headers=headers) + return _response(request) + + with OpenAI( + workload_identity=_identity(), http_client=httpx2.Client(transport=httpx2.MockTransport(handler)), max_retries=2 + ) as client: + client.models.list() + assert client._workload_identity_auth is not None + client._workload_identity_auth._cached_token_refresh_at_monotonic = time.monotonic() - 1 + assert client.models.list().object == "list" + client._workload_identity_auth._cached_token_expires_at_monotonic = time.monotonic() - 1 + with pytest.raises(OpenAIError, match=str(status_code)): + client.models.list() + + +@pytest.mark.parametrize( + ("status_code", "headers"), + [(429, {}), (500, {}), (503, {}), (418, {"x-should-retry": "true"}), (425, {"x-should-retry": "true"})], +) +async def test_async_x509_uses_unexpired_token_when_proactive_refresh_gets_transient_status( + monkeypatch: pytest.MonkeyPatch, status_code: int, headers: dict[str, str] +) -> None: + async def no_sleep(_delay: float) -> None: + return None + + monkeypatch.setattr(x509_auth.anyio, "sleep", no_sleep) + exchange_count = 0 + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal exchange_count + if str(request.url) == _TOKEN_URL: + exchange_count += 1 + if exchange_count > 1: + return httpx2.Response(status_code, request=request, headers=headers) + return _response(request) + + async with AsyncOpenAI( + workload_identity=_identity(), + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), + max_retries=2, + ) as client: + await client.models.list() + assert client._workload_identity_auth is not None + client._workload_identity_auth._cached_token_refresh_at_monotonic = time.monotonic() - 1 + assert (await client.models.list()).object == "list" + client._workload_identity_auth._cached_token_expires_at_monotonic = time.monotonic() - 1 + with pytest.raises(OpenAIError, match=str(status_code)): + await client.models.list() + + +@pytest.mark.parametrize("status_code", [400, 401, 403]) +@pytest.mark.parametrize("server_requests_retry", [False, True]) +def test_sync_x509_never_falls_back_after_permanent_oauth_rejection( + status_code: int, server_requests_retry: bool +) -> None: + exchange_count = 0 + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal exchange_count + if str(request.url) == _TOKEN_URL: + exchange_count += 1 + if exchange_count > 1: + headers = {"x-should-retry": "true"} if server_requests_retry else {} + return httpx2.Response(status_code, request=request, headers=headers, json={"error": "invalid_grant"}) + return _response(request) + + with OpenAI( + workload_identity=_identity(), http_client=httpx2.Client(transport=httpx2.MockTransport(handler)), max_retries=0 + ) as client: + client.models.list() + assert client._workload_identity_auth is not None + client._workload_identity_auth._cached_token_refresh_at_monotonic = time.monotonic() - 1 + with pytest.raises(OAuthError): + client.models.list() + + +@pytest.mark.parametrize("status_code", [400, 401, 403]) +@pytest.mark.parametrize("server_requests_retry", [False, True]) +async def test_async_x509_never_falls_back_after_permanent_oauth_rejection( + status_code: int, server_requests_retry: bool +) -> None: + exchange_count = 0 + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal exchange_count + if str(request.url) == _TOKEN_URL: + exchange_count += 1 + if exchange_count > 1: + headers = {"x-should-retry": "true"} if server_requests_retry else {} + return httpx2.Response(status_code, request=request, headers=headers, json={"error": "invalid_grant"}) + return _response(request) + + async with AsyncOpenAI( + workload_identity=_identity(), + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), + max_retries=0, + ) as client: + await client.models.list() + assert client._workload_identity_auth is not None + client._workload_identity_auth._cached_token_refresh_at_monotonic = time.monotonic() - 1 + with pytest.raises(OAuthError): + await client.models.list() + + +@pytest.mark.parametrize("timeout", [0.125, 2.5]) +def test_sync_x509_token_exchange_uses_configured_timeout(timeout: float) -> None: + requests: list[httpx2.Request] = [] + http_client = httpx2.Client(transport=httpx2.MockTransport(lambda request: _record(requests, request))) + + with OpenAI(workload_identity=_identity(), http_client=http_client, timeout=timeout, max_retries=0) as client: + client.models.list() + + assert requests[0].extensions["timeout"]["connect"] == timeout + assert requests[0].extensions["timeout"]["read"] == timeout + + +@pytest.mark.parametrize("timeout", [0.125, 2.5]) +async def test_async_x509_token_exchange_uses_configured_timeout(timeout: float) -> None: + requests: list[httpx2.Request] = [] + http_client = httpx2.AsyncClient(transport=httpx2.MockTransport(lambda request: _record(requests, request))) + + async with AsyncOpenAI( + workload_identity=_identity(), http_client=http_client, timeout=timeout, max_retries=0 + ) as client: + await client.models.list() + + assert requests[0].extensions["timeout"]["connect"] == timeout + assert requests[0].extensions["timeout"]["read"] == timeout + + +class _UnreadableSeekability(io.BytesIO): + @override + def seekable(self) -> bool: + raise io.UnsupportedOperation("seekability metadata unavailable") + + +def test_sync_x509_still_sends_uploads_when_seekability_inspection_fails() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + if str(request.url) == _TOKEN_URL: + return _response(request) + return httpx2.Response(200, request=request, json={"id": "file_123", "object": "file"}) + + with OpenAI( + workload_identity=_identity(), http_client=httpx2.Client(transport=httpx2.MockTransport(handler)), max_retries=0 + ) as client: + result = client.files.create(file=("payload.txt", _UnreadableSeekability(b"payload")), purpose="assistants") + + assert result.id == "file_123" + assert len(requests) == 2 + + +async def test_async_x509_still_sends_uploads_when_seekability_inspection_fails() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + if str(request.url) == _TOKEN_URL: + return _response(request) + return httpx2.Response(200, request=request, json={"id": "file_123", "object": "file"}) + + async with AsyncOpenAI( + workload_identity=_identity(), + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), + max_retries=0, + ) as client: + result = await client.files.create( + file=("payload.txt", _UnreadableSeekability(b"payload")), purpose="assistants" + ) + + assert result.id == "file_123" + assert len(requests) == 2 + + +@pytest.mark.parametrize("client_type", [OpenAI, AsyncOpenAI]) +@pytest.mark.parametrize("field", ["identity_provider_id", "service_account_id"]) +@pytest.mark.parametrize("invalid", [True, 42, {"nested": "value"}, ["value"]]) +def test_x509_rejects_non_string_identity_identifiers( + client_type: type[OpenAI] | type[AsyncOpenAI], field: str, invalid: object +) -> None: + identity = cast(X509WorkloadIdentity, {**_identity(), field: invalid}) + with pytest.raises(OpenAIError, match="identity-provider and service-account IDs"): + client_type(workload_identity=identity) + + +@pytest.mark.parametrize("replace_authorization", [False, True]) +@pytest.mark.parametrize("overlapping_tokens", [False, True]) +def test_sync_x509_pins_concurrent_reconstructed_requests_to_the_correct_identity( + replace_authorization: bool, overlapping_tokens: bool +) -> None: + arrived = threading.Barrier(2) + tokens = ( + {"one": "token", "two": "token.extended"} if overlapping_tokens else {"one": "token-one", "two": "token-two"} + ) + + def handler(request: httpx2.Request) -> httpx2.Response: + if str(request.url) == _TOKEN_URL: + identity = json.loads(request.content)["identity_provider_id"] + return _response(request, token=tokens[identity.rsplit("-", 1)[-1]]) + return _response(request) + + def replace(request: httpx2.Request) -> None: + if replace_authorization and request.headers.get("Authorization") == f"Bearer {tokens['two']}": + request.headers["Authorization"] = f"Bearer {tokens['one']}" + + class CrossThreadClient(httpx2.Client): + @override + def send(self, request: httpx2.Request, **kwargs: Any) -> httpx2.Response: + arrived.wait(timeout=5) + copied = httpx2.Request(request.method, request.url, headers=dict(request.headers)) + with ThreadPoolExecutor(max_workers=1) as executor: + return executor.submit(super().send, copied, **kwargs).result() + + transport = CrossThreadClient(transport=httpx2.MockTransport(handler), event_hooks={"request": [replace]}) + clients = [ + OpenAI( + workload_identity=x509_workload_identity(identity_provider_id=f"idp-{suffix}", service_account_id="svc"), + http_client=transport, + max_retries=0, + ) + for suffix in ("one", "two") + ] + + with ThreadPoolExecutor(max_workers=2) as executor: + requests = [executor.submit(client.models.list) for client in clients] + assert requests[0].result(timeout=5).object == "list" + if replace_authorization: + with pytest.raises(OpenAIError, match="authorization cannot be changed"): + requests[1].result(timeout=5) + else: + assert requests[1].result(timeout=5).object == "list" + + +@pytest.mark.parametrize("replace_authorization", [False, True]) +@pytest.mark.parametrize("overlapping_tokens", [False, True]) +async def test_async_x509_pins_concurrent_reconstructed_requests_to_the_correct_identity( + replace_authorization: bool, overlapping_tokens: bool +) -> None: + arrived = 0 + both_arrived = asyncio.Event() + tokens = ( + {"one": "token", "two": "token.extended"} if overlapping_tokens else {"one": "token-one", "two": "token-two"} + ) + + def handler(request: httpx2.Request) -> httpx2.Response: + if str(request.url) == _TOKEN_URL: + identity = json.loads(request.content)["identity_provider_id"] + return _response(request, token=tokens[identity.rsplit("-", 1)[-1]]) + return _response(request) + + async def replace(request: httpx2.Request) -> None: + if replace_authorization and request.headers.get("Authorization") == f"Bearer {tokens['two']}": + request.headers["Authorization"] = f"Bearer {tokens['one']}" + + class CrossContextClient(httpx2.AsyncClient): + @override + async def send(self, request: httpx2.Request, **kwargs: Any) -> httpx2.Response: + nonlocal arrived + arrived += 1 + if arrived == 2: + both_arrived.set() + await asyncio.wait_for(both_arrived.wait(), timeout=5) + copied = httpx2.Request(request.method, request.url, headers=dict(request.headers)) + return await Context().run(asyncio.create_task, super().send(copied, **kwargs)) + + transport = CrossContextClient(transport=httpx2.MockTransport(handler), event_hooks={"request": [replace]}) + clients = [ + AsyncOpenAI( + workload_identity=x509_workload_identity(identity_provider_id=f"idp-{suffix}", service_account_id="svc"), + http_client=transport, + max_retries=0, + ) + for suffix in ("one", "two") + ] + + responses = await asyncio.gather(*(client.models.list() for client in clients), return_exceptions=True) + first = responses[0] + assert not isinstance(first, BaseException) + assert first.object == "list" + if replace_authorization: + assert isinstance(responses[1], OpenAIError) + assert "authorization cannot be changed" in str(responses[1]) + else: + second = responses[1] + assert not isinstance(second, BaseException) + assert second.object == "list" + + +def _record(requests: list[httpx2.Request], request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return _response(request) diff --git a/tests/test_x509_workload_identity_transport.py b/tests/test_x509_workload_identity_transport.py new file mode 100644 index 0000000000..58944231cd --- /dev/null +++ b/tests/test_x509_workload_identity_transport.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +from typing import Any +from typing_extensions import override + +import httpx2 +import pytest + +from openai import OpenAI, AsyncOpenAI, OpenAIError +from openai.auth import X509WorkloadIdentity, x509_workload_identity + +_TOKEN_URL = "https://mtls.auth.openai.com/oauth/token" + + +_API_URL = "https://mtls.api.openai.com/v1/models" + + +def _identity() -> X509WorkloadIdentity: + return x509_workload_identity(identity_provider_id="idp_example", service_account_id="svc_example") + + +def _response(request: httpx2.Request) -> httpx2.Response: + if str(request.url) == _TOKEN_URL: + return httpx2.Response(200, request=request, json={"access_token": "access-token", "expires_in": 3600}) + return httpx2.Response(200, request=request, json={"object": "list", "data": []}) + + +def _record(requests: list[httpx2.Request], request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return _response(request) + + +@pytest.mark.parametrize("credential_location", ["path", "nested_body"]) +def test_sync_x509_never_exposes_protected_dispatch_to_custom_send(credential_location: str) -> None: + requests: list[httpx2.Request] = [] + custom_sends: list[httpx2.Request] = [] + + class ReconstructingClient(httpx2.Client): + @override + def send(self, request: httpx2.Request, **kwargs: Any) -> httpx2.Response: + custom_sends.append(request) + if request.url.host == "mtls.api.openai.com": + token = request.headers["Authorization"].removeprefix("Bearer ") + url = ( + f"https://attacker.invalid/{token}" if credential_location == "path" else "https://attacker.invalid" + ) + content = token.replace("-", "%252D").encode() if credential_location == "nested_body" else None + return self.send(httpx2.Request("POST", url, content=content), **kwargs) + return super().send(request, **kwargs) + + transport = ReconstructingClient(transport=httpx2.MockTransport(lambda request: _record(requests, request))) + with OpenAI(workload_identity=_identity(), http_client=transport, max_retries=0) as client: + assert client.models.list().object == "list" + + assert custom_sends == [] + assert [str(request.url) for request in requests] == [_TOKEN_URL, _API_URL] + + +@pytest.mark.parametrize("credential_location", ["path", "nested_body"]) +async def test_async_x509_never_exposes_protected_dispatch_to_custom_send(credential_location: str) -> None: + requests: list[httpx2.Request] = [] + custom_sends: list[httpx2.Request] = [] + + class ReconstructingClient(httpx2.AsyncClient): + @override + async def send(self, request: httpx2.Request, **kwargs: Any) -> httpx2.Response: + custom_sends.append(request) + if request.url.host == "mtls.api.openai.com": + token = request.headers["Authorization"].removeprefix("Bearer ") + url = ( + f"https://attacker.invalid/{token}" if credential_location == "path" else "https://attacker.invalid" + ) + content = token.replace("-", "%252D").encode() if credential_location == "nested_body" else None + return await self.send(httpx2.Request("POST", url, content=content), **kwargs) + return await super().send(request, **kwargs) + + transport = ReconstructingClient(transport=httpx2.MockTransport(lambda request: _record(requests, request))) + async with AsyncOpenAI(workload_identity=_identity(), http_client=transport, max_retries=0) as client: + assert (await client.models.list()).object == "list" + + assert custom_sends == [] + assert [str(request.url) for request in requests] == [_TOKEN_URL, _API_URL] + + +def test_sync_x509_does_not_install_process_wide_dispatch_guards() -> None: + requests: list[httpx2.Request] = [] + original_dispatch = httpx2.Client._send_single_request + + def hook(request: httpx2.Request) -> None: + if request.url.host == "mtls.api.openai.com": + assert httpx2.Client._send_single_request is original_dispatch + assert transport.post("https://telemetry.example/collect", content=b"%41" * 1024).status_code == 200 + + transport = httpx2.Client( + transport=httpx2.MockTransport(lambda request: _record(requests, request)), event_hooks={"request": [hook]} + ) + with OpenAI(workload_identity=_identity(), http_client=transport, max_retries=0) as client: + assert client.models.list().object == "list" + + assert httpx2.Client._send_single_request is original_dispatch + assert [request.url.host for request in requests] == [ + "mtls.auth.openai.com", + "telemetry.example", + "mtls.api.openai.com", + ] + + +async def test_async_x509_does_not_install_process_wide_dispatch_guards() -> None: + requests: list[httpx2.Request] = [] + original_dispatch = httpx2.AsyncClient._send_single_request + + async def hook(request: httpx2.Request) -> None: + if request.url.host == "mtls.api.openai.com": + assert httpx2.AsyncClient._send_single_request is original_dispatch + assert (await transport.post("https://telemetry.example/collect", content=b"%41" * 1024)).status_code == 200 + + transport = httpx2.AsyncClient( + transport=httpx2.MockTransport(lambda request: _record(requests, request)), event_hooks={"request": [hook]} + ) + async with AsyncOpenAI(workload_identity=_identity(), http_client=transport, max_retries=0) as client: + assert (await client.models.list()).object == "list" + + assert httpx2.AsyncClient._send_single_request is original_dispatch + assert [request.url.host for request in requests] == [ + "mtls.auth.openai.com", + "telemetry.example", + "mtls.api.openai.com", + ] + + +def test_sync_x509_preserves_explicit_sni_for_custom_origins() -> None: + requests: list[httpx2.Request] = [] + + def hook(request: httpx2.Request) -> None: + request.extensions["sni_hostname"] = "private-pki.example" + + transport = httpx2.Client( + transport=httpx2.MockTransport(lambda request: _record(requests, request)), event_hooks={"request": [hook]} + ) + with OpenAI( + workload_identity=_identity(), http_client=transport, base_url="https://custom.example/v1", max_retries=0 + ) as client: + assert client.models.list().object == "list" + + assert [str(request.url) for request in requests] == [_TOKEN_URL, "https://custom.example/v1/models"] + assert requests[-1].extensions["sni_hostname"] == "private-pki.example" + + +async def test_async_x509_preserves_explicit_sni_for_custom_origins() -> None: + requests: list[httpx2.Request] = [] + + async def hook(request: httpx2.Request) -> None: + request.extensions["sni_hostname"] = "private-pki.example" + + transport = httpx2.AsyncClient( + transport=httpx2.MockTransport(lambda request: _record(requests, request)), event_hooks={"request": [hook]} + ) + async with AsyncOpenAI( + workload_identity=_identity(), http_client=transport, base_url="https://custom.example/v1", max_retries=0 + ) as client: + assert (await client.models.list()).object == "list" + + assert [str(request.url) for request in requests] == [_TOKEN_URL, "https://custom.example/v1/models"] + assert requests[-1].extensions["sni_hostname"] == "private-pki.example" + + +@pytest.mark.parametrize("extension", ["sni_hostname", "target"]) +def test_sync_x509_rejects_conflicting_transport_extensions_on_openai_mtls_origins(extension: str) -> None: + requests: list[httpx2.Request] = [] + + def hook(request: httpx2.Request) -> None: + request.extensions[extension] = "attacker.example" if extension == "sni_hostname" else b"https://attacker/" + + http_client = httpx2.Client( + transport=httpx2.MockTransport(lambda request: _record(requests, request)), + event_hooks={"request": [hook]}, + ) + with OpenAI(workload_identity=_identity(), http_client=http_client, max_retries=0) as client: + with pytest.raises(OpenAIError, match="hostname|target"): + client.models.list() + + assert [str(request.url) for request in requests] == [_TOKEN_URL] + + +@pytest.mark.parametrize("extension", ["sni_hostname", "target"]) +async def test_async_x509_rejects_conflicting_transport_extensions_on_openai_mtls_origins(extension: str) -> None: + requests: list[httpx2.Request] = [] + + async def hook(request: httpx2.Request) -> None: + request.extensions[extension] = "attacker.example" if extension == "sni_hostname" else b"https://attacker/" + + http_client = httpx2.AsyncClient( + transport=httpx2.MockTransport(lambda request: _record(requests, request)), + event_hooks={"request": [hook]}, + ) + async with AsyncOpenAI(workload_identity=_identity(), http_client=http_client, max_retries=0) as client: + with pytest.raises(OpenAIError, match="hostname|target"): + await client.models.list() + + assert [str(request.url) for request in requests] == [_TOKEN_URL] + + +@pytest.mark.parametrize("mutation", ["transport", "mounts"]) +def test_sync_x509_rejects_request_hook_destination_changes_after_transport_replacement(mutation: str) -> None: + requests: list[httpx2.Request] = [] + http_client = httpx2.Client(transport=httpx2.MockTransport(lambda request: _record(requests, request))) + + def hook(request: httpx2.Request) -> None: + request.url = httpx2.URL("https://attacker.invalid/capture") + request.headers["host"] = "attacker.invalid" + replacement = httpx2.MockTransport(lambda redirected: _record(requests, redirected)) + if mutation == "transport": + http_client._transport = replacement + else: + http_client._mounts.clear() + http_client._transport = replacement + + http_client.event_hooks["request"].append(hook) + with OpenAI(workload_identity=_identity(), http_client=http_client, max_retries=0) as client: + with pytest.raises(OpenAIError, match="configured API origin"): + client.models.list() + + assert [str(request.url) for request in requests] == [_TOKEN_URL] + + +@pytest.mark.parametrize("mutation", ["transport", "mounts"]) +async def test_async_x509_rejects_request_hook_destination_changes_after_transport_replacement(mutation: str) -> None: + requests: list[httpx2.Request] = [] + http_client = httpx2.AsyncClient(transport=httpx2.MockTransport(lambda request: _record(requests, request))) + + async def hook(request: httpx2.Request) -> None: + request.url = httpx2.URL("https://attacker.invalid/capture") + request.headers["host"] = "attacker.invalid" + replacement = httpx2.MockTransport(lambda redirected: _record(requests, redirected)) + if mutation == "transport": + http_client._transport = replacement + else: + http_client._mounts.clear() + http_client._transport = replacement + + http_client.event_hooks["request"].append(hook) + async with AsyncOpenAI(workload_identity=_identity(), http_client=http_client, max_retries=0) as client: + with pytest.raises(OpenAIError, match="configured API origin"): + await client.models.list() + + assert [str(request.url) for request in requests] == [_TOKEN_URL] + + +def test_sync_x509_does_not_traverse_unrelated_custom_client_state() -> None: + class UninspectableHistory(dict[str, object]): + @override + def values(self) -> Any: + raise AssertionError("unrelated application-owned request history was traversed") + + http_client = httpx2.Client(transport=httpx2.MockTransport(_response)) + vars(http_client)["request_history"] = UninspectableHistory({"nested": {"large": [object()]}}) + + with OpenAI(workload_identity=_identity(), http_client=http_client, max_retries=0) as client: + assert client.models.list().object == "list" + + +async def test_async_x509_does_not_traverse_unrelated_custom_client_state() -> None: + class UninspectableHistory(dict[str, object]): + @override + def values(self) -> Any: + raise AssertionError("unrelated application-owned request history was traversed") + + http_client = httpx2.AsyncClient(transport=httpx2.MockTransport(_response)) + vars(http_client)["request_history"] = UninspectableHistory({"nested": {"large": [object()]}}) + + async with AsyncOpenAI(workload_identity=_identity(), http_client=http_client, max_retries=0) as client: + assert (await client.models.list()).object == "list" + + +def test_sync_x509_keeps_equal_http_clients_in_distinct_security_scopes() -> None: + class EqualClient(httpx2.Client): + @override + def __eq__(self, other: object) -> bool: + return isinstance(other, EqualClient) + + @override + def __hash__(self) -> int: + return 1 + + first_requests: list[httpx2.Request] = [] + second_requests: list[httpx2.Request] = [] + first_transport = EqualClient(transport=httpx2.MockTransport(lambda request: _record(first_requests, request))) + second_transport = EqualClient(transport=httpx2.MockTransport(lambda request: _record(second_requests, request))) + + def redirect(request: httpx2.Request) -> None: + request.url = httpx2.URL("https://attacker.invalid/capture") + request.headers["host"] = "attacker.invalid" + + second_transport.event_hooks["request"].append(redirect) + with OpenAI(workload_identity=_identity(), http_client=first_transport, max_retries=0) as first: + assert first.models.list().object == "list" + with OpenAI(workload_identity=_identity(), http_client=second_transport, max_retries=0) as second: + with pytest.raises(OpenAIError, match="configured API origin"): + second.models.list() + + assert [str(request.url) for request in second_requests] == [_TOKEN_URL] + + +async def test_async_x509_keeps_equal_http_clients_in_distinct_security_scopes() -> None: + class EqualClient(httpx2.AsyncClient): + @override + def __eq__(self, other: object) -> bool: + return isinstance(other, EqualClient) + + @override + def __hash__(self) -> int: + return 1 + + first_requests: list[httpx2.Request] = [] + second_requests: list[httpx2.Request] = [] + first_transport = EqualClient(transport=httpx2.MockTransport(lambda request: _record(first_requests, request))) + second_transport = EqualClient(transport=httpx2.MockTransport(lambda request: _record(second_requests, request))) + + async def redirect(request: httpx2.Request) -> None: + request.url = httpx2.URL("https://attacker.invalid/capture") + request.headers["host"] = "attacker.invalid" + + second_transport.event_hooks["request"].append(redirect) + async with AsyncOpenAI(workload_identity=_identity(), http_client=first_transport, max_retries=0) as first: + assert (await first.models.list()).object == "list" + async with AsyncOpenAI(workload_identity=_identity(), http_client=second_transport, max_retries=0) as second: + with pytest.raises(OpenAIError, match="configured API origin"): + await second.models.list() + + assert [str(request.url) for request in second_requests] == [_TOKEN_URL] + + +def test_sync_x509_accepts_unhashable_custom_http_clients() -> None: + class UnhashableClient(httpx2.Client): + @override + def __eq__(self, other: object) -> bool: + return self is other + + http_client = UnhashableClient(transport=httpx2.MockTransport(_response)) + with OpenAI(workload_identity=_identity(), http_client=http_client, max_retries=0) as client: + assert client.models.list().object == "list" + + +async def test_async_x509_accepts_unhashable_custom_http_clients() -> None: + class UnhashableClient(httpx2.AsyncClient): + @override + def __eq__(self, other: object) -> bool: + return self is other + + http_client = UnhashableClient(transport=httpx2.MockTransport(_response)) + async with AsyncOpenAI(workload_identity=_identity(), http_client=http_client, max_retries=0) as client: + assert (await client.models.list()).object == "list" + + +def test_sync_x509_preserves_caller_default_response_encoding() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + if str(request.url) == _TOKEN_URL: + return _response(request) + return httpx2.Response(200, request=request, content=b"caf\xe9", headers={"content-type": "text/plain"}) + + transport = httpx2.Client(transport=httpx2.MockTransport(handler), default_encoding="latin-1") + with OpenAI(workload_identity=_identity(), http_client=transport, max_retries=0) as client: + response = client.get("/text", cast_to=httpx2.Response) + + assert response.encoding == "latin-1" + assert response.text == "café" + + +async def test_async_x509_preserves_caller_default_response_encoding() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + if str(request.url) == _TOKEN_URL: + return _response(request) + return httpx2.Response(200, request=request, content=b"caf\xe9", headers={"content-type": "text/plain"}) + + transport = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), default_encoding="latin-1") + async with AsyncOpenAI(workload_identity=_identity(), http_client=transport, max_retries=0) as client: + response = await client.get("/text", cast_to=httpx2.Response) + + assert response.encoding == "latin-1" + assert response.text == "café" + + +def test_sync_x509_preserves_mounted_transports_and_restores_caller_configuration() -> None: + exchange_requests: list[httpx2.Request] = [] + api_requests: list[httpx2.Request] = [] + exchange_transport = httpx2.MockTransport(lambda request: _record(exchange_requests, request)) + api_transport = httpx2.MockTransport(lambda request: _record(api_requests, request)) + http_client = httpx2.Client( + transport=exchange_transport, + mounts={"https://mtls.api.openai.com": api_transport}, + trust_env=False, + ) + original_mounts = http_client._mounts + + with OpenAI(workload_identity=_identity(), http_client=http_client, max_retries=0) as client: + assert client.models.list().object == "list" + assert http_client._transport is exchange_transport + assert http_client._mounts is original_mounts + + assert [str(request.url) for request in exchange_requests] == [_TOKEN_URL] + assert [str(request.url) for request in api_requests] == [_API_URL] + + +async def test_async_x509_preserves_mounted_transports_and_restores_caller_configuration() -> None: + exchange_requests: list[httpx2.Request] = [] + api_requests: list[httpx2.Request] = [] + exchange_transport = httpx2.MockTransport(lambda request: _record(exchange_requests, request)) + api_transport = httpx2.MockTransport(lambda request: _record(api_requests, request)) + http_client = httpx2.AsyncClient( + transport=exchange_transport, + mounts={"https://mtls.api.openai.com": api_transport}, + trust_env=False, + ) + original_mounts = http_client._mounts + + async with AsyncOpenAI(workload_identity=_identity(), http_client=http_client, max_retries=0) as client: + assert (await client.models.list()).object == "list" + assert http_client._transport is exchange_transport + assert http_client._mounts is original_mounts + + assert [str(request.url) for request in exchange_requests] == [_TOKEN_URL] + assert [str(request.url) for request in api_requests] == [_API_URL]