import os
import re

from appdirs import user_cache_dir, user_data_dir

# NVD CVE json feed url
NVD_URL = "https://nvd.nist.gov/feeds/json/cve/1.1/nvdcve-1.1-%(year)s.json.gz"

# NVD start year. 2022 keeps default artifacts bounded; 2002 is detailed but slow
NVD_START_YEAR = os.getenv("NVD_START_YEAR", "2022")
try:
    NVD_START_YEAR = int(NVD_START_YEAR)
except ValueError:
    pass

# GitHub advisory feed url
GHA_URL = os.getenv("GITHUB_GRAPHQL_URL", "https://api.github.com/graphql")

# No of pages to download from GitHub during a full refresh
GHA_PAGES_COUNT = os.getenv("GITHUB_PAGE_COUNT", "2")
NPM_PAGES_COUNT = os.getenv("NPM_PAGE_COUNT", "2")

# DB file dir
DATA_DIR = os.getenv("VDB_HOME", user_data_dir("vdb"))
if not os.path.exists(DATA_DIR):
    os.makedirs(DATA_DIR)

CACHE_DIR = os.getenv("VDB_CACHE", user_cache_dir("vdb"))
if not os.path.exists(CACHE_DIR):
    os.makedirs(CACHE_DIR)

# Binary db file
VDB_BIN_FILE = os.path.join(DATA_DIR, "data.vdb7")

# Binary DB index file
VDB_BIN_INDEX = os.path.join(DATA_DIR, "data.index.vdb7")

# db metadata file
VDB_METADATA_FILE = os.path.join(DATA_DIR, "vdb.meta")

# VDB7 type-shard local store (roadmap doc 08 §6). Each shard lives at
# $VDB_SHARDS_DIR/<shard>/data.vdb7 + data.index.vdb7 + vdb.meta (meta v2).
# The connected main DB under DATA_DIR participates too when its meta declares
# completeness=partial. Override the location with VDB_SHARDS_DIR.
VDB_SHARDS_DIR = os.getenv("VDB_SHARDS_DIR") or os.path.join(DATA_DIR, "shards")

# On-demand shard fetch is OPT-IN (roadmap doc 08 §6): a scanner reaching for
# the network mid-scan is a bad surprise in CI and fatal in air-gapped
# environments. When enabled, shard-mode purl searches may fetch a missing
# shard from the registry recorded in a local shard's vdb.meta siblings.
# The explicit API is vdb.lib.shard_store.fetch_shards().
VDB_AUTO_FETCH = os.getenv("VDB_AUTO_FETCH", "") in ("true", "1")

# NPM advisory url
NPM_SERVER = "https://registry.npmjs.org"
NPM_AUDIT_URL = NPM_SERVER + "/-/npm/v1/security/audits"
NPM_ADVISORIES_URL = NPM_SERVER + "/-/npm/v1/security/advisories"

NPM_APP_INFO = {"name": "appthreat-vdb", "version": "6.0.0"}

CVE_TPL = """
{"cve":{"data_type":"CVE","data_format":"MITRE","data_version":"4.0","CVE_data_meta":{"ID":"%(cve_id)s","ASSIGNER":"%(assigner)s"},"problemtype":{"problemtype_data":[{"description":[{"lang":"en","value":"%(cwe_id)s"}]}]},"references":{"reference_data": %(references)s},"description":{"description_data":[{"lang":"en","value":"%(description)s"}]}},"configurations":{"CVE_data_version":"4.0","nodes":[{"operator":"OR","cpe_match":[{"vulnerable":true,"cpe23Uri":"cpe:2.3:a:%(vendor)s:%(product)s:%(version)s:*:%(edition)s:*:*:*:*:*","versionStartExcluding":"%(version_start_excluding)s","versionEndExcluding":"%(version_end_excluding)s","versionStartIncluding":"%(version_start_including)s","versionEndIncluding":"%(version_end_including)s"}, {"vulnerable":false,"cpe23Uri":"cpe:2.3:a:%(vendor)s:%(product)s:%(fix_version_start_including)s:*:%(edition)s:*:*:*:*:*","versionStartExcluding":"%(fix_version_start_excluding)s","versionEndExcluding":"%(fix_version_end_excluding)s","versionStartIncluding":"%(fix_version_start_including)s","versionEndIncluding":"%(fix_version_end_including)s"}]}]},"impact":{"baseMetricV3":{"cvssV3":{"version":"3.1","vectorString":"%(vectorString)s","attackVector":"NETWORK","attackComplexity":"%(attackComplexity)s","privilegesRequired":"NONE","userInteraction":"%(userInteraction)s","scope":"UNCHANGED","confidentialityImpact":"%(severity)s","integrityImpact":"%(severity)s","availabilityImpact":"%(severity)s","baseScore":%(score).1f,"baseSeverity":"%(severity)s"},"exploitabilityScore":%(exploitabilityScore).1f,"impactScore":%(score).1f},"baseMetricV2":{"cvssV2":{"version":"2.0","vectorString":"AV:N/AC:M/Au:N/C:P/I:P/A:P","accessVector":"NETWORK","accessComplexity":"MEDIUM","authentication":"NONE","confidentialityImpact":"PARTIAL","integrityImpact":"PARTIAL","availabilityImpact":"PARTIAL","baseScore":%(score).1f},"severity":"%(severity)s","exploitabilityScore":%(exploitabilityScore).1f,"impactScore":%(score).1f,"acInsufInfo":false,"obtainAllPrivilege":false,"obtainUserPrivilege":false,"obtainOtherPrivilege":false,"userInteractionRequired":false}},"publishedDate":"%(publishedDate)s","lastModifiedDate":"%(lastModifiedDate)s"}
"""

OSV_URL_DICT = {
    "javascript": "https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip",
    "python": "https://osv-vulnerabilities.storage.googleapis.com/PyPI/all.zip",
    "go": "https://osv-vulnerabilities.storage.googleapis.com/Go/all.zip",
    "java": "https://osv-vulnerabilities.storage.googleapis.com/Maven/all.zip",
    "rust": "https://osv-vulnerabilities.storage.googleapis.com/crates.io/all.zip",
    "csharp": "https://osv-vulnerabilities.storage.googleapis.com/NuGet/all.zip",
    "ruby": "https://osv-vulnerabilities.storage.googleapis.com/RubyGems/all.zip",
    "dwf": "https://osv-vulnerabilities.storage.googleapis.com/DWF/all.zip",
    "gsd": "https://osv-vulnerabilities.storage.googleapis.com/GSD/all.zip",
    "hex": "https://osv-vulnerabilities.storage.googleapis.com/Hex/all.zip",
    "packagist": "https://osv-vulnerabilities.storage.googleapis.com/Packagist/all.zip",
    "pub": "https://osv-vulnerabilities.storage.googleapis.com/Pub/all.zip",
    "uvi": "https://osv-vulnerabilities.storage.googleapis.com/UVI/all.zip",
    "github": "https://osv-vulnerabilities.storage.googleapis.com/GitHub%20Actions/all.zip",
    "cran": "https://osv-vulnerabilities.storage.googleapis.com/CRAN/all.zip",
    "swift": "https://osv-vulnerabilities.storage.googleapis.com/SwiftURL/all.zip",
    "git": "https://osv-vulnerabilities.storage.googleapis.com/GIT/all.zip",
    "julia": "https://osv-vulnerabilities.storage.googleapis.com/Julia/all.zip",
}

# The OSV feeds whose advisories describe a *package release*, not a distro
# release. Everything appended to OSV_URL_DICT below this point is a distro
# feed; these keys are the application ecosystems.
#
# ``dwf``, ``gsd``, ``uvi`` and ``git`` are deliberately absent even though
# they sit in the literal above: they are CPE/id-aggregator feeds, not package
# ecosystems, and they keep the floor that bounds their noise.
OSV_APP_ECOSYSTEM_FEEDS = frozenset(
    {
        "javascript",
        "python",
        "go",
        "java",
        "rust",
        "csharp",
        "ruby",
        "hex",
        "packagist",
        "pub",
        "cran",
        "swift",
        "julia",
        "github",
    }
)

# Application ecosystems are exempt from the year floor by default. The floor
# is an artifact-size policy sized for distro CVE volume, and applying it to
# library advisories deleted every application advisory older than the floor:
# a distro fix moves the whole release forward, so an old distro advisory
# describes versions nobody runs, but a lockfile does not move -- lodash
# 4.17.15 and log4j-core 2.14.1 are in real projects and are exactly what the
# database is queried for. Measured against the OSV feeds, the advisories the
# 2022 floor discards: npm 1,760, PyPI 3,310, RubyGems 600, NuGet 426.
#
# Set VDB_APP_ECOSYSTEM_START_YEAR to re-impose one (an int year, or "nvd" to
# follow NVD_START_YEAR).
APP_ECOSYSTEM_START_YEAR = os.getenv("VDB_APP_ECOSYSTEM_START_YEAR", "")


def osv_feed_start_year(feed_key: str | None):
    """The year floor to apply to one OSV feed, or ``None`` for no floor.

    ``feed_key`` is the ``OSV_URL_DICT`` key the zip was fetched under. An
    unknown or absent key keeps the global floor, so a caller that does not
    know which feed it is reading never widens coverage by accident.
    """
    if feed_key not in OSV_APP_ECOSYSTEM_FEEDS:
        return NVD_START_YEAR
    override = APP_ECOSYSTEM_START_YEAR.strip().lower()
    if not override:
        return None
    if override == "nvd":
        return NVD_START_YEAR
    try:
        return int(override)
    except ValueError:
        return None


# Support for disabling all or individual distro feeds
if os.getenv("VDB_IGNORE_OS", "") not in ("true", "1"):
    if os.getenv("VDB_IGNORE_ALMALINUX", "") not in ("true", "1"):
        OSV_URL_DICT["almalinux"] = (
            "https://osv-vulnerabilities.storage.googleapis.com/AlmaLinux/all.zip"
        )
    if os.getenv("VDB_IGNORE_ALPINE", "") not in ("true", "1"):
        OSV_URL_DICT["alpine"] = (
            "https://osv-vulnerabilities.storage.googleapis.com/Alpine/all.zip"
        )
    if os.getenv("VDB_IGNORE_REDHAT", "") not in ("true", "1"):
        OSV_URL_DICT["redhat"] = (
            "https://osv-vulnerabilities.storage.googleapis.com/Red%20Hat/all.zip"
        )
    if os.getenv("VDB_IGNORE_DEBIAN", "") not in ("true", "1"):
        OSV_URL_DICT["debian"] = (
            "https://osv-vulnerabilities.storage.googleapis.com/Debian/all.zip"
        )
    if os.getenv("VDB_IGNORE_ROCKYLINUX", "") not in ("true", "1"):
        OSV_URL_DICT["rockylinux"] = (
            "https://osv-vulnerabilities.storage.googleapis.com/Rocky%20Linux/all.zip"
        )
    if os.getenv("VDB_IGNORE_MAGEIA", "") not in ("true", "1"):
        OSV_URL_DICT["mageia"] = (
            "https://osv-vulnerabilities.storage.googleapis.com/Mageia/all.zip"
        )
    if os.getenv("VDB_IGNORE_ALPAQUITA", "") not in ("true", "1"):
        OSV_URL_DICT["alpaquita"] = (
            "https://osv-vulnerabilities.storage.googleapis.com/Alpaquita/all.zip"
        )
    if os.getenv("VDB_IGNORE_MINIMOS", "") not in ("true", "1"):
        OSV_URL_DICT["minimos"] = (
            "https://osv-vulnerabilities.storage.googleapis.com/MinimOS/all.zip"
        )
    if os.getenv("VDB_IGNORE_UBUNTU", "") not in ("true", "1"):
        OSV_URL_DICT["ubuntu"] = (
            "https://osv-vulnerabilities.storage.googleapis.com/Ubuntu/all.zip"
        )
    # SUSE and openSUSE (VDB7 task 10.11). The two feeds republish each
    # other's advisories (byte-identical content for the 4,104 common
    # ids), so both are ingested and the store's (cve_id, vers,
    # purl_prefix) primary key dedups them deterministically. This
    # replaces the cvrf/suse vuln-list ingest (AquaSource.suse_to_vuln),
    # which stored rows under product-display-name locators
    # (pkg:rpm/opensuse-tumbleweed/java) no scanner emits.
    #
    # Both are OPT-IN. These two feeds have the
    # heaviest errata fan-out of any source — a single erratum reaching 894
    # CVEs across 30 affected packages becomes ~13k rows that each carry the
    # whole alias list — which is what made the start-year filter quadratic
    # and a full build appear hung. That specific defect is fixed, but the
    # fan-out shape is what finds this class of bug first, so the feeds stay
    # behind a flag until a build has run clean with them on. Opting in
    # costs nothing else; opting out means NO SUSE coverage at all, because
    # the vuln-list cvrf ingest they replaced is gone.
    #
    # This does not affect the match-set gate: contrib/match_set.py pins its
    # own feed URLs for the fixture slice and does not read OSV_URL_DICT.
    if os.getenv("VDB_INCLUDE_SUSE", "") in ("true", "1"):
        OSV_URL_DICT["suse"] = (
            "https://osv-vulnerabilities.storage.googleapis.com/SUSE/all.zip"
        )
    if os.getenv("VDB_INCLUDE_OPENSUSE", "") in ("true", "1"):
        OSV_URL_DICT["opensuse"] = (
            "https://osv-vulnerabilities.storage.googleapis.com/openSUSE/all.zip"
        )
    # Azure Linux / CBL-Mariner (VDB7 task 10.12). One feed covers both
    # releases (ecosystems "Azure Linux:2" and "Azure Linux:3"); ids are
    # AZL-<n> with the CVE in ``upstream`` (one CVE per advisory, no
    # fan-out). The feed's purls all say pkg:rpm/azure-linux/<name> with
    # no qualifiers. This is the only source of these rows: the
    # vuln-list mariner subtree stays ignored
    # (aqua.DEFAULT_IGNORE_SOURCE_PATTERNS).
    if os.getenv("VDB_IGNORE_AZURE_LINUX", "") not in ("true", "1"):
        OSV_URL_DICT["azure-linux"] = (
            "https://osv-vulnerabilities.storage.googleapis.com/Azure%20Linux/all.zip"
        )


# These feeds introduce too much false positives
if os.getenv("OSV_INCLUDE_FUZZ"):
    OSV_URL_DICT["linux"] = (
        "https://osv-vulnerabilities.storage.googleapis.com/Linux/all.zip"
    )
    OSV_URL_DICT["oss-fuzz"] = (
        "https://osv-vulnerabilities.storage.googleapis.com/OSS-Fuzz/all.zip"
    )
    OSV_URL_DICT["android"] = (
        "https://osv-vulnerabilities.storage.googleapis.com/Android/all.zip"
    )

VULN_LIST_URL = "https://github.com/appthreat/vuln-list/archive/refs/heads/main.zip"

# Placeholder fix version to use to indicate max versions.
#
# Legacy encoding, no longer written. "Affected with no fix available" is
# a range with a lower bound only, so nothing needs an invented ceiling: a
# sentinel is stripped by vers normalization before matching anyway, and
# it reaches consumers as a literal fix version of "99.99.9". The last
# writers were the vuln-list Debian path (`aqua.debian_to_vuln`, removed
# in task 10.8) and the Ubuntu unfixed-range ceiling (removed in task
# 10.7); read tolerance for already-published databases lives on in
# `vdb.lib.vers.range` and the utils read paths. Do not reintroduce
# writers.
PLACEHOLDER_FIX_VERSION = "99.99.9"

# How many CVEs should be packed and written to the db file as a unit
# A large value here requires a larger max_buffer_size. Else could lead to msgpack.exceptions.BufferFull exceptions during read
BATCH_WRITE_SIZE = 20

# The Linux kernel CVE feed dominates the database (~130k rows, ~10% of the
# total) with commit-sha version ranges that cannot be matched by semantic
# version lookups. Application scanners rarely need these, so the kernel is
# excluded by default. Set VDB_INCLUDE_LINUX_KERNEL=true to keep these records.
VDB_IGNORE_LINUX_KERNEL = os.getenv("VDB_IGNORE_LINUX_KERNEL", "true") in (
    "true",
    "1",
) and os.getenv("VDB_INCLUDE_LINUX_KERNEL", "") not in ("true", "1")

# Canonical purl_prefix aliases. Some packages are ingested under several
# purl forms depending on the source (NVD reference URLs, OSV feeds, GSD).
# Mapping them to a single canonical prefix consolidates the rows, shrinks the
# database and - critically - makes lookups find every record for a package.
# Applied by normalize_purl_prefix() at the store chokepoint. See dep-scan #503.
#
# Each entry maps a matcher (prefix or exact) to the canonical purl_prefix.
# The Linux kernel is the worst offender: it appears as a raw git URL
# (pkg:generic/git.kernel.org/...), a non-standard type (pkg:linux/kernel) and
# a doubled generic (pkg:generic/kernel/kernel). All consolidate to
# pkg:generic/linux.
# Each entry maps an alias purl_prefix to its canonical form. Entries are
# matched by exact equality, except keys ending with "/" which are treated as
# prefixes (used for the long, unambiguous git-URL form).
PURL_CANONICAL_PREFIXES = {
    # Form 1: raw git URL leaked from NVD reference urls (prefix match)
    "pkg:generic/git.kernel.org/": "pkg:generic/linux",
    # Form 2: non-standard "linux" purl type from OSV/GSD feeds. The NVD CPE
    # cpe:/o:linux:linux_kernel maps to type "linux" which is not in the official
    # purl type index. Consolidate ALL linux-type purls (kernel, subsystem
    # drivers like infiniband_hfi1_driver, layer_2_tunneling_protocol, etc.) to
    # the canonical kernel prefix. Prefix match catches every subsystem variant.
    "pkg:linux/": "pkg:generic/linux",
    # Form 3: generic kernel (single or doubled). The Linux kernel is the only
    # meaningful package stored under generic/kernel (exact match).
    "pkg:generic/kernel/kernel": "pkg:generic/linux",
    "pkg:generic/kernel": "pkg:generic/linux",
}


def canonical_purl_prefix(purl_prefix: str) -> str:
    """Rewrite a purl_prefix to its canonical form if an alias is configured.

    Matches by exact equality, or by prefix for keys ending with ``/`` (used
    for the long, unambiguous git-URL form). Returns the original prefix when
    no canonicalisation applies.
    """
    if not purl_prefix:
        return purl_prefix
    for alias, canonical in PURL_CANONICAL_PREFIXES.items():
        if alias.endswith("/"):
            if purl_prefix.startswith(alias):
                return canonical
        elif purl_prefix == alias:
            return canonical
    return purl_prefix


# Go ``golang.org/x/*`` sub-repositories that NVD/OSV sometimes record as bare
# module names (e.g. ``pkg:golang/crypto`` instead of
# ``pkg:golang/golang.org/x/crypto``).  The Go toolchain itself (``go``,
# ``stdlib``, ``toolchain``) is intentionally NOT included: those are valid
# standalone module names for the language runtime.
# Source: https://pkg.go.dev/golang.org/x/
GOLANG_X_SUBPACKAGES = frozenset(
    {
        "arch",
        "benchmarks",
        "blog",
        "build",
        "crypto",
        "debug",
        "example",
        "exp",
        "image",
        "mobile",
        "mod",
        "net",
        "oauth2",
        "perf",
        "pkgsite",
        "playground",
        "review",
        "scratch",
        "seccomp",
        "sync",
        "sys",
        "term",
        "text",
        "time",
        "tools",
        "tour",
        "trace",
        "vuln",
        "website",
        "xerrors",
    }
)


# Extended metadata is optional because it stores description, alias,
# reference, symbol, severity, and source data used by metadata search APIs.
# Default public databases keep these tables empty to minimize index size.
VDB_INCLUDE_METADATA = os.getenv("VDB_INCLUDE_METADATA", "") in ("true", "1")
VDB_QUIET = os.getenv("VDB_QUIET", "") in ("true", "1")
VDB_SQLITE_IMMUTABLE = os.getenv("VDB_SQLITE_IMMUTABLE", "") in ("true", "1")

try:
    VDB_PROGRESS_INTERVAL = max(1, int(os.getenv("VDB_PROGRESS_INTERVAL", "10000")))
except ValueError:
    VDB_PROGRESS_INTERVAL = 10000

# Upper bound on the number of ``affected`` entries packed into one CVE-5
# source blob by ``CVESource.store5``. Upstream feeds fan a single CVE out to
# thousands of per-distro/per-package records; without a bound the merged blob
# grows linearly with that fan-out while hydration cost (jsonb decode + CVE
# model validation + product scan) grows linearly per ROW, making a full scan
# of one high-fan-out CVE quadratic (CVE-2025-58183: 2,996 rows against one
# ~830 KB blob, >60 s including per-row model validation). Past the cap,
# additional entries spill into further blobs for the same CVE so blob size
# and per-row work stay flat. Measured cap curve (task 1.2 rework report):
# cap 32 keeps the worst-CVE full hydration at ~0.54 s and the worst blob at
# ~9.6 KB for +22% blob bytes versus unbounded. 0 disables merging.
try:
    VDB_MAX_AFFECTED_PER_BLOB = max(
        0, int(os.getenv("VDB_MAX_AFFECTED_PER_BLOB", "32"))
    )
except ValueError:
    VDB_MAX_AFFECTED_PER_BLOB = 32

# Limits size of unpacked data
MAX_BUFFER_SIZE = 200 * 1024 * 1024  # 200 MiB

THREAT_TO_SEVERITY = {
    "unspecified": "LOW",
    "": "LOW",
    "none": "LOW",
    "negligible": "LOW",
    "low": "LOW",
    "unimportant": "LOW",
    "severity_low": "LOW",
    "medium": "MEDIUM",
    "severity_medium": "MEDIUM",
    "moderate": "MEDIUM",
    "severity_moderate": "MEDIUM",
    "important": "HIGH",
    "high": "HIGH",
    "severity_important": "HIGH",
    "critical": "CRITICAL",
    "severity_critical": "CRITICAL",
}

VENDOR_TO_VERS_SCHEME = {
    "almalinux": "rpm",
    "rocky": "rpm",
    "photon": "rpm",
    "ubuntu": "deb",
    "debian": "deb",
    "suse": "rpm",
    "redhat": "rpm",
    "opensuse": "rpm",
    "alpine": "apk",
    "gentoo": "ebuild",
    "amazon": "rpm",
    "wolfi": "apk",
    "chainguard": "apk",
    "mageia": "rpm",
    "alpaquita": "apk",
    "bellsofthardenedcontainers": "apk",
    "minimos": "apk",
}

OS_PKG_TYPES = (
    "deb",
    "apk",
    "rpm",
    "swid",
    "alpm",
    "docker",
    "oci",
    "container",
    "qpkg",
    "buildroot",
    "coreos",
    "ebuild",
    "bitnami",
)

# OS purl types whose vulnerability feeds fold the distro *release* into the
# purl path (e.g. pkg:deb/debian/bookworm/curl, pkg:apk/alpine/alpine-3.19/foo).
# For these types a ``distro``/``distro_name`` release qualifier is folded into
# the name path at both store and lookup time. RPM feeds (rocky, almalinux,
# redhat, amazon, mageia, ...) are intentionally NOT here: they store a flat
# ``pkg:rpm/<vendor>/<name>`` locator with no release segment, so a release
# qualifier is dropped rather than folded. The SUSE family (VDB7 task 10.11)
# and Azure Linux (task 10.12) are the deliberate rpm exceptions: their rows
# store under a channel path (pkg:rpm/suse/sles-15.6/bash,
# pkg:rpm/azure-linux/azure-linux-3/curl) because the lookup side CAN derive
# the release (a distro qualifier) and not separating the releases produces
# measured false positives (Leap vs the rolling Tumbleweed; Azure Linux 3
# versions running far ahead of Azure Linux 2). That fold is scoped to the
# suse/opensuse/azure-linux namespaces via
# RPM_DISTRO_QUALIFIER_CHANNEL_FOLDS below rather than done via this type set,
# so every other rpm feed stays flat. See PART 2 Findings 5/6 in the 6.7.2
# data-quality report.
RELEASE_IN_PATH_TYPES = frozenset({"deb", "apk"})

# Canonical vendor namespace for OS distros. Different sources spell the same
# distro differently: cdxgen/Trivy emit ``pkg:rpm/alma/...`` while the Aqua
# vuln-list feed uses ``pkg:rpm/almalinux/...``; the feed itself has historically
# emitted both ``rocky`` and ``rocky-linux``. Azure Linux is the worst speller
# of all (task 10.12): cdxgen ≤13.0.1 emits ``azurelinux`` and ``cbl-mariner``
# namespaces (with ``mariner-2.0``/``cbl-mariner-2.0``/``azurelinux-3.0``
# distro qualifiers), the fixed cdxgen emits ``azure-linux``, and the OSV feed
# itself only ever says ``azure-linux`` — all three alias spellings meet on
# one namespace. Apply this alias identically at store time
# (cve.normalize_purl_prefix) and lookup time (search.search_by_purl_like) so
# both meet on one spelling. Keys and values are lower case. See PART 2
# Finding 6.
DISTRO_NAMESPACE_ALIAS = {
    "alma": "almalinux",
    "rocky-linux": "rocky",
    "azurelinux": "azure-linux",
    "cbl-mariner": "azure-linux",
    "mariner": "azure-linux",
}

# ---------------------------------------------------------------------------
# RPM release channels (VDB7 task 10.18)
#
# The flat rpm namespaces (redhat, almalinux, rocky, amazon, mageia) carry no
# release channel while their bounds are per RHEL minor and per module
# stream: on the 2026-08-19 shipped build 191,985 of 424,434 rpm/redhat
# locators held 2+ distinct `vers` for one CVE, and a RHEL 8.4 host patched
# to its own postgresql module stream's fix version still matched 27 of the
# 28 bounds for CVE-2026-6637 — the el8.6/el8.8/el8.10/el9 and sibling-stream
# fix lines all sit above the installed build. The channel vocabulary below
# is the ONE place these channel strings live: the store derives them from
# the OSV ecosystem string plus the bound's own version tag (vdb.lib.osv)
# or from the vuln-list package version-release (vdb.lib.aqua), and the
# lookup derives the same strings from the purl ``distro``/``distro_name``
# qualifiers plus the installed version's own tag (search._purl_search_spec).
#
# Granularity (measured on the 2026-08-19 feed):
# module-stream bounds (78,458 of 596,294) store under their module build
# context channel — the ONLY key that separates the postgresql :12/:13/:15/:16
# streams, which share one binary package name — and other bounds under the
# release their version tag names (``.el8_10`` → rhel-8.10, ``.el9`` → rhel-9,
# ``.amzn2023`` → amazon-2023). The ecosystem string is the fallback when the
# bound carries no tag (rhel_eus:9.4 → rhel-9.4; enterprise_linux:8 → rhel-8).
# ---------------------------------------------------------------------------

# A module-stream rpm release carries its build context — platform, build id
# and context hash (`.module+el8.4.0+24442+74e4dc28`). Red Hat spells the
# marker `module+el…`, the AlmaLinux/Rocky rebuilds `module_el…`. Only the
# PLATFORM part (el8.4.0 -> rhel-8.4) becomes the channel.
#
# The build id and context hash must NOT: they identify one module build, and
# a build's context appears in exactly one fix version, so a channel carrying
# it can only be queried by a host already running that exact build — whose
# version then equals the bound and is not below it. Measured on a full
# rebuild of the Red Hat feed: all 88,661 module rows had a channel derived
# from their own bound and no (package, context) pair held a second fix
# version, i.e. every one was unreachable, and a reachability audit of 400
# random channelled rows missed on exactly the module ones. Unreachable rows
# are the defect this task exists to remove.
#
# The platform alone is not enough either: sibling streams that share a binary
# name (postgresql :12 and :13 both ship in el8.4) then land on one locator,
# and a host patched on :12 matches the :13 fix bound. So the channel also
# carries the bound's UPSTREAM MAJOR — the number a module stream is named
# after (postgresql:12 -> 12.x, nodejs:18 -> 18.x, mariadb:10.5 -> 10.x).
# That number cannot change within a stream, which is what makes it safe to
# put in a locator: it can never separate a host from its own stream's row,
# only sibling streams from each other. It under-separates where two streams
# share a major (php:8.1/8.2, python38/39) — those keep colliding, no worse
# than the platform-only channel.
RPM_MODULE_STREAM_CHANNEL_RE = re.compile(
    r"module[+_](?:el)?(\d+)\.(\d+)\.\d+\+\d+\+[0-9a-f]+", re.IGNORECASE
)
RPM_UPSTREAM_MAJOR_RE = re.compile(r"^(?:\d+:)?(\d+)")


def rpm_module_stream_channel(namespace: str, version: str) -> str:
    """Module platform+stream channel for an rpm version string, or "".

    ``redhat`` + ``12.22-1.module+el8.4.0+24442+74e4dc28.4`` ->
    ``rhel-8.4-stream12``; ``almalinux`` +
    ``1.4.3.28-6.module_el8.6.0+2734+1efaf02b`` -> ``almalinux-8.6-stream1``
    (the rebuild spelling). Store side applies it to the bound, lookup side
    to the installed version, so a vulnerable host on an earlier build of
    the same stream still meets the fix row's locator while a sibling
    stream's host does not.
    """
    m = RPM_MODULE_STREAM_CHANNEL_RE.search(version or "")
    if not m:
        return ""
    ns = (namespace or "").lower()
    vendor = "rhel" if ns == "redhat" else ns
    if not vendor:
        return ""
    channel = f"{vendor}-{m.group(1)}.{m.group(2)}"
    stream = RPM_UPSTREAM_MAJOR_RE.match((version or "").strip())
    return f"{channel}-stream{stream.group(1)}" if stream else channel


# Release-tag → channel per vendor: `.el8_10` → rhel-8.10 (underscore minor
# normalised to the dot spelling os-release VERSION_ID and the distro
# qualifier use), `.amzn2023` → amazon-2023, `.mga8` → mageia-8.
RPM_RELEASE_TAG_CHANNEL_RES = {
    "redhat": re.compile(r"\.el(\d+)(?:[_](\d+))?(?=[.\-+_ ]|$)"),
    "almalinux": re.compile(r"\.el(\d+)(?:[_](\d+))?(?=[.\-+_ ]|$)"),
    "rocky": re.compile(r"\.el(\d+)(?:[_](\d+))?(?=[.\-+_ ]|$)"),
    "amazon": re.compile(r"\.amzn(\d+)(?=[.\-+_ ]|$)"),
    "mageia": re.compile(r"\.mga(\d+)(?=[.\-+_ ]|$)"),
}


def rpm_version_release_channel(namespace: str, version: str) -> str:
    """Release channel named by an rpm version's own distro tag, or "".

    ``rhel`` + ``3.0.7-24.el9_8`` -> ``rhel-9.8``; ``redhat`` +
    ``3.1.5-8.el8`` -> ``rhel-8``; ``amazon`` + ``1.19-2.amzn2023.0.2`` ->
    ``amazon-2023``. The namespace (already canonicalised through
    DISTRO_NAMESPACE_ALIAS at the caller) selects the vendor and therefore
    the channel prefix — ``redhat`` maps to the ``rhel-*`` channel family a
    scan's ``distro=redhat-9.8`` / ``distro_name=enterprise_linux-9``
    qualifiers fold onto. Versions without the vendor's tag (JBoss/Satellite
    product bounds, module contexts — which rpm_module_stream_channel
    handles first) return "".
    """
    tag_re = RPM_RELEASE_TAG_CHANNEL_RES.get((namespace or "").lower())
    if not tag_re or not version:
        return ""
    m = tag_re.search(version)
    if not m:
        return ""
    vendor = "rhel" if namespace.lower() == "redhat" else namespace.lower()
    major = m.group(1)
    minor = m.group(2) if tag_re.groups > 1 else None
    if minor:
        return f"{vendor}-{major}.{minor}"
    return f"{vendor}-{major}"


def redhat_ecosystem_release_channel(ecosystem: str) -> str:
    """Release channel for a Red Hat OSV ecosystem string, or "".

    ``Red Hat:enterprise_linux:8::appstream`` -> ``rhel-8`` (the base
    channel spans every minor); ``Red Hat:rhel_eus:9.4::appstream`` ->
    ``rhel-9.4`` (Update Services products exist per minor);
    ``Red Hat:rhel_els:7`` -> ``rhel-7``. Non-RHEL-Linux products
    (JBoss EAP, OpenShift, Satellite, hummingbird, …) return "" — no purl
    qualifier can name their channel, so their rows keep the flat locator.
    """
    parts = (ecosystem or "").split(":")
    if len(parts) < 3:
        return ""
    product, release = parts[1].lower(), parts[2]
    if not release:
        return ""
    if product in (
        "rhel_eus",
        "rhel_aus",
        "rhel_e4s",
        "rhel_tus",
        "rhel_eus_long_life",
        "enterprise_linux_eus",
    ):
        return f"rhel-{release.lower()}"
    if product in ("enterprise_linux", "rhel_els"):
        major = release.split(".")[0]
        if major.isdigit():
            return f"rhel-{major.lower()}"
    return ""


def rpm_os_ecosystem_channel(namespace: str, ecosystem: str) -> str:
    """Release channel for the per-distro OSV ecosystems of the flat rpm
    families (VDB7 task 10.18), or "".

    ``almalinux`` + ``AlmaLinux:9`` -> ``almalinux-9``; ``rocky`` +
    ``Rocky Linux:8`` -> ``rocky-8``; ``mageia`` + ``Mageia:8`` ->
    ``mageia-8``. The Red Hat family routes through
    :func:`redhat_ecosystem_release_channel` (its products are many; only
    the enterprise-linux line maps).
    """
    ns = (namespace or "").lower()
    eco = (ecosystem or "").lower()
    if ns == "redhat":
        return redhat_ecosystem_release_channel(eco)
    for prefix in (f"{ns}:", f"{ns} linux:"):
        if eco.startswith(prefix):
            release = eco[len(prefix) :].split(":", 1)[0]
            major = release.split(".")[0]
            if major.isdigit():
                return f"{ns}-{release}"
            return ""
    return ""


def _numbered_release_channels(value: str, vendor: str, prefixes: tuple) -> tuple:
    """Ordered (minor, major) channel candidates for a numbered distro
    qualifier value: ``redhat-9.8`` with vendor ``rhel`` -> ``("rhel-9.8",
    "rhel-9")``; a value with no minor yields just the major."""
    v = (value or "").lower()
    for prefix in prefixes:
        if v.startswith(prefix):
            release = v[len(prefix) :]
            if not release[:1].isdigit():
                return ()
            if "." in release:
                return (f"{vendor}-{release}", f"{vendor}-{release.split('.')[0]}")
            return (f"{vendor}-{release}",)
    return ()


def redhat_distro_qualifier_channels(distro: str, distro_name: str = "") -> tuple:
    """Ordered release-channel candidates for a pkg:rpm/redhat purl's
    ``distro`` / ``distro_name`` qualifiers.

    cdxgen stamps the os-release identity on every rpm purl; two spellings
    are in circulation — cdxgen 13.x ``distro=redhat-9.8`` with
    ``distro_name=enterprise_linux-9`` (or ``rhel-9``), older generations
    ``distro=rhel-8.4&distro_name=rhel-8``. Every spelling folds onto the
    same ``rhel-N[.M]`` vocabulary the store side derives, ordered minor
    first (the suse candidate shape): an 8.4 EUS host probes rhel-8.4 then
    the rhel-8 base channel.
    """
    for value in (distro, distro_name):
        channels = _numbered_release_channels(
            value, "rhel", ("redhat-", "rhel-", "enterprise_linux-")
        )
        if channels:
            return channels
    return ()


def rpm_flat_family_distro_qualifier_channels(
    distro: str, distro_name: str = "", *, namespace: str = "", vendor: str = ""
) -> tuple:
    """Ordered release-channel candidates for the almalinux/rocky/amazon/
    mageia families' ``distro`` / ``distro_name`` qualifiers (the generic
    numbered form of :func:`redhat_distro_qualifier_channels`; the vendor
    argument carries the channel prefix — almalinux/rocky/amazon/mageia)."""
    ns = (namespace or "").lower()
    if not vendor:
        vendor = ns
    prefixes = (f"{vendor}-",)
    if ns == "rocky":
        prefixes = ("rocky-", "rocky-linux-")
    elif ns == "amazon":
        prefixes = ("amazon-", "amazonlinux-", "amzn-")
    for value in (distro, distro_name):
        channels = _numbered_release_channels(value, vendor, prefixes)
        if channels:
            return channels
    return ()


def almalinux_distro_qualifier_channels(distro: str, distro_name: str = "") -> tuple:
    return rpm_flat_family_distro_qualifier_channels(
        distro, distro_name, namespace="almalinux"
    )


def rocky_distro_qualifier_channels(distro: str, distro_name: str = "") -> tuple:
    return rpm_flat_family_distro_qualifier_channels(
        distro, distro_name, namespace="rocky"
    )


def amazon_distro_qualifier_channels(distro: str, distro_name: str = "") -> tuple:
    return rpm_flat_family_distro_qualifier_channels(
        distro, distro_name, namespace="amazon"
    )


def mageia_distro_qualifier_channels(distro: str, distro_name: str = "") -> tuple:
    return rpm_flat_family_distro_qualifier_channels(
        distro, distro_name, namespace="mageia"
    )


def rpm_version_derived_channels(namespace: str, version: str) -> tuple:
    """Channel candidates derived from the INSTALLED version itself.

    The installed rpm's own release string names its module build context
    and/or its distro release tag — evidence the qualifiers cannot supply:
    a 9.8 host may run packages last rebuilt at 9.5 (``…-18.el9_5``), and a
    module stream host carries the context in its version. Both are derived
    with the same two functions the store applies to bounds, so the strings
    cannot drift. Ordered most-specific first.
    """
    ns = (namespace or "").lower()
    channels = []
    module_channel = rpm_module_stream_channel(ns, version or "")
    if module_channel:
        channels.append(module_channel)
    release_channel = rpm_version_release_channel(ns, version or "")
    if release_channel and release_channel not in channels:
        channels.append(release_channel)
    return tuple(channels)


def suse_distro_qualifier_channel(distro: str) -> str:
    """Release channel for a pkg:rpm/suse|opensuse purl ``distro`` qualifier.

    Store and lookup must agree on one channel vocabulary (VDB7 task
    10.11): ``sles-<maj>.<sp>``, ``opensuse-leap-<maj>.<min>`` and
    ``opensuse-tumbleweed`` — the values cdxgen derives from os-release
    (ID + VERSION_ID, e.g. ``sles-15.6``, ``opensuse-leap-15.6``).
    Tumbleweed's qualifier carries a snapshot date
    (``opensuse-tumbleweed-20260806``) the feed ecosystems never spell,
    so it is stripped. Qualifiers that map to no channel (e.g. a future
    ``suse-6.1``) return "" and the lookup falls back to the flat
    locator. The store-side mirror for OSV ecosystem strings is
    ``vdb.lib.osv.suse_ecosystem_channel``.
    """
    d = (distro or "").lower()
    if d.startswith("opensuse-tumbleweed"):
        return "opensuse-tumbleweed"
    if d.startswith(("sles-", "opensuse-leap-")):
        return d
    return ""


def suse_distro_qualifier_channels(distro: str, distro_name: str = "") -> tuple:
    """Ordered release-channel candidates for a pkg:rpm/suse|opensuse purl
    ``distro`` qualifier (VDB7 task 10.11). ``distro_name`` is accepted for
    signature parity with the other rpm fold families and ignored — the
    SUSE-family spellings all live in the ``distro`` qualifier.

    Wraps :func:`suse_distro_qualifier_channel` with the one family-specific
    extra probe: SLE umbrella channels. 1,204 affected entries sit on
    ecosystems that name a major with no service pack ("SUSE:Linux
    Enterprise Server 12", "...Workstation Extension 15"), which store
    under sles-12 / sles-15. Those apply to the major line, so an SP
    install must probe the umbrella too — otherwise 253 (cve, package)
    pairs that appear on no SP-level ecosystem anywhere in the feeds are
    stored and never reachable.
    """
    channel = suse_distro_qualifier_channel(distro)
    if not channel:
        return ()
    if channel.startswith("sles-") and "." in channel:
        return (channel, channel.split(".")[0])
    return (channel,)


def azure_linux_distro_qualifier_channels(distro: str, distro_name: str = "") -> tuple:
    """Ordered release-channel candidates for a pkg:rpm/azure-linux purl
    ``distro`` qualifier (VDB7 task 10.12). ``distro_name`` is accepted for
    signature parity with the other rpm fold families and ignored.

    Two cdxgen generations spell the qualifier differently over the same
    two releases, and all five spellings must land on the same two
    channels the store side derives from the OSV ecosystem
    (``vdb.lib.osv.azure_linux_ecosystem_channel``: "Azure Linux:2" ->
    azure-linux-2, "Azure Linux:3" -> azure-linux-3):

      * cdxgen <=13.0.1 (in circulation): azurelinux-3.0, cbl-mariner-2.0,
        mariner-2.0 on the azurelinux/cbl-mariner namespaces
      * fixed cdxgen: azure-linux-3.0, azure-linux-2.0 on the
        azure-linux namespace

    Channels are major-only because that is all the ecosystem string
    carries; the qualifier's minor (``-3.0``) is stripped so a hypothetical
    azure-linux-3.1 qualifier still folds onto the azure-linux-3 rows of
    its major line. Qualifiers outside the vocabulary return ``()`` and
    the lookup falls back to the flat locator.
    """
    d = (distro or "").lower()
    for prefix in ("azure-linux-", "azurelinux-", "cbl-mariner-", "mariner-"):
        if d.startswith(prefix):
            major = d[len(prefix) :].split(".", 1)[0]
            if major.isdigit():
                return (f"azure-linux-{major}",)
            return ()
    return ()


def photon_distro_qualifier_channels(distro: str, distro_name: str = "") -> tuple:
    """Release-channel candidates for a pkg:rpm/photon purl ``distro``
    qualifier. ``distro_name`` is accepted for signature parity with the
    other rpm fold families and ignored.

    The Photon feed stores one channel per release, keeping the minor
    (``photon-1.0`` … ``photon-5.0``; see ``aqua.AquaSource.photon_to_vuln``),
    and cdxgen builds the qualifier as os-release ``ID-VERSION_ID``, which on
    Photon is exactly ``photon-5.0``. So the fold is essentially the identity
    on a well-formed qualifier. A major-only qualifier (``photon-5``) is
    mapped onto that major's ``.0`` line because that is the only minor the
    feed publishes, and an unexpected minor also probes the ``.0`` line as a
    fallback. Qualifiers outside the vocabulary return ``()`` and the lookup
    falls back to the flat locator.
    """
    d = (distro or "").lower()
    prefix = "photon-"
    if not d.startswith(prefix):
        return ()
    parts = d[len(prefix) :].split(".")
    major = parts[0]
    if not major.isdigit():
        return ()
    channels = []
    if len(parts) > 1 and parts[1].isdigit():
        channels.append(f"photon-{major}.{parts[1]}")
    fallback = f"photon-{major}.0"
    if fallback not in channels:
        channels.append(fallback)
    return tuple(channels)


def alpine_repo_channel(repo: str) -> str:
    """Release channel for a vuln-list alpine-unfixed ``repo`` value.

    The feed's ``repo`` is ``<release>-main`` / ``<release>-community``
    (``3.22-main``, ``edge-community``); the channel is the release with
    the ``alpine-`` prefix (``alpine-3.22``, ``alpine-edge``) — the path
    segment the stored locator carries (VDB7 task 10.15). The lookup-side
    mirror deriving the same channels from purl qualifiers is
    :func:`alpine_distro_qualifier_channels`.
    """
    head = (repo or "").split("-")[0]
    return f"alpine-{head}" if head else ""


# Ubuntu Pro pocket channels (VDB7 task 10.17). The OSV Ubuntu feed's
# affected entries carry the security pocket in the purl ``distro``
# qualifier as ``<pocket>/<release>`` (esm-infra/bionic,
# fips-updates/focal, bluefield/noble, …); the pre-16.04 ESM era used the
# reversed ``<release>/esm`` spelling (trusty/esm). These rows — 561,754
# of the 2.57M ubuntu rows on the 2026-08-19 build, 446,818 tuples with
# no plain-release row at all — are unreachable by construction: a scan
# purl never proposes a pocket channel (search._purl_search_spec emits
# only the flat and distro_name-folded locators), so ESM-only fix lines
# for EOL releases (bionic glibc 2.27-3ubuntu1.6+esm1 etc.) answer no
# query. The fold in vdb.lib.osv stores such a row ALSO under the plain
# release channel when the record states no plain row of its own, and
# this vocabulary is the single place the pocket spellings live — the
# store consults ubuntu_foldable_pocket_release; the lookup side needs no
# change because the folded alias lands on the plain codename channel its
# distro_name fold already proposes.
#
# Only the ESM family folds (UBUNTU_ESM_POCKETS below). ESM is the same
# release's own archive kept alive past its standard-support date, so its
# fix line is the one a plain scan of that release must report. The
# fips/realtime/bluefield pockets are *product variants* — separately
# rebuilt binaries with their own version lines (``…fips.2.1~18.04.23.9``)
# and their own affected/unfixed status — and folding them measurably
# corrupts the plain channel: over the 2026-08-19 build they contribute
# 246,257 of the 533,823 candidate aliases and produce all 35,142
# locators that would hold two contradictory bounds for one CVE. Worked
# example (real converter output, CVE-2023-38408 openssh): the ``fips``
# pocket's unbounded row and the ``fips-updates`` rebuild bound would
# land on ``pkg:deb/ubuntu/bionic/openssh`` beside the esm-infra fix, so a
# bionic host patched to 7.6p1-4ubuntu0.7+esm1 still matched — the ESM fix
# became unreportable. Ubuntu plain channels hold zero such conflicts
# today and must keep holding zero.
UBUNTU_PRO_POCKETS = frozenset(
    {
        "esm",
        "esm-infra",
        "esm-infra-legacy",
        "esm-apps",
        "esm-apps-legacy",
        "fips",
        "fips-updates",
        "fips-preview",
        "realtime",
        "bluefield",
        "ros-esm",
    }
)

# The subset that folds onto the plain release channel: extended
# maintenance of the release's own binaries, not a rebuilt variant.
UBUNTU_ESM_POCKETS = frozenset(
    {
        "esm",
        "esm-infra",
        "esm-infra-legacy",
        "esm-apps",
        "esm-apps-legacy",
        "ros-esm",
    }
)


def ubuntu_pocket_release(channel: str) -> str | None:
    """Release codename an Ubuntu Pro pocket channel belongs to, or None.

    ``esm-infra/bionic`` -> ``bionic`` (pocket-first feed spelling);
    ``trusty/esm`` -> ``trusty`` (release-first legacy ESM spelling);
    ``bionic`` -> ``None`` (a plain release channel is not a pocket).
    Recognition only — any channel outside the UBUNTU_PRO_POCKETS
    vocabulary returns None. Use ubuntu_foldable_pocket_release to decide
    whether a pocket row also belongs on the plain release channel.
    """
    return _ubuntu_pocket_release(channel, UBUNTU_PRO_POCKETS)


def ubuntu_foldable_pocket_release(channel: str) -> str | None:
    """Release codename to alias a pocket row onto, or None.

    Same parsing as ubuntu_pocket_release, restricted to
    UBUNTU_ESM_POCKETS: ``esm-infra/bionic`` -> ``bionic``,
    ``trusty/esm`` -> ``trusty``, but ``fips-updates/focal`` ->
    ``None`` because a FIPS rebuild's version line does not describe a
    plain-archive install (see UBUNTU_ESM_POCKETS above).
    """
    return _ubuntu_pocket_release(channel, UBUNTU_ESM_POCKETS)


def _ubuntu_pocket_release(channel: str, vocabulary: frozenset) -> str | None:
    if "/" not in (channel or ""):
        return None
    first, _, second = channel.partition("/")
    if first in vocabulary:
        return second or None
    if second in vocabulary:
        return first or None
    return None


# A purl ``distro`` qualifier (os-release ID + VERSION_ID) bearing one of
# these pre-release suffixes names a rolling Alpine edge snapshot, not a
# stable release: edge's VERSION_ID is e.g. 3.25.0_alpha20260805 today and
# 3.26.0_alpha… once 3.25 branches — a static edge→release alias table
# would be wrong within months, so the marker is derived instead.
ALPINE_PRERELEASE_MARKERS = ("_alpha", "_beta", "_rc")

# The stable locator channel for edge rows. Deliberately NOT
# "alpine-3.25": the release edge is rolling toward changes its number
# every few months, while the marker rule above keeps finding it.
ALPINE_EDGE_CHANNEL = "alpine-edge"


def alpine_distro_qualifier_channels(distro: str, distro_name: str = "") -> tuple:
    """Ordered release-channel candidates for a pkg:apk/alpine purl's
    ``distro`` / ``distro_name`` qualifiers (VDB7 task 10.15).

    Store and lookup must agree on one channel vocabulary: the aqua
    alpine-unfixed rows store under ``pkg:apk/alpine/<channel>/<name>``
    with the channel from :func:`alpine_repo_channel` (``alpine-3.22``,
    ``alpine-edge``), and the OSV Alpine rows (task 10.15 D1) under the
    same shape derived from the ecosystem (``Alpine:v3.22`` →
    ``alpine-3.22``). A scan purl carries the channel either as
    ``distro_name=alpine-3.22`` (cdxgen stable spelling, both
    generations) or, for edge, ONLY as a pre-release marker inside the
    ``distro`` qualifier (``distro=alpine-3.25.0_alpha20260805`` with
    ``distro_name=alpine-3.25`` — the numeric name is the release edge is
    rolling toward, which has no rows yet and never will under that
    spelling). The marker therefore wins over the numeric name, so an
    edge scan never probes a stable release's channel. Qualifiers that
    map to no channel return ``()`` and the lookup falls back to the flat
    locator.
    """
    d = (distro or "").lower()
    if any(marker in d for marker in ALPINE_PRERELEASE_MARKERS):
        return (ALPINE_EDGE_CHANNEL,)
    for candidate in (distro_name, distro):
        c = (candidate or "").lower()
        if not c.startswith("alpine-"):
            continue
        parts = c[len("alpine-") :].split(".")
        if parts[0].isdigit():
            # Reduce to major.minor: the stored channels carry no patch
            # level (alpine-3.22, not alpine-3.22.5).
            if len(parts) > 1 and parts[1].isdigit():
                return (f"alpine-{parts[0]}.{parts[1]}",)
            return (f"alpine-{parts[0]}",)
    return ()


def bellsoft_distro_qualifier_channels(distro: str, distro_name: str = "") -> tuple:
    """Ordered release-channel candidates for a pkg:apk purl of the BellSoft
    family namespaces (VDB7 task 10.20): ``alpaquita`` and
    ``bellsoft-hardened-containers``.

    Both os-releases carry no ``VERSION_CODENAME``, so cdxgen emits no
    ``distro_name`` for them — the channel rides entirely in the
    ``distro`` qualifier as ``<ID>-<VERSION_ID>`` (measured on captured
    Alpaquita Stream scans: ``distro=alpaquita-stream``, no
    ``distro_name``; a 25 host would carry ``distro=alpaquita-25``). The
    channel vocabulary is therefore the qualifier itself, and the
    store-side mirror in ``vdb.lib.osv`` derives the same strings from
    the OSV ecosystem (``Alpaquita:stream`` → ``alpaquita-stream``,
    ``BellSoft Hardened Containers:25`` →
    ``bellsoft-hardened-containers-25``). Any release suffix is accepted
    (``stream``, ``23``, ``25``, a future ``27``) so a new release's
    scans fold without a code change; a suffix the feed never published
    simply matches no rows. Qualifiers outside the family return ``()``
    and the lookup falls back to the flat locator.
    """
    d = (distro or "").lower()
    for ns in ("alpaquita", "bellsoft-hardened-containers"):
        prefix = f"{ns}-"
        if d.startswith(prefix) and len(d) > len(prefix):
            return (d,)
    return ()


# The rpm families whose feeds store release-scoped channel locators
# (pkg:rpm/<namespace>/<channel>/<name>). search._purl_search_spec folds a
# purl ``distro``/``distro_name`` qualifier into the path for exactly these
# namespaces via this table. Each entry maps a lower-case purl namespace to
# a function returning the ordered channel candidates for the qualifiers
# (first candidate wins the matched_by key). Since task 10.18 the flat
# families are channel-scoped too (redhat, almalinux, rocky, amazon,
# mageia); their channel vocabulary additionally comes from the version
# itself (rpm_version_derived_channels). The store-side mirrors deriving
# the same channels are ``vdb.lib.osv.suse_ecosystem_channel``,
# ``vdb.lib.osv.azure_linux_ecosystem_channel``, the task-10.18 branches in
# ``vdb.lib.osv`` (via config.rpm_os_ecosystem_channel /
# rpm_module_stream_channel / rpm_version_release_channel) and, for photon,
# the ``photon-{os_version}`` path segment built in
# ``vdb.lib.aqua.AquaSource.photon_to_vuln``.
RPM_DISTRO_QUALIFIER_CHANNEL_FOLDS = {
    "suse": suse_distro_qualifier_channels,
    "opensuse": suse_distro_qualifier_channels,
    "azure-linux": azure_linux_distro_qualifier_channels,
    "photon": photon_distro_qualifier_channels,
    "redhat": redhat_distro_qualifier_channels,
    "almalinux": almalinux_distro_qualifier_channels,
    "rocky": rocky_distro_qualifier_channels,
    "amazon": amazon_distro_qualifier_channels,
    "mageia": mageia_distro_qualifier_channels,
}

# The rpm namespaces (VDB7 task 10.18) whose flat locator holds only rows
# whose channel could not be derived — non-RHEL product advisories (JBoss
# EAP, OpenShift, Satellite, hummingbird; 154,170 of 1,015,393 simulated
# feed rows) and CPE-shaped CNA/NVD rows. When a scan of one of these
# namespaces derives ANY channel candidate, the flat candidate is dropped:
# a release-naming scan must not be answered by release-less product rows
# (the RHEL-AI "hummingbird" postgresql 18.4-0.1.hum1 bound answering a
# RHEL postgresql scan — the residual cross-product collision measured in
# measured, §4). Scans that derive no channel keep
# the flat candidate, so those rows stay reachable by qualifier-less purls.
# The suse/opensuse/azure-linux/photon families keep their flat candidate
# even under channel probes: their flat locators carry NVD-CPE-sourced rows
# under the same namespace that every scan must keep reaching (task 10.11
# design).
RPM_CHANNEL_SCOPED_NAMESPACES = frozenset(
    {"redhat", "almalinux", "rocky", "amazon", "mageia"}
)

# The apk families whose feeds store release-scoped channel locators
# (pkg:apk/<namespace>/<channel>/<name>) — the apk mirror of the rpm table
# above, consulted by search._purl_search_spec after the generic
# distro_name fold. Other apk feeds (wolfi, chainguard) store flat and
# keep matching their flat locator. The fold takes BOTH qualifiers
# because alpine edge is detectable only from the ``distro`` marker
# (see alpine_distro_qualifier_channels); the store-side mirrors are
# config.alpine_repo_channel (aqua) and the ecosystem-derived editions in
# vdb.lib.osv (alpine 10.15 D1, the BellSoft family 10.20).
APK_DISTRO_QUALIFIER_CHANNEL_FOLDS = {
    "alpine": alpine_distro_qualifier_channels,
    "alpaquita": bellsoft_distro_qualifier_channels,
    "bellsoft-hardened-containers": bellsoft_distro_qualifier_channels,
}

# Well-known GitHub organizations that canonically host packages for a single
# package ecosystem. When an OSV GIT range points at such a repo
# (e.g. github.com/pypa/setuptools), normalise the purl to the real ecosystem
# (pkg:pypi/setuptools) instead of leaving the org as a bogus namespace
# (pkg:github/pypa/setuptools). Only organisations whose published artifacts
# consistently belong to one ecosystem are listed here. See dep-scan #503.
GITHUB_REPO_NAMESPACE_TO_TYPE = {
    "pypa": "pypi",
    "python-pillow": "pypi",
    "npm": "npm",
    "composer": "composer",
    "sebastianbergmann": "composer",
}

# Per-repo overrides for GitHub orgs that MIX language runtimes with library
# packages (golang, ruby, rust-lang, nodejs). Mapping the whole org would
# misclassify runtimes such as golang/go or rust-lang/rust as library
# packages, so only the confirmed library repos are promoted here. Each value
# is (purl_type, name_prefix): the repo name is rewritten to
# ``name_prefix + repo`` so it matches the canonical module/package name.
# Grounded in cross-referencing the public v6 database. See dep-scan #503.
#
# Examples:
#   golang/crypto        -> pkg:golang/golang.org/x/crypto
#   nodejs/undici        -> pkg:npm/undici
#   rust-lang/regex      -> pkg:cargo/regex
#   ruby/json            -> pkg:gem/json
GITHUB_REPO_TO_TYPE = {
    # The golang.org/x/* sub-repositories. The canonical Go module path is
    # golang.org/x/<repo>, not golang/<repo>.
    "golang/crypto": ("golang", "golang.org/x/"),
    "golang/net": ("golang", "golang.org/x/"),
    "golang/image": ("golang", "golang.org/x/"),
    "golang/text": ("golang", "golang.org/x/"),
    "golang/sys": ("golang", "golang.org/x/"),
    "golang/oauth2": ("golang", "golang.org/x/"),
    # nodejs org: first-party npm packages (the "node" runtime itself is also
    # published on npm, so it is intentionally included).
    "nodejs/undici": ("npm", ""),
    "nodejs/llhttp": ("npm", ""),
    "nodejs/node": ("npm", ""),
    "nodejs/import-in-the-middle": ("npm", ""),
    # rust-lang org: crates published to crates.io. The rust/cargo runtimes
    # are deliberately omitted.
    "rust-lang/regex": ("cargo", ""),
    "rust-lang/socket2": ("cargo", ""),
    "rust-lang/cargo": ("cargo", ""),
    "rust-lang/mdbook": ("cargo", ""),
    "rust-lang/futures-rs": ("cargo", ""),
    # ruby org: stdlib gems published to rubygems. The ruby/rubygems runtimes
    # are deliberately omitted.
    "ruby/json": ("gem", ""),
    "ruby/rexml": ("gem", ""),
    "ruby/erb": ("gem", ""),
    "ruby/date": ("gem", ""),
    "ruby/rake": ("gem", ""),
    "ruby/cgi": ("gem", ""),
    "ruby/net-imap": ("gem", ""),
    "ruby/rdoc": ("gem", ""),
    "ruby/time": ("gem", ""),
    "ruby/uri": ("gem", ""),
    "ruby/webrick": ("gem", ""),
    "ruby/zlib": ("gem", ""),
    # Language runtimes hosted on GitHub. These are not library packages, so
    # they are normalised to the generic type with the org dropped:
    # rust-lang/rust -> pkg:generic/rust. See dep-scan #503.
    "golang/go": ("generic", ""),
    "rust-lang/rust": ("generic", ""),
    "ruby/ruby": ("generic", ""),
    "ruby/rubygems": ("generic", ""),
    "python/cpython": ("generic", ""),
    # Additional project repos with a single dominant ecosystem, grounded by
    # cross-referencing the public v6 database. The org is not promoted as a
    # whole because it hosts unrelated repos too.
    "moodle/moodle": ("composer", ""),
    "openssl/openssl": ("generic", ""),
    "tensorflow/tensorflow": ("pypi", ""),
    # Discourse plugins are distributed as Ruby gems.
    "discourse/discourse-ai": ("gem", ""),
    "discourse/discourse-bbcode": ("gem", ""),
    "discourse/discourse-calendar": ("gem", ""),
    "discourse/discourse-code-review": ("gem", ""),
    "discourse/discourse-encrypt": ("gem", ""),
    "discourse/discourse-footnote": ("gem", ""),
    "discourse/discourse-group-membership-ip-block": ("gem", ""),
    "discourse/discourse-jira": ("gem", ""),
    "discourse/discourse-mermaid-theme-component": ("gem", ""),
    "discourse/discourse-microsoft-auth": ("gem", ""),
    "discourse/discourse-patreon": ("gem", ""),
    "discourse/discourse-placeholder-theme-component": ("gem", ""),
    "discourse/discourse-policy": ("gem", ""),
    "discourse/discourse-reactions": ("gem", ""),
    "discourse/discourse-yearly-review": ("gem", ""),
    "discourse/message_bus": ("gem", ""),
    "discourse/rails_multisite": ("gem", ""),
}

# URL for the pre-compiled database.
#
# These must point at v7 artifacts: this library reads .vdb7 databases only
# and refuses a v6 image outright, so the v6 defaults these replaced
# (vdbxz / vdbxz-app / vdbxz-10y / *-extended, all :v6.7.x) would have made
# every download fail. Published by AppThreat/vdb's build-vdb7.yml as
# vdb7-<name>:<tag>-<compression>.
#
# The -xz suffix is deliberate: it unpacks with the stdlib tarfile alone.
# The -zst artifacts published alongside it hold bare .zst layers that need
# compression.zstd or a zstd binary (vdb.lib.zstd_support); xz is the
# always-available default for that reason.
VDB_DATABASE_URL = os.getenv(
    "VDB_DATABASE_URL", "ghcr.io/appthreat/vdb7-full:v7.0.x-xz"
)

# A smaller application vulnerabilities database.
#
# vdb7-app-ONLY, not vdb7-app. Those are two different artifacts: vdb7-app
# is the shard store's app *group shard*, a completeness=partial slice of
# the full database emitted by shard_split, while vdb7-app-only is a
# complete database built from an app-only ingest. This default pointed at
# vdb7-app, which _validate_staged_full_db rejects for declaring
# completeness=partial — so `vdb db refresh full --app-only` and MCP
# auto-download could not install it at all.
VDB_APP_ONLY_DATABASE_URL = os.getenv(
    "VDB_APP_ONLY_DATABASE_URL", "ghcr.io/appthreat/vdb7-app-only:v7.0.x-xz"
)

# The v6 line also published 10-year (USE_VDB_10Y) and *-extended variants.
# The v7 build now publishes both, as app-scope databases:
#
#   ghcr.io/appthreat/vdb7-app-extended:v7.0.x-xz      2020+, metadata tables
#   ghcr.io/appthreat/vdb7-app-10y:v7.0.x-xz           2016+
#   ghcr.io/appthreat/vdb7-app-10y-extended:v7.0.x-xz  2016+, metadata tables
#
# There are still no USE_VDB_10Y / *_EXTENDED constants, deliberately: a
# constant that silently handed back the ordinary database to someone who
# asked for the extended one was the failure mode worth removing, and one
# variable per feed scope does not scale. Point VDB_DATABASE_URL at the
# variant you want, pass --image for a one-off URL, or let
# `vdb db refresh full --flavor <name>` resolve the published image
# (db_cmd.FULL_DB_FLAVORS), or build locally with
# `vdb --cache --include-metadata` for the metadata search APIs.

# This variable can be used to include or exclude distro-specific data
# export VDB_IGNORE_ALMALINUX=true
# export VDB_INCLUDE_ALPINE=true
# suse/opensuse were removed in task 10.11: SUSE family data comes from
# the OSV SUSE/openSUSE feeds (OSV_URL_DICT) and the cvrf/suse vuln-list
# subtree is never read.
LINUX_DISTRO_VULN_LIST_PATHS = {
    "almalinux": ["alma"],
    "alpine": ["alpine", "alpine-unfixed"],
    "amazon": ["amazon"],
    "arch": ["arch-linux"],
    "chainguard": ["chainguard"],
    "photon": ["photon"],
    "rocky": ["rocky"],
    "wolfi": ["wolfi"],
}

# Explicit path to (or name of) the zstd binary used by vdb.lib.zstd_support
# to unpack the -zst published artifacts on Pythons without compression.zstd
# (< 3.14). Empty means "search PATH for zstd" (shutil.which). An absolute
# path is useful for bundled/air-gapped deployments where zstd is not on
# PATH; a bare name is resolved against PATH as usual. Setting a value that
# does not resolve simply leaves the tier unavailable (zst refreshes are
# then refused with both remedies named, xz always works).
VDB_ZSTD_BIN = os.getenv("VDB_ZSTD_BIN", "").strip()
