diff --git a/.github/actions/fetch_ctk/action.yml b/.github/actions/fetch_ctk/action.yml index 994e35924fc..ab41918185f 100644 --- a/.github/actions/fetch_ctk/action.yml +++ b/.github/actions/fetch_ctk/action.yml @@ -19,35 +19,76 @@ inputs: description: "where the CTK components will be installed to, relative to $PWD" required: false default: "./cuda_toolkit" + redist-base-url: + description: > + Base URL of a redistrib server (manifest and component archives). + Defaults to the public NVIDIA developer server. + Override to install from an alternate redistrib server. + required: false + default: "https://developer.download.nvidia.com/compute/cuda/redist" runs: using: composite steps: + - name: Install Unix dependencies + if: ${{ runner.os == 'Linux' }} + uses: ./.github/actions/install_unix_deps + with: + dependencies: "zstd curl xz-utils" + dependent_exes: "zstd curl xz" + + - name: Install Windows dependencies + if: ${{ runner.os == 'Windows' }} + uses: ./.github/actions/install_windows_deps + with: + dependent_exes: "curl unzip tar sha256sum cygpath" + - name: Set up CTK cache variable shell: bash --noprofile --norc -xeuo pipefail {0} run: | - # Pre-process the component list to ensure hash uniqueness # Use the runtime workspace mount so this also works inside container jobs. CTK_REDIST_TOOL="${GITHUB_WORKSPACE}/ci/tools/fetch_ctk_redistrib.py" - CTK_CACHE_COMPONENTS=${{ inputs.cuda-components }} - CTK_JSON_URL="https://developer.download.nvidia.com/compute/cuda/redist/redistrib_${{ inputs.cuda-version }}.json" + CTK_BASE_URL="${{ inputs.redist-base-url }}" + # A trailing slash would defeat the public-server comparison below and double up in URLs. + CTK_BASE_URL="${CTK_BASE_URL%/}" + # RUNNER_TEMP persists across steps, so "Get CUDA components" reuses this file. + CTK_JSON_FILE="$RUNNER_TEMP/redistrib-${{ inputs.cuda-version }}.json" + curl -LSs --fail --retry 3 --retry-delay 2 "${CTK_BASE_URL}/redistrib_${{ inputs.cuda-version }}.json" -o "$CTK_JSON_FILE" + + # Pin the metadata JSON: the per-archive sha256 checks below are only + # trustworthy if the JSON that carries those hashes is itself trusted. + # The pins describe the public server; alternate servers only warn. + if [[ "$CTK_BASE_URL" == "https://developer.download.nvidia.com/compute/cuda/redist" ]]; then + METADATA_SOURCE=public + else + METADATA_SOURCE=custom + fi + python "$CTK_REDIST_TOOL" verify-metadata \ + --cuda-version "${{ inputs.cuda-version }}" \ + --metadata-path "$CTK_JSON_FILE" \ + --metadata-source "$METADATA_SOURCE" + + # Pre-process the component list to ensure hash uniqueness CTK_CACHE_COMPONENTS="$(python "$CTK_REDIST_TOOL" filter-components \ --host-platform "${{ inputs.host-platform }}" \ --cuda-version "${{ inputs.cuda-version }}" \ - --components "$CTK_CACHE_COMPONENTS" \ - --metadata-url "$CTK_JSON_URL")" + --components "${{ inputs.cuda-components }}" \ + --metadata-path "$CTK_JSON_FILE")" + # Alternate redist servers (non-default) publish different manifests for the + # same CUDA version; keying on the full manifest hash keeps them from restoring + # each other's toolkit (a truncated hash could be collided on purpose). + # Hash via stdin: with a backslash in the path (Windows RUNNER_TEMP), sha256sum + # prefixes its output line with "\", which would end up in the cache key. + MANIFEST_SHA256="$(sha256sum < "$CTK_JSON_FILE" | awk '{print $1}')" HASH=$(echo -n "${CTK_CACHE_COMPONENTS}" | sha256sum | awk '{print $1}') - echo "CTK_CACHE_KEY=mini-ctk-${{ inputs.cuda-version }}-${{ inputs.host-platform }}-$HASH" >> $GITHUB_ENV - echo "CTK_CACHE_FILENAME=mini-ctk-${{ inputs.cuda-version }}-${{ inputs.host-platform }}-$HASH.tar.gz" >> $GITHUB_ENV - echo "CTK_CACHE_COMPONENTS=${CTK_CACHE_COMPONENTS}" >> $GITHUB_ENV - - - name: Install dependencies - uses: ./.github/actions/install_unix_deps - continue-on-error: false - with: - dependencies: "zstd curl xz-utils" - dependent_exes: "zstd curl xz" + { + echo "CTK_CACHE_KEY=mini-ctk-${{ inputs.cuda-version }}-${{ inputs.host-platform }}-$HASH-m$MANIFEST_SHA256" + echo "CTK_CACHE_FILENAME=mini-ctk-${{ inputs.cuda-version }}-${{ inputs.host-platform }}-$HASH-m$MANIFEST_SHA256.tar.gz" + echo "CTK_CACHE_COMPONENTS=${CTK_CACHE_COMPONENTS}" + echo "CTK_BASE_URL=${CTK_BASE_URL}" + echo "CTK_JSON_FILE=${CTK_JSON_FILE}" + } >> "$GITHUB_ENV" - name: Download CTK cache id: ctk-get-cache @@ -68,13 +109,10 @@ runs: rm -rf $CACHE_TMP_DIR mkdir $CACHE_TMP_DIR - # The binary archives (redist) are guaranteed to be updated as part of the release posting. # Use the runtime workspace mount so this also works inside container jobs. CTK_REDIST_TOOL="${GITHUB_WORKSPACE}/ci/tools/fetch_ctk_redistrib.py" - CTK_BASE_URL="https://developer.download.nvidia.com/compute/cuda/redist/" - CTK_JSON_URL="$CTK_BASE_URL/redistrib_${{ inputs.cuda-version }}.json" - CTK_JSON_FILE="$CACHE_TMP_DIR/redistrib.json" - curl -LSs "$CTK_JSON_URL" -o "$CTK_JSON_FILE" + # CTK_BASE_URL and the verified manifest at CTK_JSON_FILE come from + # "Set up CTK cache variable". if [[ "${{ inputs.host-platform }}" == linux* ]]; then function extract() { tar -xvf $1 -C $CACHE_TMP_DIR --strip-components=1 @@ -102,6 +140,13 @@ runs: CTK_COMPONENT_URL="${CTK_BASE_URL}/${CTK_COMPONENT_REL_PATH}" CTK_COMPONENT_COMPONENT_FILENAME="$(basename $CTK_COMPONENT_REL_PATH)" download $CTK_COMPONENT_URL $CTK_COMPONENT_COMPONENT_FILENAME + # Verify the downloaded archive against the sha256 in the (pinned) + # metadata before extracting; fail closed on any mismatch. + CTK_COMPONENT_SHA256="$(python "$CTK_REDIST_TOOL" component-sha256 \ + --host-platform "${{ inputs.host-platform }}" \ + --component "$CTK_COMPONENT" \ + --metadata-path "$CTK_JSON_FILE")" + echo "${CTK_COMPONENT_SHA256} ${CTK_COMPONENT_COMPONENT_FILENAME}" | sha256sum --check --strict - extract $CTK_COMPONENT_COMPONENT_FILENAME rm $CTK_COMPONENT_COMPONENT_FILENAME } diff --git a/.github/actions/install_windows_deps/action.yml b/.github/actions/install_windows_deps/action.yml new file mode 100644 index 00000000000..2a7b9166f2e --- /dev/null +++ b/.github/actions/install_windows_deps/action.yml @@ -0,0 +1,92 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Install dependencies on Windows + +description: Install Git Bash tools from a verified portable Git bundle when required executables are missing. + +inputs: + dependent_exes: + description: Space-separated executable names provided by Git for Windows + required: true + +runs: + using: composite + steps: + - name: Install dependencies + shell: powershell + env: + DEPENDENT_EXES: ${{ inputs.dependent_exes }} + run: | + $ErrorActionPreference = 'Stop' + if ($env:DEPENDENT_EXES -notmatch '^\s*[a-zA-Z0-9_.-]+(\s+[a-zA-Z0-9_.-]+)*\s*$') { + throw 'Expected a space-separated list of executable names.' + } + # Fail the check if the list did not reach Bash (a WSL bash would not receive it). + # Keep double quotes out of $check: Windows PowerShell 5.1 strips them when + # passing arguments to native commands. The names are validated above. + $check = 'missing=0; set -- $DEPENDENT_EXES; [ $# -gt 0 ] || missing=1; for tool in $DEPENDENT_EXES; do command -v $tool || missing=1; done; exit $missing' + # Skip the WSL launcher in System32/WindowsApps: it is not a Git for Windows bash. + $bash = Get-Command bash -All -ErrorAction SilentlyContinue | + Where-Object { $_.Source -notmatch '\\(System32|SysWOW64|WindowsApps)\\' } | + Select-Object -First 1 + if ($bash) { + & $bash.Source --noprofile --norc -c $check + if ($LASTEXITCODE -eq 0) { + # Later `shell: bash` steps resolve `bash` through PATH. If that is not the + # Git Bash validated above (e.g. the WSL launcher comes first), put it first. + $pathBash = Get-Command bash -ErrorAction SilentlyContinue + if ($pathBash -and ($pathBash.Source -ne $bash.Source)) { + $bashDir = Split-Path -Parent $bash.Source + $env:PATH = "$bashDir;$env:PATH" + $bashDir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + Write-Host "Prepended $bashDir to PATH so steps use Git Bash instead of $($pathBash.Source)." + } + Write-Host 'All dependencies are found. Do nothing.' + exit 0 + } + } + + $architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + switch ($architecture) { + 'Arm64' { + $package = 'arm64' + $expectedHash = '3bf26b94d9399b16a890776e468334f501742861576cbcdea2d9134643c374bd' + } + 'X64' { + $package = '64-bit' + $expectedHash = 'ab00566336b5472120f9a52d34f2e79c5406535792acb0548001ffd0bd090e5d' + } + default { throw "Unsupported Windows architecture: $architecture" } + } + $root = Join-Path $env:RUNNER_TOOL_CACHE "WindowsDeps\Git-2.55.0.3-$package" + $complete = "$root.complete" + if (-not (Test-Path -LiteralPath $complete)) { + $archive = Join-Path $env:RUNNER_TEMP "PortableGit-2.55.0.3-$package.7z.exe" + try { + Invoke-WebRequest -UseBasicParsing -Uri "https://github.com/git-for-windows/git/releases/download/v2.55.0.windows.3/PortableGit-2.55.0.3-$package.7z.exe" -OutFile $archive + $actualHash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $expectedHash) { + throw "Portable Git checksum mismatch: expected $expectedHash, got $actualHash." + } + if (Test-Path -LiteralPath $root) { + Remove-Item -LiteralPath $root -Recurse -Force + } + $process = Start-Process -FilePath $archive -ArgumentList @('-y', ('-o"{0}"' -f $root)) -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "Portable Git extraction failed with exit code $($process.ExitCode)." + } + } + finally { + if (Test-Path -LiteralPath $archive) { + Remove-Item -LiteralPath $archive -Force + } + } + } + $env:PATH = "$root\bin;$root\usr\bin;$env:PATH" + & "$root\bin\bash.exe" --noprofile --norc -c $check + if ($LASTEXITCODE -ne 0) { + throw 'Required executables are still missing after installing portable Git.' + } + New-Item -ItemType File -Force -Path $complete | Out-Null + @("$root\bin", "$root\usr\bin") | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append diff --git a/ci/tools/fetch_ctk_redistrib.py b/ci/tools/fetch_ctk_redistrib.py index 412dbc68a7c..1a75ed471a8 100644 --- a/ci/tools/fetch_ctk_redistrib.py +++ b/ci/tools/fetch_ctk_redistrib.py @@ -9,11 +9,9 @@ from __future__ import annotations import argparse +import hashlib import json import sys -import urllib.error -import urllib.parse -import urllib.request from pathlib import Path from typing import Any @@ -29,6 +27,22 @@ "cuda_cccl": ("cccl",), } +# Known-good sha256 of the redistrib metadata JSON, keyed by CUDA version. +# Per-archive verification is only trustworthy if the JSON that carries the +# archive hashes is itself trusted, so we pin the metadata too. A version that +# is absent here is not blocked -- failing closed would break CI on every new +# CTK -- but `verify-metadata` emits a warning; add the version's hash below as +# it enters the matrix to pin it. Regenerate with: +# curl -fsSL \ +# https://developer.download.nvidia.com/compute/cuda/redist/redistrib_.json \ +# | sha256sum +KNOWN_REDISTRIB_SHA256: dict[str, str] = { + "12.6.3": "9c598598457a6463eb92889080c16b2b9dc04150e501b8bfc1536d403ba70aaf", + "12.9.1": "8335301010b0023ee1ff61eb11e2600ca62002d76780de4089011ad77e0c7630", + "13.0.2": "fce66717a81c510ffeb89ecc3e79849ab34af3b80139f750876d9033e31d71c2", + "13.4.2": "015b97aa3977d4d0bade1631b226b6d4cbe48751cc841aad9193295df7f9047a", +} + def host_platform_to_subdir(host_platform: str) -> str: try: @@ -59,24 +73,8 @@ def filter_static_components(components: list[str], host_platform: str, cuda_ver return filtered -def validate_metadata_url(metadata_url: str) -> str: - parsed = urllib.parse.urlsplit(metadata_url) - if parsed.scheme != "https" or not parsed.netloc: - raise ValueError(f"metadata URL must be an https URL: {metadata_url!r}") - return metadata_url - - -def load_metadata(*, metadata_path: str | None, metadata_url: str | None) -> dict[str, Any]: - if (metadata_path is None) == (metadata_url is None): - raise ValueError("exactly one of --metadata-path or --metadata-url is required") - - if metadata_path is not None: - return json.loads(Path(metadata_path).read_text(encoding="utf-8")) - - assert metadata_url is not None - metadata_url = validate_metadata_url(metadata_url) - with urllib.request.urlopen(metadata_url) as response: # noqa: S310 - scheme is restricted to https above - return json.load(response) +def load_metadata(metadata_path: str) -> dict[str, Any]: + return json.loads(Path(metadata_path).read_text(encoding="utf-8")) def resolve_component_name(metadata: dict[str, Any], component: str) -> str: @@ -109,7 +107,9 @@ def filter_components( return filtered, skipped -def get_component_relative_path(metadata: dict[str, Any], *, host_platform: str, component: str) -> str: +def get_component_subdir_info( + metadata: dict[str, Any], *, host_platform: str, component: str +) -> tuple[str, dict[str, Any]]: ctk_subdir = host_platform_to_subdir(host_platform) component = resolve_component_name(metadata, component) component_info = metadata.get(component) @@ -119,13 +119,70 @@ def get_component_relative_path(metadata: dict[str, Any], *, host_platform: str, subdir_info = component_info.get(ctk_subdir) if subdir_info is None: raise KeyError(f"CTK component {component!r} is not available for redistrib subdir {ctk_subdir!r}") + return ctk_subdir, subdir_info + +def get_component_relative_path(metadata: dict[str, Any], *, host_platform: str, component: str) -> str: + ctk_subdir, subdir_info = get_component_subdir_info(metadata, host_platform=host_platform, component=component) relative_path = subdir_info.get("relative_path") if relative_path is None: raise KeyError(f"CTK component {component!r} for redistrib subdir {ctk_subdir!r} is missing 'relative_path'") return relative_path +def get_component_sha256(metadata: dict[str, Any], *, host_platform: str, component: str) -> str: + ctk_subdir, subdir_info = get_component_subdir_info(metadata, host_platform=host_platform, component=component) + sha256 = subdir_info.get("sha256") + if not sha256: + raise KeyError(f"CTK component {component!r} for redistrib subdir {ctk_subdir!r} is missing 'sha256'") + return sha256 + + +def compute_file_sha256(path: str) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def verify_metadata_sha256(*, cuda_version: str, metadata_path: str, metadata_source: str = "public") -> int: + """Verify the redistrib JSON against the pinned hash for its CUDA version. + + The pins describe the public developer.download.nvidia.com manifests. A + "custom" source (an alternate redistrib server) may publish a different + manifest under the same version number, so it is never checked against + them; it only gets a warning. + + Returns a process exit code: 1 on a mismatch (fail closed), 0 when the hash + matches, the version is not pinned yet, or the source is custom (warn only). + """ + actual = compute_file_sha256(metadata_path) + if metadata_source == "custom": + print( + f"WARNING: redistrib metadata for CUDA {cuda_version!r} comes from a custom source and is not pinned; " + f"got {actual}.", + file=sys.stderr, + ) + return 0 + expected = KNOWN_REDISTRIB_SHA256.get(cuda_version) + if expected is None: + print( + f"WARNING: no pinned redistrib sha256 for CUDA {cuda_version!r}; got {actual}. " + f"Add it to KNOWN_REDISTRIB_SHA256 in {Path(__file__).name} to pin it.", + file=sys.stderr, + ) + return 0 + if actual != expected: + print( + f"ERROR: redistrib metadata sha256 mismatch for CUDA {cuda_version!r}: expected {expected}, got {actual}", + file=sys.stderr, + ) + return 1 + print(f"OK: redistrib metadata sha256 verified for CUDA {cuda_version}") + return 0 + + def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="command", required=True) @@ -134,14 +191,22 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: filter_parser.add_argument("--host-platform", required=True) filter_parser.add_argument("--cuda-version", required=True) filter_parser.add_argument("--components", required=True) - filter_parser.add_argument("--metadata-path") - filter_parser.add_argument("--metadata-url") + filter_parser.add_argument("--metadata-path", required=True) relpath_parser = subparsers.add_parser("component-relative-path") relpath_parser.add_argument("--host-platform", required=True) relpath_parser.add_argument("--component", required=True) - relpath_parser.add_argument("--metadata-path") - relpath_parser.add_argument("--metadata-url") + relpath_parser.add_argument("--metadata-path", required=True) + + sha_parser = subparsers.add_parser("component-sha256") + sha_parser.add_argument("--host-platform", required=True) + sha_parser.add_argument("--component", required=True) + sha_parser.add_argument("--metadata-path", required=True) + + verify_parser = subparsers.add_parser("verify-metadata") + verify_parser.add_argument("--cuda-version", required=True) + verify_parser.add_argument("--metadata-path", required=True) + verify_parser.add_argument("--metadata-source", choices=("public", "custom"), default="public") return parser.parse_args(argv) @@ -150,7 +215,15 @@ def main(argv: list[str] | None = None) -> int: args = parse_args(argv) try: - metadata = load_metadata(metadata_path=args.metadata_path, metadata_url=args.metadata_url) + # verify-metadata hashes the file directly; it does not parse the JSON. + if args.command == "verify-metadata": + return verify_metadata_sha256( + cuda_version=args.cuda_version, + metadata_path=args.metadata_path, + metadata_source=args.metadata_source, + ) + + metadata = load_metadata(args.metadata_path) if args.command == "filter-components": filtered, skipped = filter_components( @@ -177,8 +250,18 @@ def main(argv: list[str] | None = None) -> int: ) return 0 + if args.command == "component-sha256": + print( + get_component_sha256( + metadata, + host_platform=args.host_platform, + component=args.component, + ) + ) + return 0 + raise AssertionError(f"unexpected command: {args.command!r}") - except (ValueError, KeyError, OSError, urllib.error.URLError, json.JSONDecodeError) as exc: + except (ValueError, KeyError, OSError, json.JSONDecodeError) as exc: print(f"ERROR: {exc}", file=sys.stderr) return 1 diff --git a/ci/tools/tests/test_fetch_ctk_redistrib.py b/ci/tools/tests/test_fetch_ctk_redistrib.py new file mode 100644 index 00000000000..dad75da1a1b --- /dev/null +++ b/ci/tools/tests/test_fetch_ctk_redistrib.py @@ -0,0 +1,167 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from fetch_ctk_redistrib import ( + compute_file_sha256, + get_component_relative_path, + get_component_sha256, + main, + verify_metadata_sha256, +) + +# Minimal redistrib-shaped metadata. "cccl" exercises the cuda_cccl -> cccl +# alias; "no_sha" lacks the sha256 field. +FAKE_METADATA = { + "cuda_nvcc": { + "linux-x86_64": { + "relative_path": "cuda_nvcc/linux-x86_64/cuda_nvcc-linux-x86_64-13.0.0-archive.tar.xz", + "sha256": "a" * 64, + }, + }, + "cccl": { + "linux-x86_64": { + "relative_path": "cccl/linux-x86_64/cccl-linux-x86_64-13.0.0-archive.tar.xz", + "sha256": "b" * 64, + }, + }, + "no_sha": { + "linux-x86_64": { + "relative_path": "no_sha/linux-x86_64/no_sha.tar.xz", + }, + }, +} + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_get_component_sha256_returns_hash(): + assert get_component_sha256(FAKE_METADATA, host_platform="linux-64", component="cuda_nvcc") == "a" * 64 + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_get_component_sha256_resolves_alias(): + # cuda_cccl is absent; it must resolve to the renamed cccl key. + assert get_component_sha256(FAKE_METADATA, host_platform="linux-64", component="cuda_cccl") == "b" * 64 + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_get_component_sha256_unknown_component_raises(): + with pytest.raises(KeyError): + get_component_sha256(FAKE_METADATA, host_platform="linux-64", component="does_not_exist") + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_get_component_sha256_unavailable_subdir_raises(): + with pytest.raises(KeyError): + get_component_sha256(FAKE_METADATA, host_platform="win-64", component="cuda_nvcc") + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_get_component_sha256_missing_field_raises(): + with pytest.raises(KeyError): + get_component_sha256(FAKE_METADATA, host_platform="linux-64", component="no_sha") + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_get_component_relative_path_still_resolves(): + assert get_component_relative_path(FAKE_METADATA, host_platform="linux-64", component="cuda_nvcc").endswith( + "cuda_nvcc-linux-x86_64-13.0.0-archive.tar.xz" + ) + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_verify_metadata_match(tmp_path, monkeypatch): + f = tmp_path / "redistrib.json" + f.write_bytes(b"pinned-metadata") + monkeypatch.setitem( + sys.modules["fetch_ctk_redistrib"].KNOWN_REDISTRIB_SHA256, + "1.2.3", + compute_file_sha256(str(f)), + ) + assert verify_metadata_sha256(cuda_version="1.2.3", metadata_path=str(f)) == 0 + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_verify_metadata_mismatch_fails_closed(tmp_path, monkeypatch): + f = tmp_path / "redistrib.json" + f.write_bytes(b"tampered-metadata") + monkeypatch.setitem( + sys.modules["fetch_ctk_redistrib"].KNOWN_REDISTRIB_SHA256, + "1.2.3", + "f" * 64, + ) + assert verify_metadata_sha256(cuda_version="1.2.3", metadata_path=str(f)) == 1 + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_verify_metadata_unknown_version_warns(tmp_path, capsys): + f = tmp_path / "redistrib.json" + f.write_bytes(b"whatever") + # An unpinned version must not fail closed (it would break CI on every new + # CTK), but it must emit a warning. + assert verify_metadata_sha256(cuda_version="99.9.9", metadata_path=str(f)) == 0 + assert "WARNING" in capsys.readouterr().err + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_main_component_sha256(tmp_path, capsys): + f = tmp_path / "redistrib.json" + f.write_text(json.dumps(FAKE_METADATA), encoding="utf-8") + rc = main( + [ + "component-sha256", + "--host-platform", + "linux-64", + "--component", + "cuda_nvcc", + "--metadata-path", + str(f), + ] + ) + assert rc == 0 + assert capsys.readouterr().out.strip() == "a" * 64 + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_main_verify_metadata_mismatch_returns_1(tmp_path, monkeypatch): + f = tmp_path / "redistrib.json" + f.write_bytes(b"tampered") + monkeypatch.setitem( + sys.modules["fetch_ctk_redistrib"].KNOWN_REDISTRIB_SHA256, + "1.2.3", + "f" * 64, + ) + rc = main(["verify-metadata", "--cuda-version", "1.2.3", "--metadata-path", str(f)]) + assert rc == 1 + + +@pytest.mark.agent_authored(model="claude-opus-5.5") +def test_main_verify_metadata_custom_source_ignores_public_pin(tmp_path, monkeypatch, capsys): + # An alternate-server manifest may differ from the public one for the same version; + # the public pin must not fail it. + f = tmp_path / "redistrib.json" + f.write_bytes(b"custom-server-metadata") + monkeypatch.setitem( + sys.modules["fetch_ctk_redistrib"].KNOWN_REDISTRIB_SHA256, + "1.2.3", + "f" * 64, + ) + rc = main(["verify-metadata", "--cuda-version", "1.2.3", "--metadata-path", str(f), "--metadata-source", "custom"]) + assert rc == 0 + assert "WARNING" in capsys.readouterr().err + + +@pytest.mark.agent_authored(model="claude-opus-5.5") +def test_verify_metadata_custom_source_unpinned_version_warns(tmp_path, capsys): + f = tmp_path / "redistrib.json" + f.write_bytes(b"custom-server-metadata") + assert verify_metadata_sha256(cuda_version="99.9.9", metadata_path=str(f), metadata_source="custom") == 0 + assert "WARNING" in capsys.readouterr().err