""" Rust (Cargo) ecosystem handler. Supports: - Cargo.toml (manifest) - Cargo.lock (lockfile) """ from __future__ import annotations import logging import re from pathlib import Path import semantic_version import toml from vuln_scan.core.models import ( Dependency, Ecosystem, EcosystemParseResult, SecurityVulnerability, ) from vuln_scan.ecosystems.base import EcosystemHandler logger = logging.getLogger(__name__) class RustEcosystemHandler(EcosystemHandler): """Handler for the Rust / Cargo ecosystem.""" id = "rust" ecosystem = Ecosystem.RUST # ------------------------------------------------------- # File discovery # ------------------------------------------------------- @property def lockfile_names(self) -> set[str]: return {"Cargo.lock"} @property def manifest_names(self) -> set[str]: return {"Cargo.toml"} @property def manifest_globs(self) -> set[str]: return set() # ------------------------------------------------------- # Parsing — dispatch # ------------------------------------------------------- def parse_manifest( self, manifest_path: str, full_path: Path, ) -> EcosystemParseResult: content = full_path.read_text(encoding="utf-8", errors="ignore") return self.parse_manifest_content(manifest_path, content) def parse_manifest_content( self, manifest_path: str, content: str, ) -> EcosystemParseResult: filename = Path(manifest_path).name is_lockfile = self.is_lockfile(filename) if filename == "Cargo.lock": deps = self._parse_cargo_lock_content(content, manifest_path) elif filename == "Cargo.toml": deps = self._parse_cargo_toml_content(content, manifest_path) else: deps = [] return EcosystemParseResult( dependencies=deps, manifest_type="lockfile" if is_lockfile else "manifest", parser_name="cargo", ) # ------------------------------------------------------- # Normalization # ------------------------------------------------------- def _build_spec(self, range_str: str) -> semantic_version.NpmSpec: return semantic_version.NpmSpec(self._normalize_vuln_range(range_str)) def _normalize_version(self, version: str) -> str: # Cargo.lock versions are already semver; trim and strip leading v defensively v = (version or "").strip() return v.lstrip("vV") def _normalize_vuln_range(self, spec: str | None) -> str: """ Normalize GitHub vulnerable range strings into a form semantic_version can parse. Examples: "< 3.7.2" -> "<3.7.2" "<= 0.5.6" -> "<=0.5.6" ">= 1.0, < 2.0" -> ">=1.0 <2.0" """ spec = (spec or "").strip() if not spec: return "" # Replace commas with spaces spec = spec.replace(",", " ") # Normalize whitespace spec = " ".join(spec.split()) # Fix operator spacing spec = re.sub(r"(<=|>=|<|>|=|~|\^)\s+", r"\1", spec) return spec # ------------------------------------------------------- # Vulnerability matching # ------------------------------------------------------- def is_vulnerable( self, dependency: Dependency, alert: SecurityVulnerability, ) -> tuple[bool, str]: vuln_range = alert.vulnerable_range if not vuln_range: return True, "low" try: normalized = self._normalize_vuln_range(vuln_range) if not normalized: return True, "low" # Exact version (Cargo.lock) if dependency.version: try: dep_version = semantic_version.Version.coerce( self._normalize_version(dependency.version) ) except ValueError: return True, "low" is_vuln = self._version_in_range(dep_version, normalized) confidence = "high" if dependency.is_lockfile else "medium" return is_vuln, confidence # Range overlap (Cargo.toml) - best effort if dependency.requirement: req = self._normalize_vuln_range(dependency.requirement) if not req: return True, "low" overlap = self._ranges_overlap(req, normalized) return overlap, "low" return True, "low" except Exception: logger.warning( "Failed to evaluate vulnerability for %s, assuming vulnerable", dependency.name, exc_info=True, ) return True, "low" # ------------------------------------------------------- # Cargo.lock parsing # ------------------------------------------------------- def _parse_cargo_lock_content( self, content: str, manifest_path: str, ) -> list[Dependency]: try: data = toml.loads(content) if content.strip() else {} except Exception: logger.warning("Failed to parse Cargo.lock, skipping: %s", manifest_path, exc_info=True) return [] packages = data.get("package", []) if not isinstance(packages, list): return [] deps: list[Dependency] = [] seen: set[tuple[str, str]] = set() for pkg in packages: if not isinstance(pkg, dict): continue name = pkg.get("name") version = pkg.get("version") if not isinstance(name, str) or not name: continue if not isinstance(version, str) or not version: continue version = self._normalize_version(version) if (name, version) in seen: continue seen.add((name, version)) deps.append( Dependency( ecosystem=self.ecosystem, name=name, version=version, requirement=None, manifest_path=manifest_path, is_lockfile=True, ) ) return deps # ------------------------------------------------------- # Cargo.toml parsing # ------------------------------------------------------- def _parse_cargo_toml_content( self, content: str, manifest_path: str, ) -> list[Dependency]: try: data = toml.loads(content) if content.strip() else {} except Exception: logger.warning("Failed to parse Cargo.toml, skipping: %s", manifest_path, exc_info=True) return [] deps: list[Dependency] = [] def add_table(tbl: object) -> None: if not isinstance(tbl, dict): return for name, spec in tbl.items(): crate = str(name).strip() if not crate: continue raw_req = self._extract_requirement(spec) if raw_req is None: continue version, requirement = self._normalize_cargo_specifier(raw_req) if version is None and requirement is None: continue deps.append( Dependency( ecosystem=self.ecosystem, name=crate, version=version, requirement=requirement, manifest_path=manifest_path, is_lockfile=False, ) ) add_table(data.get("dependencies")) add_table(data.get("dev-dependencies")) add_table(data.get("build-dependencies")) target = data.get("target") if isinstance(target, dict): for _, tgt_tbl in target.items(): if not isinstance(tgt_tbl, dict): continue add_table(tgt_tbl.get("dependencies")) add_table(tgt_tbl.get("dev-dependencies")) add_table(tgt_tbl.get("build-dependencies")) return deps @staticmethod def _extract_requirement(spec: object) -> str | None: if isinstance(spec, str): s = spec.strip() return s or None if isinstance(spec, dict): v = spec.get("version") if isinstance(v, str): v = v.strip() return v or None return None return None @staticmethod def _normalize_cargo_specifier(spec: str | None) -> tuple[str | None, str | None]: """ Normalize Cargo.toml specifier into (exact_version, requirement). Cargo semantics (simplified): - "=1.2.3" is an exact pin - "1.2.3" means "^1.2.3" (caret requirement), NOT an exact pin - "*" / empty -> unknown - everything else is treated as a requirement string """ s = (spec or "").strip() if not s or s == "*": return None, None # Explicit pin if s.startswith("="): pinned = s.lstrip("=").strip() if pinned and re.fullmatch(r"\d+(\.\d+)*", pinned): return pinned, None return None, None # Bare numeric => caret requirement in Cargo if re.fullmatch(r"\d+(\.\d+)*", s): return None, f"^{s}" # Everything else: treat as requirement return None, s