from __future__ import annotations from pathlib import Path from unittest.mock import patch import pytest import semantic_version from tests.helpers import make_alert, make_dependency from vuln_scan.core.models import Ecosystem from vuln_scan.ecosystems.rust import RustEcosystemHandler # -------------------------------------------------------- # Fixtures # -------------------------------------------------------- @pytest.fixture def handler() -> RustEcosystemHandler: return RustEcosystemHandler() # -------------------------------------------------------- # File discovery # -------------------------------------------------------- def test_file_discovery_properties(handler: RustEcosystemHandler) -> None: assert handler.manifest_names == {"Cargo.toml"} assert handler.lockfile_names == {"Cargo.lock"} assert handler.manifest_globs == set() def test_supports_manifest(handler: RustEcosystemHandler) -> None: assert handler.supports_manifest("Cargo.toml") is True assert handler.supports_manifest("path/to/Cargo.toml") is True assert handler.supports_manifest("Cargo.lock") is True assert handler.supports_manifest("nope.txt") is False def test_is_lockfile(handler: RustEcosystemHandler) -> None: assert handler.is_lockfile("Cargo.lock") is True assert handler.is_lockfile("Cargo.toml") is False def test_is_manifest(handler: RustEcosystemHandler) -> None: assert handler.is_manifest("Cargo.toml") is True assert handler.is_manifest("Cargo.lock") is False assert handler.is_manifest("nope.txt") is False # -------------------------------------------------------- # parse_manifest reads file and dispatches # -------------------------------------------------------- def test_parse_manifest_reads_file_and_parses( handler: RustEcosystemHandler, tmp_path: Path, ) -> None: manifest = tmp_path / "Cargo.toml" manifest.write_text( """ [package] name = "demo" version = "0.1.0" [dependencies] protobuf = "3.7.1" """.strip(), encoding="utf-8", ) parsed = handler.parse_manifest("Cargo.toml", manifest) assert parsed.manifest_type == "manifest" assert parsed.parser_name == "cargo" assert len(parsed.dependencies) == 1 dep = parsed.dependencies[0] assert dep.ecosystem == Ecosystem.RUST assert dep.name == "protobuf" assert dep.version is None assert dep.requirement == "^3.7.1" assert dep.is_lockfile is False # -------------------------------------------------------- # parse_manifest_content dispatch # -------------------------------------------------------- @pytest.mark.parametrize( "filename, content, expected_type, expected_count, expected_first_name", [ pytest.param("unknown.xyz", "x", "manifest", 0, None, id="unknown-file-returns-empty"), pytest.param( "Cargo.toml", """ [package] name="demo" version="0.1.0" [dependencies] protobuf = "3.7.1" """.strip(), "manifest", 1, "protobuf", id="cargo-toml-dispatch", ), pytest.param( "Cargo.lock", """ version = 4 [[package]] name = "astral-tokio-tar" version = "0.5.6" """.strip(), "lockfile", 1, "astral-tokio-tar", id="cargo-lock-dispatch", ), ], ) def test_parse_manifest_content_dispatch( handler: RustEcosystemHandler, filename: str, content: str, expected_type: str, expected_count: int, expected_first_name: str | None, ) -> None: parsed = handler.parse_manifest_content(filename, content) assert parsed.manifest_type == expected_type assert parsed.parser_name == "cargo" assert len(parsed.dependencies) == expected_count if expected_first_name is not None: assert parsed.dependencies[0].name == expected_first_name # -------------------------------------------------------- # parse_manifest_content negative cases # -------------------------------------------------------- @pytest.mark.parametrize( "filename, content, expected_count", [ # ---- Cargo.lock ---- pytest.param("Cargo.lock", "not toml {{{", 0, id="cargo-lock-invalid-toml"), pytest.param("Cargo.lock", "version = 4", 0, id="cargo-lock-no-packages"), pytest.param("Cargo.lock", 'package = "not-a-list"', 0, id="cargo-lock-package-not-list"), pytest.param( "Cargo.lock", """ version = 4 package = ["not-a-dict"] """.strip(), 0, id="cargo-lock-non-dict-entry", ), pytest.param( "Cargo.lock", """ version = 4 [[package]] name = 123 version = "1.0.0" """.strip(), 0, id="cargo-lock-non-string-name", ), pytest.param( "Cargo.lock", """ version = 4 [[package]] name = "protobuf" version = 123 """.strip(), 0, id="cargo-lock-non-string-version", ), pytest.param( "Cargo.lock", """ version = 4 [[package]] name = "" version = "1.0.0" """.strip(), 0, id="cargo-lock-empty-name", ), pytest.param( "Cargo.lock", """ version = 4 [[package]] name = "protobuf" version = "" """.strip(), 0, id="cargo-lock-empty-version", ), # ---- Cargo.toml ---- pytest.param("Cargo.toml", "not toml {{{", 0, id="cargo-toml-invalid-toml"), pytest.param( "Cargo.toml", "[dependencies]\nprotobuf = 123", 0, id="cargo-toml-non-string-non-table-spec-skipped", ), pytest.param( "Cargo.toml", "[dependencies]\nprotobuf = { git = 'https://x' }", 0, id="cargo-toml-git-dep-skipped", ), pytest.param( "Cargo.toml", """ [dependencies] protobuf = "*" """.strip(), 0, id="cargo-toml-star-is-unknown-skipped", ), pytest.param( "Cargo.toml", """ [dependencies] "" = "1.2.3" """.strip(), 0, id="cargo-toml-empty-crate-name-skipped", ), ], ) def test_parse_manifest_content_negative_cases( handler: RustEcosystemHandler, filename: str, content: str, expected_count: int, ) -> None: parsed = handler.parse_manifest_content(filename, content) assert len(parsed.dependencies) == expected_count # -------------------------------------------------------- # Cargo.lock parser specifics # -------------------------------------------------------- def test_parse_cargo_lock_deduplicates(handler: RustEcosystemHandler) -> None: parsed = handler.parse_manifest_content( "Cargo.lock", """ version = 4 [[package]] name = "protobuf" version = "3.7.1" [[package]] name = "protobuf" version = "3.7.1" """.strip(), ) assert len(parsed.dependencies) == 1 dep = parsed.dependencies[0] assert dep.name == "protobuf" assert dep.version == "3.7.1" assert dep.is_lockfile is True def test_parse_cargo_lock_strips_leading_v(handler: RustEcosystemHandler) -> None: parsed = handler.parse_manifest_content( "Cargo.lock", """ version = 4 [[package]] name = "protobuf" version = "v3.7.1" """.strip(), ) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].version == "3.7.1" # -------------------------------------------------------- # Cargo.toml parser specifics # -------------------------------------------------------- def test_parse_cargo_toml_explicit_pin_is_exact_version(handler: RustEcosystemHandler) -> None: parsed = handler.parse_manifest_content( "Cargo.toml", """ [dependencies] protobuf = "=3.7.1" """.strip(), ) assert len(parsed.dependencies) == 1 dep = parsed.dependencies[0] assert dep.name == "protobuf" assert dep.version == "3.7.1" assert dep.requirement is None def test_parse_cargo_toml_string_version_pin(handler: RustEcosystemHandler) -> None: parsed = handler.parse_manifest_content( "Cargo.toml", """ [dependencies] protobuf = "3.7.1" """.strip(), ) assert len(parsed.dependencies) == 1 dep = parsed.dependencies[0] assert dep.name == "protobuf" assert dep.version is None assert dep.requirement == "^3.7.1" assert dep.is_lockfile is False def test_parse_cargo_toml_table_version(handler: RustEcosystemHandler) -> None: parsed = handler.parse_manifest_content( "Cargo.toml", """ [dependencies] protobuf = { version = "3.7.1" } """.strip(), ) assert len(parsed.dependencies) == 1 dep = parsed.dependencies[0] assert dep.name == "protobuf" assert dep.version is None assert dep.requirement == "^3.7.1" def test_parse_cargo_toml_range_requirement(handler: RustEcosystemHandler) -> None: parsed = handler.parse_manifest_content( "Cargo.toml", """ [dependencies] protobuf = "< 3.7.2" """.strip(), ) assert len(parsed.dependencies) == 1 dep = parsed.dependencies[0] assert dep.version is None assert dep.requirement == "< 3.7.2" def test_parse_cargo_toml_target_tables_best_effort(handler: RustEcosystemHandler) -> None: parsed = handler.parse_manifest_content( "Cargo.toml", """ [dependencies] protobuf = "3.7.1" [target] "cfg(unix)" = "not-a-table" [target."cfg(windows)".dependencies] astral-tokio-tar = "0.5.6" """.strip(), ) # We should get protobuf + astral-tokio-tar, and ignore the non-dict unix target names = sorted(d.name for d in parsed.dependencies) assert names == ["astral-tokio-tar", "protobuf"] # -------------------------------------------------------- # _normalize_version # -------------------------------------------------------- @pytest.mark.parametrize( "raw, expected", [ pytest.param("3.7.1", "3.7.1", id="unchanged"), pytest.param(" v3.7.1 ", "3.7.1", id="strips-leading-v-and-trims"), pytest.param("V0.5.6", "0.5.6", id="capital-v"), pytest.param("", "", id="empty"), ], ) def test_normalize_version(handler: RustEcosystemHandler, raw: str, expected: str) -> None: assert handler._normalize_version(raw) == expected # -------------------------------------------------------- # _normalize_vuln_range # -------------------------------------------------------- @pytest.mark.parametrize( "spec, expected", [ pytest.param("", "", id="empty-string"), pytest.param(" ", "", id="whitespace-only"), pytest.param(None, "", id="none-input"), pytest.param("< 3.7.2", "<3.7.2", id="lt-spacing-collapsed"), pytest.param("<= 0.5.6", "<=0.5.6", id="lte-spacing-collapsed"), pytest.param(">= 1.0, < 2.0", ">=1.0 <2.0", id="comma-to-space-and-operator-collapsed"), pytest.param("^ 1.2.3", "^1.2.3", id="caret-spacing-collapsed"), pytest.param("~ 1.2.3", "~1.2.3", id="tilde-spacing-collapsed"), ], ) def test_normalize_vuln_range( handler: RustEcosystemHandler, spec: str | None, expected: str, ) -> None: assert handler._normalize_vuln_range(spec) == expected # -------------------------------------------------------- # _build_spec # -------------------------------------------------------- def test_build_spec_returns_npm_spec(handler: RustEcosystemHandler) -> None: spec = handler._build_spec("< 3.7.2") assert isinstance(spec, semantic_version.NpmSpec) # smoke test: it should match 3.7.1 and not match 3.7.2 assert spec.match(semantic_version.Version("3.7.1")) is True assert spec.match(semantic_version.Version("3.7.2")) is False # -------------------------------------------------------- # is_vulnerable # -------------------------------------------------------- @pytest.mark.parametrize( "dep_kwargs, alert_kwargs, expected_is_vuln, expected_confidence", [ # ---- No vuln_range -> early return ---- pytest.param( dict( ecosystem=Ecosystem.RUST, name="protobuf", version="3.7.1", manifest_path="Cargo.lock", is_lockfile=True, ), dict( ecosystem=Ecosystem.RUST, package_name="protobuf", vulnerable_range="", # falsy -> immediate (True, "low") manifest_path="Cargo.lock", ), True, "low", id="no-vuln-range-early-return", ), # ---- normalized == "" (whitespace-only range) -> line 136 branch ---- pytest.param( dict( ecosystem=Ecosystem.RUST, name="protobuf", version="3.7.1", manifest_path="Cargo.lock", is_lockfile=True, ), dict( ecosystem=Ecosystem.RUST, package_name="protobuf", vulnerable_range=" ", # truthy but normalizes to "" manifest_path="Cargo.lock", ), True, "low", id="normalized-empty-range-returns-true-low", ), # ---- ValueError in Version.coerce -> lines 144-145 branch ---- pytest.param( dict( ecosystem=Ecosystem.RUST, name="protobuf", version="not-a-version", manifest_path="Cargo.lock", is_lockfile=True, ), dict( ecosystem=Ecosystem.RUST, package_name="protobuf", vulnerable_range="< 3.7.2", manifest_path="Cargo.lock", ), True, "low", id="version-coerce-value-error-returns-true-low", ), # ---- Exact version match against spacey GitHub ranges (lockfile) ---- pytest.param( dict( ecosystem=Ecosystem.RUST, name="protobuf", version="3.7.1", manifest_path="Cargo.lock", is_lockfile=True, ), dict( ecosystem=Ecosystem.RUST, package_name="protobuf", vulnerable_range="< 3.7.2", manifest_path="Cargo.lock", ), True, "high", id="lockfile-exact-version-vulnerable-lt-spacey", ), pytest.param( dict( ecosystem=Ecosystem.RUST, name="protobuf", version="3.7.2", manifest_path="Cargo.lock", is_lockfile=True, ), dict( ecosystem=Ecosystem.RUST, package_name="protobuf", vulnerable_range="< 3.7.2", manifest_path="Cargo.lock", ), False, "high", id="lockfile-exact-version-not-vulnerable-equal-boundary", ), pytest.param( dict( ecosystem=Ecosystem.RUST, name="astral-tokio-tar", version="0.5.6", manifest_path="Cargo.lock", is_lockfile=True, ), dict( ecosystem=Ecosystem.RUST, package_name="astral-tokio-tar", vulnerable_range="<= 0.5.6", manifest_path="Cargo.lock", ), True, "high", id="lockfile-exact-version-vulnerable-lte-spacey", ), pytest.param( dict( ecosystem=Ecosystem.RUST, name="astral-tokio-tar", version="0.5.7", manifest_path="Cargo.lock", is_lockfile=True, ), dict( ecosystem=Ecosystem.RUST, package_name="astral-tokio-tar", vulnerable_range="<= 0.5.6", manifest_path="Cargo.lock", ), False, "high", id="lockfile-exact-version-not-vulnerable-above-boundary", ), # ---- Exact version but NOT lockfile -> confidence medium ---- pytest.param( dict( ecosystem=Ecosystem.RUST, name="protobuf", version="3.7.1", manifest_path="Cargo.toml", is_lockfile=False, ), dict( ecosystem=Ecosystem.RUST, package_name="protobuf", vulnerable_range="< 3.7.2", manifest_path="Cargo.toml", ), True, "medium", id="manifest-exact-version-confidence-medium", ), # ---- Requirement overlap path ---- pytest.param( dict( ecosystem=Ecosystem.RUST, name="protobuf", version=None, requirement="< 3.7.2", manifest_path="Cargo.toml", is_lockfile=False, ), dict( ecosystem=Ecosystem.RUST, package_name="protobuf", vulnerable_range="<= 3.7.1", manifest_path="Cargo.toml", ), True, "low", id="requirement-overlap-true", ), # requirement normalizes to empty -> returns True, low (the `if not req` branch) pytest.param( dict( ecosystem=Ecosystem.RUST, name="protobuf", version=None, requirement=" ", manifest_path="Cargo.toml", is_lockfile=False, ), dict( ecosystem=Ecosystem.RUST, package_name="protobuf", vulnerable_range="< 3.7.2", manifest_path="Cargo.toml", ), True, "low", id="requirement-normalizes-empty-returns-true-low", ), # ---- No version and no requirement -> conservative ---- pytest.param( dict( ecosystem=Ecosystem.RUST, name="protobuf", version=None, requirement=None, manifest_path="Cargo.toml", is_lockfile=False, ), dict( ecosystem=Ecosystem.RUST, package_name="protobuf", vulnerable_range="< 3.7.2", manifest_path="Cargo.toml", ), True, "low", id="no-version-no-requirement-conservative", ), ], ) def test_is_vulnerable_parametrized( handler: RustEcosystemHandler, dep_kwargs: dict, alert_kwargs: dict, expected_is_vuln: bool, expected_confidence: str, ) -> None: dep = make_dependency(**dep_kwargs) alert = make_alert(**alert_kwargs) is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is expected_is_vuln assert confidence == expected_confidence def test_is_vulnerable_unexpected_exception_returns_true_low(handler: RustEcosystemHandler) -> None: dep = make_dependency( ecosystem=Ecosystem.RUST, name="protobuf", version="3.7.1", manifest_path="Cargo.lock", is_lockfile=True, ) alert = make_alert( ecosystem=Ecosystem.RUST, package_name="protobuf", vulnerable_range="<3.7.2", manifest_path="Cargo.lock", ) # Ruby tests do this; keep parity: force an internal exception with patch.object(handler, "_normalize_vuln_range", side_effect=RuntimeError("boom")): is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is True assert confidence == "low" # -------------------------------------------------------- # base helper fallbacks (mirrors Ruby patterns) # -------------------------------------------------------- def test_version_in_range_exception_returns_true(handler: RustEcosystemHandler) -> None: with patch.object(handler, "_build_spec", side_effect=RuntimeError("boom")): assert handler._version_in_range(semantic_version.Version("1.0.0"), "<2.0.0") is True def test_ranges_overlap_exception_returns_true(handler: RustEcosystemHandler) -> None: with patch.object(handler, "_build_spec", side_effect=RuntimeError("boom")): assert handler._ranges_overlap("<1.0.0", ">=2.0.0") is True # -------------------------------------------------------- # handler id / ecosystem # -------------------------------------------------------- def test_handler_id_and_ecosystem(handler: RustEcosystemHandler) -> None: assert handler.id == "rust" assert handler.ecosystem == Ecosystem.RUST # -------------------------------------------------------- # _normalize_cargo_specifier # -------------------------------------------------------- @pytest.mark.parametrize( "spec, expected_version, expected_requirement", [ # Explicit pin (valid) -> return pinned, None pytest.param("=3.7.1", "3.7.1", None, id="explicit-pin-valid"), pytest.param("==3.7.1", "3.7.1", None, id="explicit-pin-double-equals-valid"), pytest.param("= 1.2", "1.2", None, id="explicit-pin-with-whitespace"), # Explicit pin (invalid) -> return None, None pytest.param("=", None, None, id="explicit-pin-empty"), pytest.param("= ", None, None, id="explicit-pin-whitespace-only"), pytest.param("=abc", None, None, id="explicit-pin-non-numeric"), pytest.param("=1.2.x", None, None, id="explicit-pin-non-numeric-segment"), pytest.param("=v1.2.3", None, None, id="explicit-pin-leading-v-not-allowed"), ], ) def test_normalize_cargo_specifier_explicit_pin_branch( spec: str, expected_version: str | None, expected_requirement: str | None, ) -> None: v, r = RustEcosystemHandler._normalize_cargo_specifier(spec) assert v == expected_version assert r == expected_requirement