diff --git a/apps/desktop/src-tauri/.cargo/audit.toml b/apps/desktop/src-tauri/.cargo/audit.toml index 008c8166..6ba6743f 100644 --- a/apps/desktop/src-tauri/.cargo/audit.toml +++ b/apps/desktop/src-tauri/.cargo/audit.toml @@ -17,5 +17,6 @@ ignore = [ "RUSTSEC-2025-0080", # unic-common: unmaintained "RUSTSEC-2025-0100", # unic-ucd-ident: unmaintained "RUSTSEC-2025-0098", # unic-ucd-version: unmaintained - "RUSTSEC-2024-0429" # glib: unsoundness in VariantStrIter + "RUSTSEC-2024-0429", # glib: unsoundness in VariantStrIter + "RUSTSEC-2026-0097", # rand 0.7.3: transitive via Tauri/kuchikiki phf 0.8; remove when upstream drops the chain ] diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 1810cd42..f6c4b826 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -719,9 +719,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fdeflate" @@ -2171,7 +2171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" dependencies = [ "phf_shared 0.10.0", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -2181,7 +2181,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -2474,9 +2474,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", diff --git a/docs/security/dependency-policy.md b/docs/security/dependency-policy.md index a369502e..2e521d14 100644 --- a/docs/security/dependency-policy.md +++ b/docs/security/dependency-policy.md @@ -104,6 +104,12 @@ Current controlled exception: - `GHSA-5239-wwwm-4pmq` (`Pygments <=2.19.2`) in Python dev/test dependency path; no patched version is available at this time, impact is low/local-access ReDoS, and BandScope does not expose Pygments parsing on untrusted runtime input paths. The CI `security-audit` workflow applies a targeted ignore for this advisory only. - Cargo audit warnings for legacy `gtk3`, `glib`, and `fxhash` vulnerabilities (e.g. `RUSTSEC-2024-0413`, `RUSTSEC-2024-0429`, `RUSTSEC-2025-0057`) inherited through Tauri v2 `wry`/`webkit2gtk` integration are explicitly allowed. These are deep framework dependencies with no alternative, so they are documented exceptions and ignored by default. +Tracked third-party deprecation signal: + +- `proc-macro-hack v0.5.20+deprecated` remains transitive through `tauri` / `tauri-build` -> `tauri-utils` -> `kuchikiki` -> `cssparser` -> `phf`. This is Cargo semver build metadata printed during dependency resolution, not a Rust compiler warning or an application runtime dependency. Do not suppress the output with broad quiet flags; remove it only through an upstream Tauri/html parsing dependency update that drops the `phf 0.10` owner chain. +- `RUSTSEC-2026-0097` for legacy `rand 0.7.3` remains transitive through `tauri` / `tauri-build` -> `tauri-utils` -> `kuchikiki 0.8.8-speedreader` -> `selectors 0.24.0` -> `phf_codegen 0.8.0` -> `phf_generator 0.8.0`. The repo-controlled Dependabot alert for `GHSA-cq8v-f236-94qc` is fixed by keeping the `rand 0.8` line on `0.8.6` or newer; the remaining `rand 0.7.3` advisory is an externally owned Tauri/kuchikiki build/transitive path with no compatible lockfile-only update available. Do not suppress it with broad quiet flags; remove it through an upstream Tauri/html parsing dependency update that drops the `phf 0.8` owner chain. +- Yanked `fastrand 2.4.0` was transiently inherited through target-specific `wry`/`dom_query` HTML parsing dependencies and must stay updated to `2.4.1` or newer in `apps/desktop/src-tauri/Cargo.lock`; `scripts/checks/verify_supply_chain.py` guards against reintroducing the yanked version. + ## Required checks intent The expected required status checks are documented in `docs/security/github-required-checks.md`. diff --git a/scripts/checks/verify_supply_chain.py b/scripts/checks/verify_supply_chain.py index f6a2822b..67f46006 100644 --- a/scripts/checks/verify_supply_chain.py +++ b/scripts/checks/verify_supply_chain.py @@ -1,7 +1,9 @@ """Verify that repository-controlled supply-chain controls stay in place.""" +import ast import re import shlex +from itertools import pairwise from pathlib import Path REQUIRED_FILES = [ @@ -49,6 +51,22 @@ ) RELEASE_ASSET_MAPFILE = "mapfile -t release_assets < release-assets.txt" WORKSPACE_EXEC_PATTERN = re.compile(r"\bnpm\s+exec\s+--workspace\b") +RUST_RAND_ADVISORY_ID = "GHSA-cq8v-f236-94qc" +RUST_RAND_LEGACY_EXCEPTION_VERSION = "0.7.3" +RUST_RAND_PATCHED_VERSIONS = { + (0, 8): (0, 8, 6), + (0, 9): (0, 9, 3), + (0, 10): (0, 10, 1), +} +RUST_RAND_LEGACY_EXCEPTION_CHAIN = ( + "tauri-utils 2.8.3", + "kuchikiki 0.8.8-speedreader", + "selectors 0.24.0", + "phf_codegen 0.8.0", + "phf_generator 0.8.0", + "rand 0.7.3", +) +RUST_FASTRAND_YANKED_VERSION = "2.4.0" RELEASE_CREATE_VALUE_FLAGS = { "--discussion-category", "--latest", @@ -645,6 +663,218 @@ def verify_release_asset_allowlist_policy() -> list[str]: return violations +def rust_dependency_advisory_violations( + lockfile: Path = Path("apps/desktop/src-tauri/Cargo.lock"), +) -> list[str]: + """Return Rust lockfile dependency versions with known required patches.""" + violations: list[str] = [] + if not lockfile.exists(): + return [f"Cargo.lock missing: {lockfile}"] + package_dependencies = cargo_lock_package_dependencies(lockfile) + legacy_exception_allowed = cargo_lock_has_dependency_chain( + package_dependencies, RUST_RAND_LEGACY_EXCEPTION_CHAIN + ) + expected_legacy_owner = RUST_RAND_LEGACY_EXCEPTION_CHAIN[-2] + legacy_rand_owners = cargo_lock_dependency_owners( + package_dependencies, RUST_RAND_LEGACY_EXCEPTION_CHAIN[-1] + ) + for package in cargo_lock_packages(lockfile): + current_name = str(package.get("name", "")) + version = str(package.get("version", "")) + if current_name == "fastrand" and version == RUST_FASTRAND_YANKED_VERSION: + violations.append( + f"{lockfile}: fastrand {version} is yanked and must stay updated" + ) + continue + if current_name != "rand": + continue + if version == RUST_RAND_LEGACY_EXCEPTION_VERSION: + if legacy_exception_allowed and legacy_rand_owners == {expected_legacy_owner}: + continue + violations.append( + f"{lockfile}: rand {version} matches the legacy exception version " + "but does not have the documented Tauri/kuchikiki owner chain " + f"for {RUST_RAND_ADVISORY_ID}" + ) + continue + parsed_parts: list[int] = [] + segments = version.split(".") + if any(not segment.isdecimal() for segment in segments): + violations.append( + f"{lockfile}: rand {version} has a non-numeric version segment " + f"for {RUST_RAND_ADVISORY_ID}" + ) + continue + if len(segments) > 3: + violations.append( + f"{lockfile}: rand {version} has a non-standard extra version segment " + f"for {RUST_RAND_ADVISORY_ID}" + ) + continue + for part in segments: + parsed_parts.append(int(part)) + if len(parsed_parts) != len(segments): + continue + while len(parsed_parts) < 3: + parsed_parts.append(0) + parts = tuple(parsed_parts[:3]) + rand_series = (parts[0], parts[1]) + if rand_series == (0, 7): + violations.append( + f"{lockfile}: rand {version} is not allowed for " + f"{RUST_RAND_ADVISORY_ID}; only rand " + f"{RUST_RAND_LEGACY_EXCEPTION_VERSION} on the documented " + "legacy owner chain is temporarily allowed" + ) + continue + patched_version = RUST_RAND_PATCHED_VERSIONS.get(rand_series) + if patched_version is not None and parts < patched_version: + patched = ".".join(str(part) for part in patched_version) + violations.append( + f"{lockfile}: rand {version} is below patched {patched} " + f"for {RUST_RAND_ADVISORY_ID}" + ) + return violations + + +def cargo_lock_package_dependencies(lockfile: Path) -> dict[str, list[str]]: + """Return Cargo package keys and dependency tokens from a lockfile.""" + packages: dict[str, list[str]] = {} + for package in cargo_lock_packages(lockfile): + current_name = str(package.get("name", "")) + current_version = str(package.get("version", "")) + if not current_name or not current_version: + continue + dependencies = package.get("dependencies", []) + if not isinstance(dependencies, list): + dependencies = [] + packages[f"{current_name} {current_version}"] = [ + str(dependency).strip() for dependency in dependencies + ] + return cargo_lock_normalized_package_dependencies(packages) + + +def cargo_lock_packages(lockfile: Path) -> list[dict[str, object]]: + """Return Cargo package tables from supported lockfile TOML forms.""" + packages: list[dict[str, object]] = [] + current_package: dict[str, object] | None = None + in_dependencies = False + dependency_tokens: list[str] = [] + + def store_current_package() -> None: + if current_package is not None: + if in_dependencies: + current_package["dependencies"] = dependency_tokens.copy() + packages.append(current_package.copy()) + + for line in [*lockfile.read_text(encoding="utf-8").splitlines(), "[[package]]"]: + stripped = line.strip() + if not stripped: + continue + if stripped == "[[package]]": + store_current_package() + current_package = {} + in_dependencies = False + dependency_tokens = [] + continue + if current_package is None: + continue + if in_dependencies: + if stripped == "]": + current_package["dependencies"] = dependency_tokens.copy() + in_dependencies = False + continue + if stripped.startswith('"'): + dependency_tokens.append(stripped.strip('",')) + continue + key, separator, value = stripped.partition("=") + if not separator: + continue + normalized_key = key.strip() + normalized_value = value.strip() + if normalized_key == "dependencies": + if normalized_value == "[": + in_dependencies = True + dependency_tokens = [] + continue + current_package["dependencies"] = parse_cargo_lock_string_list( + normalized_value + ) + continue + if normalized_key in {"name", "version"}: + current_package[normalized_key] = parse_cargo_lock_scalar(normalized_value) + return packages + + +def parse_cargo_lock_string_list(value: str) -> list[str]: + """Return strings from an inline Cargo.lock dependency array.""" + parsed_value = ast.literal_eval(value) + if not isinstance(parsed_value, list): + return [] + return [str(item).strip() for item in parsed_value] + + +def parse_cargo_lock_scalar(value: str) -> str: + """Return a scalar Cargo.lock TOML value as text.""" + parsed_value = ast.literal_eval(value) + return str(parsed_value) + + +def cargo_lock_normalized_package_dependencies( + package_dependencies: dict[str, list[str]], +) -> dict[str, list[str]]: + """Return dependency tokens normalized to exact package keys when possible.""" + package_keys_by_name: dict[str, list[str]] = {} + for package_key in package_dependencies: + package_name = package_key.rsplit(" ", maxsplit=1)[0] + package_keys_by_name.setdefault(package_name, []).append(package_key) + + normalized: dict[str, list[str]] = {} + for package_key, dependency_tokens in package_dependencies.items(): + normalized_tokens: list[str] = [] + for dependency_token in dependency_tokens: + dependency = dependency_token.strip() + if dependency in package_dependencies: + normalized_tokens.append(dependency) + continue + matching_package_keys = package_keys_by_name.get(dependency, []) + if len(matching_package_keys) == 1: + normalized_tokens.append(matching_package_keys[0]) + continue + normalized_tokens.append(dependency) + normalized[package_key] = normalized_tokens + return normalized + + +def cargo_lock_dependency_owners( + package_dependencies: dict[str, list[str]], dependency: str +) -> set[str]: + """Return package keys that directly reference the target dependency key.""" + return { + owner + for owner, dependency_tokens in package_dependencies.items() + if dependency in dependency_tokens + } + + +def cargo_lock_has_dependency_chain( + package_dependencies: dict[str, list[str]], package_chain: tuple[str, ...] +) -> bool: + """Return whether Cargo dependencies contain the exact package chain.""" + return all( + cargo_dependency_targets_package(package_dependencies, owner, dependency) + for owner, dependency in pairwise(package_chain) + ) + + +def cargo_dependency_targets_package( + package_dependencies: dict[str, list[str]], owner: str, dependency: str +) -> bool: + """Return whether an owner package depends on the target package key.""" + dependency_tokens = package_dependencies.get(owner, []) + return dependency in dependency_tokens + + def main() -> int: """Return a failing exit code when supply-chain controls are incomplete.""" violations: list[str] = [] @@ -656,6 +886,7 @@ def main() -> int: violations.extend(verify_release_asset_allowlist_policy()) violations.extend(verify_workflow_npx_policy()) violations.extend(verify_workflow_workspace_exec_policy()) + violations.extend(rust_dependency_advisory_violations()) if violations: print("Supply-chain verification failed:") diff --git a/services/analysis-engine/tests/test_supply_chain_policy.py b/services/analysis-engine/tests/test_supply_chain_policy.py index 77ba26b1..7fbeafff 100644 --- a/services/analysis-engine/tests/test_supply_chain_policy.py +++ b/services/analysis-engine/tests/test_supply_chain_policy.py @@ -340,6 +340,372 @@ def test_supply_chain_check_accepts_repo_ossf_publish_restrictions( assert not any("ossf scorecard" in violation for violation in violations) +def test_supply_chain_check_rejects_vulnerable_rust_rand_lockfile( + tmp_path: Path, +) -> None: + """Ensure the Rust lockfile cannot regress to vulnerable rand ranges.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", "verify_supply_chain_rust_rand_vulnerable" + ) + lockfile = tmp_path / "Cargo.lock" + lockfile.write_text( + """ +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "placeholder" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "newer-vulnerable-api-series" + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "latest-vulnerable-api-series" +""".strip(), + encoding="utf-8", + ) + + violations = supply_chain.rust_dependency_advisory_violations(lockfile) + + assert (f"{lockfile}: rand 0.8.5 is below patched 0.8.6 for GHSA-cq8v-f236-94qc") in violations + assert (f"{lockfile}: rand 0.9.2 is below patched 0.9.3 for GHSA-cq8v-f236-94qc") in violations + assert ( + f"{lockfile}: rand 0.10.0 is below patched 0.10.1 for GHSA-cq8v-f236-94qc" + ) in violations + + +def test_supply_chain_check_rejects_non_exception_rust_rand_0_7_lockfile( + tmp_path: Path, +) -> None: + """Ensure only the documented legacy rand 0.7.3 exception can pass.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", "verify_supply_chain_rust_rand_0_7" + ) + lockfile = tmp_path / "Cargo.lock" + lockfile.write_text( + """ +[[package]] +version = "0.7.4" +name = "rand" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "unexpected-legacy-series" +""".strip(), + encoding="utf-8", + ) + + violations = supply_chain.rust_dependency_advisory_violations(lockfile) + + assert ( + f"{lockfile}: rand 0.7.4 is not allowed for GHSA-cq8v-f236-94qc; " + "only rand 0.7.3 on the documented legacy owner chain is temporarily allowed" + ) in violations + + +def test_supply_chain_check_handles_version_first_and_inline_dependency_fixtures( + tmp_path: Path, +) -> None: + """Ensure valid Cargo.lock key order and inline dependencies stay guarded.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", + "verify_supply_chain_rust_rand_format_variants", + ) + lockfile = tmp_path / "Cargo.lock" + lockfile.write_text( + """ +[[package]] +version = "1.0.0" +name = "bad-owner" +dependencies = ["rand 0.7.3"] + +[[package]] +version = "0.7.3" +name = "rand" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "version-first-inline-owner" +""".strip(), + encoding="utf-8", + ) + + violations = supply_chain.rust_dependency_advisory_violations(lockfile) + + assert ( + f"{lockfile}: rand 0.7.3 matches the legacy exception version but does not " + "have the documented Tauri/kuchikiki owner chain for GHSA-cq8v-f236-94qc" + ) in violations + + +def test_supply_chain_check_reports_missing_rust_lockfile(tmp_path: Path) -> None: + """Ensure missing Cargo.lock is reported as a supply-chain violation.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", "verify_supply_chain_rust_lock_missing" + ) + lockfile = tmp_path / "missing" / "Cargo.lock" + + violations = supply_chain.rust_dependency_advisory_violations(lockfile) + + assert f"Cargo.lock missing: {lockfile}" in violations + + +def test_supply_chain_check_rejects_unowned_legacy_rust_rand_exception( + tmp_path: Path, +) -> None: + """Ensure rand 0.7.3 is exempt only on the documented Tauri owner chain.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", "verify_supply_chain_rust_rand_unowned" + ) + lockfile = tmp_path / "Cargo.lock" + lockfile.write_text( + """ +[[package]] +version = "0.7.3" +name = "rand" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "wrong-owner" +""".strip(), + encoding="utf-8", + ) + + violations = supply_chain.rust_dependency_advisory_violations(lockfile) + + assert ( + f"{lockfile}: rand 0.7.3 matches the legacy exception version but does not " + "have the documented Tauri/kuchikiki owner chain for GHSA-cq8v-f236-94qc" + ) in violations + + +def test_supply_chain_check_rejects_inline_dependency_legacy_rust_rand_owner( + tmp_path: Path, +) -> None: + """Ensure inline dependency arrays are included in legacy owner checks.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", + "verify_supply_chain_rust_rand_inline_owner", + ) + lockfile = tmp_path / "Cargo.lock" + lockfile.write_text( + """ +[[package]] +name = "tauri-utils" +version = "2.8.3" +dependencies = ["kuchikiki 0.8.8-speedreader"] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +dependencies = ["selectors 0.24.0"] + +[[package]] +name = "selectors" +version = "0.24.0" +dependencies = ["phf_codegen 0.8.0"] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +dependencies = ["phf_generator 0.8.0"] + +[[package]] +name = "phf_generator" +version = "0.8.0" +dependencies = ["rand 0.7.3"] + +[[package]] +name = "bad-owner" +version = "1.0.0" +dependencies = ["rand 0.7.3"] + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "legacy-exception" +""".strip(), + encoding="utf-8", + ) + + violations = supply_chain.rust_dependency_advisory_violations(lockfile) + + assert ( + f"{lockfile}: rand 0.7.3 matches the legacy exception version but does not " + "have the documented Tauri/kuchikiki owner chain for GHSA-cq8v-f236-94qc" + ) in violations + + +def test_supply_chain_check_reports_non_numeric_rust_rand_versions( + tmp_path: Path, +) -> None: + """Ensure non-standard rand versions are reported instead of crashing.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", + "verify_supply_chain_rust_rand_non_numeric_version", + ) + lockfile = tmp_path / "Cargo.lock" + lockfile.write_text( + """ +[[package]] +name = "rand" +version = "0.9.3-alpha.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "non-stable" + +[[package]] +name = "rand" +version = "0.8.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "extra-numeric-segment" +""".strip(), + encoding="utf-8", + ) + + violations = supply_chain.rust_dependency_advisory_violations(lockfile) + + assert ( + f"{lockfile}: rand 0.9.3-alpha.1 has a non-numeric version segment for GHSA-cq8v-f236-94qc" + ) in violations + assert ( + f"{lockfile}: rand 0.8.6.1 has a non-standard extra version segment for GHSA-cq8v-f236-94qc" + ) in violations + + +def test_supply_chain_check_rejects_mixed_owner_legacy_rust_rand_exception( + tmp_path: Path, +) -> None: + """Ensure a valid legacy chain does not exempt unrelated rand owners.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", "verify_supply_chain_rust_rand_mixed_owner" + ) + lockfile = tmp_path / "Cargo.lock" + lockfile.write_text( + """ +[[package]] +name = "tauri-utils" +version = "2.8.3" +dependencies = [ + "kuchikiki 0.8.8-speedreader", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +dependencies = [ + "selectors 0.24.0", +] + +[[package]] +name = "selectors" +version = "0.24.0" +dependencies = [ + "phf_codegen 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +dependencies = [ + "phf_generator 0.8.0", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +dependencies = [ + "rand 0.7.3", +] + +[[package]] +name = "bad-owner" +version = "1.0.0" +dependencies = [ + "rand 0.7.3", +] + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "legacy-exception" +""".strip(), + encoding="utf-8", + ) + + violations = supply_chain.rust_dependency_advisory_violations(lockfile) + + assert ( + f"{lockfile}: rand 0.7.3 matches the legacy exception version but does not " + "have the documented Tauri/kuchikiki owner chain for GHSA-cq8v-f236-94qc" + ) in violations + + +def test_supply_chain_check_accepts_repo_rust_rand_patch() -> None: + """Ensure the checked-in Rust lockfile keeps rand on the patched 0.8 line.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", "verify_supply_chain_rust_rand_repo" + ) + repo_root = Path(__file__).resolve().parents[3] + + violations = supply_chain.rust_dependency_advisory_violations( + repo_root / "apps" / "desktop" / "src-tauri" / "Cargo.lock" + ) + + assert not violations + + +def test_supply_chain_check_rejects_yanked_rust_fastrand_lockfile( + tmp_path: Path, +) -> None: + """Ensure the Rust lockfile cannot regress to yanked fastrand 2.4.0.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", "verify_supply_chain_rust_fastrand_yanked" + ) + lockfile = tmp_path / "Cargo.lock" + lockfile.write_text( + """ +[[package]] +name = "fastrand" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "placeholder" +""".strip(), + encoding="utf-8", + ) + + violations = supply_chain.rust_dependency_advisory_violations(lockfile) + + assert f"{lockfile}: fastrand 2.4.0 is yanked and must stay updated" in violations + + +def test_supply_chain_check_accepts_repo_rust_fastrand_update() -> None: + """Ensure the checked-in Rust lockfile keeps fastrand off yanked 2.4.0.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", "verify_supply_chain_rust_fastrand_repo" + ) + repo_root = Path(__file__).resolve().parents[3] + + violations = supply_chain.rust_dependency_advisory_violations( + repo_root / "apps" / "desktop" / "src-tauri" / "Cargo.lock" + ) + + assert not violations + + +def test_supply_chain_check_requires_tracked_rust_rand_legacy_exception() -> None: + """Ensure the remaining legacy rand advisory is narrowly documented in audit config.""" + repo_root = Path(__file__).resolve().parents[3] + audit_config = repo_root / "apps" / "desktop" / "src-tauri" / ".cargo" / "audit.toml" + content = audit_config.read_text(encoding="utf-8") + + assert ( + '"RUSTSEC-2026-0097", # rand 0.7.3: transitive via Tauri/kuchikiki phf 0.8; ' + "remove when upstream drops the chain" + ) in content + + def test_supply_chain_check_rejects_release_published_asset_upload( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: