#!/usr/bin/env python3
"""Generate CycloneDX 1.6 and SPDX 2.3 SBOMs for wolfssl."""

import argparse
import hashlib
import io
import json
import os
import re
import subprocess
import sys
import uuid
from datetime import datetime, timezone


# Tool identification.  Bump GEN_SBOM_VERSION whenever the SBOM output
# shape changes in any auditor-visible way (new property, new field,
# semantic change to an existing one) so downstream consumers can pin
# their parser against a known producer.  Carried in the CycloneDX
# `metadata.tools.components[].version` and SPDX `creationInfo.creators`
# fields.  Reproducibility CI keys on byte-equal SBOMs across re-runs,
# so this constant must change in lockstep with the output it produces.
#
# The CLI counts as auditor-visible: '1.2' shipped in two incompatible
# shapes because dropping --dep-liboqs did not bump it, leaving vendored
# copies indistinguishable by the only identifier the SBOM records.
#
# 1.8  CPE and PURL identifiers drop `+` build metadata (OpenSSL
#      BUILD_METADATA, PEP 440 local versions). A raw `+` is not legal
#      in CPE 2.3 and does not match an upstream git tag. version /
#      versionInfo still records the full string. Constructors (cpe23_uri,
#      wolfssl_project_purl, DEP_META lambdas) strip; this is not a
#      full CPE sanitizer.
# 1.7  wolfBoot CPE is registered in the NVD Official CPE Dictionary
#      (published 2026-08-10). PRODUCT_CPE status flips to registered so
#      the main package emits `cpe` and the pending properties stop.
# 1.6  A product whose CPE is only pending at NVD no longer emits a `cpe`
#      field; the intended identifier moves to
#      `wolfssl:sbom:cpe-requested` alongside
#      `wolfssl:sbom:cpe-status=pending`.  wolfCrypt is nested inside the
#      wolfssl component (CycloneDX sub-component + CONTAINS on the SPDX
#      side) instead of sitting beside it, and `--crypto-only` records
#      `wolfssl:sbom:wolfssl-subset=wolfcrypt-only` when only the crypto
#      subset of the wolfSSL release is compiled in.
# 1.5  Emit wolfCrypt as its own component (registered CPE
#      wolfssl:wolfcrypt).  Main-package CPEs come from PRODUCT_CPE only:
#      registered NVD pairs, plus pending submissions (e.g. wolfBoot) so
#      the SBOM and the future dictionary entry agree.  wolfssh uses the
#      NVD vendor `wolfssh`, not `wolfssl`.
# 1.4  Dependency components carry a CPE 2.3 identifier, so a scanner that
#      matches on CPE (NVD) sees the linked dependency and not only the
#      product. pkg:github PURLs use the canonical lowercase namespace and
#      name, and a version that is the project's real release tag.
# 1.3  Valueless '#define X' records an empty value instead of '1'.
#      Warns when the licence file is the full GPL text, where the
#      -only/-or-later distinction cannot be inferred.
# 1.2  Dropped --dep-liboqs (unversioned; see above).
GEN_SBOM_TOOL_NAME = 'wolfssl-sbom-gen'
GEN_SBOM_VERSION = '1.8'

# Placeholder recorded in the component checksum fields when the operator
# passes --no-artifact-hash: a build (ROM image, HSM firmware, binary-only
# redistribution) where neither a library archive nor the compiled source
# files are accessible to hash.  64 zero hex digits is an obviously-synthetic
# SHA-256 that can never collide with a real artefact, and the companion
# `wolfssl:sbom:hash-source=none` property plus the note below tell a
# downstream auditor the value is intentional, not a generation bug.
_NO_HASH_SENTINEL = '0' * 64
_NO_HASH_NOTE = (
    'No artefact hash was available at SBOM generation time '
    '(--no-artifact-hash). The checksum field is a placeholder, not a real '
    'SHA-256 of any wolfSSL component. Contact wolfssl@wolfssl.com to '
    'arrange integrity verification appropriate to this build before relying '
    'on this SBOM for CRA conformance.'
)

# Stable namespace for deterministic uuid5 derivation.  The seed string is
# an opaque input to uuid5 -- it only needs to be (a) constant across
# releases so the derived UUIDs reproduce byte-for-byte (any consumer
# pinning a wolfSSL SBOM hash would otherwise see a content rotation
# from a seed change alone), and (b) unlikely to collide with another
# project's uuid5 namespace.  It is NOT a URL the SBOM resolves to and
# is NOT what we serialize as the SPDX documentNamespace -- that field
# is now `urn:uuid:<derived>` (see generate_spdx).  The historical
# string is preserved verbatim to keep derived UUIDs (bom-refs,
# serialNumbers, the documentNamespace UUID component) stable across
# the documentNamespace shape change.
SBOM_UUID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, 'https://wolfssl.com/sbom/')


def project_urls(name):
    """Canonical wolfSSL GitHub URLs for a project, derived from its package
    name.  Keeping these name-derived (rather than hardcoded to wolfssl) lets
    the same generator emit correct VCS / issue-tracker / advisory / download
    URLs for every product in the wolfSSL stack (wolfssl, wolfssh, wolfmqtt,
    ...).  For name='wolfssl' the result is byte-identical to the historical
    hardcoded URLs, so existing wolfSSL SBOMs do not change."""
    base = f'https://github.com/wolfSSL/{name}'
    return {
        'vcs': base,
        'issues': f'{base}/issues',
        'advisories': f'{base}/security/advisories',
    }


# Release-tag form per project, keyed by lowercased package name.  The version
# of a pkg:github PURL is a git ref, so `@v5.9.1` does not resolve for wolfSSL,
# whose releases are tagged `v5.9.1-stable`: an integrator's scanner cannot
# fetch the reference the SBOM points at.  Only a project whose tag differs
# from the plain `v<version>` form needs an entry here.
GITHUB_TAG_FORMS = {
    'wolfssl': 'v{version}-stable',
    'wolfssh': 'v{version}-stable',
    'wolfclu': 'v{version}-stable',
    'wolfscep': 'v{version}-stable',
    'wolfhsm': 'wolfHSM-v{version}',
}
DEFAULT_GITHUB_TAG_FORM = 'v{version}'


def github_release_tag(project, version):
    """Return the git tag a pkg:github PURL for `project` must point at."""
    form = GITHUB_TAG_FORMS.get(project.lower(), DEFAULT_GITHUB_TAG_FORM)
    return form.format(version=version)


def github_purl(namespace, repo, tag):
    """Build a canonical pkg:github PURL.

    purl-spec requires the namespace and the name of the `github` type to be
    lowercased, so `pkg:github/wolfSSL/wolfssl` is not canonical: a consumer
    that compares PURL strings (Dependency-Track, Trivy, OSV) reads it as a
    different package from the one every other producer emits.  The version is
    a git ref and keeps the case the project tags with."""
    return f'pkg:github/{namespace.lower()}/{repo.lower()}@{tag}'


def identifier_version(version):
    """Return the version used in CPE 2.3 and PURL identifiers.

    OpenSSL BUILD_METADATA and PEP 440 local versions append `+...`.
    A raw `+` is not a legal CPE 2.3 version character, and the suffix
    is not an upstream git tag, so NVD / OSV cannot match it. Keep the
    full string in version / versionInfo; use this for identifiers.

    This is not a full CPE 2.3 sanitizer: other illegal characters
    (`:`, `*`, whitespace) are not rewritten.
    """
    if not version:
        return version
    return version.split('+', 1)[0]


def cpe23_uri(vendor, product, version):
    """CPE 2.3 application URI. Version uses identifier_version()."""
    ident = identifier_version(version)
    return f'cpe:2.3:a:{vendor}:{product}:{ident}:*:*:*:*:*:*:*'


def wolfssl_project_purl(name, version):
    """Canonical pkg:github PURL for a wolfSSL-stack project."""
    return github_purl(
        'wolfSSL', name, github_release_tag(name, identifier_version(version)))


# Official CPE 2.3 vendor:product pairs.  Main-package CPEs are emitted only
# from this table so a product without an entry never invents a silent false
# match against NVD.
#
# status:
#   registered — present in the Official CPE Dictionary; scanners can match
#                NVD advisories today, so the `cpe` field is emitted.
#   pending    — intended name for an NVD dictionary submission, NOT in the
#                dictionary yet.  No `cpe` field is emitted: a scanner
#                cannot distinguish an unlisted CPE from a listed one with
#                no advisories, so publishing it asserts a match that does
#                not exist.  The intended string is recorded instead as
#                `wolfssl:sbom:cpe-requested` plus
#                `wolfssl:sbom:cpe-status=pending`, which keeps the SBOM
#                and the eventual dictionary entry byte-identical without
#                claiming registration.  Promote the entry to `registered`
#                once NVD publishes it.  See docs/cpe-requests/ for the
#                NIST submission materials.
#
# NVD currently registers under vendor wolfssl: wolfssl, wolfcrypt,
# wolfmqtt, yassl.  wolfSSH is registered under vendor wolfssh.
PRODUCT_CPE = {
    'wolfssl':   {'vendor': 'wolfssl', 'product': 'wolfssl',   'status': 'registered'},
    'wolfcrypt': {'vendor': 'wolfssl', 'product': 'wolfcrypt', 'status': 'registered'},
    'wolfmqtt':  {'vendor': 'wolfssl', 'product': 'wolfmqtt',  'status': 'registered'},
    'wolfssh':   {'vendor': 'wolfssh', 'product': 'wolfssh',   'status': 'registered'},
    'wolfboot':  {'vendor': 'wolfssl', 'product': 'wolfboot',  'status': 'registered'},
}


def _product_cpe_string(meta, version):
    return cpe23_uri(meta['vendor'], meta['product'], version)


def product_cpe(name, version):
    """Return the CPE 2.3 to publish for a product, or None.

    Only a `registered` product yields a value.  A `pending` product returns
    None here and surfaces through product_cpe_requested() instead."""
    meta = PRODUCT_CPE.get((name or '').lower())
    if not meta or not version or meta['status'] != 'registered':
        return None
    return _product_cpe_string(meta, version)


def product_cpe_requested(name, version):
    """Return the CPE 2.3 a `pending` product has submitted to NVD, else None."""
    meta = PRODUCT_CPE.get((name or '').lower())
    if not meta or not version or meta['status'] != 'pending':
        return None
    return _product_cpe_string(meta, version)


def product_cpe_status(name):
    """Return 'registered', 'pending', or None for a product name."""
    meta = PRODUCT_CPE.get((name or '').lower())
    return meta['status'] if meta else None


# Defining this macro compiles the wolfSSL tree with the TLS layer removed,
# leaving only wolfCrypt.  wolfBoot sets it in include/user_settings.h for
# every build except wolfHSM-server-with-cert-chain-verify, so it is the
# authoritative signal for "this image holds the crypto subset only" -- more
# reliable than a build-system flag, which would have to restate that
# condition and would drift from it.
CRYPTO_ONLY_MACRO = 'WOLFCRYPT_ONLY'

# Value recorded for wolfssl:sbom:wolfssl-subset when the macro (or an
# explicit --crypto-only yes) says only wolfCrypt is compiled in.
WOLFSSL_SUBSET_CRYPTO_ONLY = 'wolfcrypt-only'


def resolve_crypto_only(mode, build_props):
    """Return (is_crypto_only, basis) for --crypto-only auto|yes|no.

    basis is 'declared' when the operator stated it, 'captured' when it was
    read out of the configuration macros this SBOM records, and 'unknown'
    when there are no captured macros to read (a --source-only front end
    such as Zephyr, where nothing in the inputs can settle the question).

    A crypto-only image does NOT drop the wolfssl component.  NVD maps 30
    CVEs to cpe:2.3:a:wolfssl:wolfssl:<ver> and zero to
    cpe:2.3:a:wolfssl:wolfcrypt, because wolfCrypt advisories are filed
    against the wolfssl product; removing the wolfssl component to be
    precise about the subset would silently take the scan from 30
    matchable advisories to none.  The subset is recorded as a property and
    narrowed per-CVE with VEX instead."""
    mode = (mode or 'auto').lower()
    if mode in ('yes', 'no'):
        return mode == 'yes', 'declared'
    if not build_props:
        return False, 'unknown'
    return any(k == CRYPTO_ONLY_MACRO for k, _ in build_props), 'captured'


def derived_uuid(*parts):
    """Deterministic UUID from joined parts under the wolfSSL SBOM namespace.
    Re-runs of `make sbom` against the same source produce identical UUIDs,
    which is required for reproducible-build-style SBOM hashing.

    Uses NUL as a separator so no aliasing is possible between e.g.
    derived_uuid('a/b', 'c') and derived_uuid('a', 'b/c'); NUL cannot
    appear in any of the call-site inputs (package name, version, role
    label, dep key)."""
    return str(uuid.uuid5(SBOM_UUID_NAMESPACE, '\x00'.join(parts)))


def config_identity(lib_hash, license_id, build_props, enabled_deps,
                    dep_versions, *, supplier=None, component_type=None,
                    license_text=None, hash_kind=None, hash_source=None,
                    file_names=None, wolfssl_subset=None, subset_basis=None):
    """Stable digest of the build configuration behind one name+version.

    Folded into the SBOM serialNumber / SPDX documentNamespace so two builds
    of one release with different configuration (FIPS vs non-FIPS, a different
    --srcs set, a different feature-flag set) get distinct identifiers, while
    an identical configuration still reproduces byte-for-byte.  Fields are
    tagged and NUL-delimited so no two configurations serialize alike.

    Invariant: every CLI input that can change the document body reaches this
    digest.  One that does not is a collision -- two documents with differing
    content under one serialNumber.  tests/test_sbom_identity.py classifies
    the whole option set and fails on an unclassified new option.

    lib_hash is folded in deliberately, so on the --lib path the identity
    follows the built binary: the same sources under a different toolchain get
    a different serialNumber.  An SBOM from --lib describes one artefact.  The
    --srcs and --no-artifact-hash paths do not have this property."""
    h = hashlib.sha256()

    def _field(tag, value):
        h.update(tag.encode())
        h.update(b'\0')
        h.update((value or '').encode())
        h.update(b'\0')

    _field('lib_hash', lib_hash)
    _field('license', license_id)
    for k, v in sorted(build_props):
        _field('prop', f'{k}={v}')
    for key in sorted(enabled_deps):
        _field('dep', f'{key}={dep_versions.get(key) or ""}')
    _field('supplier', supplier)
    _field('component_type', component_type)
    # Digested, not embedded: the text is a document field of its own and can
    # differ while the SPDX identifier is unchanged.
    _field('license_text',
           hashlib.sha256(license_text.encode()).hexdigest()
           if license_text else '')
    _field('hash_kind', hash_kind)
    _field('hash_source', hash_source)
    # Filenames reach the document body without passing through lib_hash,
    # which covers bytes only: a rename changes the body but not the hash.
    for fname in sorted(file_names or []):
        _field('file', fname)
    _field('wolfssl_subset', wolfssl_subset)
    _field('subset_basis', subset_basis)
    return h.hexdigest()


def build_timestamp():
    """Return (datetime, ISO-8601-Z string) honoring SOURCE_DATE_EPOCH.
    Reproducible Builds convention: if the env var is set to a valid
    integer, use it as the SBOM creation timestamp instead of wallclock."""
    sde = os.environ.get('SOURCE_DATE_EPOCH', '').strip()
    if sde:
        try:
            dt = datetime.fromtimestamp(int(sde), tz=timezone.utc)
        except (ValueError, OverflowError, OSError) as e:
            print(f"WARNING: ignoring invalid SOURCE_DATE_EPOCH={sde!r}: {e}",
                  file=sys.stderr)
            dt = datetime.now(timezone.utc)
    else:
        dt = datetime.now(timezone.utc)
    return dt, dt.strftime('%Y-%m-%dT%H:%M:%SZ')


# Known metadata for optional external dependencies.  Version is detected
# at runtime via pkg-config; falls back to None.  Each entry must describe
# the *linked artefact* (so vulnerability scanners like OSV / Grype / Trivy
# / Dependency-Track resolve CVEs against the right package).  Algorithm
# enablement is captured separately via build_props (HAVE_FALCON, ...).
#
# Every entry carries both machine-resolvable identifiers, because the two
# scanner families do not agree on one.  PURL serves the ecosystem scanners
# (OSV, GHSA, Trivy, Dependency-Track); CPE serves NVD, which is what a CRA /
# IEC 62443 vulnerability-monitoring process keys on.  A dependency with only
# a PURL is invisible to a CPE-driven scan, so wolfSSL advisories never reach
# the integrator of a product that embeds wolfSSL.  Each `cpe` value must be
# the vendor:product pair NVD actually registers for that dependency; never
# synthesize one.
DEP_META = {
    # wolfssl itself, declared as a dependency by downstream wolfSSL-stack
    # products (wolfSSH, wolfMQTT, wolfTPM, ...) that link libwolfssl.  Only
    # emitted when the caller passes --dep-wolfssl yes; wolfSSL's own
    # `make sbom` never enables it (a package is not its own dependency).
    # Recording it is what lets a CRA / vulnerability scanner associate
    # wolfSSL advisories with a product that embeds wolfSSL.
    'wolfssl': {
        'name': 'wolfssl',
        'supplier': 'wolfSSL Inc.',
        # wolfSSL is distributed under GPLv3 (LICENSING: "version 3 (GPLv3)",
        # no "or later"), with a commercial option.  This matches what
        # detect_license() infers for wolfSSL's own main-package SBOM, so a
        # downstream product's wolfssl dependency entry and wolfSSL's own
        # self-SBOM agree on the licence.
        'license': 'GPL-3.0-only',
        'download': 'https://github.com/wolfSSL/wolfssl',
        'pkgconfig': 'wolfssl',
        'purl': lambda v: wolfssl_project_purl('wolfssl', v),
        # The CPE NVD registers for the wolfSSL library.
        'cpe': lambda v: cpe23_uri('wolfssl', 'wolfssl', v),
    },
    # wolfCrypt is a separate NVD product (cpe:2.3:a:wolfssl:wolfcrypt).
    # Embedders such as wolfBoot compile wolfcrypt sources into the image;
    # wolfSSL itself co-ships wolfCrypt.  Emitting it as a component lets a
    # CPE-driven scan match wolfCrypt advisories, which NVD indexes under
    # wolfcrypt rather than only under wolfssl.
    'wolfcrypt': {
        'name': 'wolfcrypt',
        'supplier': 'wolfSSL Inc.',
        'license': 'GPL-3.0-only',
        'download': 'https://github.com/wolfSSL/wolfssl',
        # Co-released with wolfssl; no separate .pc file.  Version comes from
        # --dep-version wolfcrypt=X.Y.Z, or is inherited from the wolfssl
        # dep / main package version (see main()).
        'pkgconfig': None,
        # wolfcrypt lives in the wolfssl repository.  The resolvable PURL is
        # the wolfssl release that ships it, with a #wolfcrypt subpath so it
        # does not collide with the wolfssl component's own PURL.  NVD
        # matching keys on the wolfcrypt CPE below.
        'purl': lambda v: wolfssl_project_purl('wolfssl', v) + '#wolfcrypt',
        'cpe': lambda v: cpe23_uri('wolfssl', 'wolfcrypt', v),
    },
    'libz': {
        'name': 'zlib',
        'supplier': 'Jean-loup Gailly and Mark Adler',
        'license': 'Zlib',
        'download': 'https://github.com/madler/zlib',
        'pkgconfig': 'zlib',
        # pkg:github resolves in OSV / GHSA / Snyk / Trivy without the
        # vendor:product mapping a pkg:generic PURL would force.  zlib tags
        # its releases `vX.Y.Z`, so the bare pkg-config version needs the `v`.
        'purl': lambda v: github_purl(
            'madler', 'zlib', f'v{identifier_version(v)}'),
        'cpe': lambda v: cpe23_uri('zlib', 'zlib', v),
    },
    # openssl, declared as a dependency by the OpenSSL-compat products
    # (wolfProvider, wolfEngine) that link libcrypto/libssl alongside wolfSSL.
    # Only emitted when the caller passes --dep-openssl yes.  These products
    # target the OpenSSL 3.x provider/engine ABI, which is Apache-2.0 (older
    # 1.1.x was the SPDX "OpenSSL" licence); Apache-2.0 is therefore the correct
    # id for the supported surface.  The purl uses OpenSSL 3.x's "openssl-X.Y.Z"
    # git tag form so it resolves in OSV / GHSA.
    'openssl': {
        'name': 'openssl',
        'supplier': 'OpenSSL Software Foundation',
        'license': 'Apache-2.0',
        'download': 'https://github.com/openssl/openssl',
        'pkgconfig': 'openssl',
        'purl': lambda v: github_purl(
            'openssl', 'openssl', f'openssl-{identifier_version(v)}'),
        'cpe': lambda v: cpe23_uri('openssl', 'openssl', v),
    },
}


# Matches a single SPDX `LicenseRef-` identifier as defined in SPDX 2.3
# Annex D ("idstring = 1*(ALPHA / DIGIT / '-' / '.')").  We use this to
# discover custom license refs inside an arbitrary SPDX expression and to
# decide whether a `licenseConcluded` value needs an accompanying
# `hasExtractedLicensingInfos` block.
LICENSEREF_RE = re.compile(r'LicenseRef-[A-Za-z0-9.\-]+')

# Matches a "simple" SPDX-listed license ID such as `GPL-2.0-or-later` or
# `MIT` (no spaces, no operators, no LicenseRef-).  Anything that does not
# match must be expressed via `licenses[].license.name` / `licenses[].expression`
# in CycloneDX, since `license.id` is restricted to the SPDX licence list.
SIMPLE_SPDX_ID_RE = re.compile(r'\A[A-Za-z0-9.+\-]+\Z')


def is_simple_spdx_id(value):
    return bool(SIMPLE_SPDX_ID_RE.match(value)) and \
        not value.startswith('LicenseRef-') and value != 'NOASSERTION'


def extract_license_refs(expr):
    """Return a sorted, deduplicated list of LicenseRef-* IDs found in expr."""
    return sorted(set(LICENSEREF_RE.findall(expr or '')))


def load_license_text(path):
    """Read the license text file given via --license-text, exit on error."""
    if not path:
        return None
    try:
        with open(path) as f:
            return f.read()
    except OSError as e:
        sys.exit(f"ERROR: cannot read --license-text {path}: {e}")


def build_extracted_licensing_infos(license_expr, license_text):
    """Return SPDX `hasExtractedLicensingInfos` array for license_expr.

    SPDX 2.3 §10 requires every LicenseRef-* used in `licenseConcluded`/
    `licenseDeclared` to be declared once at document level via
    `hasExtractedLicensingInfos`.  Returns None when no LicenseRef-* is
    present so the caller can omit the field entirely.

    `license_text=None` produces a placeholder entry; main() rejects
    that combination upfront, so this fallback is only reachable from
    direct programmatic callers (e.g. tests, library reuse).
    """
    refs = extract_license_refs(license_expr)
    if not refs:
        return None
    if license_text is None:
        license_text = (
            'NOASSERTION. The text for this LicenseRef has not been '
            'embedded in the SBOM. Provide it via the gen-sbom '
            '--license-text PATH flag (or `make sbom SBOM_LICENSE_TEXT=...`).'
        )
    infos = []
    for ref in refs:
        infos.append({
            'licenseId': ref,
            'extractedText': license_text,
            'name': ref[len('LicenseRef-'):].replace('-', ' ').strip(),
        })
    return infos


def cdx_license_block(license_expr, license_text):
    """Return the CycloneDX `licenses[]` entry for an arbitrary SPDX
    expression.  CDX 1.6 distinguishes:
      * `license.id`      - an entry from the SPDX licence list
      * `license.name`    - a non-listed licence (e.g. a LicenseRef-*)
      * `expression`      - a compound SPDX expression
    Picking the wrong shape causes downstream tooling to reject the SBOM."""
    # NOASSERTION is a reserved SPDX value, not a parseable SPDX expression;
    # emit it via license.name so CDX validators don't choke trying to parse
    # it as one.
    if license_expr == 'NOASSERTION':
        return [{'license': {'name': 'NOASSERTION'}}]
    if is_simple_spdx_id(license_expr):
        return [{'license': {'id': license_expr}}]
    refs = extract_license_refs(license_expr)
    if len(refs) == 1 and refs[0] == license_expr:
        block = {'name': license_expr}
        if license_text:
            block['text'] = {'contentType': 'text/plain', 'content': license_text}
        return [{'license': block}]
    return [{'expression': license_expr}]


# Section headings that appear only in the verbatim GNU licence text, never
# in a short per-project licensing statement such as wolfSSL's LICENSING.
_FULL_LICENSE_MARKERS = (
    'terms and conditions for copying, distribution and modification',
    'terms and conditions',
    'how to apply these terms to your new programs',
)


def _is_full_license_text(text):
    """True when the licence file is the verbatim GNU licence rather than a
    statement about how the project licenses under it."""
    low = text.lower()
    return sum(marker in low for marker in _FULL_LICENSE_MARKERS) >= 2


def detect_license(license_file):
    """Parse LICENSING file and return an SPDX license ID.

    Looks for 'GNU General Public License version N' and whether
    'or later' / 'or any later version' follows.  Returns None and
    prints a warning if the file cannot be parsed.
    """
    try:
        with open(license_file) as f:
            text = f.read()
    except OSError as e:
        print(f"WARNING: cannot read license file {license_file}: {e}",
              file=sys.stderr)
        return None

    m = re.search(
        r'gnu general public license\s+version\s+(\d+)',
        text, re.IGNORECASE
    )
    or_later_plus = False
    if not m:
        # Abbreviated form: some wolfSSL-stack LICENSING files (e.g. wolfSSH)
        # say "GPLv3" rather than the canonical "GNU General Public License
        # version 3", so the long-form regex above misses and detection would
        # fall back to NOASSERTION.  A trailing "+" (GPLv3+) denotes the
        # or-later variant; otherwise fall through to the shared "or later"
        # prose check below.
        m = re.search(r'\bGPLv(\d+)(\+)?', text, re.IGNORECASE)
        if m and m.group(2) == '+':
            or_later_plus = True
    if not m:
        print(f"WARNING: no GPL version found in {license_file}",
              file=sys.stderr)
        return None

    version = m.group(1)
    if or_later_plus:
        return f'GPL-{version}.0-or-later'
    excerpt = text[m.end():m.end() + 100]
    # Match upgrade-permission wording in the 100-byte excerpt that
    # follows the version mention.  Three FSF-derived shapes:
    #   * canonical preamble:   "or (at your option) any later version"
    #   * preamble variant:     "or (at the licensee's option) any later"
    #   * compact form:         "or later" / "or any later"
    # The optional `[^,.;\n]*?\s+` group consumes parenthesised
    # asides without crossing sentence boundaries so unrelated
    # "or" / "later" mentions in surrounding prose do not match.
    if re.search(r'or\s+(?:[^,.;\n]*?\s+)?(?:any\s+)?later',
                 excerpt, re.IGNORECASE):
        return f'GPL-{version}.0-or-later'
    if _is_full_license_text(text):
        # The verbatim GPL is the licence itself, not a statement about how
        # this project licenses under it.  Whether the project grants "or any
        # later version" appears only in the per-file headers, so -only here
        # is a guess that silently narrows the grant.  wolfBoot ships the full
        # GPLv3 as LICENSE while every source header says "either version 3
        # ... or (at your option) any later version", i.e. GPL-3.0-or-later.
        print(
            f"WARNING: {license_file} is the full GPL text, not a licensing "
            f"statement.\n"
            f"         It cannot say whether this project grants "
            f"'or any later version', so\n"
            f"         GPL-{version}.0-only is assumed and may understate the "
            f"grant. Confirm against\n"
            f"         your source headers and pass --license-override "
            f"GPL-{version}.0-or-later if so\n"
            f"         (Make: SBOM_LICENSE_OVERRIDE).",
            file=sys.stderr)
    return f'GPL-{version}.0-only'


def sha256_file(path):
    h = hashlib.sha256()
    try:
        with open(path, 'rb') as f:
            for chunk in iter(lambda: f.read(65536), b''):
                h.update(chunk)
    except OSError as e:
        sys.exit(f"ERROR: cannot read library for hashing: {e}")
    return h.hexdigest()


def sha1_sha256_file(path):
    """Return (sha1_hex, sha256_hex) computed in a single pass.
    SPDX 2.3 §8.4 requires SHA-1 on every file entry (`packageFileChecksum`
    cardinality 1..*, with SHA-1 mandatory).  CycloneDX accepts either.
    Reading the file twice would double the I/O on builds with many
    source files; one pass keeps `make sbom` fast on embedded trees."""
    s1 = hashlib.sha1()
    s256 = hashlib.sha256()
    try:
        with open(path, 'rb') as f:
            for chunk in iter(lambda: f.read(65536), b''):
                s1.update(chunk)
                s256.update(chunk)
    except OSError as e:
        sys.exit(f"ERROR: cannot read file for hashing: {e}")
    return s1.hexdigest(), s256.hexdigest()




def pkgconfig_version(pkgname):
    """Return version string from pkg-config, or None if unavailable."""
    try:
        r = subprocess.run(
            ['pkg-config', '--modversion', pkgname],
            capture_output=True, text=True
        )
        if r.returncode == 0:
            return r.stdout.strip()
    except FileNotFoundError:
        pass
    return None


def dep_version(key, overrides=None):
    """Resolve the runtime version of a DEP_META entry.

    Resolution order:
      1. Explicit override from `overrides[key]` (set via the
         --dep-version CLI flag).  This is the only path that works
         for embedded / cross-compile builds where pkg-config is not
         available on the host that runs gen-sbom.
      2. `pkg-config --modversion <pkgconfig>`.  Used by the autotools
         path on a typical Linux server where the linked dep was
         installed via the system package manager.
      3. None.  Caller emits NOASSERTION (SPDX) / omits the version
         (CycloneDX).

    A previous source-tree fallback that used `git describe` against
    `git_root` was removed once libxmss/liblms were dropped upstream;
    if a future PQ dep returns to a source-only integration, restore
    the fallback here together with a `git_root` field on the DEP_META
    entry."""
    if overrides and key in overrides:
        return overrides[key]
    pkg = DEP_META[key].get('pkgconfig')
    if not pkg:
        return None
    return pkgconfig_version(pkg)


# Patterns for #define names that pollute the SBOM with build-environment
# noise rather than wolfSSL configuration.  Applied identically to
# parse_options_h (no-pcpp / autotools path) and parse_user_settings
# (pcpp embedded path) so both entry points produce semantically
# equivalent build-property sets for the same effective configuration.
#
# Three families are filtered:
#
# 1. Compiler / preprocessor reserved identifiers (`__*`, `_[A-Z]*`).
#    ISO C 7.1.3 reserves these for the implementation; clang, gcc, and
#    pcpp emit dozens of them (`__VERSION__`, `__SSE2__`, `_LP64`, ...).
#    They describe the build *host*, not wolfSSL, and break SBOM
#    reproducibility across hosts (same wolfSSL config built on macOS
#    clang vs. arm-none-eabi-gcc otherwise produces different SBOMs).
#
# 2. Apple <TargetConditionals.h> macros (`TARGET_OS_*`,
#    `TARGET_IPHONE_*`).  The no-pcpp escape hatch
#    (`$CC -dM -E -include settings.h`) on macOS transitively pulls in
#    macOS system headers and emits this entire family; without the
#    filter, a wolfSSL SBOM for an STM32 firmware would falsely
#    advertise TARGET_OS_MAC=1 if generated on a Mac.
#
# 3. Header include guards (`*_H` whose token does NOT carry an
#    autoconf / wolfSSL configuration prefix).
#    wolfssl/options.h itself and many internal wolfSSL headers define
#    guards like WOLFSSL_OPTIONS_H, WOLF_CRYPT_SETTINGS_H, and
#    WOLFCRYPT_TEST_*_H to prevent double inclusion.  Those describe
#    *which file was parsed*, not configuration choices.
#
#    The carve-out tokens (`HAVE_`, `NO_`, `USE_`) are critical: real
#    wolfSSL configuration flags also end in `_H` and would otherwise
#    be silently filtered out, falsifying the SBOM for the customers
#    who rely on them most:
#
#      * `HAVE_*_H` / `WOLFSSL_HAVE_*_H` - autoconf AC_CHECK_HEADER
#        results (HAVE_STDINT_H, WOLFSSL_HAVE_ATOMIC_H,
#        WOLFSSL_HAVE_ASSERT_H, ...).  Gates `#if defined(...)`
#        branches in wc_port.h / types.h.
#      * `NO_*_H` / `WOLFSSL_NO_*_H` - explicit stdlib / feature
#        suppression (NO_STDINT_H, NO_STDLIB_H, NO_LIMITS_H,
#        NO_CTYPE_H, NO_STRING_H, NO_STDDEF_H, WOLFSSL_NO_ASSERT_H).
#        Set by NETOS / Telit / other RTOS profiles in settings.h to
#        replace stdlib headers with vendor headers; gates branches
#        in types.h:398 / settings.h:3850 / sp.h:42.
#      * `USE_*_H` - build-mode toggles (USE_FLAT_TEST_H,
#        USE_FLAT_BENCHMARK_H).  Gates which test/benchmark layout
#        is compiled in test.c:165 / benchmark.c:219 / server.c:70.
#
#    Heuristic limitation: a stray feature flag that ends in `_H`
#    without one of those tokens (e.g. WOLFSSL_DEBUG_TRACE_ERROR_CODES_H,
#    a debug-only opt-in) would still be filtered.  Customers who
#    depend on such a flag can either move it to a non-`_H`-suffixed
#    name in their user_settings.h, or feed gen-sbom the full
#    `$CC -dM -E` dump via --options-h together with a hand-edited
#    add-back file.  None of the embedded customer profiles in the
#    tree (NETOS, Telit, Zephyr, ESP-IDF, GCC-ARM, MDK, IAR, NUTTX)
#    use such flags, which is why we accept the heuristic.
_NOISE_MACRO_RE = re.compile(
    r'^(?:'
    r'__\w+'                        # compiler/preprocessor reserved
    r'|_[A-Z][A-Z0-9_]*'            # ISO C reserved (e.g. _LP64)
    r'|TARGET_OS_\w+'               # Apple TargetConditionals leak
    r'|TARGET_IPHONE_\w+'           # Apple TargetConditionals leak
    r')$'
)

# Tokens that, when present anywhere in a `*_H` macro name, mark it as
# real wolfSSL / autoconf configuration rather than a header include
# guard.  Kept tight on purpose - widening (e.g. adding `DEBUG_` or
# `WOLFSSL_`) would let through real guards like WOLFSSL_OPTIONS_H.
_CONFIG_H_TOKENS = ('HAVE_', 'NO_', 'USE_')


def _is_noise_macro(name):
    """True if `name` is a build-environment artefact rather than wolfSSL
    configuration, and therefore must not appear as a SBOM
    `wolfssl:build:*` property.

    Drops three families (see the module-level comment block on
    `_NOISE_MACRO_RE` for full rationale):
      1. Compiler / preprocessor reserved (`__*`, `_[A-Z]*`).
      2. Apple <TargetConditionals.h> (`TARGET_OS_*`, `TARGET_IPHONE_*`).
      3. Header include guards (`*_H` not carrying any of
         `_CONFIG_H_TOKENS`).
    """
    if _NOISE_MACRO_RE.match(name):
        return True
    if name.endswith('_H') and not any(t in name for t in _CONFIG_H_TOKENS):
        return True
    return False


def _strip_define_comment(raw):
    """Strip trailing C/C++ comment from a #define value while preserving
    `/`-bearing characters that appear inside a double-quoted string.

    Earlier versions used `re.split(r'/\\*|//', raw, maxsplit=1)[0]`, which
    is unaware of string literals.  That regex corrupts autoconf-generated
    defines such as

        #define PACKAGE_URL "https://www.wolfssl.com"
        #define PACKAGE_BUGREPORT "https://github.com/wolfssl/wolfssl/issues"

    by truncating at the first `//` inside the URL — both end up as
    `"https:` in the SBOM build properties, falsely showing PACKAGE_URL
    drifting between releases when nothing actually changed.

    Char literals are not handled: autoconf-generated options.h does not
    emit them, and pcpp normalises customer user_settings.h before this
    helper sees the value, so the only realistic source of `/` in a
    #define value is a quoted string."""
    in_str = False
    i = 0
    n = len(raw)
    while i < n:
        c = raw[i]
        if in_str:
            if c == '\\' and i + 1 < n:
                i += 2
                continue
            if c == '"':
                in_str = False
        else:
            if c == '"':
                in_str = True
            elif c == '/' and i + 1 < n and raw[i + 1] in '/*':
                return raw[:i]
        i += 1
    return raw


def parse_options_h(path):
    """Parse a flat `#define` header and return a sorted deduplicated
    list of (name, value) pairs for every wolfSSL-relevant macro.

    Accepts both autotools-generated `wolfssl/options.h` (curated by
    ./configure, contains only wolfSSL macros plus its own header guard)
    and raw compiler output from `$CC -dM -E -include settings.h ...`
    (the no-pcpp escape hatch documented in doc/SBOM.md § 1.5).  The
    latter case motivates the `_is_noise_macro` filter: a `clang -dM -E`
    dump contains hundreds of compiler internals (`__VERSION__`,
    `__SSE2__`, `__INT_FAST32_MAX__`) and Apple system header leaks
    (`TARGET_OS_MAC`) that would otherwise drown out the wolfSSL
    configuration in the SBOM and break reproducibility across hosts.

    Trailing C/C++ comments on a #define line (`#define HAVE_FOO 42 /* x */`
    or `// y`) are stripped; otherwise they would land verbatim in the
    SBOM build properties.  String literals are preserved intact so that
    URLs in PACKAGE_URL / PACKAGE_BUGREPORT are not truncated at the
    first `//` (see _strip_define_comment)."""
    try:
        with open(path) as f:
            text = f.read()
    except OSError as e:
        print(f"WARNING: cannot read options.h {path}: {e}", file=sys.stderr)
        return []

    defines = {}
    for m in re.finditer(r'^#define[ \t]+(\w+)(?:[ \t]+(.*))?$', text, re.MULTILINE):
        name = m.group(1)
        if _is_noise_macro(name):
            continue
        raw = (m.group(2) or '')
        raw = _strip_define_comment(raw)
        defines[name] = raw.strip()
    return sorted(defines.items())


def parse_user_settings(settings_h_path, include_dirs, predefines):
    """Walk wolfssl/wolfcrypt/settings.h through pcpp and return the same
    sorted [(name, value), ...] list shape that parse_options_h() returns.

    The customer's user_settings.h is included transitively via the
    standard `#ifdef WOLFSSL_USER_SETTINGS` gate inside settings.h, so the
    caller predefines `WOLFSSL_USER_SETTINGS` and adds the directory of
    user_settings.h to `include_dirs`.  This mirrors the way the C compiler
    actually sees the wolfSSL build, so the SBOM build properties reflect
    the real compiled configuration rather than just the literal text of
    user_settings.h.

    Filters (see `_is_noise_macro` for the shared family list used by
    both this function and parse_options_h):
      * compiler/preprocessor reserved names (`__*`, `_[A-Z]*`).  pcpp's
        own internals (__DATE__/__TIME__/__PCPP__/__FILE__) and any host
        compiler defines transitively leaking through pcpp's preprocess
        would otherwise break reproducibility across build hosts.
      * Apple <TargetConditionals.h> macros (`TARGET_OS_*`,
        `TARGET_IPHONE_*`).  Defensive: pcpp does not auto-include
        system headers, but a customer's user_settings.h may.
      * header guards (`*_H` whose token does not carry an autoconf /
        wolfSSL config prefix - see _CONFIG_H_TOKENS).  wolfSSL's own
        settings.h / visibility.h emit guards like
        WOLF_CRYPT_SETTINGS_H that describe inclusion, not
        configuration; real `_H` configuration flags (NO_STDINT_H,
        USE_FLAT_TEST_H, WOLFSSL_NO_ASSERT_H) are preserved.
      * function-like macros are dropped (they are API surface, not
        build configuration; including their post-expansion body would
        also break reproducibility under whitespace/token-render drift).

    pcpp is imported lazily so the autotools path (which uses
    parse_options_h) does not require the dependency.
    """
    try:
        from pcpp import Preprocessor
    except ImportError:
        sys.exit(
            "ERROR: --user-settings requires the 'pcpp' Python preprocessor.\n"
            "       Install: pip install pcpp\n"
            "       Or pre-process externally and pass the result via "
            "--options-h instead\n"
            "       (e.g. $CC -dM -E -include wolfssl/wolfcrypt/settings.h "
            "-DWOLFSSL_USER_SETTINGS - < /dev/null)."
        )

    pp = Preprocessor()
    pp.line_directive = None
    for d in include_dirs:
        pp.add_path(d)
    for predefine in predefines:
        # Compiler-style `-D KEY=VALUE` is the universal CLI shape;
        # translate to the `"KEY VALUE"` form pcpp.define() expects.
        # Bare `-D KEY` (no value) maps to `"KEY"`, also accepted.
        spec = predefine.replace('=', ' ', 1) if '=' in predefine else predefine
        pp.define(spec)

    try:
        with open(settings_h_path) as f:
            text = f.read()
    except OSError as e:
        sys.exit(f"ERROR: cannot read settings.h {settings_h_path}: {e}")

    pp.parse(text, source=settings_h_path)
    # pcpp.write() is what actually drives the preprocessor through #if /
    # #ifdef resolution and populates pp.macros with the surviving
    # defines.  The output stream is intentionally discarded - we only
    # care about pp.macros - but this call is NOT optional.
    sink = io.StringIO()
    pp.write(sink)

    # pcpp signals fatal preprocessing problems (an `#error` directive
    # firing, an unbalanced `#if`, a missing #include, etc.) by setting
    # pp.return_code to non-zero and printing to stderr; it does NOT
    # raise.  For an SBOM tool whose contract is "this artefact
    # faithfully describes the build", a partial macro table produced
    # before the failure is the worst possible output - the SBOM would
    # silently omit configuration the customer set.  Hard-fail instead
    # so the build pipeline notices.
    if pp.return_code != 0:
        sys.exit(
            f"ERROR: pcpp failed to preprocess {settings_h_path} "
            f"(return_code={pp.return_code}); the resulting SBOM would "
            f"be incomplete.  Check the pcpp diagnostics printed above "
            f"for the offending #error / #include / #if directive."
        )

    defines = {}
    for name, macro in pp.macros.items():
        if _is_noise_macro(name):
            continue
        if macro.arglist is not None:
            continue
        tokens = macro.value or []
        defines[name] = ' '.join(t.value for t in tokens).strip()
    return sorted(defines.items())


def gitoid_blob_sha256(path):
    """Compute the OmniBOR / git SHA-256 gitoid for a single file.

    The format is `sha256("blob " + filesize + "\\0" + filecontents)`
    which is byte-identical to `git hash-object --object-format=sha256`.
    Using the gitoid (rather than a plain SHA-256) lets the source-set
    Merkle hash interoperate with bomsh/OmniBOR tooling: a customer can
    cross-reference the wolfSSL SBOM's component hash with the entries
    in an OmniBOR artifact dependency graph and confirm the same files
    on both sides.

    The well-known empty-blob gitoid sha256 is
    473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813
    (regression-tested in scripts/test_gen_sbom.py).
    """
    h = hashlib.sha256()
    try:
        with open(path, 'rb') as f:
            # Take the size from the open descriptor (not a prior
            # os.path.getsize) so the gitoid header length and the bytes
            # hashed below come from the same file, with no TOCTOU window.
            size = os.fstat(f.fileno()).st_size
            h.update(f'blob {size}\x00'.encode())
            for chunk in iter(lambda: f.read(65536), b''):
                h.update(chunk)
    except OSError as e:
        sys.exit(f"ERROR: cannot read source for hashing: {e}")
    return h.hexdigest()


def srcs_merkle_hash(src_paths):
    """Deterministic SHA-256 over a sorted list of (basename, gitoid)
    pairs for the given source files.

    Two customers compiling the same wolfSSL release with the same set
    of source files get identical hashes regardless of where their
    wolfSSL tree lives on disk, the order they passed --srcs, or the
    filesystem they built on.  Sorting on basename only (not full path)
    is what makes this true; collisions across basenames would matter
    in theory but wolfSSL's source layout has unique basenames per file
    by construction.

    A one-byte change in any compiled-in source produces a different
    hash, which is the property that makes this useful as the SBOM
    component checksum for embedded builds with no separate library
    archive."""
    seen = set()
    entries = []
    for path in src_paths:
        name = os.path.basename(path)
        if name in seen:
            sys.exit(
                f"ERROR: duplicate basename in --srcs: {name!r}\n"
                f"       Source files must have unique basenames so the "
                f"Merkle hash is order-independent.")
        seen.add(name)
        entries.append((name, gitoid_blob_sha256(path)))
    entries.sort()
    h = hashlib.sha256()
    for name, oid in entries:
        h.update(f'{name}\x00{oid}\n'.encode())
    return h.hexdigest()


def _collect_srcs(srcs_args, srcs_file):
    """Merge the --srcs list and the --srcs-file list into one ordered,
    path-deduplicated list of source files.

    --srcs-file is the file-driven companion to --srcs: one path per line,
    with blank lines and `#` comment lines ignored.  It exists because an
    embedded link line can run to hundreds of wolfSSL .c files -- more than
    fits comfortably on a command line -- and because an IDE / build system
    can emit such a list mechanically (from a link map or project export),
    which is exactly how a *complete* source set should be produced rather
    than hand-curated.

    Identical paths appearing in both inputs are collapsed (first occurrence
    wins) so that combining a base --srcs-file with a couple of extra --srcs
    overrides does not trip srcs_merkle_hash's duplicate-basename guard on a
    file the operator listed twice by accident.  Genuine distinct files that
    share a basename are still rejected downstream -- that guard is what keeps
    the Merkle hash order-independent.
    """
    paths = list(srcs_args or [])
    if srcs_file:
        try:
            with open(srcs_file, 'r') as f:
                raw_lines = f.read().splitlines()
        except OSError as e:
            sys.exit(f"ERROR: cannot read --srcs-file {srcs_file!r}: {e}")
        for line in raw_lines:
            stripped = line.strip()
            if not stripped or stripped.startswith('#'):
                continue
            paths.append(stripped)

    seen = set()
    deduped = []
    for p in paths:
        if p not in seen:
            seen.add(p)
            deduped.append(p)

    if not deduped:
        sys.exit(
            "ERROR: --srcs / --srcs-file produced an empty source list.\n"
            "       Pass at least one wolfSSL .c file, or use "
            "--no-artifact-hash if no hashable artefact exists.")
    return deduped


def cdx_dep_component(name, pkg_version, key, dep_version_overrides=None):
    """Return (bom_ref, component_dict) for a CDX dependency component.
    bom_ref is deterministic for reproducibility."""
    meta = DEP_META[key]
    version = dep_version(key, dep_version_overrides)
    bom_ref = derived_uuid(name, pkg_version, 'dep', key)
    comp = {
        'bom-ref': bom_ref,
        'type': 'library',
        'supplier': {'name': meta['supplier']},
        'name': meta['name'],
        'licenses': [{'license': {'id': meta['license']}}],
        'externalReferences': [{'type': 'vcs', 'url': meta['download']}],
    }
    if version:
        # Pass the full version: constructors apply identifier_version().
        # version / versionInfo keep the local string (BUILD_METADATA).
        comp['version'] = version
        comp['purl'] = meta['purl'](version)
        # Both identifiers are version-bearing, so neither can be emitted
        # without a resolved version: a CPE with an empty version field
        # matches every release of the dependency in an NVD scan.
        comp['cpe'] = meta['cpe'](version)
    else:
        print(f"WARNING: version unknown for {meta['name']}; "
              "omitting version, purl and cpe", file=sys.stderr)
    return bom_ref, comp


def spdx_dep_package(key, dep_version_overrides=None):
    """Return (spdx_id, package_dict) for an SPDX dependency package."""
    meta = DEP_META[key]
    version = dep_version(key, dep_version_overrides)
    spdx_id = 'SPDXRef-Package-' + re.sub(r'[^A-Za-z0-9.]', '', meta['name'])
    pkg = {
        'SPDXID': spdx_id,
        'name': meta['name'],
        'versionInfo': version if version else 'NOASSERTION',
        'supplier': f"Organization: {meta['supplier']}",
        'downloadLocation': meta['download'],
        'filesAnalyzed': False,
        'licenseConcluded': meta['license'],
        'licenseDeclared': meta['license'],
        'copyrightText': 'NOASSERTION',
    }
    if version:
        pkg['externalRefs'] = [
            {
                'referenceCategory': 'SECURITY',
                'referenceType': 'cpe23Type',
                'referenceLocator': meta['cpe'](version),
            },
            {
                'referenceCategory': 'PACKAGE-MANAGER',
                'referenceType': 'purl',
                'referenceLocator': meta['purl'](version),
            },
        ]
    return spdx_id, pkg


# CycloneDX 1.6 component.type -> SPDX 2.3 primaryPackagePurpose, so the two
# documents agree on what kind of artifact this is.  A bootloader described as
# a 'library' misfiles the product for anyone triaging by artifact class, which
# under IEC 62443 is the difference between a component and the firmware it
# boots.  Only the values a wolfSSL-stack product can legitimately be.
SPDX_PACKAGE_PURPOSE = {
    'library': 'LIBRARY',
    'firmware': 'FIRMWARE',
    'application': 'APPLICATION',
    'framework': 'FRAMEWORK',
    'device': 'DEVICE',
    'file': 'FILE',
}


def generate_cdx(name, version, supplier, license_id, license_text, lib_hash,
                 timestamp, year, serial, enabled_deps, build_props,
                 dep_version_overrides=None, hash_kind='library-binary',
                 hash_source='lib', srcs_basenames=None, file_entries=None,
                 component_type='library', wolfssl_subset=None,
                 subset_basis=None):
    bom_ref = derived_uuid(name, version, 'package')
    urls = project_urls(name)

    # wolfCrypt is shipped inside the wolfSSL release, not beside it, so when
    # both are recorded the wolfcrypt component nests in the wolfssl one and
    # the dependency edge runs wolfssl -> wolfcrypt.  wolfssl stays top-level
    # because it is the only identifier of the pair that NVD maps advisories
    # to (see resolve_crypto_only).
    dep_refs, dep_comps = {}, {}
    for key in enabled_deps:
        ref, comp = cdx_dep_component(name, version, key, dep_version_overrides)
        dep_refs[key] = ref
        dep_comps[key] = comp

    nest_wolfcrypt = 'wolfcrypt' in dep_comps and 'wolfssl' in dep_comps
    if nest_wolfcrypt:
        dep_comps['wolfssl']['components'] = [dep_comps['wolfcrypt']]

    top_keys = [k for k in enabled_deps
                if not (nest_wolfcrypt and k == 'wolfcrypt')]
    components = [dep_comps[k] for k in top_keys]
    dep_bom_refs = [dep_refs[k] for k in top_keys]

    # A valueless `#define X` is recorded with an empty value, not '1'.
    # Coercing to '1' made the two indistinguishable and produced actively
    # misleading entries where the macro names a quantity: wolfBoot's
    # target.h emits `#define WOLFBOOT_LOAD_ADDRESS` with nothing after it
    # when the target does not set a load address, which an auditor then read
    # as the address literally being 1.
    properties = [
        {'name': f'wolfssl:build:{k}', 'value': v}
        for k, v in build_props
    ]
    # Document what the SHA-256 in `hashes` represents, on every entry
    # point.  Without this property an auditor reading the SBOM has to
    # guess whether the SHA-256 is over a library binary, a source-set
    # Merkle hash, or something else.  Emitting it unconditionally
    # turns "what does this hash mean?" from forensic guesswork into
    # a single property lookup.
    properties.append(
        {'name': 'wolfssl:sbom:hash-kind', 'value': hash_kind})
    # hash-source is the coarse, stable provenance tag downstream tooling
    # keys on: which *input* the checksum came from -- 'lib' (library
    # archive), 'srcs' (compiled source set), or 'none' (no hashable
    # artefact).  hash-kind above carries the finer implementation detail
    # (e.g. source-merkle-omnibor); hash-source is the value an integrator
    # filters on without needing to know our hashing internals.
    properties.append(
        {'name': 'wolfssl:sbom:hash-source', 'value': hash_source})
    if hash_source == 'none':
        properties.append(
            {'name': 'wolfssl:sbom:no-artifact-hash-note',
             'value': _NO_HASH_NOTE})
    if srcs_basenames:
        properties.append({
            'name': 'wolfssl:sbom:source-set',
            'value': ','.join(srcs_basenames),
        })

    if wolfssl_subset:
        # Which part of the wolfSSL release is actually compiled in.  Without
        # this an integrator reading a wolfssl component assumes the TLS
        # stack is present and triages TLS advisories that cannot apply.
        properties.append({
            'name': 'wolfssl:sbom:wolfssl-subset',
            'value': wolfssl_subset,
        })
        properties.append({
            'name': 'wolfssl:sbom:wolfssl-subset-basis',
            'value': subset_basis or 'unknown',
        })

    cpe = product_cpe(name, version)
    cpe_requested = product_cpe_requested(name, version)
    if cpe_requested:
        # NVD does not list this product yet, so no `cpe` field is emitted.
        # Record the submitted identifier and its status so the SBOM and the
        # dictionary entry agree the day NVD publishes it.
        properties.append({
            'name': 'wolfssl:sbom:cpe-status',
            'value': 'pending',
        })
        properties.append({
            'name': 'wolfssl:sbom:cpe-requested',
            'value': cpe_requested,
        })

    main_component = {
        'bom-ref': bom_ref,
        'type': component_type,
        'supplier': {'name': supplier},
        'name': name,
        'version': version,
        'licenses': cdx_license_block(license_id, license_text),
        'copyright': f'Copyright (C) 2006-{year} wolfSSL Inc.',
        'purl': wolfssl_project_purl(name, version),
        'hashes': [{'alg': 'SHA-256', 'content': lib_hash}],
        'externalReferences': [
            {'type': 'vcs',
             'url': urls['vcs']},
            {'type': 'website',
             'url': 'https://www.wolfssl.com/'},
            {'type': 'issue-tracker',
             'url': urls['issues']},
            {'type': 'advisories',
             'url': urls['advisories']},
            {'type': 'security-contact',
             'url': 'https://www.wolfssl.com/.well-known/security.txt'},
        ],
        'properties': properties,
    }
    if cpe:
        main_component['cpe'] = cpe
    # Sub-component file entries (CycloneDX file-typed components nested
    # under the library).  Autotools paths nest the linked library
    # binary so an auditor running a CDX parser can resolve the SHA-256
    # in `hashes` back to a concrete file path; embedded paths skip
    # this since the source-set Merkle hash already captures the inputs.
    if file_entries:
        main_component['components'] = [
            {
                'type': 'file',
                'name': fe['name'],
                'hashes': [
                    {'alg': 'SHA-1', 'content': fe['sha1']},
                    {'alg': 'SHA-256', 'content': fe['sha256']},
                ],
            }
            for fe in file_entries
        ]

    return {
        '$schema': 'http://cyclonedx.org/schema/bom-1.6.schema.json',
        'bomFormat': 'CycloneDX',
        'specVersion': '1.6',
        'serialNumber': f'urn:uuid:{serial}',
        'version': 1,
        'metadata': {
            'timestamp': timestamp,
            'tools': {
                'components': [{
                    'type': 'application',
                    'author': 'wolfSSL Inc.',
                    'name': GEN_SBOM_TOOL_NAME,
                    'version': GEN_SBOM_VERSION,
                }]
            },
            'component': main_component,
        },
        'components': components,
        'dependencies': [
            {'ref': bom_ref, 'dependsOn': dep_bom_refs},
            *[
                {
                    'ref': dep_refs[k],
                    'dependsOn': ([dep_refs['wolfcrypt']]
                                  if nest_wolfcrypt and k == 'wolfssl'
                                  else []),
                }
                for k in enabled_deps
            ],
        ],
    }


def generate_spdx(name, version, supplier, license_id, license_text, lib_hash,
                  timestamp, year, doc_ns_uuid, enabled_deps, build_props,
                  dep_version_overrides=None, hash_kind='library-binary',
                  hash_source='lib', srcs_basenames=None,
                  document_namespace=None, file_entries=None,
                  component_type='library', wolfssl_subset=None,
                  subset_basis=None):
    build_defines = ', '.join(k for k, _ in build_props)
    # Hash-kind / source-set / bomsh-traced-binary information used to
    # be stuffed into the package `comment` as `key=value` slugs, which
    # forced anyone reading the SPDX to grep free-form text.  SPDX 2.3
    # §8.5 provides `annotations[]` for exactly this -- structured
    # producer notes that validators understand and downstream parsers
    # can consume directly.  The `comment` field now carries only the
    # build-config define list a human reader scans first.

    # Annotations on the wolfssl package: structured producer notes
    # that the comment field used to carry as positional `key=value`
    # slugs.  Covered by the SPDX 2.3 §8.5 schema, so validators see
    # them as first-class data instead of opaque text.
    annotations = []

    def _annotate(payload):
        annotations.append({
            'annotationDate': timestamp,
            'annotationType': 'OTHER',
            'annotator': f'Tool: {GEN_SBOM_TOOL_NAME}-{GEN_SBOM_VERSION}',
            'comment': payload,
        })

    _annotate(f'wolfssl:sbom:hash-kind={hash_kind}')
    _annotate(f'wolfssl:sbom:hash-source={hash_source}')
    if hash_source == 'none':
        _annotate(f'wolfssl:sbom:no-artifact-hash-note={_NO_HASH_NOTE}')
    if srcs_basenames:
        _annotate('wolfssl:sbom:source-set=' + ','.join(srcs_basenames))
    if wolfssl_subset:
        _annotate(f'wolfssl:sbom:wolfssl-subset={wolfssl_subset}')
        _annotate('wolfssl:sbom:wolfssl-subset-basis='
                  + (subset_basis or 'unknown'))

    urls = project_urls(name)
    # Main-package SPDXID derived from --name (sanitised per SPDX 2.3 idstring
    # rules) rather than hardcoded to wolfssl, so a wolfSSH/wolfMQTT SBOM does
    # not mislabel its own package as wolfssl.  For name='wolfssl' the result
    # is 'SPDXRef-Package-wolfssl', unchanged from before.
    main_spdx_id = 'SPDXRef-Package-' + re.sub(r'[^A-Za-z0-9.]', '', name)

    cpe = product_cpe(name, version)
    cpe_requested = product_cpe_requested(name, version)
    if cpe_requested:
        # Pending at NVD: no cpe23Type external ref, since that reference
        # category asserts a dictionary entry a scanner can resolve.
        _annotate('wolfssl:sbom:cpe-status=pending')
        _annotate(f'wolfssl:sbom:cpe-requested={cpe_requested}')

    external_refs = []
    if cpe:
        external_refs.append({
            'referenceCategory': 'SECURITY',
            'referenceType': 'cpe23Type',
            'referenceLocator': cpe,
        })
    external_refs.extend([
        {
            'referenceCategory': 'PACKAGE-MANAGER',
            'referenceType': 'purl',
            'referenceLocator': wolfssl_project_purl(name, version),
        },
        {
            'referenceCategory': 'SECURITY',
            'referenceType': 'advisory',
            'referenceLocator': urls['advisories'],
        },
    ])

    wolfssl_pkg = {
        'SPDXID': main_spdx_id,
        'name': name,
        'versionInfo': version,
        'supplier': f'Organization: {supplier}',
        'downloadLocation': urls['vcs'],
        'filesAnalyzed': False,
        'checksums': [{'algorithm': 'SHA256', 'checksumValue': lib_hash}],
        'licenseConcluded': license_id,
        'licenseDeclared': license_id,
        'copyrightText': f'Copyright (C) 2006-{year} wolfSSL Inc.',
        'primaryPackagePurpose': SPDX_PACKAGE_PURPOSE.get(
            component_type, 'LIBRARY'),
        'comment': f'Build configuration defines: {build_defines}',
        'annotations': annotations,
        'externalRefs': external_refs,
    }

    # No SPDX `files[]` / `hasFiles[]` inventory.  spdx-tools (the
    # validator the autotools `make sbom` recipe runs) treats any
    # `hasFiles` linkage as an implicit CONTAINS relationship, and
    # SPDX 2.3 forbids package elements when `filesAnalyzed` is False.
    # Flipping `filesAnalyzed` to True is not honest for wolfSSL: the
    # package contains hundreds of source/header files, of which we
    # only enumerate the linked binary, and `packageVerificationCode`
    # under §8.10 requires every file in the package to be hashed.
    # The CycloneDX side (which is more permissive about file
    # sub-components) carries the linked-binary inventory; the SPDX
    # side relies on the package-level SHA-256 plus the
    # `wolfssl:sbom:hash-kind` annotation to identify the artefact.
    # `file_entries` is accepted for parameter symmetry with
    # generate_cdx but ignored here; if a future SPDX 2.4 / 3.0 model
    # makes file inventory cleanly compatible with `filesAnalyzed:
    # False`, this is the place to add it back.
    del file_entries  # unused on the SPDX side; see comment above.

    packages = [wolfssl_pkg]
    relationships = [{
        'spdxElementId': 'SPDXRef-DOCUMENT',
        'relatedSpdxElement': main_spdx_id,
        'relationshipType': 'DESCRIBES',
    }]

    # SPDX has no nested-package construct, so the containment the CycloneDX
    # side expresses by nesting is a CONTAINS relationship here: wolfcrypt is
    # part of the wolfssl release the product depends on, not a second thing
    # the product depends on directly.
    dep_spdx_ids = {}
    for key in enabled_deps:
        spdx_id, pkg = spdx_dep_package(key, dep_version_overrides)
        dep_spdx_ids[key] = spdx_id
        packages.append(pkg)

    # The container is the wolfssl release: the dependency package when the
    # product embeds wolfSSL, or this package itself in wolfSSL's own SBOM.
    if 'wolfssl' in dep_spdx_ids:
        wolfcrypt_container = dep_spdx_ids['wolfssl']
    elif name.lower() == 'wolfssl':
        wolfcrypt_container = main_spdx_id
    else:
        wolfcrypt_container = None

    for key in enabled_deps:
        if key == 'wolfcrypt' and wolfcrypt_container:
            relationships.append({
                'spdxElementId': wolfcrypt_container,
                'relatedSpdxElement': dep_spdx_ids['wolfcrypt'],
                'relationshipType': 'CONTAINS',
            })
        else:
            relationships.append({
                'spdxElementId': main_spdx_id,
                'relatedSpdxElement': dep_spdx_ids[key],
                'relationshipType': 'DEPENDS_ON',
            })

    # SPDX 2.3 §6.5: documentNamespace must be a unique URI; it is NOT
    # required to resolve to anything.  Default to `urn:uuid:<derived>`
    # rather than a `https://wolfssl.com/sbom/...` URL the project does
    # not actually host -- emitting an unresolvable URL misleads any
    # downstream tool that follows it.  Downstream packagers who DO host
    # a per-version mirror can override via `--document-namespace`
    # (Makefile.am: SBOM_DOCUMENT_NAMESPACE).
    doc_namespace = document_namespace or f'urn:uuid:{doc_ns_uuid}'
    doc = {
        'spdxVersion': 'SPDX-2.3',
        'dataLicense': 'CC0-1.0',
        'SPDXID': 'SPDXRef-DOCUMENT',
        'name': f'{name}-{version}',
        'documentNamespace': doc_namespace,
        'creationInfo': {
            'creators': [
                f'Organization: {supplier}',
                f'Tool: {GEN_SBOM_TOOL_NAME}-{GEN_SBOM_VERSION}',
            ],
            'created': timestamp,
        },
        'packages': packages,
        'relationships': relationships,
    }

    extracted = build_extracted_licensing_infos(license_id, license_text)
    if extracted:
        doc['hasExtractedLicensingInfos'] = extracted

    return doc


def _parse_dep_version_overrides(spec_list):
    """Parse repeated --dep-version KEY=VERSION flags into a dict.
    Rejects unknown keys early so a typo (e.g. --dep-version libssl=…)
    does not silently produce an SBOM that omits the dep version."""
    overrides = {}
    for spec in spec_list:
        if '=' not in spec:
            sys.exit(
                f"ERROR: --dep-version expects KEY=VERSION, got {spec!r}")
        key, _, value = spec.partition('=')
        if key not in DEP_META:
            sys.exit(
                f"ERROR: --dep-version key {key!r} is not a known wolfSSL "
                f"dependency. Known keys: {', '.join(sorted(DEP_META))}.")
        overrides[key] = value
    return overrides


def _resolve_dep_versions(enabled_deps, overrides):
    """Resolve each enabled dependency's version exactly once, mutating and
    returning `overrides` so both the CDX and SPDX emitters reuse the same
    value instead of each re-invoking pkg-config.  Caching the result
    (including None) means a later dep_version() lookup short-circuits on the
    membership check rather than re-shelling to `pkg-config --modversion`, so
    a default --with-libz build calls pkg-config once per dep
    (not once per dep per output format) and the two documents can never
    disagree if pkg-config output were ever non-deterministic."""
    for key in enabled_deps:
        if key not in overrides:
            overrides[key] = dep_version(key, overrides)
    return overrides


def _inherit_wolfcrypt_version(enabled_deps, overrides, name, version):
    """Give wolfcrypt a version so its CPE and PURL are not dropped.

    wolfcrypt has no pkg-config file of its own, so `dep_version` can never
    resolve it.  Prefer an explicit `--dep-version wolfcrypt=`, else the
    wolfssl dependency's version, else (for wolfSSL's own SBOM) the package
    version.

    MUST be called AFTER `_resolve_dep_versions`.  The wolfssl version
    normally arrives from pkg-config rather than from `--dep-version`, so
    inheriting before the resolve step only ever saw an explicit override: a
    downstream embedder such as wolfBoot that did not pass
    `--dep-version wolfssl=` emitted a wolfcrypt component with no version,
    no purl and no cpe, and still exited 0.

    Tests the value rather than the key, because `_resolve_dep_versions`
    caches a None for every dep pkg-config cannot resolve, so by this point
    'wolfcrypt' is always present in `overrides`.
    """
    if 'wolfcrypt' not in enabled_deps or overrides.get('wolfcrypt'):
        return overrides
    if overrides.get('wolfssl'):
        overrides['wolfcrypt'] = overrides['wolfssl']
    elif name.lower() == 'wolfssl':
        overrides['wolfcrypt'] = version
    return overrides
def _enabled_deps(dep_flags):
    """Return the enabled dependency keys, rejecting values outside {yes, no}.

    Treating anything that is not 'yes' as 'no' loses a dependency silently.
    A build fragment wiring `--dep-wolfssl=$(HAVE_WOLFSSL)` where the variable
    expands to 1, true, on or Y would drop a CVE-bearing component from the
    SBOM with no diagnostic, which is the one failure mode a CRA tool must
    never have.  Fail loudly instead.
    """
    enabled = []
    for key, flag_name, value in dep_flags:
        normalized = (value or '').strip().lower()
        if normalized not in ('yes', 'no'):
            sys.exit(f"ERROR: {flag_name} expects 'yes' or 'no', got {value!r}. "
                     f"A value outside that set would silently drop {key} from "
                     f"the SBOM.")
        if normalized == 'yes':
            enabled.append(key)
    return enabled


def main():
    parser = argparse.ArgumentParser(
        description='Generate CycloneDX and SPDX SBOMs for wolfssl. '
                    'Supports two entry-point shapes: the autotools / '
                    'library-binary form (--options-h + --lib) used by '
                    '`make sbom`, and the standalone embedded form '
                    '(--user-settings + --srcs) used by customers who '
                    'build with their own Makefile / IDE and never run '
                    './configure.'
    )
    parser.add_argument('--name', required=True, help='Package name')
    parser.add_argument('--version', required=True, help='Package version')
    parser.add_argument('--supplier', default='wolfSSL Inc.',
                        help='Supplier name (default: wolfSSL Inc.)')
    parser.add_argument('--component-type', default='library',
                        choices=sorted(SPDX_PACKAGE_PURPOSE),
                        help='What kind of artifact this is: CycloneDX '
                             'component.type, mirrored to SPDX '
                             'primaryPackagePurpose. Use firmware for a '
                             'bootloader such as wolfBoot (default: library)')
    parser.add_argument('--license-file', required=True,
                        help='Path to LICENSING file for SPDX ID detection')
    parser.add_argument('--license-override', default='',
                        help='Override the detected SPDX license expression '
                             '(e.g. LicenseRef-wolfSSL-Commercial). Useful '
                             'for commercial licensees regenerating the SBOM '
                             'for their own product.')
    parser.add_argument('--license-text', default='',
                        help='Path to a plain-text licence file whose '
                             'contents are embedded in the SBOM as the '
                             '`extractedText` for any LicenseRef-* used in '
                             '`--license-override`.  Required by SPDX 2.3 '
                             'validators (e.g. pyspdxtools) for any custom '
                             'licence reference.')
    # Build-configuration source: pick exactly one.
    parser.add_argument('--options-h',
                        help='Path to wolfssl/options.h for build config '
                             '(autotools entry point).  The file is read '
                             'as a flat list of #define directives; pre-'
                             'processed `$CC -dM -E -include settings.h` '
                             'output works equivalently.')
    parser.add_argument('--user-settings',
                        help='Path to wolfssl/wolfcrypt/settings.h to walk '
                             'through pcpp (embedded entry point).  Combine '
                             'with --user-settings-include to point at the '
                             'directory containing user_settings.h, and '
                             '`--user-settings-define WOLFSSL_USER_SETTINGS` '
                             'to enable the user_settings.h inclusion gate.')
    parser.add_argument('--user-settings-include', action='append', default=[],
                        metavar='DIR',
                        help='Add an include path for --user-settings '
                             'preprocessing (repeatable). Equivalent to -I '
                             'on the compiler command line.')
    parser.add_argument('--user-settings-define', action='append', default=[],
                        metavar='NAME[=VALUE]',
                        help='Predefine a macro for --user-settings '
                             'preprocessing (repeatable). Equivalent to -D '
                             'on the compiler command line.  At minimum '
                             'pass `WOLFSSL_USER_SETTINGS` so settings.h '
                             'pulls in user_settings.h.')
    # Component checksum source: pick exactly one.
    parser.add_argument('--lib',
                        help='Path to the wolfSSL library artifact '
                             '(shared or static) for SHA-256 hashing '
                             '(autotools entry point).')
    parser.add_argument('--srcs', nargs='+', default=None,
                        help='wolfSSL source files compiled into the '
                             'firmware (embedded entry point).  Their '
                             'OmniBOR-compatible gitoid Merkle hash is '
                             'used as the SBOM component checksum '
                             'instead of --lib.  May be combined with '
                             '--srcs-file.')
    parser.add_argument('--srcs-file', default=None, metavar='PATH',
                        help='Path to a file listing wolfSSL source files, '
                             'one per line (blank lines and lines starting '
                             'with `#` are ignored).  The file-driven '
                             'companion to --srcs for link lines too long '
                             'for the command line, or lists emitted '
                             'mechanically by an IDE / build system (link '
                             'map, project export).  Merged with --srcs and '
                             'hashed the same way.')
    parser.add_argument('--no-artifact-hash', action='store_true',
                        help='Record a placeholder component checksum when '
                             'no hashable artefact exists (ROM image, HSM '
                             'firmware, binary-only redistribution).  Emits '
                             'wolfssl:sbom:hash-source=none and a note '
                             'directing integrators to contact wolfSSL.  '
                             'Mutually exclusive with --lib / --srcs / '
                             '--srcs-file.')
    parser.add_argument('--dep-wolfssl', default='no',
                        help='yes to record wolfssl as a dependency component '
                             '(for downstream wolfSSL-stack products such as '
                             'wolfSSH / wolfMQTT that link libwolfssl). '
                             'wolfSSL\'s own SBOM leaves this off. Combine '
                             'with --dep-version wolfssl=X.Y.Z on hosts '
                             'without wolfssl.pc.')
    parser.add_argument('--dep-wolfcrypt', default='no',
                        help='yes to record wolfcrypt as a component with its '
                             'registered NVD CPE (cpe:2.3:a:wolfssl:wolfcrypt). '
                             'Use for embedders (wolfBoot) and for wolfSSL\'s '
                             'own SBOM (containment). Combine with '
                             '--dep-version wolfcrypt=X.Y.Z, or inherit the '
                             'wolfssl / package version when unset.')
    parser.add_argument('--crypto-only', default='auto',
                        choices=['auto', 'yes', 'no'],
                        help='Whether only the wolfCrypt subset of the '
                             'wolfSSL release is compiled in. auto (default) '
                             'reads the ' + CRYPTO_ONLY_MACRO + ' macro out '
                             'of the captured build configuration. Records '
                             'wolfssl:sbom:wolfssl-subset; it never removes '
                             'the wolfssl component, which is the only one '
                             'NVD maps advisories to.')
    parser.add_argument('--dep-openssl', default='no',
                        help='yes to record openssl as a dependency component '
                             '(for OpenSSL-compat products such as wolfProvider '
                             '/ wolfEngine that link libcrypto/libssl). Combine '
                             'with --dep-version openssl=X.Y.Z on hosts without '
                             'openssl.pc.')
    parser.add_argument('--dep-libz', default='no',
                        help='yes if built with --with-libz')
    parser.add_argument('--dep-version', action='append', default=[],
                        metavar='KEY=VERSION',
                        help='Override pkg-config version detection for a '
                             'dependency (repeatable). KEY is one of: '
                             + ', '.join(sorted(DEP_META)) + '. Required '
                             'on hosts without pkg-config (typical embedded '
                             'cross-compile setups).')
    parser.add_argument('--document-namespace', default='',
                        metavar='URI',
                        help='Override SPDX documentNamespace.  Default '
                             'is a deterministic urn:uuid derived from '
                             '--name and --version.  Set to a URI you '
                             'actually host (e.g. '
                             'https://example.com/sbom/wolfssl-X.Y.Z.spdx.json) '
                             'when re-publishing the SBOM under your own '
                             'distribution.  SPDX 2.3 §6.5 requires only '
                             'uniqueness, not resolvability.')
    parser.add_argument('--cdx-out', required=True,
                        help='Output path for CycloneDX JSON')
    parser.add_argument('--spdx-out', required=True,
                        help='Output path for SPDX JSON')
    args = parser.parse_args()

    # Mutual exclusion + at-least-one validation for the two entry-point
    # shapes.  Surfacing this here keeps argparse's --required machinery
    # simple and produces a friendlier error than argparse's auto-text.
    if bool(args.options_h) == bool(args.user_settings):
        sys.exit(
            "ERROR: pass exactly one of --options-h or --user-settings.\n"
            "       --options-h: autotools entry point (a flat #define file "
            "such as wolfssl/options.h).\n"
            "       --user-settings: embedded entry point (path to "
            "wolfssl/wolfcrypt/settings.h, with --user-settings-include "
            "pointing at the directory containing user_settings.h).")
    srcs_provided = bool(args.srcs) or bool(args.srcs_file)
    hash_sources = [bool(args.lib), srcs_provided, bool(args.no_artifact_hash)]
    if sum(hash_sources) != 1:
        sys.exit(
            "ERROR: pass exactly one component-checksum source.\n"
            "       --lib: hash a built library artefact (.so/.a/.dylib).\n"
            "       --srcs / --srcs-file: hash the wolfSSL source files "
            "compiled into your firmware (OmniBOR gitoid Merkle hash).\n"
            "       --no-artifact-hash: record a placeholder when no "
            "hashable artefact exists (ROM/HSM/binary-only).")

    # SPDX 2.3 §6.5 requires documentNamespace to be a unique absolute URI
    # per RFC 3986.  `make sbom` runs pyspdxtools afterwards and would
    # catch a malformed value, but the standalone entry point has no
    # validation gate -- a typo in SBOM_DOCUMENT_NAMESPACE / a packager
    # passing a relative path would otherwise land malformed SPDX in
    # downstream artefacts.  An absolute URI per RFC 3986 §3 has a
    # non-empty scheme; urlparse extracts that.
    if args.document_namespace:
        from urllib.parse import urlparse
        scheme = urlparse(args.document_namespace).scheme
        if not scheme:
            sys.exit(
                f"ERROR: --document-namespace {args.document_namespace!r} "
                "is not an absolute URI (SPDX 2.3 §6.5 requires RFC 3986 "
                "absolute URI form).  Expected e.g. "
                "https://example.com/sbom/wolfssl-X.Y.Z.spdx.json or "
                "urn:uuid:00000000-0000-0000-0000-000000000000.")

    enabled_deps = _enabled_deps([
        ('wolfssl',   '--dep-wolfssl',   args.dep_wolfssl),
        ('wolfcrypt', '--dep-wolfcrypt', args.dep_wolfcrypt),
        ('openssl',   '--dep-openssl',   args.dep_openssl),
        ('libz',      '--dep-libz',      args.dep_libz),
    ])
    dep_version_overrides = _parse_dep_version_overrides(args.dep_version)
    # Resolve each enabled dependency's version once, here, and feed the
    # result to both the CDX and SPDX emitters via the overrides map (see
    # _resolve_dep_versions for the once-per-dep pkg-config rationale).
    _resolve_dep_versions(enabled_deps, dep_version_overrides)
    _inherit_wolfcrypt_version(enabled_deps, dep_version_overrides,
                               args.name, args.version)

    if args.license_override:
        license_id = args.license_override
    else:
        license_id = detect_license(args.license_file)
        if license_id is None:
            print("WARNING: license could not be determined; using NOASSERTION",
                  file=sys.stderr)
            license_id = 'NOASSERTION'

    license_text = load_license_text(args.license_text)
    if extract_license_refs(license_id) and license_text is None:
        sys.exit(
            "ERROR: --license-override contains a LicenseRef-* identifier "
            "but --license-text was not provided.\n"
            "       SPDX 2.3 requires the licence text to be embedded in "
            "hasExtractedLicensingInfos for any LicenseRef-* used in "
            "licenseConcluded/licenseDeclared.\n"
            "       Re-run with --license-text PATH (or "
            "`make sbom SBOM_LICENSE_TEXT=PATH`)."
        )

    if args.options_h:
        build_props = parse_options_h(args.options_h)
    else:
        build_props = parse_user_settings(
            args.user_settings,
            args.user_settings_include,
            args.user_settings_define,
        )

    crypto_only, subset_basis = resolve_crypto_only(args.crypto_only,
                                                    build_props)
    wolfssl_subset = WOLFSSL_SUBSET_CRYPTO_ONLY if crypto_only else None
    if subset_basis == 'unknown' and 'wolfssl' in enabled_deps:
        # A front end that captures no macros (--source-only) cannot answer
        # the question either way.  Say so rather than defaulting silently to
        # "the whole of wolfSSL is in here".
        print(
            f"NOTE: no build configuration was captured, so {CRYPTO_ONLY_MACRO} "
            "could not be read; the SBOM does not state whether only wolfCrypt "
            "is compiled in. Pass --crypto-only yes|no to record it.",
            file=sys.stderr)

    file_entries = None
    if args.lib:
        # Refuse the empty-file SHA-256 as a component checksum.  A
        # build that points --lib at /dev/null, a stub touch(1)'d
        # placeholder, or an empty .a that failed to ar-create would
        # otherwise emit a valid-looking SBOM whose hash matches no
        # compiled wolfSSL artefact ever shipped.  The SBOM passes
        # both spec validators -- nothing else catches it.
        try:
            lib_size = os.path.getsize(args.lib)
        except OSError as e:
            sys.exit(f"ERROR: cannot stat --lib {args.lib!r}: {e}")
        if lib_size == 0:
            sys.exit(
                f"ERROR: --lib {args.lib!r} is empty (0 bytes); refusing "
                "to emit an SBOM with the empty-file SHA-256 as the "
                "component checksum.  Verify your build produced a "
                "real library artefact.")
        lib_sha1, lib_hash = sha1_sha256_file(args.lib)
        hash_kind = 'library-binary'
        hash_source = 'lib'
        srcs_basenames = None
        # Single SPDX file entry / CycloneDX file sub-component for
        # the linked library, so the SBOM names the artefact whose
        # SHA-256 it is reporting (rather than only carrying the hash
        # in `checksums[]`).  Auditors and downstream tooling can
        # then cross-reference the binary by its canonical filename
        # without out-of-band knowledge of the build layout.
        file_entries = [{
            'name': os.path.basename(args.lib),
            'sha1': lib_sha1,
            'sha256': lib_hash,
        }]
    elif args.no_artifact_hash:
        # No hashable artefact available (ROM image, HSM firmware,
        # binary-only redistribution).  Record an obviously-synthetic
        # placeholder rather than a real SHA-256, flagged by both the
        # hash-source property and the contact note so a downstream
        # auditor cannot mistake it for a genuine artefact digest.
        print(
            "NOTE: --no-artifact-hash: recording a placeholder component "
            "checksum (no library or source set to hash). Contact "
            "wolfssl@wolfssl.com for integrity verification options.",
            file=sys.stderr)
        lib_hash = _NO_HASH_SENTINEL
        hash_kind = 'none'
        hash_source = 'none'
        srcs_basenames = None
    else:
        # --srcs / --srcs-file is the embedded entry point.  Zero-byte
        # files in the set are uncommon but not necessarily wrong (a
        # cross-compile toolchain may stub a per-target source with
        # touch); warn rather than fail so the customer can decide
        # whether the gitoid for an empty blob is what they want
        # recorded.
        srcs = _collect_srcs(args.srcs, args.srcs_file)
        zero_byte_srcs = [
            p for p in srcs if os.path.isfile(p) and os.path.getsize(p) == 0
        ]
        if zero_byte_srcs:
            print(
                "WARNING: zero-byte source files in --srcs (gitoid will "
                "be the well-known empty-blob hash for these): "
                + ', '.join(zero_byte_srcs),
                file=sys.stderr)
        lib_hash = srcs_merkle_hash(srcs)
        hash_kind = 'source-merkle-omnibor'
        hash_source = 'srcs'
        srcs_basenames = sorted({os.path.basename(p) for p in srcs})

    dt, timestamp = build_timestamp()
    year = dt.year
    # SPDX 2.3 6.5 requires documentNamespace to be unique, and a CycloneDX
    # version:1 sharing a serialNumber over differing content is
    # self-contradictory.  Every body-affecting input goes into the digest;
    # see config_identity for the invariant.
    identity_file_names = (
        [fe['name'] for fe in file_entries] if file_entries
        else (srcs_basenames or [])
    )
    config_id = config_identity(
        lib_hash, license_id, build_props, enabled_deps,
        dep_version_overrides,
        supplier=args.supplier,
        component_type=args.component_type,
        license_text=license_text,
        hash_kind=hash_kind,
        hash_source=hash_source,
        file_names=identity_file_names,
        wolfssl_subset=wolfssl_subset,
        subset_basis=subset_basis,
    )
    serial = derived_uuid(args.name, args.version, 'serial', config_id)
    doc_ns_uuid = derived_uuid(args.name, args.version, 'document', config_id)

    cdx = generate_cdx(
        args.name, args.version, args.supplier,
        license_id, license_text, lib_hash, timestamp, year, serial,
        enabled_deps, build_props,
        dep_version_overrides=dep_version_overrides,
        hash_kind=hash_kind, hash_source=hash_source,
        srcs_basenames=srcs_basenames,
        file_entries=file_entries,
        component_type=args.component_type,
        wolfssl_subset=wolfssl_subset,
        subset_basis=subset_basis,
    )
    spdx = generate_spdx(
        args.name, args.version, args.supplier,
        license_id, license_text, lib_hash, timestamp, year, doc_ns_uuid,
        enabled_deps, build_props,
        dep_version_overrides=dep_version_overrides,
        hash_kind=hash_kind, hash_source=hash_source,
        srcs_basenames=srcs_basenames,
        document_namespace=(args.document_namespace or None),
        file_entries=file_entries,
        component_type=args.component_type,
        wolfssl_subset=wolfssl_subset,
        subset_basis=subset_basis,
    )

    try:
        with open(args.cdx_out, 'w') as f:
            json.dump(cdx, f, indent=2)
            f.write('\n')
        with open(args.spdx_out, 'w') as f:
            json.dump(spdx, f, indent=2)
            f.write('\n')
    except OSError as e:
        sys.exit(f"ERROR: cannot write SBOM output: {e}")

    print(f"Generated: {args.cdx_out}")
    print(f"Generated: {args.spdx_out}")


if __name__ == '__main__':
    main()
