diff --git a/.git_archival.txt b/.git_archival.txt new file mode 100644 index 0000000..7c51009 --- /dev/null +++ b/.git_archival.txt @@ -0,0 +1,3 @@ +node: $Format:%H$ +node-date: $Format:%cI$ +describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..04e00a9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +.git_archival.txt export-subst +tests/_generate/pinned/** -text +src/python_sbom/_validate/schemas/** -text diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..9b32546 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @gaborbernat diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..8090cea --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,18 @@ +# Contributing + +Add a `docs/changelog.d/+short-name..rst` fragment for a change that affects package users. Choose a unique short +name; the release workflow links the fragment to its merged squash pull request and credits the human author. A pull +request number is not needed in the filename. + +Use `feature` for Added, `change` for Changes, `bugfix` for Fixed, and `doc` for Documentation. Use `breaking`, +`security`, `performance`, `deprecation`, `removal`, or `packaging` only when the change affects users in that way. A +supported Python version added or removed is a `feature` and results in a minor release. + +Use `skip news` for internal maintenance without a user-facing change. The label may be added after opening the pull +request. Dependabot and maintenance bot pull requests without a fragment receive it automatically; a bot pull request +with a fragment still needs the news check to pass. For a change written by more than one person, put all human authors +in a `by` credit. Do not list bots or reviewers. + +The documentation preview shows fragments under Unreleased. The versioned `docs/changelog.rst` changes only during +release preparation. The release workflow chooses a version from the fragment types, runs `prek` on the assembled +changelog, and publishes GitHub notes from the tagged changelog after PyPI succeeds. diff --git a/.github/FUNDING.yaml b/.github/FUNDING.yaml new file mode 100644 index 0000000..220f93d --- /dev/null +++ b/.github/FUNDING.yaml @@ -0,0 +1,2 @@ +github: gaborbernat +thanks_dev: u/gh/gaborbernat diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..68357af --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,47 @@ +# Security policy + +python-sbom writes a CycloneDX SBOM into wheels, from a hatchling build hook or from `python-sbom embed`, which rewrites +a wheel another build backend built. `python-sbom archive` describes another zip archive a project publishes, such as a +zipapp, and `python-sbom spdx` and `validate` render or check an SBOM. This policy says how to report a security problem +and how we decide whether a report is one. + +## Reporting a vulnerability + +**Do not open a public issue for a security problem.** Use +[GitHub's private vulnerability reporting](https://github.com/tox-dev/python-sbom/security/advisories/new). If neither +works for you, email [gaborjbernat@gmail.com](mailto:gaborjbernat@gmail.com). + +Include what the attacker controls and what they gain, the steps or a proof of concept, and the versions and platform +you tested. Write the report yourself; unreviewed tool or model output takes longer to triage. + +## What to expect + +Volunteers maintain python-sbom, with no on-call rotation. We aim to acknowledge a report within **5 working days** and +assess it within **15 working days**. Fixes are developed in a private fork attached to the draft advisory, ship in a +new release, and the advisory, with a CVE requested through GitHub, goes out with that release. The default embargo is +**90 days** from the report; we agree any change with the reporter. We credit reporters in the advisory under the name +they choose, or not at all. There is no bug bounty. + +## Supported versions + +Only the latest release receives fixes. Confirm the problem reproduces on it before reporting. + +## Vulnerability or bug + +A report is a vulnerability when an attacker who controls only an input that the [threat model](THREAT_MODEL.md) lists +as untrusted breaks a guarantee python-sbom makes. A report that first needs control of something the threat model +trusts, such as the project source, its `pyproject.toml` or the build environment, is a bug: we fix it in the open and +publish no advisory. Classify by who supplies the dangerous input, not by how bad the outcome sounds. + +**In scope** + +- A wheel bundled in a wheel or in an archive given to `python-sbom archive`, or an SBOM given to `python-sbom spdx` or + `validate`, that makes python-sbom write outside its output file or the wheel or archive it rewrites, or run code. +- An SBOM that misstates what a wheel holds in a way its inputs cannot explain, such as a wrong hash. + +**Out of scope** + +- Anything hatchling's own build trusts. +- Vulnerabilities in the packages an SBOM lists; report those to their projects. + +If you cannot tell which side a finding falls on, report it privately and we will decide. diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md new file mode 100644 index 0000000..474bf1d --- /dev/null +++ b/.github/THREAT_MODEL.md @@ -0,0 +1,78 @@ +# Threat model + +This model says what python-sbom trusts, where untrusted input enters, and which guarantees hold at each boundary, so a +report can be classified as a vulnerability or a bug. [SECURITY.md](SECURITY.md) covers reporting; if the two disagree, +SECURITY.md wins and we fix this file. + +## How python-sbom works + +The build hook runs inside a hatchling wheel build. It reads the wheel's core metadata, the project's license files, the +wheels the project bundles and the installed distributions of the build environment, and writes a CycloneDX document +that it writes into the built wheel. `python-sbom embed` does the same for a wheel another backend built, +`python-sbom archive` describes another zip archive such as a zipapp, and `python-sbom spdx` and `validate` read +CycloneDX documents and write or check them. + +```mermaid +flowchart LR + project[("Project source, pyproject.toml
(trusted)")] --> hook["python-sbom"] + env[("Build environment
(trusted)")] --> hook + bundled[("Bundled wheels
(third-party content)")] -->|"metadata, RECORD, vendor list"| hook + sbom[("SBOM files given to spdx, validate")] -->|"untrusted JSON"| hook + hook --> wheel[("Wheel")] + classDef trusted fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a; + classDef boundary fill:#ffe3a3,stroke:#d29200,color:#3a2c00; + class project,env,hook,wheel trusted; + class bundled,sbom boundary; +``` + +## Trusted + +An attacker who controls any of these already has what python-sbom could give them: + +- The project source, its `pyproject.toml`, the build hook script, and the module the `content-provider` option or + `--content-provider` names: python-sbom imports that module from the project directory, so `embed` and `archive` run + code from `--root`. +- The git checkout that tracks the project, whose `HEAD` python-sbom reads. A repository that merely contains the + project directory is not trusted: git is used only when it tracks the project, and runs with `core.fsmonitor` off. +- The build environment and the build backend. +- The wheel handed to `python-sbom embed` and the archive handed to `python-sbom archive`, which the project's own build + produced. + +## Untrusted inputs + +| Input | Comes from | What python-sbom guarantees | +| --------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Metadata and `RECORD` of bundled wheels | Upstream projects | Read as text with capped sizes, copied as JSON strings; every member is checked against its `RECORD` row, duplicate members are refused, nothing is executed. | +| `_vendor/vendor.txt` of bundled wheels | Upstream projects | Each line must pin one exact version, or the build fails. | +| SBOM files given to `spdx`, `validate` | Whoever produced the file | Parsed as JSON only, with sizes capped by the component cap; `validate` refuses nesting past 128 levels and reports problems instead of raising them; `spdx` reports input too deep to parse as an error and writes the one file named on the command line. | + +## Threats and controls + +| STRIDE | Threat | Control | +| ---------------------- | ---------------------------------------------------------- | ------------------------------------------------------------- | +| Tampering | A bundled wheel's metadata injects JSON into the document. | The document is serialized with `json.dumps`. | +| Tampering | A bundled wheel changes and the SBOM still lists it. | Each bundled wheel is hashed from its bytes at build time. | +| Elevation of privilege | A bundled wheel runs code during the build. | Wheels are read as zip archives; nothing in them is imported. | + +## Release chain + +Releases run on the shared [tox-dev/github-workflows](https://github.com/tox-dev/github-workflows) pipeline. A release +App, not a person, pushes the release commit and tag; the build installs its tools from a hash-checked `pylock.toml` +resolved with a seven-day upload cooldown by default; PyPI receives the files through +[trusted publishing](https://docs.pypi.org/trusted-publishers/) with attestations, and the GitHub Release is immutable. +Every workflow pins third-party actions to a commit SHA. + +## Severity + +- **High:** a bundled wheel or SBOM file that makes python-sbom run code or write outside its output. +- **Medium:** an SBOM that misstates a hash, a license or the contents of a wheel. +- **Low:** a crash on a malformed bundled wheel or SBOM file instead of a clean error. + +## Accepted risks + +- The SBOM reports what the build environment holds; it does not check that environment against a lock. + +## Keeping this current + +Revisit this model when python-sbom gains a new input, a new way to run code, or a new place it writes to, and whenever +an advisory is published. diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..3c1c011 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,30 @@ +version: 2 +multi-ecosystem-groups: + dependencies: + schedule: + interval: weekly + day: tuesday + labels: + - dependencies + - skip news +updates: + - package-ecosystem: github-actions + directory: / + patterns: + - '*' + multi-ecosystem-group: dependencies + labels: + - dependencies + - skip news + cooldown: + default-days: 7 + - package-ecosystem: pre-commit + directory: / + patterns: + - '*' + multi-ecosystem-group: dependencies + labels: + - dependencies + - skip news + cooldown: + default-days: 7 diff --git a/.github/release.yaml b/.github/release.yaml new file mode 100644 index 0000000..5f89818 --- /dev/null +++ b/.github/release.yaml @@ -0,0 +1,5 @@ +changelog: + exclude: + authors: + - dependabot[bot] + - pre-commit-ci[bot] diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..3506978 --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,62 @@ +name: ๐Ÿงช check +on: + workflow_dispatch: + push: + branches: ["main"] + pull_request: + types: [opened, synchronize, reopened] + schedule: + - cron: "27 8 * * *" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + test: + name: ๐Ÿงช test + if: github.event_name != 'schedule' || github.repository == 'tox-dev/python-sbom' + uses: tox-dev/github-workflows/.github/workflows/check.yaml@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2 + permissions: + contents: read + with: + include: | + [ + {"env": "3.15", "python": "3.15"}, + {"env": "3.14", "python": "3.14"}, + {"env": "3.13", "python": "3.13"}, + {"env": "3.12", "python": "3.12"}, + {"env": "3.11", "python": "3.11"}, + {"env": "3.15", "python": "3.15", "os": "windows-2025"}, + {"env": "3.11", "python": "3.11", "os": "windows-2025"}, + {"env": "3.15", "python": "3.15", "os": "macos-26"}, + {"env": "dev"}, + {"env": "docs"}, + {"env": "fix"}, + {"env": "type"}, + {"env": "pkg_meta"} + ] + release: + name: ๐Ÿท๏ธ Release dry run + uses: $/.github/workflows/prepare-release.yaml + permissions: + contents: read + pull-requests: read + with: + dry-run: true + hooks: + name: ๐Ÿงน Hooks under the cooldown + uses: tox-dev/github-workflows/.github/workflows/cooled-hooks.yaml@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2 + permissions: + contents: read + gate: + name: ๐Ÿšฆ ${{ github.event_name == 'pull_request' && 'Required checks pass' || 'Non-PR checks pass' }} + if: ${{ !cancelled() }} # codespell:ignore cancelled + needs: [test, release, hooks] + runs-on: ubuntu-26.04 + permissions: {} + steps: + - name: ๐Ÿšฆ Require all needed jobs to pass + uses: tox-dev/github-workflows/required-checks@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2 + with: + needs: ${{ toJSON(needs) }} diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml new file mode 100644 index 0000000..7ac1c0c --- /dev/null +++ b/.github/workflows/codeql.yaml @@ -0,0 +1,22 @@ +name: ๐Ÿ›ก๏ธ CodeQL +on: + workflow_dispatch: + push: + branches: ["main"] + pull_request: + schedule: + - cron: "35 8 * * 2" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: {} +jobs: + analyze: + name: ๐Ÿ›ก๏ธ Analyze + uses: tox-dev/github-workflows/.github/workflows/codeql.yaml@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2 + permissions: + contents: read + security-events: write + with: + languages: '["actions","python"]' + paths-ignore: '["tests"]' diff --git a/.github/workflows/news.yaml b/.github/workflows/news.yaml new file mode 100644 index 0000000..f194282 --- /dev/null +++ b/.github/workflows/news.yaml @@ -0,0 +1,30 @@ +name: Changelog +on: + pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled] +permissions: + contents: read + pull-requests: read +jobs: + changelog: + name: ๐Ÿ“ News fragment + runs-on: ubuntu-26.04 + steps: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: ๐Ÿ Install uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + with: + version: latest-known + python-version: "3.15" + enable-cache: false + - name: ๐Ÿ“ฆ Install locked release tools + uses: tox-dev/github-workflows/install-locked-tools@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2 + with: + groups: release-news + venv: ${{ runner.temp }}/release-tools + - name: ๐Ÿ” Validate changelog fragments + uses: tox-dev/github-workflows/validate-news@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2 diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml new file mode 100644 index 0000000..edfa8aa --- /dev/null +++ b/.github/workflows/prepare-release.yaml @@ -0,0 +1,34 @@ +name: Prepare release +on: + workflow_dispatch: + inputs: + bump: + description: Version bump strategy + required: true + type: choice + options: + - auto + - major + - minor + - patch + default: auto + workflow_call: + inputs: + dry-run: + description: Assemble the release without pushing, for the pull request gate + type: boolean + required: true +permissions: {} +jobs: + prepare: + name: ๐Ÿท๏ธ Prepare Towncrier release + uses: tox-dev/github-workflows/.github/workflows/prepare-towncrier-release.yaml@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2 + permissions: + contents: read + pull-requests: read + with: + bump: ${{ inputs.bump || 'auto' }} + dry-run: ${{ inputs.dry-run == true }} + release-auth-deployment: false + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..ab0d396 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,56 @@ +name: Release to PyPI +on: + push: + tags: ["*"] +permissions: + contents: read +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false +jobs: + build: + name: ๐Ÿ—๏ธ Build + uses: tox-dev/github-workflows/.github/workflows/validate-python-release.yaml@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2 + permissions: + contents: read + notes: + name: ๐Ÿ“ Render GitHub Release notes + uses: tox-dev/github-workflows/.github/workflows/release-notes.yaml@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2 + permissions: + contents: read + with: + tag: ${{ github.ref_name }} + changelog: docs/changelog.rst + artifact-url: https://pypi.org/project/python-sbom/${{ github.ref_name }}/ + publish: + name: ๐Ÿš€ Publish to PyPI + needs: [build, notes] + runs-on: ubuntu-26.04 + environment: + name: release + url: https://pypi.org/project/python-sbom/${{ github.ref_name }} + permissions: + id-token: write + steps: + - name: ๐Ÿ“ฉ Download distributions + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.build.outputs.distributions-artifact-id }} + path: dist/ + - name: ๐Ÿš€ Publish to PyPI + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 + with: + attestations: true + packages-dir: dist + release: + name: ๐Ÿš€ Publish GitHub Release + needs: [build, publish, notes] + uses: tox-dev/github-workflows/.github/workflows/github-release.yaml@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2 + permissions: + contents: write + with: + project: python-sbom + version: ${{ github.ref_name }} + tag: ${{ github.ref_name }} + artifact-pattern: python-package-distributions + notes-artifact-id: ${{ needs.notes.outputs.artifact-id }} diff --git a/.github/workflows/scorecard.yaml b/.github/workflows/scorecard.yaml new file mode 100644 index 0000000..aedc96d --- /dev/null +++ b/.github/workflows/scorecard.yaml @@ -0,0 +1,26 @@ +name: ๐Ÿ›ก๏ธ scorecard +on: + branch_protection_rule: + schedule: + - cron: "43 8 * * 2" + push: + branches: ["main"] + workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: {} +jobs: + analysis: + name: ๐Ÿ›ก๏ธ Scorecard analysis + if: github.repository == 'tox-dev/python-sbom' + permissions: + actions: read # read workflow runs for the Packaging check + checks: read # read check runs for the CI-Tests and SAST checks + contents: read # check out and scan the repository + id-token: write # authenticate the results published to the scorecard API + issues: read # read issue activity for the Maintained check + pull-requests: read # read reviews for the Code-Review check + security-events: write # upload the SARIF results to code scanning + statuses: read # read commit statuses for the CI-Tests check + uses: tox-dev/github-workflows/.github/workflows/scorecard.yaml@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2 diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml new file mode 100644 index 0000000..5e650e0 --- /dev/null +++ b/.github/workflows/upgrade-release-tools.yaml @@ -0,0 +1,17 @@ +name: Upgrade release tools +on: + workflow_dispatch: + schedule: + - cron: "51 8 * * 2" +permissions: {} +concurrency: + group: upgrade-release-tools + cancel-in-progress: false +jobs: + upgrade: + name: ๐Ÿ”€ Upgrade release tools + uses: tox-dev/github-workflows/.github/workflows/upgrade-release-tools.yaml@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2 + permissions: + contents: read + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.gitignore b/.gitignore index 52482c9..1e8968b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,6 @@ -/magic -.idea +*.pyc *.egg-info +dist/ .tox/ -/.venv -.coverage* -coverage.xml -.*_cache -__pycache__ -**.pyc -/build -dist -src/hatch_pep770/version.py +/src/python_sbom/version.py /uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..594a58f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,88 @@ +# prek runs lower priorities first and equal ones concurrently, so hooks sharing a priority never write the same file; +# pre-commit ignores the key and runs hooks in file order. +ci: + autoupdate_schedule: quarterly +default_language_version: + python: python3.15 +# the CycloneDX schema files ship byte for byte as upstream released them +exclude: ^src/python_sbom/_validate/schemas/ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 + hooks: + - id: end-of-file-fixer + priority: 0 + - id: trailing-whitespace + priority: 1 + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: a76b92851fc9eede9f9bc4073eb55202f34eb0df # frozen: 0.39.0 + hooks: + - id: check-github-workflows + priority: 6 + args: ["--verbose"] + - repo: https://github.com/codespell-project/codespell + rev: 57b21406f092110c18776e39b0bda50d37c945c8 # frozen: v2.4.3 + hooks: + - id: codespell + priority: 6 + - repo: https://github.com/tox-dev/tox-toml-fmt + rev: "5125f601c3e134347288c3d42f3b34298b7cf1be" # frozen: v1.11.1 + hooks: + - id: tox-toml-fmt + priority: 2 + - repo: https://github.com/tox-dev/pyproject-fmt + rev: "50e0f3aee9dc5747a5f173f79a7417403c18dda6" # frozen: v2.30.3 + hooks: + - id: pyproject-fmt + priority: 2 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "25bcf95026b96e2dd3c0aec3090c3d6ef6ca2e6c" # frozen: v0.17.3 + hooks: + - id: ruff-check + priority: 3 + args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] + - repo: https://github.com/gaborbernat/simplint-pre-commit + rev: "c8719dca99c13381570bc69f8955c9448fc271b7" # frozen: v0.7.1 + hooks: + - id: simplint + priority: 4 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "25bcf95026b96e2dd3c0aec3090c3d6ef6ca2e6c" # frozen: v0.17.3 + hooks: + - id: ruff-format + priority: 5 + - repo: https://github.com/google/yamlfmt + rev: "21ca5323a9c87ee37a434e0ca908efc0a89daa07" # frozen: v0.21.0 + hooks: + - id: yamlfmt + priority: 2 + - repo: https://github.com/executablebooks/mdformat + rev: 82912cdaea4fb830f751504486a7879c70526547 # frozen: 1.0.0 + hooks: + - id: mdformat + priority: 2 + args: ["--wrap", "120"] + additional_dependencies: + - mdformat-gfm==1.0.0 + - mdformat-ruff==0.1.3 + - repo: https://github.com/LilSpazJoekp/docstrfmt + rev: c1813841673cdcd6cf602dde1edb78a5d5e45235 # frozen: v2.2.1 + hooks: + - id: docstrfmt + priority: 2 + files: ^docs/(changelog|contributing)[.]rst$ + args: ["-l", "120", "--preserve-adornments"] + additional_dependencies: + - "sphinx==9.2.0" + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: c92afdf2c6ca336aaa8f39e2bd8d48f62dbd8e1f # frozen: v1.31.1 + hooks: + - id: zizmor + priority: 6 + args: ["--min-severity", "high"] + - repo: meta + hooks: + - id: check-hooks-apply + priority: 6 + - id: check-useless-excludes + priority: 6 diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..6f130a0 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,8 @@ +version: 2 +build: + os: ubuntu-lts-latest + tools: {} + commands: + - curl -LsSf https://astral.sh/uv/install.sh | sh + - ~/.local/bin/uv tool install tox --with tox-uv -p 3.15 --managed-python + - ~/.local/bin/tox run -e docs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9739d57 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ +# Contributing + +## Tests + +Add a test for every new feature and bug fix. A change without a test needs a reason in the pull request description. diff --git a/README.md b/README.md index ed8faf6..3d316ce 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,51 @@ -# hatch-pep770 +# python-sbom -A [hatchling](https://hatch.pypa.io/latest/) build hook that writes a [CycloneDX](https://cyclonedx.org/) SBOM into -every wheel, where [PEP 770](https://peps.python.org/pep-0770/) says installers and scanners look for it. +[![PyPI](https://img.shields.io/pypi/v/python-sbom?style=flat-square)](https://pypi.org/project/python-sbom/) +[![Supported Python versions](https://img.shields.io/pypi/pyversions/python-sbom.svg)](https://pypi.org/project/python-sbom/) +[![check](https://github.com/tox-dev/python-sbom/actions/workflows/check.yaml/badge.svg)](https://github.com/tox-dev/python-sbom/actions/workflows/check.yaml) +[![Documentation Status](https://readthedocs.org/projects/python-sbom/badge/?version=latest)](https://python-sbom.readthedocs.io/en/latest/?badge=latest) +[![Downloads](https://static.pepy.tech/badge/python-sbom/month)](https://pepy.tech/project/python-sbom) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/tox-dev/python-sbom/badge)](https://scorecard.dev/viewer/?uri=github.com/tox-dev/python-sbom) + +SBOMs for Python distributions. python-sbom writes a [CycloneDX](https://cyclonedx.org/) SBOM into every wheel you +build, where [PEP 770](https://peps.python.org/pep-0770/) tells installers to keep it. The SBOM records your project, +the runtime dependencies it declares, any wheels it bundles, and the build toolchain, and a rebuild with the same inputs +reproduces it byte for byte. + +## Install + +A hatchling project names `python-sbom[hatch]` in its build requirements, as shown below. To embed an SBOM into a wheel +another backend built, install the command line into the environment that built the wheel, since the SBOM lists that +environment's packages as the build tools (or point `--build-env` at it): + +```console +python -m pip install python-sbom +``` + +To convert and validate SBOMs in CI, a separate tool environment is fine: + +```console +pipx install 'python-sbom[validate]' +``` + +## Use + +With [hatchling](https://hatch.pypa.io/latest/), add the hook to the build requirements and enable it: + +```toml +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling>=1.28", "python-sbom[hatch]>=0.1"] + +[tool.hatch.build.hooks.python-sbom] +``` + +With another build backend, such as meson-python, embed the SBOM after the build: + +```console +python-sbom embed dist/*.whl +``` + +Describe another zip archive the project publishes, such as a zipapp, with `python-sbom archive`. The +[documentation](https://python-sbom.readthedocs.io) covers the options, adding project knowledge through a content +provider, rendering an SBOM as SPDX and validating it. diff --git a/bench/__init__.py b/bench/__init__.py new file mode 100644 index 0000000..c03b83b --- /dev/null +++ b/bench/__init__.py @@ -0,0 +1 @@ +"""Benchmarks of python-sbom's hot paths, timed with pyperf; run them with ``python -m bench``.""" diff --git a/bench/__main__.py b/bench/__main__.py new file mode 100644 index 0000000..91b3704 --- /dev/null +++ b/bench/__main__.py @@ -0,0 +1,337 @@ +""" +CLI entry point: ``python -m bench ``, run from the project root or with the root on ``PYTHONPATH``. + +``prepare`` builds the fixtures into the work directory, the only command that needs the network. ``run`` times each +case in its own pyperf process tree, one JSON file per case under ``//``; options after ``--`` reach pyperf +and override the case's defaults (e.g. ``--rigorous``). + +``profile`` runs each case once under cProfile after an untimed warm-up, and under py-spy with ``--py-spy``. ``report`` +renders pyperf JSON as a Markdown table, ``compare`` two result directories side by side, and ``list`` prints the cases. + +The timed process imports whichever python-sbom the running interpreter has installed, so comparing two revisions means +running the same harness from two environments. +""" + +from __future__ import annotations + +import argparse +import cProfile +import io +import os +import pstats +import shutil +import subprocess +import sys +import tempfile +import time +from importlib.metadata import version +from operator import itemgetter +from pathlib import Path +from typing import TYPE_CHECKING, Final + +from ._cases import CASES, TIERS, make_step +from ._fixtures import load, prepare + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + from ._cases import Step + +_ROOT: Final[Path] = Path(__file__).parents[1] +_PYTHON: Final[str] = f"{sys.implementation.name}{sys.version_info.major}.{sys.version_info.minor}" + + +def main(arguments: Sequence[str]) -> None: + if arguments[:1] == ["worker"]: + _worker(arguments[1], Path(arguments[2]), arguments[3], arguments[4:]) + return + passthrough: list[str] = [] + if "--" in arguments: + split = arguments.index("--") + arguments, passthrough = arguments[:split], list(arguments[split + 1 :]) + options = _parser().parse_args(arguments) + match options.command: + case "prepare": + prepare(options.work, options.project, options.virtualenv) + case "run": + out = options.out / (_PYTHON if options.clock == "wall" else f"{_PYTHON}-{options.clock}") + _run(options.work, out, _selected(options.cases), options.clock, passthrough) + case "profile": + _profile(options.work, options.out / _PYTHON / "profile", _selected(options.cases), py_spy=options.py_spy) + case "once": + _once(options.case, options.work, options.cprofile) + case "report": + sys.stdout.write(_report(options.paths)) + case "compare": + sys.stdout.write(_compare(options.before, options.current)) + case "list": + sys.stdout.write("".join(f"{name}\n" for name in CASES)) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python -m bench") + commands = parser.add_subparsers(dest="command", required=True) + work = argparse.ArgumentParser(add_help=False) + work.add_argument("--work", type=Path, default=_ROOT / ".tox" / "bench-work", help="fixture directory") + output = argparse.ArgumentParser(add_help=False) + output.add_argument("--out", type=Path, default=_ROOT / ".tox" / "bench-results", help="result directory") + setup = commands.add_parser("prepare", parents=[work], help="build the fixtures") + setup.add_argument("--project", type=Path, default=_ROOT, help="python-sbom checkout whose own wheel to build") + setup.add_argument("--virtualenv", type=Path, help="virtualenv checkout carrying [tool.python-sbom]") + for name, text in (("run", "time cases with pyperf"), ("profile", "profile cases once")): + command = commands.add_parser(name, parents=[work, output], help=text) + command.add_argument("cases", nargs="*", metavar="CASE", help="cases to run (default: all)") + commands.choices["run"].add_argument( + "--clock", + choices=("wall", "cpu"), + default="wall", + help="wall time, or the CPU time of the process and its children, steadier on a loaded machine", + ) + commands.choices["profile"].add_argument("--py-spy", action="store_true", help="also record with py-spy") + once = commands.add_parser("once", parents=[work], help="run one case once, for a profiler") + once.add_argument("case") + once.add_argument("--cprofile", type=Path, metavar="STATS", help="run it under cProfile, writing its stats here") + commands.add_parser("report", help="render results as Markdown").add_argument("paths", nargs="+", type=Path) + compare = commands.add_parser("compare", help="render two result directories side by side as Markdown") + compare.add_argument("before", type=Path, help="result directory of the baseline") + compare.add_argument("current", type=Path, help="result directory of the revision under test") + commands.add_parser("list", help="print the case names") + return parser + + +def _selected(names: list[str]) -> list[str]: + if unknown := sorted(set(names) - CASES.keys()): + msg = f"unknown case(s) {', '.join(unknown)}; known: {', '.join(CASES)}" + raise SystemExit(msg) + return names or list(CASES) + + +def _run(work: Path, out: Path, names: list[str], clock: str, passthrough: list[str]) -> None: + out.mkdir(parents=True, exist_ok=True) + fixtures: Final = load(work) + for name in names: + if missing := [key for key in CASES[name].needs if key not in fixtures]: + sys.stdout.write(f"skip {name}: no fixture {', '.join(missing)}\n") + continue + (result := out / f"{name}.json").unlink(missing_ok=True) + subprocess.run( + [ + sys.executable, + "-m", + "bench", + "worker", + name, + str(work), + clock, + "--output", + str(result), + "--inherit-environ", + "PYTHONPATH", + *TIERS[CASES[name].tier], + *passthrough, + ], + check=True, + ) + + +def _worker(name: str, work: Path, clock: str, arguments: Sequence[str]) -> None: + import pyperf # ruff: ignore[import-outside-top-level] # only the timed processes need it + + runner = pyperf.Runner(program_args=("-m", "bench", "worker", name, str(work), clock)) + runner.parse_args(list(arguments)) + with tempfile.TemporaryDirectory(dir=work, prefix="scratch-") as scratch: + step = make_step(name, load(work), Path(scratch)) + runner.metadata["python_sbom_version"] = version("python-sbom") + runner.metadata["bench_clock"] = clock + runner.bench_time_func(name, _timed, step, time.perf_counter if clock == "wall" else _cpu_time) + + +def _timed(loops: int, step: Step, clock: Callable[[], float]) -> float: + total = 0.0 + for _ in range(loops): + step.reset() + start = clock() + step.run() + total += clock() - start + return total + + +def _cpu_time() -> float: + # children count once waited for, which covers git and the cold cases' interpreter + import resource # ruff: ignore[import-outside-top-level] # Unix only, and only the cpu clock needs it + + own, children = resource.getrusage(resource.RUSAGE_SELF), resource.getrusage(resource.RUSAGE_CHILDREN) + return own.ru_utime + own.ru_stime + children.ru_utime + children.ru_stime + + +def _profile(work: Path, out: Path, names: list[str], *, py_spy: bool) -> None: + out.mkdir(parents=True, exist_ok=True) + fixtures: Final = load(work) + summary: Final[list[str]] = [] + for name in names: + if any(key not in fixtures for key in CASES[name].needs): + continue + stats_file = out / f"{name}.pstats" + # each case in a fresh process, so one case's warm caches never shorten another's profile + subprocess.run( + [sys.executable, "-m", "bench", "once", name, "--work", str(work), "--cprofile", str(stats_file)], + check=True, + ) + summary.append(_hot_spots(name, stats_file)) + (out / f"{name}.txt").write_text(_stats_text(stats_file), encoding="utf-8") + # next to the interpreter too, as a venv's scripts are when its python runs without activating it + if py_spy and ( + spy := shutil.which("py-spy", path=f"{Path(sys.executable).parent}{os.pathsep}{os.environ.get('PATH', '')}") + ): + subprocess.run( + [ + spy, + "record", + "--format", + "speedscope", + "--rate", + "500", + "--output", + str(out / f"{name}.speedscope.json"), + "--", + sys.executable, + "-m", + "bench", + "once", + name, + "--work", + str(work), + ], + check=True, + ) + (target := out / "hot-spots.md").write_text("\n".join(summary), encoding="utf-8") + sys.stdout.write(f"wrote {target}\n") + + +# a helper of main's because, inlined into main, it pushes main over ruff's C901 complexity limit +def _once(name: str, work: Path, stats_file: Path | None) -> None: # simplint: ignore[SMP505] # see above + # without a stats file for an outside profiler such as py-spy + with tempfile.TemporaryDirectory(dir=work, prefix="scratch-") as scratch: + step = make_step(name, load(work), Path(scratch)) + step.reset() + if stats_file is None: + step.run() + return + if step.command is not None: + command = [step.command[0], "-m", "cProfile", "-o", str(stats_file), *step.command[1:]] + subprocess.run(command, check=True, stdout=subprocess.DEVNULL) + return + if CASES[name].tier != "huge": # warm, as the timed values are; huge values are each a process's first call + step.run() + step.reset() + profiler = cProfile.Profile() + profiler.runcall(step.run) + profiler.dump_stats(stats_file) + + +def _stats_text(stats_file: Path) -> str: + buffer = io.StringIO() + stats = pstats.Stats(str(stats_file), stream=buffer) + stats.sort_stats("cumulative").print_stats(40) + stats.sort_stats("tottime").print_stats(40) + return buffer.getvalue() + + +def _hot_spots(name: str, stats_file: Path) -> str: + entries = pstats.Stats(str(stats_file)).stats # ty: ignore[unresolved-attribute] # the table pstats keeps is not in its stubs + total = sum(entry[2] for entry in entries.values()) or 1.0 + rows = [ + (_label(function), entry[1], entry[0], entry[2], entry[3]) + for function, entry in entries.items() + if not _harness(function) + ] + lines = [f"### {name}", "", f"profiled total {total:.3f}s", ""] + for title, key in (("cumulative", 4), ("own time", 3)): + lines += [ + f"by {title}:", + "", + "| function | calls | own s | cumulative s | cumulative % |", + "| --- | ---: | ---: | ---: | ---: |", + ] + lines += [ + f"| `{label}` | {calls if calls == primitive else f'{calls}/{primitive}'} | {own:.3f} | {cumulative:.3f} |" + f" {100 * cumulative / total:.1f} |" + for label, calls, primitive, own, cumulative in sorted(rows, key=itemgetter(key), reverse=True)[:15] + ] + lines.append("") + return "\n".join(lines) + + +def _harness(function: tuple[str, int, str]) -> bool: + file, _, func = function + return Path(file).parent == Path(__file__).parent or func in { + "", + "", + } + + +def _label(function: tuple[str, int, str]) -> str: + file, line, func = function + if file == "~": + return func + parts = Path(file).parts + for anchor in ("site-packages", "lib"): + if anchor in parts: + parts = parts[len(parts) - parts[::-1].index(anchor) :] + break + return f"{'/'.join(parts[-3:])}:{line}({func})" + + +def _report(paths: list[Path]) -> str: + import pyperf # ruff: ignore[import-outside-top-level] # see _worker + + files = sorted(file for path in paths for file in (path.glob("*.json") if path.is_dir() else [path])) + lines = [ + "| path | python | median | IQR (p25 - p75) | min - max | values |", + "| --- | --- | ---: | ---: | ---: | ---: |", + ] + for file in files: + benchmark = pyperf.Benchmark.load(str(file)) + values = benchmark.get_values() + python = benchmark.get_metadata().get("python_version", "?") + lines.append( + f"| {benchmark.get_name()} | {python} | {_seconds(benchmark.median())} |" + f" {_seconds(benchmark.percentile(25))} - {_seconds(benchmark.percentile(75))} |" + f" {_seconds(min(values))} - {_seconds(max(values))} | {len(values)} |" + ) + return "".join(f"{line}\n" for line in lines) + + +def _compare(before: Path, current: Path) -> str: + import pyperf # ruff: ignore[import-outside-top-level] # see _worker + + lines = [ + "| path | before median ยฑ MAD | current median ยฑ MAD | change |", + "| --- | ---: | ---: | ---: |", + ] + for name in sorted({file.stem for file in (*before.glob("*.json"), *current.glob("*.json"))}): + old, new = ( + pyperf.Benchmark.load(str(file)) if (file := directory / f"{name}.json").is_file() else None + for directory in (before, current) + ) + # the median absolute deviation, which one value a loaded host stretched barely moves + cells = [ + "-" if benchmark is None else f"{_seconds(benchmark.median())} ยฑ {_seconds(benchmark.median_abs_dev())}" + for benchmark in (old, new) + ] + change = "-" if old is None or new is None else _change(old.median(), new.median()) + lines.append(f"| {name} | {cells[0]} | {cells[1]} | {change} |") + return "".join(f"{line}\n" for line in lines) + + +def _change(before: float, current: float) -> str: + ratio = before / current + return f"{ratio:.2f}x faster" if ratio >= 1 else f"{1 / ratio:.2f}x slower" + + +def _seconds(value: float) -> str: + return f"{value:.2f} s" if value >= 1 else f"{value * 1000:.1f} ms" + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/bench/_cases.py b/bench/_cases.py new file mode 100644 index 0000000..4f6505a --- /dev/null +++ b/bench/_cases.py @@ -0,0 +1,238 @@ +""" +The timed paths: each case turns the fixtures into an untimed reset and a timed step. + +A case runs in its own pyperf process tree, so module caches one case fills never speed up another. Steady-state cases +time the step in a warm process, as a build backend or a long-lived tool sees it; the ``cold-`` cases time a fresh +``python -m python_sbom`` process, imports and schema loading included, as a shell user sees it. +""" + +from __future__ import annotations + +import contextlib +import importlib +import importlib.util +import inspect +import io +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Final + +from ._fixtures import SOURCE_DATE_EPOCH + +if TYPE_CHECKING: + from collections.abc import Callable + +#: pyperf options per cost: many calibrated loops for cheap steps, fewer single-loop values for slow ones +TIERS: Final[dict[str, tuple[str, ...]]] = { + "fast": ("--processes", "10", "--values", "5", "--warmups", "1"), + "slow": ("--processes", "6", "--values", "3", "--warmups", "1", "--loops", "1"), + "huge": ("--processes", "3", "--values", "1", "--warmups", "0", "--loops", "1"), +} + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Step: + run: Callable[[], None] + #: undoes what ``run`` changed on disk, untimed, before every call + reset: Callable[[], None] = lambda: None + #: the command a cold case runs, which the profiler runs under cProfile instead of profiling the subprocess call + command: tuple[str, ...] | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Case: + tier: str + needs: tuple[str, ...] + make: Callable[[dict[str, str], Path], Step] + + +def _hatch(root_key: str, *, hooks: bool) -> Callable[[dict[str, str], Path], Step]: + def make(fixtures: dict[str, str], scratch: Path) -> Step: + from hatchling.builders.wheel import WheelBuilder # ruff: ignore[import-outside-top-level] # only hatch cases + + if not hooks: + os.environ["HATCH_BUILD_NO_HOOKS"] = "true" + root = fixtures[root_key] + + def run() -> None: + list(WheelBuilder(root).build(directory=str(scratch), versions=["standard"])) + + return Step(run=run, reset=lambda: _empty(scratch)) + + return make + + +def _embed(*, cold: bool) -> Callable[[dict[str, str], Path], Step]: + def make(fixtures: dict[str, str], scratch: Path) -> Step: + wheel = scratch / Path(fixtures["meson-wheel"]).name + arguments = ("embed", str(wheel), "--root", fixtures["meson"], "--build-env", fixtures["fat-env"]) + + def reset() -> None: + shutil.copyfile(fixtures["meson-wheel"], wheel) + + return Step( + run=_cold(arguments) if cold else _cli(arguments), + reset=reset, + command=_command(arguments) if cold else None, + ) + + return make + + +def _archive(fixtures: dict[str, str], scratch: Path) -> Step: + return Step( + run=_cli(( + "archive", + fixtures["zipapp"], + "--root", + fixtures["virtualenv"], + "--build-script", + "tasks/make_zipapp.py", + "--output", + str(scratch / "virtualenv.pyz.cdx.json"), + )) + ) + + +def _validate( + key: str, *, expect_valid: bool = True, max_components: int | None = None +) -> Callable[[dict[str, str], Path], Step]: + def make(fixtures: dict[str, str], scratch: Path) -> Step: # ruff: ignore[unused-function-argument] # one signature + from python_sbom._validate._artifact import validate # ruff: ignore[import-outside-top-level] # loads the schema + + path = Path(fixtures[key]) + # revisions before the component cap take no limit and check any count, which is what a raised limit times + limit = ( + () + if max_components is None or "max_components" not in inspect.signature(validate).parameters + else (max_components,) + ) + + def run() -> None: + if (problems := validate(path, None, *limit)) and expect_valid: + msg = f"{path.name} is invalid: {problems}" + raise RuntimeError(msg) + + return Step(run=run) + + return make + + +def _cold_validate(fixtures: dict[str, str], scratch: Path) -> Step: # ruff: ignore[unused-function-argument] # one signature + arguments = ("validate", fixtures["own-wheel"]) + return Step(run=_cold(arguments), command=_command(arguments)) + + +def _spdx(fixtures: dict[str, str], scratch: Path) -> Step: + return Step(run=_cli(("spdx", fixtures["zipapp-sbom"], str(scratch / "virtualenv.spdx.json")))) + + +def _collect_facts(fixtures: dict[str, str], scratch: Path) -> Step: # ruff: ignore[unused-function-argument] # one signature + from python_sbom._collect._facts import collect_facts # ruff: ignore[import-outside-top-level] # see _validate + + root, path = Path(fixtures["meson"]), [fixtures["fat-env"]] + + def run() -> None: + # no revision, so only reading the environment's distributions is timed + collect_facts(root, lambda _root: None, path=path) + + return Step(run=run) + + +def _bundled(key: str) -> Callable[[dict[str, str], Path], Step]: + def make(fixtures: dict[str, str], scratch: Path) -> Step: # ruff: ignore[unused-function-argument] # one signature + # revisions before the move keep the reader in _collect._source, and those before the batch reader take one + # wheel's bytes rather than a list and a way to open each + moved = importlib.util.find_spec("python_sbom._wheel._bundled") is not None + module = importlib.import_module("python_sbom._wheel._bundled" if moved else "python_sbom._collect._source") + wheel = Path(fixtures[key]) + data = wheel.read_bytes() + if (single := getattr(module, "read_bundled_wheel", None)) is not None: + return Step(run=lambda: single(data, wheel.name)) + batch = module.read_bundled_wheels + return Step(run=lambda: batch([wheel.name], lambda _name: io.BytesIO(data))) + + return make + + +CASES: Final[dict[str, Case]] = { + "hatch-small": Case(tier="slow", needs=("small",), make=_hatch("small", hooks=True)), + "hatch-small-nohook": Case(tier="slow", needs=("small",), make=_hatch("small", hooks=False)), + "hatch-virtualenv": Case(tier="slow", needs=("virtualenv",), make=_hatch("virtualenv", hooks=True)), + "hatch-virtualenv-nohook": Case(tier="slow", needs=("virtualenv",), make=_hatch("virtualenv", hooks=False)), + "embed-meson-fat-env": Case(tier="slow", needs=("meson", "meson-wheel", "fat-env"), make=_embed(cold=False)), + "cold-embed-meson-fat-env": Case(tier="slow", needs=("meson", "meson-wheel", "fat-env"), make=_embed(cold=True)), + "archive-zipapp": Case(tier="slow", needs=("virtualenv", "zipapp"), make=_archive), + "validate-own-wheel": Case(tier="slow", needs=("own-wheel",), make=_validate("own-wheel")), + "cold-validate-own-wheel": Case(tier="slow", needs=("own-wheel",), make=_cold_validate), + # virtualenv's SBOMs list over 5,000 components, files included, so a limit above that times the full check + "validate-virtualenv-wheel": Case( + tier="slow", needs=("virtualenv-wheel",), make=_validate("virtualenv-wheel", max_components=20_000) + ), + "validate-zipapp": Case( + tier="slow", needs=("zipapp-embedded",), make=_validate("zipapp-embedded", max_components=20_000) + ), + # at the default cap, so every revision times the full schema check + "validate-hostile-5k": Case(tier="huge", needs=("hostile-5k",), make=_validate("hostile-5k", expect_valid=False)), + # the default limit: a revision with the component cap times the rejection, one without the full schema check + "validate-hostile-10k": Case( + tier="huge", needs=("hostile-10k",), make=_validate("hostile-10k", expect_valid=False) + ), + # a limit above the count, so every revision times the full schema check + "validate-hostile-10k-uncapped": Case( + tier="huge", needs=("hostile-10k",), make=_validate("hostile-10k", expect_valid=False, max_components=20_000) + ), + "spdx-zipapp": Case(tier="fast", needs=("zipapp-sbom",), make=_spdx), + "collect-facts-fat-env": Case(tier="slow", needs=("meson", "fat-env"), make=_collect_facts), + "bundled-wheel-pip": Case(tier="fast", needs=("pip-wheel",), make=_bundled("pip-wheel")), + "bundled-wheel-setuptools": Case(tier="fast", needs=("setuptools-wheel",), make=_bundled("setuptools-wheel")), +} + + +def make_step(name: str, fixtures: dict[str, str], scratch: Path) -> Step: + os.environ["SOURCE_DATE_EPOCH"] = SOURCE_DATE_EPOCH + case = CASES[name] + if missing := [key for key in case.needs if key not in fixtures]: + msg = f"{name} needs the fixture(s) {', '.join(missing)}; prepare with --virtualenv to build them" + raise SystemExit(msg) + return case.make(fixtures, scratch) + + +def _cli(arguments: tuple[str, ...]) -> Callable[[], None]: + from python_sbom._cli._main import main # ruff: ignore[import-outside-top-level] # see _validate + + def run() -> None: + with contextlib.redirect_stdout(io.StringIO()): + main(list(arguments)) + + return run + + +def _command(arguments: tuple[str, ...]) -> tuple[str, ...]: + return (sys.executable, "-m", "python_sbom", *arguments) + + +def _cold(arguments: tuple[str, ...]) -> Callable[[], None]: + command = _command(arguments) + + def run() -> None: + subprocess.run(command, check=True, stdout=subprocess.DEVNULL) + + return run + + +def _empty(directory: Path) -> None: + shutil.rmtree(directory, ignore_errors=True) + directory.mkdir(parents=True) + + +__all__ = [ + "CASES", + "TIERS", + "Step", + "make_step", +] diff --git a/bench/_fixtures.py b/bench/_fixtures.py new file mode 100644 index 0000000..e809c29 --- /dev/null +++ b/bench/_fixtures.py @@ -0,0 +1,319 @@ +""" +Build every input the benchmarks read, once, into a work directory. + +This is the only step that needs the network: the fat build environment and virtualenv's zipapp download wheels. The +timed runs then read only what this wrote, listed in ``fixtures.json``. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import random +import shutil +import subprocess +import sys +import sysconfig +import zipfile +from pathlib import Path +from typing import Final + +#: a fixed build time, so every SBOM the fixtures and the timed runs write is the same +SOURCE_DATE_EPOCH: Final[str] = "1767225600" +#: resolve the fat environment as of this day, so every machine installs the same distributions +_EXCLUDE_NEWER: Final[str] = "2026-10-01T00:00:00Z" +# packages whose dependency closure gives a build environment of about seventy distributions, pure and platform ones, +# among them botocore, whose RECORD lists thousands of files +_FAT_PACKAGES: Final[tuple[str, ...]] = ( + "black", + "boto3", + "click", + "cryptography", + "httpx", + "hypothesis", + "jinja2", + "pydantic", + "pytest", + "pyyaml", + "requests", + "rich", + "setuptools", + "sphinx", + "tox", + "wheel", +) +#: component counts of the hostile documents: the default cap, which still gets the full schema check, and twice it +_HOSTILE_COMPONENTS: Final[tuple[int, ...]] = (5_000, 10_000) +_LICENSE: Final[str] = ( + "MIT License\n\nCopyright (c) 2026 Bench Authors\n\nPermission is hereby granted, free of charge.\n" +) + + +def prepare(work: Path, project: Path, virtualenv: Path | None) -> None: + work.mkdir(parents=True, exist_ok=True) + os.environ["SOURCE_DATE_EPOCH"] = SOURCE_DATE_EPOCH + fixtures: Final[dict[str, str]] = {} + fixtures["small"] = str(_small_project(work / "small")) + fixtures["small-wheel"] = str(_hatchling_wheel(work / "small", work / "small-dist")) + for count in _HOSTILE_COMPONENTS: + target = work / f"hostile-{count // 1000}k.cdx.json" + fixtures[f"hostile-{count // 1000}k"] = str(_hostile_document(Path(fixtures["small-wheel"]), target, count)) + fixtures["meson"] = str(_meson_project(work / "meson")) + fixtures["meson-wheel"] = str(_meson_wheel(work / "meson-dist")) + fixtures["fat-env"] = _fat_environment(work / "fat-env") + _snapshot(project, work / "python-sbom") + fixtures["own-wheel"] = str(_hatchling_wheel(work / "python-sbom", work / "own-dist")) + if virtualenv is not None: + fixtures.update(_virtualenv(virtualenv, work)) + (work / "fixtures.json").write_text(f"{json.dumps(fixtures, indent=2)}\n", encoding="utf-8") + sys.stdout.write(f"wrote {work / 'fixtures.json'}\n") + + +def _small_project(root: Path) -> Path: + # a pure-Python project of forty modules that turns the hook on, as a typical hatchling user does + shutil.rmtree(root, ignore_errors=True) + (package := root / "src" / "proj").mkdir(parents=True) + (root / "LICENSE").write_text(_LICENSE, encoding="utf-8") + (root / "pyproject.toml").write_text( + """\ +[build-system] +build-backend = "hatchling.build" +requires = [ "hatchling", "python-sbom[hatch]" ] + +[project] +name = "proj" +version = "1.0" +license = "MIT" +license-files = [ "LICENSE" ] +requires-python = ">=3.11" +dependencies = [ "packaging>=24" ] + +[tool.hatch.build.targets.wheel] +packages = [ "src/proj" ] + +[tool.hatch.build.targets.wheel.hooks.python-sbom] + +[tool.python-sbom] +supplier = { name = "Bench Authors", url = [ "https://example.org" ] } +repository = "https://github.com/example/proj" +""", + encoding="utf-8", + ) + (package / "__init__.py").write_text('"""A small project."""\n', encoding="utf-8") + for index in range(40): + (package / f"module_{index:02d}.py").write_text( + "".join(f"def function_{name}() -> int:\n return {name}\n\n\n" for name in range(50)), encoding="utf-8" + ) + _commit(root) + return root + + +def _commit(root: Path) -> None: + # a checkout, as projects build from one, so the timed runs include reading the source revision with git + _run(["git", "init", "--quiet", str(root)]) + _run(["git", "-C", str(root), "add", "."]) + _run([ + "git", + "-C", + str(root), + "-c", + "user.name=bench", + "-c", + "user.email=bench@example.org", + "commit", + "--quiet", + "--no-verify", + "--no-gpg-sign", + "-m", + "bench fixture", + ]) + + +def _run(command: list[str], *, cwd: Path | None = None, env: dict[str, str] | None = None) -> None: + subprocess.run(command, check=True, cwd=cwd, env=env) + + +def _hatchling_wheel(root: Path, directory: Path) -> Path: + # without isolation, in this environment, so the hook under test is the one installed here + shutil.rmtree(directory, ignore_errors=True) + _run([sys.executable, "-m", "hatchling", "build", "--target", "wheel", "--directory", str(directory)], cwd=root) + (wheel,) = directory.glob("*.whl") + return wheel + + +def _hostile_document(wheel: Path, target: Path, count: int) -> Path: + # the small project's SBOM with every component in one array of unique ones: over the default component cap the + # document is rejected before the schema check, at or under it the uniqueItems check compares every pair of them. + # The build tools go, as their file components would count toward the cap too + with zipfile.ZipFile(wheel) as archive: + (name,) = (member for member in archive.namelist() if "/sboms/" in member) + hostile = json.loads(archive.read(name)) + hostile["metadata"]["tools"]["components"] = [] + hostile["components"] = [{"type": "library", "bom-ref": f"r{index}", "name": f"n{index}"} for index in range(count)] + target.write_text(json.dumps(hostile), encoding="utf-8") + return target + + +def _meson_project(root: Path) -> Path: + # the project directory `python-sbom embed` reads [tool.python-sbom] and the source revision from + shutil.rmtree(root, ignore_errors=True) + root.mkdir(parents=True) + (root / "LICENSE").write_text(_LICENSE, encoding="utf-8") + (root / "pyproject.toml").write_text( + """\ +[build-system] +build-backend = "mesonpy" +requires = [ "meson-python" ] + +[project] +name = "meson-proj" +version = "1.0" +license = "MIT" +license-files = [ "LICENSE" ] + +[tool.python-sbom] +supplier = { name = "Bench Authors", url = [ "https://example.org" ] } +""", + encoding="utf-8", + ) + _commit(root) + return root + + +def _meson_wheel(directory: Path) -> Path: + # what meson-python writes: a platform wheel holding an extension module next to its Python sources + shutil.rmtree(directory, ignore_errors=True) + directory.mkdir(parents=True) + abi = f"cp{sys.version_info.major}{sys.version_info.minor}" + platform = sysconfig.get_platform().replace("-", "_").replace(".", "_") + dist_info = "meson_proj-1.0.dist-info" + generator = random.Random(0) # ruff: ignore[suspicious-non-cryptographic-random-usage] # only needs the same bytes on every run + members: Final[dict[str, bytes]] = { + "meson_proj/__init__.py": b'"""A meson-python project."""\n', + f"meson_proj/_core{sysconfig.get_config_var('EXT_SUFFIX')}": generator.randbytes(4 * 1024 * 1024), + **{ + f"meson_proj/module_{index:02d}.py": "".join( + f"def function_{name}() -> int:\n return {name}\n\n\n" for name in range(50) + ).encode("utf-8") + for index in range(30) + }, + f"{dist_info}/METADATA": ( + b"Metadata-Version: 2.4\nName: meson-proj\nVersion: 1.0\nLicense-Expression: MIT\nLicense-File: LICENSE\n" + b"Requires-Python: >=3.11\nRequires-Dist: requests>=2\n" + ), + f"{dist_info}/WHEEL": ( + f"Wheel-Version: 1.0\nGenerator: meson\nRoot-Is-Purelib: false\nTag: {abi}-{abi}-{platform}\n" + ).encode(), + f"{dist_info}/licenses/LICENSE": _LICENSE.encode(), + } + record = "".join( + f"{name},sha256={base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b'=').decode()},{len(data)}\n" + for name, data in members.items() + ) + wheel = directory / f"meson_proj-1.0-{abi}-{abi}-{platform}.whl" + with zipfile.ZipFile(wheel, "w", zipfile.ZIP_DEFLATED) as handle: + for name, data in {**members, f"{dist_info}/RECORD": f"{record}{dist_info}/RECORD,,\n".encode()}.items(): + handle.writestr(zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0)), data, zipfile.ZIP_DEFLATED) + return wheel + + +def _fat_environment(target: Path) -> str: + if (uv := shutil.which("uv")) is None: + msg = "the fat build environment needs uv on PATH" + raise SystemExit(msg) + shutil.rmtree(target, ignore_errors=True) + _run([uv, "venv", "--quiet", "--python", sys.executable, str(target)]) + python = target / ("Scripts" if sys.platform == "win32" else "bin") / "python" + _run([ + uv, + "pip", + "install", + "--quiet", + "--python", + str(python), + "--exclude-newer", + _EXCLUDE_NEWER, + *_FAT_PACKAGES, + ]) + return _output([str(python), "-c", "import sysconfig; print(sysconfig.get_path('purelib'))"]).strip() + + +def _output(command: list[str]) -> str: + return subprocess.run(command, check=True, capture_output=True, text=True).stdout + + +def _snapshot(source: Path, target: Path) -> Path: + # a clone of the checkout plus its uncommitted changes, so builds write their version files there and never into the + # checkout itself; --shared borrows its objects, so even a detached HEAD resolves + shutil.rmtree(target, ignore_errors=True) + _run(["git", "clone", "--quiet", "--shared", "--no-checkout", str(source), str(target)]) + head = _output(["git", "-C", str(source), "rev-parse", "HEAD"]).strip() + _run(["git", "-C", str(target), "checkout", "--quiet", "--detach", head]) + if diff := subprocess.run( + ["git", "-C", str(source), "diff", "--binary", "HEAD"], check=True, capture_output=True + ).stdout: + subprocess.run(["git", "-C", str(target), "apply", "--index"], check=True, input=diff) + for name in _output(["git", "-C", str(source), "ls-files", "--others", "--exclude-standard", "-z"]).split("\0"): + if name: + (target / name).parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source / name, target / name) + return target + + +def _virtualenv(source: Path, work: Path) -> dict[str, str]: + root = _snapshot(source, work / "virtualenv") + wheel = _hatchling_wheel(root, work / "virtualenv-dist") + zipapp = work / "virtualenv.pyz" + _run( + [sys.executable, "-m", "tasks.make_zipapp", "--wheel-dir", str(wheel.parent), "--dest", str(zipapp)], + cwd=root, + env={**os.environ, "PYTHONPATH": str(root)}, + ) + embedded = work / "virtualenv-sbom.pyz" + shutil.copyfile(zipapp, embedded) + sbom = work / "virtualenv.pyz.cdx.json" + _run([ + sys.executable, + "-m", + "python_sbom", + "archive", + str(embedded), + "--root", + str(root), + "--build-script", + "tasks/make_zipapp.py", + "--output", + str(sbom), + "--embed", + ]) + embed = root / "src" / "virtualenv" / "seed" / "wheels" / "embed" + return { + "virtualenv": str(root), + "virtualenv-wheel": str(wheel), + "zipapp": str(zipapp), + "zipapp-embedded": str(embedded), + "zipapp-sbom": str(sbom), + # the newest of each, the wheels virtualenv seeds on current Pythons + "pip-wheel": str(max(embed.glob("pip-*.whl"), key=_wheel_version)), + "setuptools-wheel": str(max(embed.glob("setuptools-*.whl"), key=_wheel_version)), + } + + +def _wheel_version(wheel: Path) -> tuple[int, ...]: + return tuple(int(part) for part in wheel.name.split("-")[1].split(".") if part.isdigit()) + + +def load(work: Path) -> dict[str, str]: + if not (manifest := work / "fixtures.json").is_file(): + msg = f"{manifest} does not exist; run `python -m bench prepare` first" + raise SystemExit(msg) + return json.loads(manifest.read_text(encoding="utf-8")) + + +__all__ = [ + "SOURCE_DATE_EPOCH", + "load", + "prepare", +] diff --git a/docs/__init__.py b/docs/__init__.py new file mode 100644 index 0000000..8e33bc6 --- /dev/null +++ b/docs/__init__.py @@ -0,0 +1 @@ +"""Sphinx configuration, importable for static analysis.""" diff --git a/docs/_static/changelog-toc.css b/docs/_static/changelog-toc.css new file mode 100644 index 0000000..add3823 --- /dev/null +++ b/docs/_static/changelog-toc.css @@ -0,0 +1,12 @@ +@media (min-width: 64em) and (max-width: 82em) { + .toc-content-icon { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 10; + padding: 0.5rem; + background: var(--color-background-primary); + border: 1px solid var(--color-foreground-border); + border-radius: 0.25rem; + } +} diff --git a/docs/changelog.d/+python-sbom.feature.rst b/docs/changelog.d/+python-sbom.feature.rst new file mode 100644 index 0000000..35eac74 --- /dev/null +++ b/docs/changelog.d/+python-sbom.feature.rst @@ -0,0 +1,6 @@ +Add the ``python-sbom`` hatchling build hook, which writes a CycloneDX 1.6 SBOM into each wheel, the Python API behind +it, and the ``python-sbom`` command: ``embed`` writes the same SBOM into wheels other build backends build, ``archive`` +describes another zip archive a project publishes, such as a zipapp, and with ``--embed`` adds that SBOM to it, +deflated, ``spdx`` renders an SBOM as SPDX, and ``validate`` checks the SBOMs in wheels and archives against the +official CycloneDX schema files python-sbom ships, with jsonschema-rs, and rejects one listing more than 100,000 +components unless ``--max-components`` allows more. diff --git a/docs/changelog.d/.gitkeep b/docs/changelog.d/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 0000000..9e3a308 --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,4 @@ +Changelog +========= + +.. towncrier release notes start diff --git a/docs/cli.rst b/docs/cli.rst new file mode 100644 index 0000000..8d3ba9a --- /dev/null +++ b/docs/cli.rst @@ -0,0 +1,5 @@ +.. sphinx_argparse_cli:: + :module: python_sbom._cli._parser + :func: build_parser + :title: Command line interface + :group_title_prefix: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..f2de70c --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,133 @@ +"""Documentation generation configuration.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path +from tomllib import loads +from typing import TYPE_CHECKING, Final + +import python_sbom +from python_sbom import __version__ + +if TYPE_CHECKING: + from docutils.nodes import Element, reference + from sphinx.addnodes import pending_xref + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment + + +def _inheritance_aliases() -> dict[str, str]: + aliases: dict[str, str] = {} + for entry in python_sbom.__all__: + to_module = getattr(getattr(python_sbom, entry), "__module__", "") + # first instance wins + aliases.setdefault(f"{to_module}{'.' if to_module else ''}{entry}", f"{python_sbom.__name__}.{entry}") + return aliases + + +project: Final[str] = "python-sbom" +_NOW: Final[datetime] = datetime.now(tz=UTC) +globals()["copyright"] = f"2026-{_NOW.year}" +version: Final[str] = __version__ +release: Final[str] = __version__.split("+")[0] + +extensions: Final[list[str]] = [ + "sphinx.ext.autosectionlabel", + "sphinx.ext.autodoc", + "sphinx.ext.extlinks", + "sphinx.ext.intersphinx", + "sphinx.ext.inheritance_diagram", + "sphinx_argparse_cli", + "sphinx_autodoc_typehints", + "sphinxcontrib.mermaid", +] + +master_doc: Final[str] = "index" +source_suffix: Final[str] = ".rst" +exclude_patterns: Final[list[str]] = ["changelog.d/*"] +autosectionlabel_maxdepth: Final[int] = 2 + +html_theme: Final[str] = "furo" +html_title: Final[str] = "python-sbom: SBOMs for Python distributions" +html_last_updated_fmt: Final[str] = _NOW.isoformat() +html_static_path: Final[list[str]] = ["_static"] +html_css_files: Final[list[str]] = ["changelog-toc.css"] +pygments_style: Final[str] = "sphinx" +pygments_dark_style: Final[str] = "monokai" + +autoclass_content: Final[str] = "both" +inheritance_alias: Final[dict[str, str]] = _inheritance_aliases() +inheritance_graph_attrs: Final[dict[str, str]] = {"rankdir": "TB"} +# each directive names the members it documents, so hatchling's lifecycle methods and inherited members stay out +autodoc_default_options: Final[dict[str, bool | str]] = {"member-order": "bysource", "show-inheritance": True} +typehints_fully_qualified: Final[bool] = False +always_document_param_types: Final[bool] = True +typehints_document_rtype: Final[bool] = True +typehints_defaults: Final[str] = "comma" + +intersphinx_mapping: Final[dict[str, tuple[str, None]]] = {"python": ("https://docs.python.org/3.11", None)} +extlinks: Final[dict[str, tuple[str, str]]] = { + "pr": ("https://github.com/tox-dev/python-sbom/pull/%s", "#%s"), + "user": ("https://github.com/%s", "@%s"), +} +nitpicky: Final[bool] = True +nitpick_ignore: Final[list[tuple[str, str]]] = [ + # hatchling publishes no Sphinx inventory + ("py:class", "hatchling.builders.hooks.plugin.interface.BuildHookInterface"), + ("py:class", "hatchling.metadata.core.ProjectMetadata"), + ("py:class", "hatchling.bridge.app.Application"), +] + + +def setup(app: Sphinx) -> None: + # annotations name the private modules that define a type, and pathlib.Path an undocumented private alias + app.connect("missing-reference", _resolve_private_path) + app.connect("source-read", _include_unreleased) + + +def _resolve_private_path( + app: Sphinx, + env: BuildEnvironment, + node: pending_xref, + contnode: Element, +) -> reference | None: + module, _, name = node["reftarget"].rpartition(".") + if node["reftarget"] == "pathlib._local.Path": + node["reftarget"] = "pathlib.Path" + elif module.startswith("python_sbom._") and name in python_sbom.__all__: + node["reftarget"] = f"python_sbom.{name}" + else: + return None + domain = env.get_domain("py") + return domain.resolve_xref(env, node["refdoc"], app.builder, node["reftype"], node["reftarget"], node, contnode) + + +def _include_unreleased(app: Sphinx, docname: str, source: list[str]) -> None: + if docname != "changelog": + return + root = Path(__file__).parent.parent + fragments = root / "docs" / "changelog.d" + app.env.note_dependency(str(fragments)) + sections: list[str] = [] + for category in loads((root / "pyproject.toml").read_text(encoding="utf-8"))["tool"]["towncrier"]["type"]: + entries = [ + f"- {' '.join(fragment.read_text(encoding='utf-8').split())}" + for fragment in sorted(fragments.glob(f"*.{category['directory']}.rst")) + ] + if entries: + sections.extend(( + f".. _release-unreleased-{category['name'].lower().replace(' ', '-')}:", + "", + category["name"], + "~" * len(category["name"]), + "", + *entries, + "", + )) + if sections: + joined = "\n".join(sections) + source[0] = source[0].replace( + ".. towncrier release notes start", + f".. _release-unreleased:\n\nUnreleased\n----------\n\n{joined}\n.. towncrier release notes start", + ) diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..0f2c0f0 --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,118 @@ +Contributing +============ + +How the code fits together +-------------------------- + +Each artifact has one pipeline in ``_generate``, and the two read alike. An adapter, the hatchling hook or a command of +the command line, gathers the options, the content provider, the build scripts and the build facts (time, source +revision and toolchain). The pipeline opens the artifact once, reads it into a tree (``_collect/_wheel.py`` or +``_collect/_archive.py``), maps the tree to an ``Artifact`` with a pure function (``_model/_wheel.py`` or +``_model/_archive.py``), and hands that to ``_model/_bom.py``, which adds what every artifact of the project shares. +:doc:`explanation` draws the packages and their imports. + +To record a new field, read it at the edge into ``_model/_inputs.py`` and write it in ``_model``; to add a subcommand, +write a ``run`` function in a new ``_cli/_.py`` and register its arguments and the +``"python_sbom._cli._:run"`` handler in ``_cli/_parser.py``, which loads the module only when the command runs. +Tests mirror the source: ``tests//test_.py``. + +What each layer guarantees +-------------------------- + +A change is easiest to review against the guarantee of the layer it touches, and the test named here is the one that +fails when that guarantee breaks. Each test crosses a boundary on purpose: unit tests of one layer did not catch the +mismatches between layers these guard against. + +- ``_wheel`` reads an artifact through one open handle per build, checked once for clashing member names, and never + trusts a member's stated size. The bytes it hands on are the bytes the artifact ships. + ``tests/_wheel/test_reader.py``, and ``test_embed_rewrites_record_by_parsed_path`` for rewriting RECORD. +- ``_collect`` turns the project and the artifact into inputs. Source configuration only selects: a bundled wheel is + described from the artifact's copy, matched by digest, and a selected wheel the artifact does not ship is refused. A + provider module another project already imported in the same process is refused rather than reused. + ``test_bundled_wheel_not_shipped_is_refused``, ``test_provider_of_another_project_is_not_reused``. +- ``_model`` builds documents with pure functions. ``_model/_graph.py`` defines the sets every layer judges alike: + ``artifact_contents`` is what the artifact holds or declares wherever the document nests it, ``shipped_libraries`` is + what it carries bytes for, and ``reference_problems`` is the one check of repeated and unknown references that + generation refuses and validation reports. ``test_archive_bundled_wheel_judged_wherever_it_sits``. +- ``_validate`` reads hostile input: bounded size, depth and component count before the schema runs, then references, + the SPDX rendering and the license policy over ``artifact_contents``. + ``test_repeated_dependency_entry_cannot_hide_an_invalid_one``. +- ``_formats`` renders without losing identity: every SPDX id is unique because it ends in the full digest of its + ``bom-ref``. ``test_spdx_ids_of_refs_differing_in_dropped_characters_stay_distinct``. +- ``_generate`` orders reading, building and writing: an artifact stays open while its document is built, is closed + before it is rewritten, and is never overwritten by its own SBOM. ``test_archive_refuses_output_over_archive``, in + ``tests/_cli`` since the command line is where an output path reaches it. + +Updating the schemas +-------------------- + +``python-sbom validate`` checks documents against the official CycloneDX schema files, which ship byte for byte under +``src/python_sbom/_validate/schemas``. Each comes from a tag of `CycloneDX/specification +`_: ``bom-1.6.schema.json`` and the ``jsf-0.82.schema.json`` it references +from ``1.6.2``, and ``spdx.schema.json`` and the Apache-2.0 ``LICENSE`` from ``1.7.2``, the newest, since the +cyclonedx.org URL every schema version references serves the newest license list. These commands fetch the shipped files +from their raw URLs; change the tag to take a newer release: + +.. code-block:: console + + $ cd src/python_sbom/_validate/schemas + $ curl -fsSLO https://raw.githubusercontent.com/CycloneDX/specification/1.6.2/schema/bom-1.6.schema.json + $ curl -fsSLO https://raw.githubusercontent.com/CycloneDX/specification/1.6.2/schema/jsf-0.82.schema.json + $ curl -fsSLO https://raw.githubusercontent.com/CycloneDX/specification/1.7.2/schema/spdx.schema.json + $ curl -fsSLO https://raw.githubusercontent.com/CycloneDX/specification/1.7.2/LICENSE + $ sha256sum * + +Put each file's tag and SHA-256 into the cases of ``test_schema_files_are_the_upstream_release`` in +``tests/_validate/test_schemas.py``, which fails when a shipped file is not the release it names. ``.gitattributes`` +marks the directory ``-text`` and ``.pre-commit-config.yaml`` excludes it, so neither git nor the hooks rewrite the +files' line endings, whitespace or formatting. + +Benchmarks +---------- + +``bench/`` times python-sbom's hot paths with `pyperf `_; CI does not run it. The +``bench`` tox environment passes its arguments to ``python -m bench``: + +.. code-block:: console + + $ tox r -e bench -- prepare + $ tox r -e bench -- run + $ tox r -e bench -- compare ../baseline/.tox/bench-results/cpython3.15 .tox/bench-results/cpython3.15 + +``prepare`` builds the fixtures, the only step that needs the network. ``run`` times each case in its own pyperf process +tree and writes one JSON file per case under ``.tox/bench-results//``; options after a second ``--`` reach +pyperf. ``profile`` runs each case once under cProfile, and under py-spy with ``--py-spy``. Both take case names to run +only those, and ``list`` prints the names. ``report`` renders result files as a Markdown table, and ``compare`` puts two +result directories side by side. The timed process imports the python-sbom its environment installed, so to compare two +revisions, run the cases from a checkout of each and compare their result directories. + +Changelog fragments +------------------- + +Add a changelog fragment under ``docs/changelog.d`` when a pull request changes what package users can use or observe. +Name it ``+short-name..rst``, for example ``+vendored-licenses.feature.rst``. The name describes the change; you +do not need a pull request number before opening the pull request. Write one change per fragment, and use several +fragments when a pull request has several user-visible changes. + +Choose the type by its effect on users: + +- ``feature`` (Added): a new capability, including adding or removing support for a Python version. +- ``change`` (Changes): a change in behavior that remains available. +- ``bugfix`` (Fixed): a correction to existing behavior. +- ``doc`` (Documentation): a documentation change users need to know about. +- ``breaking`` (Breaking changes): a change that requires users to change their usage. +- ``security`` (Security): a security fix users need to act on. +- ``performance`` (Performance): a visible speed or resource improvement. +- ``deprecation`` (Deprecated): a supported behavior scheduled for removal. +- ``removal`` (Removed): removal of a user-facing capability. +- ``packaging`` (Packaging): a change to installation or distributions. + +Apply the ``skip news`` label to a pull request with no user-visible change. You can add the label after opening the +pull request. Supplied fragments are still checked when the label is present. Bot pull requests need no fragment unless +they include one. + +The release workflow links each fragment to its merged pull request and credits its human author. If several people +authored a change, put their `GitHub `_ handles in a ``by`` credit in the fragment; omit bots and +reviewers. The changelog uses linked names, while ``GitHub`` Release notes use ``@handle``. + +Build the documentation with ``tox r -e docs``; ``tox r -e docs -- -b linkcheck`` checks its external links. diff --git a/docs/explanation.rst b/docs/explanation.rst new file mode 100644 index 0000000..3b15a12 --- /dev/null +++ b/docs/explanation.rst @@ -0,0 +1,130 @@ +How it works +============ + +Architecture +------------ + +Each concern has its own subpackage, and only the adapters know a build system or the command line. The hatchling hook +and the ``embed`` and ``archive`` commands gather the options, the build scripts and the build facts (time, revision, +toolchain), and hand them, with a :class:`~python_sbom.ContentProvider`, to the pipeline for their artifact. Each +pipeline opens its artifact once, reads it, gives the provider a :class:`~python_sbom.ProviderContext` that reads from +the same open artifact, and maps what it read to components; one function of the model then adds what every artifact +of the project shares: supplier, source links, revision, tools, dependencies and formulation. + +The diagram follows the imports between the subpackages: a solid arrow points at what a package imports when it loads, a +dotted arrow labeled *lazy* at what it imports only once a function needs it, and a dotted arrow labeled *types* at +what it imports only for type annotations. The command line loads each command's module, and what it imports, only when +that command runs. + +.. mermaid:: + + flowchart TB + subgraph adapters[Adapters] + hook[hatch
hatchling build hook] + cli[_cli
embed, archive, spdx, validate] + end + api[python_sbom
provider API, __main__] + generate[_generate
wheel and archive pipelines] + collect[_collect
settings, facts, revision, project tree, wheels, archives, providers] + validate[_validate
limits, schema, references, licenses] + formats[_formats
CycloneDX JSON, SPDX 2.3] + wheel[_wheel
artifact reader, zip reads and writes, RECORD, bundled wheel checks] + model[_model
inputs, types, identity, graph rules, document building] + errors[_errors
SbomError] + version[version] + hook --> collect + hook -. lazy .-> generate + hook -. types .-> model + cli --> generate + cli --> validate + cli --> formats + cli --> version + cli -. types .-> model + api --> cli + api --> model + generate --> collect + generate --> formats + generate --> wheel + generate --> model + collect --> wheel + collect --> model + validate --> formats + validate --> wheel + validate --> model + formats --> model + formats --> version + model --> errors + wheel --> model + classDef edge fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a; + classDef core fill:#d8f3dc,stroke:#2d6a4f,color:#081c15; + class hook,cli,collect,api edge; + class generate,validate,formats,wheel,model,errors,version core; + +Every package raises :class:`~python_sbom.SbomError`, which the command line reports without a traceback; the diagram +draws only the model's import of it, since the model imports nothing else of the package. Only the hook imports +hatchling, so the provider API and the command line work without it. + +The one extension point is the :class:`~python_sbom.ContentProvider`, which every entry point loads from the +``content-provider`` option. A hatchling custom hook can override +:meth:`~python_sbom.hatch.SbomBuildHook.content_provider` to build the provider itself. + +What the SBOM records +--------------------- + +The document's root component is the project, described from the wheel's ``METADATA``: name, version, summary, license +expression, maintainers, project URLs, classifiers and keywords. Links to the PyPI release, the wheel's attestation and, +with a repository, the license file follow. A build from a git checkout adds the commit and a link to that exact +revision. Links to the license file and build scripts point at that commit when the checkout is clean, and at the +default branch otherwise. + +CycloneDX requires each URL to be an IRI reference. A project URL or home page that is not one gets percent-encoded, +or left out when encoding cannot mend it, as with an scp-style ``git@host:path`` address; either way a +``python:project-url`` or ``python:home-page`` property keeps the text as written. + +The components below the root are the wheels the project bundles and the runtime dependencies it declares. A bundled +wheel carries its SHA-256, its license, a component per file from its ``RECORD``, and the packages it vendors. A +declared dependency carries no version, because the installer picks it at install time; the document marks the root's +dependencies incomplete for that reason. An extra's dependencies are left out. + +The formulation records how the wheel was built: the interpreter's version and implementation, every distribution of the +build environment with its dependencies, the files and hashes of each pure-Python one, and ``SOURCE_DATE_EPOCH`` when it +set the build time. + +Property names start with the project's normalized name, such as ``virtualenv:bundled-wheel``, or with ``python:`` for +values copied from core metadata. + +Reproducibility +--------------- + +The document holds nothing about the build machine. Its timestamp comes from ``SOURCE_DATE_EPOCH``. When that is unset, +a reproducible hatchling build uses hatchling's fixed 2020-02-02, the time it stamps on the wheel's entries, and any +other build uses the current time. Its serial number derives from the content, so identical documents share one and any +difference gets a new one. + +The build tools are listed down to their files, so a rebuild can check it installed the same bytes. Only what is the +same on every machine goes in: platform wheels appear without their files, because those differ per operating system and +architecture, and editable installs without theirs, because they point at a checkout. Installer metadata, console-script +launchers and uv's cache files are left out too, since they name the frontend, the environment's path or a time rather +than the distribution. + +Hashing each tool's ``RECORD`` instead would be smaller, but that file lists the installer metadata, so two frontends +installing the same tools would give two hashes. The cost is size: the file list grows with the build environment, and +a large build tool such as setuptools adds hundreds of entries. + +So a rebuild reproduces the wheel byte for byte on any operating system when these match the original build: the source +tree, as a checkout of the same commit; ``SOURCE_DATE_EPOCH``; the Python version; and the versions of the build backend +and its dependencies. The SBOM records the last three, so it says what to install before rebuilding. + +Limitations +----------- + +- The document describes what the project declares and bundles, not the full tree of runtime dependencies, which depends + on the environment the wheel is installed into. +- The links to the PyPI release and attestation assume the project publishes to PyPI with attestations. +- A wheel built from an sdist of a dirty checkout names no commit, since no commit reproduces it. +- ``python-sbom embed`` and ``archive`` record the packages of the environment ``--build-env`` names, by default the one + they run in, and always the interpreter they run on. Unless that is the environment the artifact was built in, the + build tool record does not match the real build. +- The SPDX rendering leaves out per-file hashes and the build record, which SPDX 2.3 cannot hold. +- A tool that renames the wheel after the build, such as ``auditwheel repair``, leaves the attestation link pointing at + the old file name until ``python-sbom embed`` runs on the renamed wheel. diff --git a/docs/extend.rst b/docs/extend.rst new file mode 100644 index 0000000..9ec9dcf --- /dev/null +++ b/docs/extend.rst @@ -0,0 +1,113 @@ +Extend it +========= + +The options cover what every project shares. A project that knows more than its metadata says adds it through a content +provider, and the same provider serves the wheel's SBOM and the SBOM of any other archive the project publishes. + +Add content with a provider +--------------------------- + +A :class:`~python_sbom.ContentProvider` has two methods. Each receives a :class:`~python_sbom.ProviderContext`: the +project directory, its :class:`~python_sbom.Settings`, its :class:`~python_sbom.ProjectIdentity` with the prefix of its +properties, and :meth:`~python_sbom.ProviderContext.read`, which reads a file of the artifact being described, a member +of the built wheel or of the archive. Each returns a value whose fields default to empty, so a provider names only what +it adds. + +- :meth:`~python_sbom.ContentProvider.bundled_wheel` returns the properties and the supplier of one bundled wheel. The + properties follow ``:bundled-wheel`` and come before those from the wheel's metadata. A wheel's supplier comes + only from here, never from the wheel's own metadata. +- :meth:`~python_sbom.ContentProvider.contributions` returns extra top-level components, the dependencies between them, + root properties and build tools, such as the C libraries a native wheel links. They join the document before the + root's dependencies and the serial number derive from it. The build fails when a component or tool lacks ``type``, + ``bom-ref`` or ``name``, a ``bom-ref`` repeats, the ``formula:`` and ``workflow:`` refs of the build's formulation + included, a dependency names one that no component has, a ``bom-ref`` gets a second dependency entry (the root's + included, which python-sbom writes), or a root property uses the project's own prefix. + +Name the provider in the ``content-provider`` option, as ``module:attribute`` importable from the project root, and the +hook, ``python-sbom embed`` and ``python-sbom archive`` all load it. When the provider's class lives in the project, the +SBOM lists its module among the build tools, hashed, because it shapes the document a rebuild has to reproduce: + +.. code-block:: python + + from python_sbom import ContentProvider, Contributions + + + class Content(ContentProvider): + def contributions(self, context): + zlib = {'type': 'library', 'bom-ref': 'pkg:generic/zlib@1.3.1', 'name': 'zlib', 'version': '1.3.1'} + return Contributions(components=[zlib]) + +The context has no method that lists files. A provider that needs the wheel's file list reads its ``RECORD`` through +:meth:`~python_sbom.ProviderContext.read`, by the path of the project's ``.dist-info`` directory, such as +``seeder-1.0.dist-info/RECORD``; an archive holds that directory at its top level, and the path reads the same there. The +provider knows the project's name, and a hook subclass, shown below, can hand it hatchling's ``self.metadata.version``. +Any other member reads the same way, by its path inside the artifact. + +Worked example: a seeder +------------------------ + +A seeder, a project like `virtualenv `_, ships the wheels it installs into new +environments under ``src/seeder/embed``. A ``BUNDLE_SUPPORT`` table in that package's ``__init__.py``, after a +``BUNDLE_FOLDER`` assignment, decides which Python versions receive each wheel. The project's options name the supplier +and the wheels, and enable hatchling's custom hook, which loads ``hatch_build.py`` from the project root: + +.. literalinclude:: ../tests/_seeder/pyproject.toml + :language: toml + :start-at: [tool.hatch] + +The hook script subclasses :class:`~python_sbom.hatch.SbomBuildHook` and overrides +:meth:`~python_sbom.hatch.SbomBuildHook.content_provider`, so it builds the provider with the table's name, an argument +the ``content-provider`` option has no way to pass. ``get_build_hook`` tells hatchling which class to load, since the +script also imports the base class. The provider reads the table next to each wheel through the context, by name, +without importing the package, and names the project's supplier as the wheels' supplier, since the seeder bundles only +that supplier's wheels. The test suite builds this project: + +.. literalinclude:: ../tests/_seeder/hatch_build.py + :language: python + +The SBOM lists ``hatch_build.py`` among the build tools, hashed. The bundled pip wheel's component then carries these +properties first, in this order, followed by those from its own metadata: + +.. list-table:: + :header-rows: 1 + + * - Property + - Value + * - ``seeder:bundled-wheel`` + - ``seeder/embed/pip-1.0-py3-none-any.whl`` + * - ``seeder:seeded-for-python`` + - ``3.14`` + * - ``seeder:seeded-for-python`` + - ``3.15`` + +A bundled wheel is named by its path inside the artifact, so the wheel's SBOM and an archive's give it the same +``bom-ref``. + +A hook subclass serves only the hatchling build: ``python-sbom embed`` and ``python-sbom archive`` load the provider the +``content-provider`` option or their ``--content-provider`` names, and build a class without arguments. A project that +wants the same provider in every SBOM it publishes gives the provider a constructor without arguments, or a module-level +instance, and names it in the option instead of overriding the hook. + +Describe another archive +------------------------ + +A project may also publish a zipapp, such as virtualenv's ``virtualenv.pyz``, which holds virtualenv's own +``.dist-info`` at its top level, the wheels it seeds, and its dependencies unpacked under ``__virtualenv__/``. +``python-sbom archive`` describes such an archive the way the hook describes a wheel: the archive's own files, the +project's distribution, each wheel it bundles as a ``.whl`` member, and each one unpacked into a directory with a +``.dist-info``. The content provider the option names hears of every bundled wheel, and its context reads the archive's +files: + +.. code-block:: console + + $ python-sbom archive virtualenv.pyz --build-script tasks/make_zipapp.py --embed + wrote virtualenv.pyz.cdx.json + wrote virtualenv.pyz.cdx.json into virtualenv.pyz + +The document takes its options, source revision and toolchain the way the wheel's does, so both name the project alike. +Its build time comes from ``SOURCE_DATE_EPOCH``, or the current time without it; set ``SOURCE_DATE_EPOCH`` for both +builds to give them one time. ``--embed`` adds the document to the archive's top level, where ``python-sbom validate +virtualenv.pyz`` finds it. It goes in deflated at level 9 whatever the archive's other members use, since a zipapp may +store them all and the SBOM, megabytes of JSON, would then add its full size. The entry carries a fixed time and Unix +attributes and the other members keep their bytes, so adding it to one archive gives the same bytes on every system with +the same zlib. diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..70083ca --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,43 @@ +python-sbom +=========== + +SBOMs for Python distributions. python-sbom writes a `CycloneDX `_ 1.6 SBOM into every wheel you +build, at ``-.dist-info/sboms/.cdx.json``. That is where `PEP 770 +`_ tells installers to keep SBOMs, so they land in the environment next to the +installed package, where scanners look for them. + +Why a wheel needs its own SBOM +------------------------------ + +General-purpose scanners read the dependencies a package declares or the packages an environment has installed. They +cannot see a wheel your project ships as a data file, and they cannot say how the wheel was built. The SBOM this package +writes records both: the wheels you bundle, with their hashes, licenses and vendored packages, and the Python version +and packages of the build environment. A third party can use that record to rebuild the wheel and compare the bytes. + +Quick start +----------- + +For a project built with `hatchling `_, add the hook to the build requirements and enable +it in ``pyproject.toml``: + +.. code-block:: toml + + [build-system] + build-backend = 'hatchling.build' + requires = ['hatchling>=1.28', 'python-sbom[hatch]>=0.1'] + + [tool.hatch.build.hooks.python-sbom] + +Every wheel you build now carries the SBOM. For a project built with another backend, such as meson-python, run +``python-sbom embed dist/*.whl`` after the build; :doc:`usage` shows both. + +.. toctree:: + :maxdepth: 2 + + usage + extend + explanation + cli + py_api + changelog + contributing diff --git a/docs/py_api.rst b/docs/py_api.rst new file mode 100644 index 0000000..a9a4bea --- /dev/null +++ b/docs/py_api.rst @@ -0,0 +1,87 @@ +Python API +========== + +The Python API is what a project needs to add its own knowledge to the SBOM: the hatchling build hook and the content +provider it calls, with the types those take and return. Everything else, from embedding to validation, runs through the +:doc:`cli`. The core is importable from ``python_sbom`` without hatchling; the hook lives in ``python_sbom.hatch``. + +.. py:data:: python_sbom.__version__ + :type: str + + The installed version of python-sbom. + +Hatchling build hook +-------------------- + +.. autoclass:: python_sbom.hatch.SbomBuildHook + :members: content_provider + +Content provider +---------------- + +.. autoclass:: python_sbom.ContentProvider + :members: bundled_wheel, contributions + +.. autoclass:: python_sbom.ProviderContext + :members: read + +.. autoclass:: python_sbom.BundledWheelInfo + +.. autoclass:: python_sbom.Contributions + +.. autoclass:: python_sbom.Settings + +.. autoclass:: python_sbom.ProjectIdentity + +Component types +--------------- + +These :class:`~typing.TypedDict` types describe the JSON a provider builds. Each follows the CycloneDX 1.6 object of the +same name; the `CycloneDX 1.6 reference `_ describes every key. + +.. py:class:: python_sbom.Component + + A CycloneDX 1.6 component, a distribution, an application, the interpreter or a file, as a dictionary with the keys + the CycloneDX component object defines. python-sbom uses ``type``, ``bom-ref``, ``name``, ``version``, ``purl``, + ``supplier`` (an :class:`~python_sbom.OrganizationalEntity`), ``authors`` (:class:`~python_sbom.Contact`), + ``description``, ``licenses`` (:class:`~python_sbom.License`), ``copyright``, ``hashes`` + (:class:`~python_sbom.Hash`), ``externalReferences`` (:class:`~python_sbom.ExternalReference`), ``properties`` + (:class:`~python_sbom.Property`), ``evidence`` (:class:`~python_sbom.Evidence`) and the nested ``components``. + +.. autoclass:: python_sbom.Property + :no-members: + +.. autoclass:: python_sbom.Dependency + :no-members: + +.. autoclass:: python_sbom.OrganizationalEntity + :no-members: + +.. autoclass:: python_sbom.Contact + :no-members: + +.. autoclass:: python_sbom.ExternalReference + :no-members: + +.. autoclass:: python_sbom.Hash + :no-members: + +.. autoclass:: python_sbom.License + :no-members: + +.. autoclass:: python_sbom.LicenseInfo + :no-members: + +.. autoclass:: python_sbom.Evidence + :no-members: + +.. autoclass:: python_sbom.Identity + :no-members: + +.. autoclass:: python_sbom.IdentityMethod + :no-members: + +Errors +------ + +.. autoexception:: python_sbom.SbomError diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..363a3b4 --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,177 @@ +Use it +====== + +With hatchling +-------------- + +Add the hook to ``build-system.requires`` and enable it with an empty table, as the quick start on the :doc:`index` +shows. The ``hatch`` extra brings hatchling; the command line needs none. The hook writes the SBOM into wheels only: an +editable wheel carries none, and an sdist carries the source revision instead, so a wheel built from it still names its +commit. + +The hook writes the SBOM once hatchling has written the wheel, so the document describes the final file name and +metadata, whatever dependencies or tag other hooks changed. Hatchling runs the hooks in the order of their tables; see +its `build hook documentation `_. The order does not matter for +this hook. + +Options +~~~~~~~ + +The options live in a ``[tool.python-sbom]`` table, the one place the hook, ``python-sbom embed`` and ``python-sbom +archive`` all read, so every SBOM of a project names it alike. Each option is optional; an unknown key fails the build, +so a typo cannot pass silently. The hook's own table holds only hatchling's options, and a python-sbom option there +fails the build with a pointer to ``[tool.python-sbom]``. + +``supplier`` + The organization that supplies the project: a table with a ``name`` string and an optional ``url`` string array. It + becomes the supplier and manufacturer of the document and the supplier of the project. Left out, the document names + no supplier. + + .. code-block:: toml + + [tool.python-sbom] + supplier = { name = 'Python Packaging Authority', url = ['https://www.pypa.io'] } + +``repository`` + The repository URL, used for the link to the source revision, the license file and, on GitHub, the security policy + and advisories. It defaults to the first ``Source``, ``Repository`` or ``GitHub`` URL in ``[project.urls]``. + + .. code-block:: toml + + [tool.python-sbom] + repository = 'https://gitlab.com/example/project' + +``default-branch`` + The branch in links to files of the repository when the build's commit is unknown or the checkout has + uncommitted changes, ``main`` by default; otherwise the links name the commit. + + .. code-block:: toml + + [tool.python-sbom] + default-branch = 'trunk' + +``bundled-wheels`` + Glob patterns, relative to the project root, of wheels the project ships as data files. Each matching wheel becomes + a component with its SHA-256, license, files and the packages it vendors. + + .. code-block:: toml + + [tool.python-sbom] + bundled-wheels = ['src/project/wheels/*.whl'] + +``content-provider`` + A :class:`~python_sbom.ContentProvider`, as ``module:attribute`` importable from the project root, that adds what + the project knows beyond its metadata; :doc:`extend` shows one. Loading it imports, and so runs, that module. + + .. code-block:: toml + + [tool.python-sbom] + content-provider = 'sbom_content:Content' + +A bundled wheel's files come from its ``RECORD``, and the build fails when a member is missing from it, a row names a +member the wheel lacks, or a digest differs from the member's content. Its vendored packages come from +``_vendor/vendor.txt``, pip's manifest, whose lines must each pin one exact version, and from any +``.dist-info/METADATA`` under ``_vendor/``. A bundled wheel gets a supplier only from the project's content provider, +never from its own metadata. + +The source revision +~~~~~~~~~~~~~~~~~~~ + +The SBOM names the commit the wheel was built from. It takes the first source that knows it: + +#. the ``PYTHON_SBOM_COMMIT`` environment variable, or ``--commit`` on the command line; +#. git, when the project's ``pyproject.toml`` is tracked by the checkout that holds it, so a project unpacked inside an + unrelated repository never borrows that repository's commit; +#. ``.python-sbom-revision``, which the hook writes into every sdist built from a clean checkout, so a wheel built from + the sdist, as ``python -m build`` and ``uv build`` do by default, keeps the commit; +#. the ``node`` line of an expanded ``.git_archival.txt``, for a ``git archive``. + +A commit must be a full hash of 40 or 64 hex characters. A checkout with changes, including untracked files that are not +ignored, gets a ``:vcs-dirty`` property, and the link to the revision says so. When git cannot read the +checkout, the build warns and moves on to the next source. git runs with ``core.fsmonitor`` off, so a repository's +configuration runs no command. + +With another build backend +-------------------------- + +A backend other than hatchling, such as `meson-python `_, cannot run the hook. +``python-sbom embed`` writes the same document into a wheel that backend built, adds the ``RECORD`` row, and keeps every +other entry of the archive as it was. It reads the options from ``[tool.python-sbom]``: + +.. code-block:: toml + + [tool.python-sbom] + supplier = { name = 'tox-dev', url = ['https://github.com/tox-dev'] } + +Build, then embed, from the project root: + +.. code-block:: console + + $ python -m build --wheel + $ python-sbom embed dist/*.whl + wrote turbohtml-1.0.dist-info/sboms/turbohtml.cdx.json into turbohtml-1.0-cp314-cp314-linux_x86_64.whl + +``--commit`` names the source revision when the wheel came from an sdist, and ``--content-provider`` overrides the +``content-provider`` option. + +The build tool record lists the packages of the environment named by ``--build-env``, by default the one ``embed`` runs +in; the interpreter it records is always the one running ``embed``. To record the real toolchain, build without +isolation in the environment ``embed`` runs in, from the same lock. On Linux, meson-python also needs ``patchelf`` +there: + +.. code-block:: console + + $ python -m pip install build python-sbom meson-python meson ninja patchelf + $ python -m build --wheel --no-isolation + $ python-sbom embed dist/*.whl + +``auditwheel repair`` renames a Linux wheel to its ``manylinux`` tag, so the attestation link in an SBOM written before +the repair names the old file. Run ``embed`` on the repaired wheel, after ``auditwheel``, to describe the file you +publish; auditwheel's own SBOM in the same directory stays untouched. + +Render it as SPDX +----------------- + +Some tools read only `SPDX `_. ``python-sbom spdx`` renders the CycloneDX document as SPDX 2.3 JSON: + +.. code-block:: console + + $ unzip -p dist/project-1.0-py3-none-any.whl '*/sboms/project.cdx.json' > project.cdx.json + $ python-sbom spdx project.cdx.json project.spdx.json + +The SPDX document keeps the packages, their hashes, licenses and purls, and the containment, dependency and build tool +relationships. It leaves out the per-file hashes, because SPDX 2.3 wants a SHA-1 for every file and wheel ``RECORD`` +files hold SHA-256, and the build record, which SPDX 2.3 has no field for. Its namespace ends in the CycloneDX serial +number, so both documents name the same build. + +Check it in CI +-------------- + +``python-sbom validate`` needs the ``validate`` extra, and says so when it is missing. It checks every CycloneDX SBOM in +each wheel's ``.dist-info/sboms`` against the CycloneDX 1.6 schema. For the SBOM python-sbom wrote, it also requires the +``urn:uuid`` serial number ``actions/attest`` needs to recognize the format, checks that every reference resolves, and +validates the SPDX rendering; an SBOM another tool wrote, such as auditwheel's, gets the schema check alone: + +.. code-block:: console + + $ python -m pip install 'python-sbom[validate]' + $ python-sbom validate dist/ --allow-license MIT --allow-license Apache-2.0 + +Each ``--allow-license`` adds an SPDX id that bundled wheels may use. With any given, a bundled wheel whose license the +ids do not allow fails the check, and so does one that declares a license only by name. + +Before anything else, ``validate`` counts the components an SBOM lists, nested ones included, and fails an SBOM that +lists more than 100,000 without checking it further. Every check takes time in proportion to the count: 100,000 files +take about a second, and 100,000 packages, each also checked in the SPDX rendering, about six. The cap thus bounds what +an untrusted SBOM can ask of a CI job while staying far above real SBOMs: every file of the wheel, of each bundled wheel +and of each build tool counts, and virtualenv's wheel lists about 5,200. Raise the cap for an SBOM you expect to be +larger, or lower it to refuse more: + +.. code-block:: console + + $ python-sbom validate dist/ --max-components 500000 + +The cap also sets the size limit: 1 KiB per component plus 1 MiB, 103,448,576 bytes at the default, about twice the 54 +MB python-sbom writes for 100,000 components. ``validate`` refuses a larger SBOM before parsing it, and reads the SBOMs +of a wheel one at a time, so memory holds only one however many the wheel packs. It also refuses an SBOM nested more +than 128 levels deep; the SBOMs python-sbom and other tools write nest about a dozen levels. diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 0000000..1c36882 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,45 @@ +"""Write python-sbom's own SBOM into its wheel with the hook the package ships.""" + +from __future__ import annotations + +import sys +from functools import cached_property +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + +if TYPE_CHECKING: + from python_sbom.hatch import SbomBuildHook + + +class SelfSbomHook(BuildHookInterface): # simplint: ignore[SMP409] # hatchling loads it from this script + def initialize( + self, + version: str, + build_data: dict[str, Any], # simplint: ignore[SMP602] # hatchling's signature + ) -> None: + self._hook.initialize(version, build_data) + + @cached_property + def _hook(self) -> SbomBuildHook: + # python-sbom cannot be a build requirement of itself, so its hook comes from this source tree; by now the vcs + # hook has written the version module the package imports. One hook serves the whole build, so finalize reuses + # the revision initialize read and removes the revision file it wrote + sys.path.insert(0, str(Path(self.root) / "src")) + from python_sbom.hatch import SbomBuildHook # ruff: ignore[import-outside-top-level] # see above + + hook = SbomBuildHook( + self.root, self.config, self.build_config, self.metadata, self.directory, self.target_name, self.app + ) + # as hatchling names a custom hook, so the SBOM lists this script among the build tools + hook.PLUGIN_NAME = self.PLUGIN_NAME + return hook + + def finalize( + self, + version: str, + build_data: dict[str, Any], # simplint: ignore[SMP602] # hatchling's signature + artifact_path: str, + ) -> None: + self._hook.finalize(version, build_data, artifact_path) diff --git a/pylock.toml b/pylock.toml new file mode 100644 index 0000000..1fa3661 --- /dev/null +++ b/pylock.toml @@ -0,0 +1,3119 @@ +lock-version = "1.0" +environments = [ + "python_version == \"3.15\" and sys_platform == \"linux\" and platform_machine == \"x86_64\" and implementation_name == \"cpython\" and os_name == \"posix\" and platform_python_implementation == \"CPython\" and platform_system == \"Linux\"", +] +requires-python = ">=3.11" +extras = [ + "hatch", + "validate", +] +dependency-groups = [ + "bench", + "build", + "dev", + "docs", + "lint", + "pkg-meta", + "project-runtime", + "release-news", + "release-notes", + "release-prepare", + "test", + "type", +] +default-groups = [ + "project-runtime", +] +created-by = "nab" + +[[packages]] +name = "accessible-pygments" +version = "0.0.5" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "pygments" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "accessible_pygments-0.0.5.tar.gz" +upload-time = 2024-05-10 11:23:10.216775+00:00 +url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz" +size = 1377899 + +[packages.sdist.hashes] +sha256 = "40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872" + +[[packages.wheels]] +name = "accessible_pygments-0.0.5-py3-none-any.whl" +upload-time = 2024-05-10 11:23:08.421378+00:00 +url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl" +size = 1395903 + +[packages.wheels.hashes] +sha256 = "88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7" + +[[packages]] +name = "alabaster" +version = "1.0.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "alabaster-1.0.0.tar.gz" +upload-time = 2024-07-26 18:15:03.762569+00:00 +url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz" +size = 24210 + +[packages.sdist.hashes] +sha256 = "c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e" + +[[packages.wheels]] +name = "alabaster-1.0.0-py3-none-any.whl" +upload-time = 2024-07-26 18:15:02.050654+00:00 +url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl" +size = 13929 + +[packages.wheels.hashes] +sha256 = "fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b" + +[[packages]] +name = "annotated-types" +version = "0.8.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "annotated_types-0.8.0.tar.gz" +upload-time = 2026-07-23 20:16:13.995706+00:00 +url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz" +size = 15893 + +[packages.sdist.hashes] +sha256 = "13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7" + +[[packages.wheels]] +name = "annotated_types-0.8.0-py3-none-any.whl" +upload-time = 2026-07-23 20:16:12.938651+00:00 +url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl" +size = 13427 + +[packages.wheels.hashes] +sha256 = "f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0" + +[[packages]] +name = "attrs" +version = "26.1.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "attrs-26.1.0.tar.gz" +upload-time = 2026-03-19 14:22:25.026315+00:00 +url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz" +size = 952055 + +[packages.sdist.hashes] +sha256 = "d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32" + +[[packages.wheels]] +name = "attrs-26.1.0-py3-none-any.whl" +upload-time = 2026-03-19 14:22:23.645947+00:00 +url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl" +size = 67548 + +[packages.wheels.hashes] +sha256 = "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309" + +[[packages]] +name = "babel" +version = "2.18.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "babel-2.18.0.tar.gz" +upload-time = 2026-02-01 12:30:56.078194+00:00 +url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz" +size = 9959554 + +[packages.sdist.hashes] +sha256 = "b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d" + +[[packages.wheels]] +name = "babel-2.18.0-py3-none-any.whl" +upload-time = 2026-02-01 12:30:53.445172+00:00 +url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl" +size = 10196845 + +[packages.wheels.hashes] +sha256 = "e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35" + +[[packages]] +name = "beartype" +version = "0.23.1" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "beartype-0.23.1.tar.gz" +upload-time = 2026-10-11 05:36:55.251880+00:00 +url = "https://files.pythonhosted.org/packages/79/34/c0cfdaee9c796c8381439fed0fcab62810753b8b0adb1ec88fa7f41eaa5d/beartype-0.23.1.tar.gz" +size = 1993714 + +[packages.sdist.hashes] +sha256 = "8b805f246b32931c74f2b321ea0939fe5bb2ff43c12a3eff19cc4c854b832e1d" + +[[packages.wheels]] +name = "beartype-0.23.1-py3-none-any.whl" +upload-time = 2026-10-11 05:36:52.431648+00:00 +url = "https://files.pythonhosted.org/packages/1c/cb/2f6aa982e2860c1156830db3da7f823b71784ed7d533ca289ea9b6c6347a/beartype-0.23.1-py3-none-any.whl" +size = 1582101 + +[packages.wheels.hashes] +sha256 = "4461b4dc57e3fdd6c8a8464b22cf118002eea107c33e9fef651c69068ab3cce3" + +[[packages]] +name = "beautifulsoup4" +version = "4.15.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.7.0" +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "beautifulsoup4-4.15.0.tar.gz" +upload-time = 2026-06-07 16:44:20.453113+00:00 +url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz" +size = 632571 + +[packages.sdist.hashes] +sha256 = "288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7" + +[[packages.wheels]] +name = "beautifulsoup4-4.15.0-py3-none-any.whl" +upload-time = 2026-06-07 16:44:21.566528+00:00 +url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl" +size = 109924 + +[packages.wheels.hashes] +sha256 = "d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9" + +[[packages]] +name = "boolean-py" +version = "5.0" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "boolean_py-5.0.tar.gz" +upload-time = 2025-04-03 10:39:49.734090+00:00 +url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz" +size = 37047 + +[packages.sdist.hashes] +sha256 = "60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95" + +[[packages.wheels]] +name = "boolean_py-5.0-py3-none-any.whl" +upload-time = 2025-04-03 10:39:48.449265+00:00 +url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl" +size = 26577 + +[packages.wheels.hashes] +sha256 = "ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9" + +[[packages]] +name = "certifi" +version = "2026.10.23" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "certifi-2026.10.23.tar.gz" +upload-time = 2026-10-23 11:36:16.478386+00:00 +url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz" +size = 136225 + +[packages.sdist.hashes] +sha256 = "a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd" + +[[packages.wheels]] +name = "certifi-2026.10.23-py3-none-any.whl" +upload-time = 2026-10-23 11:36:14.763067+00:00 +url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl" +size = 135082 + +[packages.wheels.hashes] +sha256 = "e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1" + +[[packages]] +name = "cffi" +version = "2.1.1" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "pycparser" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "cffi-2.1.1.tar.gz" +upload-time = 2026-08-03 21:21:18.939988+00:00 +url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz" +size = 530807 + +[packages.sdist.hashes] +sha256 = "dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be" + +[[packages.wheels]] +name = "cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-08-03 21:20:57.336082+00:00 +url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 221630 + +[packages.wheels.hashes] +sha256 = "aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399" + +[[packages]] +name = "cfgv" +version = "3.5.0" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "cfgv-3.5.0.tar.gz" +upload-time = 2025-11-19 20:55:51.612035+00:00 +url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz" +size = 7334 + +[packages.sdist.hashes] +sha256 = "d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132" + +[[packages.wheels]] +name = "cfgv-3.5.0-py2.py3-none-any.whl" +upload-time = 2025-11-19 20:55:50.744331+00:00 +url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl" +size = 7445 + +[packages.wheels.hashes] +sha256 = "a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0" + +[[packages]] +name = "charset-normalizer" +version = "3.5.2" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "charset_normalizer-3.5.2.tar.gz" +upload-time = 2026-09-30 04:39:23.398219+00:00 +url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz" +size = 172659 + +[packages.sdist.hashes] +sha256 = "39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef" + +[[packages.wheels]] +name = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 04:37:37.397915+00:00 +url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +size = 257336 + +[packages.wheels.hashes] +sha256 = "3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229" + +[[packages.wheels]] +name = "charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" +upload-time = 2026-09-30 04:38:28.032254+00:00 +url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" +size = 253276 + +[packages.wheels.hashes] +sha256 = "1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7" + +[[packages.wheels]] +name = "charset_normalizer-3.5.2-py3-none-any.whl" +upload-time = 2026-09-30 04:39:21.828049+00:00 +url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl" +size = 68872 + +[packages.wheels.hashes] +sha256 = "b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685" + +[[packages]] +name = "check-wheel-contents" +version = "0.6.3" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "attrs" }, + { name = "click" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "wheel-filename" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "check_wheel_contents-0.6.3.tar.gz" +upload-time = 2025-08-02 14:01:45.546952+00:00 +url = "https://files.pythonhosted.org/packages/95/37/2b9e0d38c2f668791ffe8b97711185c364d91c027e47f189c371c2348d18/check_wheel_contents-0.6.3.tar.gz" +size = 586023 + +[packages.sdist.hashes] +sha256 = "10e6939e2fe4e6ce1edf2ff6ec6157808677e80782e78021ae139dd88473a442" + +[[packages.wheels]] +name = "check_wheel_contents-0.6.3-py3-none-any.whl" +upload-time = 2025-08-02 14:01:43.968164+00:00 +url = "https://files.pythonhosted.org/packages/be/05/f39fde9f31ef80b285ef5822fad4ddabf73fec62a1f02c5beb4b2f328972/check_wheel_contents-0.6.3-py3-none-any.whl" +size = 27541 + +[packages.wheels.hashes] +sha256 = "5ae39c8c434b972f0740d04610759168590713175aab584b012b1b84f6771874" + +[[packages]] +name = "click" +version = "8.5.0" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "click-8.5.0.tar.gz" +upload-time = 2026-08-26 13:33:14.560531+00:00 +url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz" +size = 382235 + +[packages.sdist.hashes] +sha256 = "ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34" + +[[packages.wheels]] +name = "click-8.5.0-py3-none-any.whl" +upload-time = 2026-08-26 13:33:12.928043+00:00 +url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl" +size = 125251 + +[packages.wheels.hashes] +sha256 = "255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360" + +[[packages]] +name = "covdefaults" +version = "2.3.0" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.7" +dependencies = [ + { name = "coverage" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "covdefaults-2.3.0.tar.gz" +upload-time = 2023-03-05 16:43:34.779146+00:00 +url = "https://files.pythonhosted.org/packages/44/ee/9a6f2611f72e4c5657ae5542a510cf4164d2c673687c0ea73bb1cbd85b4d/covdefaults-2.3.0.tar.gz" +size = 4835 + +[packages.sdist.hashes] +sha256 = "4e99f679f12d792bc62e5510fa3eb59546ed47bd569e36e4fddc4081c9c3ebf7" + +[[packages.wheels]] +name = "covdefaults-2.3.0-py2.py3-none-any.whl" +upload-time = 2023-03-05 16:43:33.245581+00:00 +url = "https://files.pythonhosted.org/packages/76/4c/823bc951445aa97e5a1b7e337690db3abf85212c8d138e170922e7916ac8/covdefaults-2.3.0-py2.py3-none-any.whl" +size = 5144 + +[packages.wheels.hashes] +sha256 = "2832961f6ffcfe4b57c338bc3418a3526f495c26fb9c54565409c5532f7c41be" + +[[packages]] +name = "coverage" +version = "7.17.0" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "coverage-7.17.0.tar.gz" +upload-time = 2026-10-22 16:06:50.009670+00:00 +url = "https://files.pythonhosted.org/packages/d2/79/e78bd8f75a9b8e32233ce92d4607f0a21ce88a9156cace2884e7175a82f5/coverage-7.17.0.tar.gz" +size = 991021 + +[packages.sdist.hashes] +sha256 = "cc010a0a7fbdd9bb8bdfd0d3f04a5151275860213cbd7e2b876b29778489c712" + +[[packages.wheels]] +name = "coverage-7.17.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" +upload-time = 2026-10-22 16:06:08.861264+00:00 +url = "https://files.pythonhosted.org/packages/95/ee/84ed138990ed752abd73db4adffc1842d6152017d8e21809cc109a413fda/coverage-7.17.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" +size = 261735 + +[packages.wheels.hashes] +sha256 = "aed06129a9f530a948928260d7d2d81c47261355b0127e3c782251b38958463c" + +[[packages.wheels]] +name = "coverage-7.17.0-py3-none-any.whl" +upload-time = 2026-10-22 16:06:48.471847+00:00 +url = "https://files.pythonhosted.org/packages/c9/17/48032919343afa2906112d458aceda2043afdb24a2ee8eb4c60904d8ddf1/coverage-7.17.0-py3-none-any.whl" +size = 217383 + +[packages.wheels.hashes] +sha256 = "bac487dce0d2aff28ddf1a533a916f771416eee0e493960f611e49037b878e9d" + +[[packages]] +name = "cryptography" +version = "50.0.2" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = "!=3.9.0,!=3.9.1,>=3.9" +dependencies = [ + { name = "cffi" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "cryptography-50.0.2.tar.gz" +upload-time = 2026-09-30 15:30:04.884601+00:00 +url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz" +size = 880623 + +[packages.sdist.hashes] +sha256 = "7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-09-30 14:43:49.010540+00:00 +url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 4719841 + +[packages.wheels.hashes] +sha256 = "630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 14:43:55.272150+00:00 +url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl" +size = 4753050 + +[packages.wheels.hashes] +sha256 = "4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl" +upload-time = 2026-09-30 14:44:04.545137+00:00 +url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl" +size = 4752576 + +[packages.wheels.hashes] +sha256 = "9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-09-30 14:44:45.769143+00:00 +url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 4966465 + +[packages.wheels.hashes] +sha256 = "f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 14:44:53.379605+00:00 +url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl" +size = 5001199 + +[packages.wheels.hashes] +sha256 = "58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl" +upload-time = 2026-09-30 14:45:05.009031+00:00 +url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl" +size = 5000859 + +[packages.wheels.hashes] +sha256 = "e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-09-30 15:29:26.807001+00:00 +url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 4749145 + +[packages.wheels.hashes] +sha256 = "ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl" +upload-time = 2026-09-30 15:29:32.605292+00:00 +url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl" +size = 4781387 + +[packages.wheels.hashes] +sha256 = "f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5" + +[[packages.wheels]] +name = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl" +upload-time = 2026-09-30 15:29:41.251208+00:00 +url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl" +size = 4780973 + +[packages.wheels.hashes] +sha256 = "1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c" + +[[packages]] +name = "distlib" +version = "0.4.4" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "distlib-0.4.4.tar.gz" +upload-time = 2026-10-16 13:56:34.203008+00:00 +url = "https://files.pythonhosted.org/packages/68/bc/d36966de7304047a1001a7559fbf266c464bb5d8cc85464536ef8c9bcc32/distlib-0.4.4.tar.gz" +size = 621304 + +[packages.sdist.hashes] +sha256 = "ca4175b4ac7f71223cc30ed7248c45d2b38c2439d8ee6e498ab67ed63cf3508d" + +[[packages.wheels]] +name = "distlib-0.4.4-py2.py3-none-any.whl" +upload-time = 2026-10-16 13:56:32.053304+00:00 +url = "https://files.pythonhosted.org/packages/c1/2b/238f02a4927fa2e27aa995371d240a468b52e42eae44a678434aff736b38/distlib-0.4.4-py2.py3-none-any.whl" +size = 472099 + +[packages.wheels.hashes] +sha256 = "cf09d55b1e85a89576b036aeb71550bfd68320f3f21761ca329eadadad918638" + +[[packages]] +name = "docutils" +version = "0.23" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "docutils-0.23.tar.gz" +upload-time = 2026-05-27 17:41:06.934109+00:00 +url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz" +size = 2303823 + +[packages.sdist.hashes] +sha256 = "746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e" + +[[packages.wheels]] +name = "docutils-0.23-py3-none-any.whl" +upload-time = 2026-05-27 17:40:58.442528+00:00 +url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl" +size = 634701 + +[packages.wheels.hashes] +sha256 = "25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea" + +[[packages]] +name = "filelock" +version = "4.2.5" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "filelock-4.2.5.tar.gz" +upload-time = 2026-10-30 19:27:22.237276+00:00 +url = "https://files.pythonhosted.org/packages/aa/4b/2320d9fa03b65b4beb43b521fe1b12b7f87ab91ee42de3a286075016d205/filelock-4.2.5.tar.gz" +size = 659943 + +[packages.sdist.hashes] +sha256 = "37d8fa4f8b5e9233612e68ab23bf64cae41771322c8797361088ae2895b12894" + +[[packages.wheels]] +name = "filelock-4.2.5-py3-none-any.whl" +upload-time = 2026-10-30 19:27:20.409828+00:00 +url = "https://files.pythonhosted.org/packages/14/2d/2cf36eca330b7a97c0e9076e8fc4272127c29398fa2bb7b24d4321b8b476/filelock-4.2.5-py3-none-any.whl" +size = 163877 + +[packages.wheels.hashes] +sha256 = "46ce42d27e5cdde09ee7197349663e97cb106679bd9e75db3a09679fbc7aa1a8" + +[[packages]] +name = "furo" +version = "2025.12.19" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.8" +dependencies = [ + { name = "accessible-pygments" }, + { name = "beautifulsoup4" }, + { name = "pygments" }, + { name = "sphinx" }, + { name = "sphinx-basic-ng" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "furo-2025.12.19.tar.gz" +upload-time = 2025-12-19 17:34:40.889109+00:00 +url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz" +size = 1661473 + +[packages.sdist.hashes] +sha256 = "188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7" + +[[packages.wheels]] +name = "furo-2025.12.19-py3-none-any.whl" +upload-time = 2025-12-19 17:34:38.905692+00:00 +url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl" +size = 339262 + +[packages.wheels.hashes] +sha256 = "bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f" + +[[packages]] +name = "hatch-fancy-pypi-readme" +version = "25.1.0" +marker = "\"bench\" in dependency_groups or \"build\" in dependency_groups or \"dev\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.8" +dependencies = [ + { name = "hatchling" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "hatch_fancy_pypi_readme-25.1.0.tar.gz" +upload-time = 2025-05-01 10:34:42.989334+00:00 +url = "https://files.pythonhosted.org/packages/f3/0f/aed57c301f339936eb91cb4d8c1e5088a101081854bd3ec18a889df32365/hatch_fancy_pypi_readme-25.1.0.tar.gz" +size = 29701 + +[packages.sdist.hashes] +sha256 = "9c58ed3dff90d51f43414ce37009ad1d5b0f08ffc9fc216998a06380f01c0045" + +[[packages.wheels]] +name = "hatch_fancy_pypi_readme-25.1.0-py3-none-any.whl" +upload-time = 2025-05-01 10:34:41.204793+00:00 +url = "https://files.pythonhosted.org/packages/58/b4/ad71e051b34f1935783de37beffee16e343ff2514b204dc515f9e939d4af/hatch_fancy_pypi_readme-25.1.0-py3-none-any.whl" +size = 10046 + +[packages.wheels.hashes] +sha256 = "ce0134c40d63d874ac48f48ccc678b8f3b62b8e50e9318520d2bffc752eedaf3" + +[[packages]] +name = "hatch-vcs" +version = "0.5.0" +marker = "\"bench\" in dependency_groups or \"build\" in dependency_groups or \"dev\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "hatchling" }, + { name = "setuptools-scm" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "hatch_vcs-0.5.0.tar.gz" +upload-time = 2025-05-27 05:16:04.490926+00:00 +url = "https://files.pythonhosted.org/packages/6b/b0/4cc743d38adbee9d57d786fa496ed1daadb17e48589b6da8fa55717a0746/hatch_vcs-0.5.0.tar.gz" +size = 11424 + +[packages.sdist.hashes] +sha256 = "0395fa126940340215090c344a2bf4e2a77bcbe7daab16f41b37b98c95809ff9" + +[[packages.wheels]] +name = "hatch_vcs-0.5.0-py3-none-any.whl" +upload-time = 2025-05-27 05:16:03.184909+00:00 +url = "https://files.pythonhosted.org/packages/5f/48/1f85cee4b7b4f40b9b814b1febbc661bda6ced9649e410a0b74f6e415dd0/hatch_vcs-0.5.0-py3-none-any.whl" +size = 8507 + +[packages.wheels.hashes] +sha256 = "b49677dbdc597460cc22d01b27ab3696f5e16a21ecf2700fb01bc28e1f2a04a7" + +[[packages]] +name = "hatchling" +version = "1.33.0" +marker = "\"bench\" in dependency_groups or \"build\" in dependency_groups or \"dev\" in dependency_groups or \"docs\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"hatch\" in extras" +requires-python = ">=3.10" +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "tomlkit" }, + { name = "trove-classifiers" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "hatchling-1.33.0.tar.gz" +upload-time = 2026-10-28 02:27:30.694770+00:00 +url = "https://files.pythonhosted.org/packages/7a/f1/e47b937226ae3b093bbd0132eb0b7ee5bfad12c2b5324afd10ae49946b05/hatchling-1.33.0.tar.gz" +size = 58079 + +[packages.sdist.hashes] +sha256 = "4c422bd4d554fcff5de5b73e9596f85e41e9f97f7e553d8f7595ecb31a627bef" + +[[packages.wheels]] +name = "hatchling-1.33.0-py3-none-any.whl" +upload-time = 2026-10-28 02:27:29.033037+00:00 +url = "https://files.pythonhosted.org/packages/32/64/4afe5dd77b2939a93e8c9513f8082dbd049ecba9c1484a379a0f6ae29d6b/hatchling-1.33.0-py3-none-any.whl" +size = 78750 + +[packages.wheels.hashes] +sha256 = "415290b84f464d2884303fca8974d38e7b85579069fab02a15db0828fbb1446d" + +[[packages]] +name = "id" +version = "1.6.1" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "urllib3" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "id-1.6.1.tar.gz" +upload-time = 2026-02-04 16:19:41.260133+00:00 +url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz" +size = 18088 + +[packages.sdist.hashes] +sha256 = "d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069" + +[[packages.wheels]] +name = "id-1.6.1-py3-none-any.whl" +upload-time = 2026-02-04 16:19:40.051745+00:00 +url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl" +size = 14689 + +[packages.wheels.hashes] +sha256 = "f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca" + +[[packages]] +name = "identify" +version = "2.6.20" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "identify-2.6.20.tar.gz" +upload-time = 2026-09-26 20:29:07.187608+00:00 +url = "https://files.pythonhosted.org/packages/53/35/d70c0006c7cee65999ea94a6273e60b2094f600a3d8b71b04318253fc643/identify-2.6.20.tar.gz" +size = 99681 + +[packages.sdist.hashes] +sha256 = "ad729860a923858d26917c2f4fb0a1d83d27a75b1e090c06440c573f048f3285" + +[[packages.wheels]] +name = "identify-2.6.20-py2.py3-none-any.whl" +upload-time = 2026-09-26 20:29:06.054092+00:00 +url = "https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl" +size = 99506 + +[packages.wheels.hashes] +sha256 = "6a16b69b93187244e0548cbfd25b3e4a6f9a7a2ad784625c3bec2b8d27b81aaa" + +[[packages]] +name = "idna" +version = "3.20" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "idna-3.20.tar.gz" +upload-time = 2026-09-17 14:11:04.752193+00:00 +url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz" +size = 216463 + +[packages.sdist.hashes] +sha256 = "a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44" + +[[packages.wheels]] +name = "idna-3.20-py3-none-any.whl" +upload-time = 2026-09-17 14:11:03.168153+00:00 +url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl" +size = 69583 + +[packages.wheels.hashes] +sha256 = "ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c" + +[[packages]] +name = "imagesize" +version = "2.0.1" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "imagesize-2.0.1.tar.gz" +upload-time = 2026-08-24 12:35:19.130667+00:00 +url = "https://files.pythonhosted.org/packages/fb/5e/513ff06670c84e7b9887c1fdf61b2d42b4f574a831f2f1d2222023049d8a/imagesize-2.0.1.tar.gz" +size = 1883774 + +[packages.sdist.hashes] +sha256 = "b2ba6a4dea487a7ebcd53248d3476aca449d30db12a2dde5e0c5ca9624fd77e5" + +[[packages.wheels]] +name = "imagesize-2.0.1-py3-none-any.whl" +upload-time = 2026-08-24 12:35:12.548956+00:00 +url = "https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl" +size = 14794 + +[packages.wheels.hashes] +sha256 = "ea0c9a0384df69ed86a943a15cde37d0360b82491b3910dc2215e202e62b5b02" + +[[packages]] +name = "iniconfig" +version = "2.3.1" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "iniconfig-2.3.1.tar.gz" +upload-time = 2026-10-06 22:48:38.076390+00:00 +url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz" +size = 21209 + +[packages.sdist.hashes] +sha256 = "67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960" + +[[packages.wheels]] +name = "iniconfig-2.3.1-py3-none-any.whl" +upload-time = 2026-10-06 22:48:36.959685+00:00 +url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl" +size = 7552 + +[packages.wheels.hashes] +sha256 = "9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" + +[[packages]] +name = "installer" +version = "1.1.0" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"project-runtime\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "installer-1.1.0.tar.gz" +upload-time = 2026-10-12 19:35:52.851170+00:00 +url = "https://files.pythonhosted.org/packages/56/d3/2fab33d15004d41ae8e7b5d3e310510f7f34a7e1dd032a3e60d8b38d8fba/installer-1.1.0.tar.gz" +size = 481257 + +[packages.sdist.hashes] +sha256 = "5adc0c01ce62d809989e246987cd68944fe5df8af562d0358f65b2ab15cb5de7" + +[[packages.wheels]] +name = "installer-1.1.0-py3-none-any.whl" +upload-time = 2026-10-12 19:35:51.096942+00:00 +url = "https://files.pythonhosted.org/packages/d7/7a/63547a792a12148882205d79e07644091d5b7690f40c2bb9d41452a8bdc2/installer-1.1.0-py3-none-any.whl" +size = 464466 + +[packages.wheels.hashes] +sha256 = "f55f5728ff61d3dd54dbc5a397cf3f7307cb64b180c20b72afbebf01c9e2c8a0" + +[[packages]] +name = "jaraco-classes" +version = "3.4.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.8" +dependencies = [ + { name = "more-itertools" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "jaraco.classes-3.4.0.tar.gz" +upload-time = 2024-03-31 07:27:36.643708+00:00 +url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz" +size = 11780 + +[packages.sdist.hashes] +sha256 = "47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd" + +[[packages.wheels]] +name = "jaraco.classes-3.4.0-py3-none-any.whl" +upload-time = 2024-03-31 07:27:34.792832+00:00 +url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl" +size = 6777 + +[packages.wheels.hashes] +sha256 = "f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" + +[[packages]] +name = "jaraco-context" +version = "6.1.2" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "jaraco_context-6.1.2.tar.gz" +upload-time = 2026-03-20 22:13:33.922545+00:00 +url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz" +size = 16801 + +[packages.sdist.hashes] +sha256 = "f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3" + +[[packages.wheels]] +name = "jaraco_context-6.1.2-py3-none-any.whl" +upload-time = 2026-03-20 22:13:32.808832+00:00 +url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl" +size = 7871 + +[packages.wheels.hashes] +sha256 = "bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535" + +[[packages]] +name = "jaraco-functools" +version = "4.6.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "more-itertools" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "jaraco_functools-4.6.0.tar.gz" +upload-time = 2026-07-14 01:28:02.544761+00:00 +url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz" +size = 20837 + +[packages.sdist.hashes] +sha256 = "880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280" + +[[packages.wheels]] +name = "jaraco_functools-4.6.0-py3-none-any.whl" +upload-time = 2026-07-14 01:28:01.590470+00:00 +url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl" +size = 11677 + +[packages.wheels.hashes] +sha256 = "99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30" + +[[packages]] +name = "jeepney" +version = "0.9.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "jeepney-0.9.0.tar.gz" +upload-time = 2025-02-27 18:51:01.684959+00:00 +url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz" +size = 106758 + +[packages.sdist.hashes] +sha256 = "cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732" + +[[packages.wheels]] +name = "jeepney-0.9.0-py3-none-any.whl" +upload-time = 2025-02-27 18:51:00.104279+00:00 +url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl" +size = 49010 + +[packages.wheels.hashes] +sha256 = "97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683" + +[[packages]] +name = "jinja2" +version = "3.1.6" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.7" +dependencies = [ + { name = "markupsafe" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "jinja2-3.1.6.tar.gz" +upload-time = 2025-03-05 20:05:02.478302+00:00 +url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz" +size = 245115 + +[packages.sdist.hashes] +sha256 = "0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" + +[[packages.wheels]] +name = "jinja2-3.1.6-py3-none-any.whl" +upload-time = 2025-03-05 20:05:00.369721+00:00 +url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl" +size = 134899 + +[packages.wheels.hashes] +sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" + +[[packages]] +name = "jsonschema-rs" +version = "0.60.3" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "jsonschema_rs-0.60.3.tar.gz" +upload-time = 2026-10-27 10:34:36.803131+00:00 +url = "https://files.pythonhosted.org/packages/36/17/383e687f63ba7c8c730ff770cb5ff70df32bd7f5114bfdcbd4387b19af54/jsonschema_rs-0.60.3.tar.gz" +size = 2926972 + +[packages.sdist.hashes] +sha256 = "1d831d8eeb775af547aec0e9f75da51122a9c3563486d02e43db88d425b32c86" + +[[packages.wheels]] +name = "jsonschema_rs-0.60.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-27 10:33:53.610644+00:00 +url = "https://files.pythonhosted.org/packages/df/5d/f16cf6b7616b812c057921d31b9d58f42e311808afbcbb7bd3c58e608b65/jsonschema_rs-0.60.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +size = 6183925 + +[packages.wheels.hashes] +sha256 = "326ec5b6800552ccd76a1acca131f70511ac013b377230bdd541db3244adc9fa" + +[[packages]] +name = "keyring" +version = "25.7.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney" }, + { name = "secretstorage" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "keyring-25.7.0.tar.gz" +upload-time = 2025-11-16 16:26:09.482176+00:00 +url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz" +size = 63516 + +[packages.sdist.hashes] +sha256 = "fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b" + +[[packages.wheels]] +name = "keyring-25.7.0-py3-none-any.whl" +upload-time = 2025-11-16 16:26:08.402146+00:00 +url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl" +size = 39160 + +[packages.wheels.hashes] +sha256 = "be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f" + +[[packages]] +name = "license-expression" +version = "30.4.4" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.9" +dependencies = [ + { name = "boolean-py" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "license_expression-30.4.4.tar.gz" +upload-time = 2025-07-22 11:13:32.170576+00:00 +url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz" +size = 186402 + +[packages.sdist.hashes] +sha256 = "73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd" + +[[packages.wheels]] +name = "license_expression-30.4.4-py3-none-any.whl" +upload-time = 2025-07-22 11:13:31.217173+00:00 +url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl" +size = 120615 + +[packages.wheels.hashes] +sha256 = "421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4" + +[[packages]] +name = "markdown-it-py" +version = "4.2.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "mdurl" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "markdown_it_py-4.2.0.tar.gz" +upload-time = 2026-05-07 12:08:28.360612+00:00 +url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz" +size = 82454 + +[packages.sdist.hashes] +sha256 = "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" + +[[packages.wheels]] +name = "markdown_it_py-4.2.0-py3-none-any.whl" +upload-time = 2026-05-07 12:08:27.182789+00:00 +url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl" +size = 91687 + +[packages.wheels.hashes] +sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" + +[[packages]] +name = "markupsafe" +version = "3.0.4" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "markupsafe-3.0.4.tar.gz" +upload-time = 2026-10-02 23:07:22.290270+00:00 +url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz" +size = 153777 + +[packages.sdist.hashes] +sha256 = "2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6" + +[[packages.wheels]] +name = "markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +upload-time = 2026-10-02 23:06:22.524416+00:00 +url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +size = 23111 + +[packages.wheels.hashes] +sha256 = "925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369" + +[[packages]] +name = "mdformat" +version = "1.0.0" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "markdown-it-py" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "mdformat-1.0.0.tar.gz" +upload-time = 2025-10-16 12:05:03.695033+00:00 +url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz" +size = 56953 + +[packages.sdist.hashes] +sha256 = "4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928" + +[[packages.wheels]] +name = "mdformat-1.0.0-py3-none-any.whl" +upload-time = 2025-10-16 12:05:02.607432+00:00 +url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl" +size = 53288 + +[packages.wheels.hashes] +sha256 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b" + +[[packages]] +name = "mdformat-gfm" +version = "1.0.0" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "markdown-it-py" }, + { name = "mdformat" }, + { name = "mdit-py-plugins" }, + { name = "wcwidth" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "mdformat_gfm-1.0.0.tar.gz" +upload-time = 2025-10-16 09:12:22.402429+00:00 +url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz" +size = 10112 + +[packages.sdist.hashes] +sha256 = "d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623" + +[[packages.wheels]] +name = "mdformat_gfm-1.0.0-py3-none-any.whl" +upload-time = 2025-10-16 09:12:21.276157+00:00 +url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl" +size = 10970 + +[packages.wheels.hashes] +sha256 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005" + +[[packages]] +name = "mdit-py-plugins" +version = "0.6.1" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "markdown-it-py" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "mdit_py_plugins-0.6.1.tar.gz" +upload-time = 2026-05-13 09:03:38.910230+00:00 +url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz" +size = 56114 + +[packages.sdist.hashes] +sha256 = "a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0" + +[[packages.wheels]] +name = "mdit_py_plugins-0.6.1-py3-none-any.whl" +upload-time = 2026-05-13 09:03:37.760000+00:00 +url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl" +size = 66663 + +[packages.wheels.hashes] +sha256 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d" + +[[packages]] +name = "mdurl" +version = "0.1.2" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "mdurl-0.1.2.tar.gz" +upload-time = 2022-08-14 12:40:10.846281+00:00 +url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz" +size = 8729 + +[packages.sdist.hashes] +sha256 = "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + +[[packages.wheels]] +name = "mdurl-0.1.2-py3-none-any.whl" +upload-time = 2022-08-14 12:40:09.779676+00:00 +url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl" +size = 9979 + +[packages.wheels.hashes] +sha256 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" + +[[packages]] +name = "more-itertools" +version = "11.2.1" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "more_itertools-11.2.1.tar.gz" +upload-time = 2026-10-14 18:50:30.407709+00:00 +url = "https://files.pythonhosted.org/packages/52/b8/c63995bebbb9ac9c059350e0fb8c82c2420c85aeca7d34e40aecf3df9281/more_itertools-11.2.1.tar.gz" +size = 154133 + +[packages.sdist.hashes] +sha256 = "cbf08fd0af284dc69718b9b76a8e6203df624d70209ea511e4219d350c856f63" + +[[packages.wheels]] +name = "more_itertools-11.2.1-py3-none-any.whl" +upload-time = 2026-10-14 18:50:29.116732+00:00 +url = "https://files.pythonhosted.org/packages/5d/57/3c4b1c6f2e0cc29ba023802458ea3d2a996f87085559e4a5f2192d549532/more_itertools-11.2.1-py3-none-any.whl" +size = 74405 + +[packages.wheels.hashes] +sha256 = "35a7377edd1dd6608dcb2cdf534ded55ea32d49448875ad042cd3f879fb1ded0" + +[[packages]] +name = "nh3" +version = "0.3.7" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "nh3-0.3.7.tar.gz" +upload-time = 2026-08-23 14:26:30.728660+00:00 +url = "https://files.pythonhosted.org/packages/18/2f/022b27146d52d24b1b353b003359134788ecbcd6fcdf6283adbd57c0fbc8/nh3-0.3.7.tar.gz" +size = 25662 + +[packages.sdist.hashes] +sha256 = "71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848" + +[[packages.wheels]] +name = "nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-08-23 14:26:17.272038+00:00 +url = "https://files.pythonhosted.org/packages/a6/ed/c5510c615dce55b6fcc364aa1838142f938beed64f5e4927490dfcaf4405/nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +size = 832161 + +[packages.wheels.hashes] +sha256 = "70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42" + +[[packages]] +name = "nodeenv" +version = "1.11.1" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "nodeenv-1.11.1.tar.gz" +upload-time = 2026-10-29 20:54:04.261005+00:00 +url = "https://files.pythonhosted.org/packages/ab/27/79acfd3d6f526a4a16f3d209dedcf7b5bc1ea15c5bf2bbb82f2c2285057e/nodeenv-1.11.1.tar.gz" +size = 99853 + +[packages.sdist.hashes] +sha256 = "d7fa027c1df437bdd974b0502a8dc76fa1b3183e369ac9539602bc6bab9100ba" + +[[packages.wheels]] +name = "nodeenv-1.11.1-py2.py3-none-any.whl" +upload-time = 2026-10-29 20:54:02.752512+00:00 +url = "https://files.pythonhosted.org/packages/40/35/a09e0b1dac4027e35b872e4f123a9f9e7bbc39e6424bdc9776920d3a6491/nodeenv-1.11.1-py2.py3-none-any.whl" +size = 34473 + +[packages.wheels.hashes] +sha256 = "bc0fb085d8ed0d67a6471892535f3d9b795eb911b7cd129d3a24967aab7a538f" + +[[packages]] +name = "packageurl-python" +version = "0.17.6" +marker = "\"project-runtime\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "packageurl_python-0.17.6.tar.gz" +upload-time = 2025-11-24 15:20:17.998933+00:00 +url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz" +size = 50618 + +[packages.sdist.hashes] +sha256 = "1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25" + +[[packages.wheels]] +name = "packageurl_python-0.17.6-py3-none-any.whl" +upload-time = 2025-11-24 15:20:16.962118+00:00 +url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl" +size = 36776 + +[packages.wheels.hashes] +sha256 = "31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9" + +[[packages]] +name = "packaging" +version = "26.3" +marker = "\"hatch\" in extras or \"bench\" in dependency_groups or \"build\" in dependency_groups or \"dev\" in dependency_groups or \"docs\" in dependency_groups or \"lint\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"project-runtime\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "packaging-26.3.tar.gz" +upload-time = 2026-08-04 18:15:28.737071+00:00 +url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz" +size = 313412 + +[packages.sdist.hashes] +sha256 = "94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" + +[[packages.wheels]] +name = "packaging-26.3-py3-none-any.whl" +upload-time = 2026-08-04 18:15:27.159957+00:00 +url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl" +size = 129956 + +[packages.wheels.hashes] +sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" + +[[packages]] +name = "pathspec" +version = "1.1.1" +marker = "\"bench\" in dependency_groups or \"build\" in dependency_groups or \"dev\" in dependency_groups or \"docs\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"hatch\" in extras" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pathspec-1.1.1.tar.gz" +upload-time = 2026-04-27 01:46:08.907631+00:00 +url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz" +size = 135180 + +[packages.sdist.hashes] +sha256 = "17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a" + +[[packages.wheels]] +name = "pathspec-1.1.1-py3-none-any.whl" +upload-time = 2026-04-27 01:46:07.060850+00:00 +url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl" +size = 57328 + +[packages.wheels.hashes] +sha256 = "a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189" + +[[packages]] +name = "platformdirs" +version = "4.14.0" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "platformdirs-4.14.0.tar.gz" +upload-time = 2026-10-28 18:27:30.973421+00:00 +url = "https://files.pythonhosted.org/packages/40/28/1c058a9143d2304b3078f8dadcbb9dc27f7d3581b5d05157c84869c4cc0d/platformdirs-4.14.0.tar.gz" +size = 70387 + +[packages.sdist.hashes] +sha256 = "83f009c1e8c5fc36869b5171755b291887bd2cade7965233cdfabd23d754720b" + +[[packages.wheels]] +name = "platformdirs-4.14.0-py3-none-any.whl" +upload-time = 2026-10-28 18:27:29.659262+00:00 +url = "https://files.pythonhosted.org/packages/69/2b/887969ef4bb53e0cb271dc7c3f4a73d50c5d58bb88aeaae311e3340cd57e/platformdirs-4.14.0-py3-none-any.whl" +size = 35259 + +[packages.wheels.hashes] +sha256 = "1dadfc59a8a5d7565b3d9270ae8fa71c065595352c4277b41c11ee6c222c4b91" + +[[packages]] +name = "pluggy" +version = "1.7.0" +marker = "\"bench\" in dependency_groups or \"build\" in dependency_groups or \"dev\" in dependency_groups or \"docs\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"hatch\" in extras" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pluggy-1.7.0.tar.gz" +upload-time = 2026-10-15 09:50:58.343484+00:00 +url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz" +size = 123304 + +[packages.sdist.hashes] +sha256 = "d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8" + +[[packages.wheels]] +name = "pluggy-1.7.0-py3-none-any.whl" +upload-time = 2026-10-15 09:50:56.808462+00:00 +url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl" +size = 27082 + +[packages.wheels.hashes] +sha256 = "7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec" + +[[packages]] +name = "ply" +version = "3.11" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "ply-3.11.tar.gz" +upload-time = 2018-02-15 19:01:31.097641+00:00 +url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz" +size = 159130 + +[packages.sdist.hashes] +sha256 = "00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3" + +[[packages.wheels]] +name = "ply-3.11-py2.py3-none-any.whl" +upload-time = 2018-02-15 19:01:27.172483+00:00 +url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl" +size = 49567 + +[packages.wheels.hashes] +sha256 = "096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce" + +[[packages]] +name = "pre-commit" +version = "4.7.0" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = ">=3.11" +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pre_commit-4.7.0.tar.gz" +upload-time = 2026-10-12 20:45:40.320969+00:00 +url = "https://files.pythonhosted.org/packages/3a/c3/6965c2690ddce885ec75121572d065a5b94c5a5d8d3e657927ecda9fd715/pre_commit-4.7.0.tar.gz" +size = 198120 + +[packages.sdist.hashes] +sha256 = "1f3deee12b914c8fa184bd2b5953391c2e4f36763e403d4a23dc9983fa7c6e86" + +[[packages.wheels]] +name = "pre_commit-4.7.0-py2.py3-none-any.whl" +upload-time = 2026-10-12 20:45:38.858255+00:00 +url = "https://files.pythonhosted.org/packages/eb/83/ce0a196e4f7b20b11ce3aa1fc72fb973f64b7628d51c1b8aebad37fb7f81/pre_commit-4.7.0-py2.py3-none-any.whl" +size = 226261 + +[packages.wheels.hashes] +sha256 = "2e229038ad3656081b70c27913157e4b636ef306f643f024a8a8e3f0f7b2f877" + +[[packages]] +name = "pre-commit-uv" +version = "4.4.1" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = ">=3.11" +dependencies = [ + { name = "pre-commit" }, + { name = "uv" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pre_commit_uv-4.4.1.tar.gz" +upload-time = 2026-10-27 07:00:28.638309+00:00 +url = "https://files.pythonhosted.org/packages/98/62/2791f79c1cf48993ef0bee64e0636c9e86212467cdd3d0486a21d51f2438/pre_commit_uv-4.4.1.tar.gz" +size = 11474 + +[packages.sdist.hashes] +sha256 = "e59ce77dc49d0b0481efbc97293c4fe235ddeb5ab5028c8a9e20e6cb5cce9756" + +[[packages.wheels]] +name = "pre_commit_uv-4.4.1-py3-none-any.whl" +upload-time = 2026-10-27 07:00:27.254598+00:00 +url = "https://files.pythonhosted.org/packages/46/3d/ca2481fa9d5fd3c99e88be090dbadf5320dda8ddb80df13931e832ea46ef/pre_commit_uv-4.4.1-py3-none-any.whl" +size = 5525 + +[packages.wheels.hashes] +sha256 = "5c387a044d74c215090835c97bd41e469659025c6551ddc73f9f8e7d8d323c00" + +[[packages]] +name = "prek" +version = "0.5.9" +marker = "\"release-prepare\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "prek-0.5.9.tar.gz" +upload-time = 2026-10-30 10:32:01.272337+00:00 +url = "https://files.pythonhosted.org/packages/f2/c6/89312171d4b87b7d2419a077f84e638bee69d1b7943598c72685655f86a7/prek-0.5.9.tar.gz" +size = 603716 + +[packages.sdist.hashes] +sha256 = "47bc660a7e959860a44afb5ff3dc167d96f04d929b405020a0f2c7683d3d00d8" + +[[packages.wheels]] +name = "prek-0.5.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-30 10:31:52.132595+00:00 +url = "https://files.pythonhosted.org/packages/7c/de/9f538470c56f0524215d5ede21fdb092b5a5a55ee8b4ccc88a35487d57e0/prek-0.5.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +size = 5942047 + +[packages.wheels.hashes] +sha256 = "1ef04fd7b4bae3a31e1180bc87e877a3a5584063fe79320a125267afee47090d" + +[[packages]] +name = "psutil" +version = "7.2.2" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.6" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "psutil-7.2.2.tar.gz" +upload-time = 2026-01-28 18:14:54.428400+00:00 +url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz" +size = 493740 + +[packages.sdist.hashes] +sha256 = "0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372" + +[[packages.wheels]] +name = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl" +upload-time = 2026-01-28 18:15:25.976610+00:00 +url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl" +size = 155560 + +[packages.wheels.hashes] +sha256 = "076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9" + +[[packages]] +name = "pycparser" +version = "3.12" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.11" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pycparser-3.12.tar.gz" +upload-time = 2026-10-22 13:40:03.560254+00:00 +url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz" +size = 158785 + +[packages.sdist.hashes] +sha256 = "f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6" + +[[packages.wheels]] +name = "pycparser-3.12-py3-none-any.whl" +upload-time = 2026-10-22 13:40:02.021539+00:00 +url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl" +size = 62600 + +[packages.wheels.hashes] +sha256 = "a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e" + +[[packages]] +name = "pydantic" +version = "2.14.2" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pydantic-2.14.2.tar.gz" +upload-time = 2026-10-29 16:37:12.476407+00:00 +url = "https://files.pythonhosted.org/packages/9f/d5/b4867fa7c7f04c34997d7d8aad744f64d210bb99d67cf18bf3fb1eea1aed/pydantic-2.14.2.tar.gz" +size = 911596 + +[packages.sdist.hashes] +sha256 = "fb7b2db6cefe8414dd612edeede49c4c433acb0432b0f92b95024e45f66c505d" + +[[packages.wheels]] +name = "pydantic-2.14.2-py3-none-any.whl" +upload-time = 2026-10-29 16:37:10.181777+00:00 +url = "https://files.pythonhosted.org/packages/97/01/c71a71258b58659c72504e5fd65b451f6e36e34dc931d0b237ded213c814/pydantic-2.14.2-py3-none-any.whl" +size = 490403 + +[packages.wheels.hashes] +sha256 = "7efbf7507b937931cd32a5755acb00ea1cf5ccb1d4e8067a5850d5069e382118" + +[[packages]] +name = "pydantic-core" +version = "2.50.2" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "typing-extensions" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pydantic_core-2.50.2.tar.gz" +upload-time = 2026-10-29 16:34:57.465908+00:00 +url = "https://files.pythonhosted.org/packages/af/8e/23ec1021834e35041fbb2f10946141e6acdfd39379c45cc4d65a4e8a1d98/pydantic_core-2.50.2.tar.gz" +size = 518711 + +[packages.sdist.hashes] +sha256 = "1b0e8dc3932cf4e9ed5727570325cacc716b9409ffc794a221c0ccc72e8eb775" + +[[packages.wheels]] +name = "pydantic_core-2.50.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-10-29 16:33:53.333286+00:00 +url = "https://files.pythonhosted.org/packages/e6/d0/dd0e355694094f0045922f4da60ac9bc7a314babc494e46089fb00640fd3/pydantic_core-2.50.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 2249999 + +[packages.wheels.hashes] +sha256 = "5299296ff5cafcb69851fd652de8f9268a0bb036bcbedbfcaa94cf743baf3986" + +[[packages]] +name = "pygments" +version = "2.21.0" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pygments-2.21.0.tar.gz" +upload-time = 2026-08-17 08:02:48.824391+00:00 +url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz" +size = 5005329 + +[packages.sdist.hashes] +sha256 = "610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" + +[[packages.wheels]] +name = "pygments-2.21.0-py3-none-any.whl" +upload-time = 2026-08-17 08:02:44.912148+00:00 +url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl" +size = 1250147 + +[packages.wheels.hashes] +sha256 = "2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" + +[[packages]] +name = "pyparsing" +version = "3.3.3" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pyparsing-3.3.3.tar.gz" +upload-time = 2026-09-20 20:59:05.609622+00:00 +url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz" +size = 1050487 + +[packages.sdist.hashes] +sha256 = "928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36" + +[[packages.wheels]] +name = "pyparsing-3.3.3-py3-none-any.whl" +upload-time = 2026-09-20 20:59:04.025856+00:00 +url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl" +size = 126420 + +[packages.wheels.hashes] +sha256 = "ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4" + +[[packages]] +name = "pyperf" +version = "2.10.0" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "psutil" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pyperf-2.10.0.tar.gz" +upload-time = 2026-02-07 11:35:14.693655+00:00 +url = "https://files.pythonhosted.org/packages/16/91/39ca77aa58f13e8c65d747ac7e06584b55acabfa98987fb8d546bc24860d/pyperf-2.10.0.tar.gz" +size = 227609 + +[packages.sdist.hashes] +sha256 = "dd93ccfda79214725293e95f1fa6e00cb4a64adcf1326039486d4e1f91caaa62" + +[[packages.wheels]] +name = "pyperf-2.10.0-py3-none-any.whl" +upload-time = 2026-02-07 11:35:12.636042+00:00 +url = "https://files.pythonhosted.org/packages/91/26/f7bd5e37c254c2671f4dfff316d123eba13663bdfee941a01b09ab02d72d/pyperf-2.10.0-py3-none-any.whl" +size = 144430 + +[packages.wheels.hashes] +sha256 = "79196bc4a11e3c926dd4c6b14c80136c6b37f884fe913cbc57037f37636e9841" + +[[packages]] +name = "pytest" +version = "9.2.0" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pytest-9.2.0.tar.gz" +upload-time = 2026-10-27 21:30:20.728655+00:00 +url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz" +size = 1797555 + +[packages.sdist.hashes] +sha256 = "97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b" + +[[packages.wheels]] +name = "pytest-9.2.0-py3-none-any.whl" +upload-time = 2026-10-27 21:30:19.131148+00:00 +url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl" +size = 411776 + +[packages.wheels.hashes] +sha256 = "07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588" + +[[packages]] +name = "pytest-cov" +version = "7.1.0" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pytest_cov-7.1.0.tar.gz" +upload-time = 2026-03-21 20:11:16.284275+00:00 +url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz" +size = 55592 + +[packages.sdist.hashes] +sha256 = "30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2" + +[[packages.wheels]] +name = "pytest_cov-7.1.0-py3-none-any.whl" +upload-time = 2026-03-21 20:11:14.438324+00:00 +url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl" +size = 22876 + +[packages.wheels.hashes] +sha256 = "a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678" + +[[packages]] +name = "pytest-mock" +version = "3.17.0" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "pytest" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pytest_mock-3.17.0.tar.gz" +upload-time = 2026-10-17 16:21:42.435701+00:00 +url = "https://files.pythonhosted.org/packages/97/c2/28b9f5b955ff2bcd4727a6f768e2ca9b586cd7c37b95ea5f7b9b873ac7b3/pytest_mock-3.17.0.tar.gz" +size = 43064 + +[packages.sdist.hashes] +sha256 = "e36576e0985e01027e999891e7a966c67822d40c89ef1b4c8b6e22737e3590b4" + +[[packages.wheels]] +name = "pytest_mock-3.17.0-py3-none-any.whl" +upload-time = 2026-10-17 16:21:41.265482+00:00 +url = "https://files.pythonhosted.org/packages/e8/ea/47fbdc8f37410956fd445720e927589b1e2a47772c5f28109f0ce2fa26d9/pytest_mock-3.17.0-py3-none-any.whl" +size = 11248 + +[packages.wheels.hashes] +sha256 = "1b82aa4b37137248bd687055e109723debeb07b489de1565297dc9589c5f2d6b" + +[[packages]] +name = "python-discovery" +version = "1.9.0" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "filelock" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "python_discovery-1.9.0.tar.gz" +upload-time = 2026-10-30 23:24:24.910342+00:00 +url = "https://files.pythonhosted.org/packages/86/15/0405206e0cfe0c14b91ac038d31f30d1dd78139b3b2a285e6be41bf6e5af/python_discovery-1.9.0.tar.gz" +size = 125151 + +[packages.sdist.hashes] +sha256 = "88beb57e51b919c30f303b053ec41359298ca349192035b0a802c76347ae96d7" + +[[packages.wheels]] +name = "python_discovery-1.9.0-py3-none-any.whl" +upload-time = 2026-10-30 23:24:23.452190+00:00 +url = "https://files.pythonhosted.org/packages/cb/3c/da1cae963a076132dbc4d3c1f0292dd2f373b9d3e072d42f4620801159c1/python_discovery-1.9.0-py3-none-any.whl" +size = 39841 + +[packages.wheels.hashes] +sha256 = "fb65edfff3541b7ceb3a39be334a0f69f4cb5148287a053e1874748742fda93b" + +[[packages]] +name = "pyyaml" +version = "6.0.3" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"docs\" in dependency_groups or \"lint\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "pyyaml-6.0.3.tar.gz" +upload-time = 2025-09-25 21:33:16.546886+00:00 +url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz" +size = 130960 + +[packages.sdist.hashes] +sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" + +[[packages]] +name = "rdflib" +version = "7.6.0" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.8.1" +dependencies = [ + { name = "pyparsing" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "rdflib-7.6.0.tar.gz" +upload-time = 2026-02-13 07:15:55.938955+00:00 +url = "https://files.pythonhosted.org/packages/98/f5/18bb77b7af9526add0c727a3b2048959847dc5fb030913e2918bf384fec3/rdflib-7.6.0.tar.gz" +size = 4943826 + +[packages.sdist.hashes] +sha256 = "6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df" + +[[packages.wheels]] +name = "rdflib-7.6.0-py3-none-any.whl" +upload-time = 2026-02-13 07:15:46.487762+00:00 +url = "https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl" +size = 615416 + +[packages.wheels.hashes] +sha256 = "30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd" + +[[packages]] +name = "readme-renderer" +version = "46.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "readme_renderer-46.0.tar.gz" +upload-time = 2026-08-28 15:18:32.490778+00:00 +url = "https://files.pythonhosted.org/packages/25/d7/9309494fad74ee831d4546f69325b5519f37c6dfb2d9ba495db8c6d4f4ca/readme_renderer-46.0.tar.gz" +size = 38382 + +[packages.sdist.hashes] +sha256 = "af3e964914f6310a33ff67b72a4bdd940bed8d7c3bdecd2d14f40edf284bfe90" + +[[packages.wheels]] +name = "readme_renderer-46.0-py3-none-any.whl" +upload-time = 2026-08-28 15:18:31.132970+00:00 +url = "https://files.pythonhosted.org/packages/4b/72/ac5ca81fe9121fcaa9d828d21017cba00a16a98e4ea5fb60c878f93dda4f/readme_renderer-46.0-py3-none-any.whl" +size = 14239 + +[packages.wheels.hashes] +sha256 = "d0dae1f74bb273b534770cb4cccb6bb78735540afdb03c2146f4e19dcd412560" + +[[packages]] +name = "release-creator" +version = "4.4.0" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.15" +dependencies = [ + { name = "docutils" }, + { name = "markdown-it-py" }, + { name = "mdformat" }, + { name = "mdformat-gfm" }, + { name = "packaging" }, + { name = "towncrier" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "release_creator-4.4.0.tar.gz" +upload-time = 2026-11-02 21:44:48.475063+00:00 +url = "https://files.pythonhosted.org/packages/d8/cf/8f376cc925c26fa5434d327ba03f4ed709b80d2077edda865fc3b2fe1940/release_creator-4.4.0.tar.gz" +size = 171450 + +[packages.sdist.hashes] +sha256 = "263d518ddee6371fa13d25bd07f7b5af0fa7a6e421b16f1e8143b0a9443bc84d" + +[[packages.wheels]] +name = "release_creator-4.4.0-py3-none-any.whl" +upload-time = 2026-11-02 21:44:47.195366+00:00 +url = "https://files.pythonhosted.org/packages/7b/29/6f2a3158b082b3bf8a94eb93662d80b4db4212abea0fc1fae07ac9a043da/release_creator-4.4.0-py3-none-any.whl" +size = 54784 + +[packages.wheels.hashes] +sha256 = "560ed208cca0dc53bc227878e5596e3e26e44fa277658c5d0dc662901d95f8cb" + +[[packages]] +name = "requests" +version = "2.34.2" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "requests-2.34.2.tar.gz" +upload-time = 2026-05-14 19:25:27.735762+00:00 +url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz" +size = 142856 + +[packages.sdist.hashes] +sha256 = "f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" + +[[packages.wheels]] +name = "requests-2.34.2-py3-none-any.whl" +upload-time = 2026-05-14 19:25:26.443000+00:00 +url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl" +size = 73075 + +[packages.wheels.hashes] +sha256 = "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" + +[[packages]] +name = "requests-toolbelt" +version = "1.0.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +dependencies = [ + { name = "requests" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "requests-toolbelt-1.0.0.tar.gz" +upload-time = 2023-05-01 04:11:33.229998+00:00 +url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz" +size = 206888 + +[packages.sdist.hashes] +sha256 = "7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" + +[[packages.wheels]] +name = "requests_toolbelt-1.0.0-py2.py3-none-any.whl" +upload-time = 2023-05-01 04:11:28.427086+00:00 +url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl" +size = 54481 + +[packages.wheels.hashes] +sha256 = "cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" + +[[packages]] +name = "rfc3986" +version = "2.0.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "rfc3986-2.0.0.tar.gz" +upload-time = 2022-01-10 00:52:30.832978+00:00 +url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz" +size = 49026 + +[packages.sdist.hashes] +sha256 = "97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" + +[[packages.wheels]] +name = "rfc3986-2.0.0-py2.py3-none-any.whl" +upload-time = 2022-01-10 00:52:29.594625+00:00 +url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl" +size = 31326 + +[packages.wheels.hashes] +sha256 = "50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd" + +[[packages]] +name = "rich" +version = "15.0.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.9.0" +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "rich-15.0.0.tar.gz" +upload-time = 2026-04-12 08:24:00.750018+00:00 +url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz" +size = 230680 + +[packages.sdist.hashes] +sha256 = "edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" + +[[packages.wheels]] +name = "rich-15.0.0-py3-none-any.whl" +upload-time = 2026-04-12 08:24:02.830783+00:00 +url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl" +size = 310654 + +[packages.wheels.hashes] +sha256 = "33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" + +[[packages]] +name = "roman-numerals" +version = "4.1.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "roman_numerals-4.1.0.tar.gz" +upload-time = 2025-12-17 18:25:34.381818+00:00 +url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz" +size = 9077 + +[packages.sdist.hashes] +sha256 = "1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2" + +[[packages.wheels]] +name = "roman_numerals-4.1.0-py3-none-any.whl" +upload-time = 2025-12-17 18:25:33.098993+00:00 +url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl" +size = 7676 + +[packages.wheels.hashes] +sha256 = "647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7" + +[[packages]] +name = "secretstorage" +version = "3.5.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "secretstorage-3.5.0.tar.gz" +upload-time = 2025-11-23 19:02:53.191898+00:00 +url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz" +size = 19884 + +[packages.sdist.hashes] +sha256 = "f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be" + +[[packages.wheels]] +name = "secretstorage-3.5.0-py3-none-any.whl" +upload-time = 2025-11-23 19:02:51.545472+00:00 +url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl" +size = 15554 + +[packages.wheels.hashes] +sha256 = "0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137" + +[[packages]] +name = "semantic-version" +version = "2.10.0" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=2.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "semantic_version-2.10.0.tar.gz" +upload-time = 2022-05-26 13:35:23.454764+00:00 +url = "https://files.pythonhosted.org/packages/7d/31/f2289ce78b9b473d582568c234e104d2a342fd658cc288a7553d83bb8595/semantic_version-2.10.0.tar.gz" +size = 52289 + +[packages.sdist.hashes] +sha256 = "bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c" + +[[packages.wheels]] +name = "semantic_version-2.10.0-py2.py3-none-any.whl" +upload-time = 2022-05-26 13:35:21.206388+00:00 +url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl" +size = 15552 + +[packages.wheels.hashes] +sha256 = "de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177" + +[[packages]] +name = "setuptools" +version = "84.2.1" +marker = "\"bench\" in dependency_groups or \"build\" in dependency_groups or \"dev\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "setuptools-84.2.1.tar.gz" +upload-time = 2026-10-20 19:01:37.742699+00:00 +url = "https://files.pythonhosted.org/packages/70/b3/c19426f783614d625699a4b247faf628ea9ab1db228674491767b417f9e1/setuptools-84.2.1.tar.gz" +size = 1230289 + +[packages.sdist.hashes] +sha256 = "3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc" + +[[packages.wheels]] +name = "setuptools-84.2.1-py3-none-any.whl" +upload-time = 2026-10-20 19:01:35.678013+00:00 +url = "https://files.pythonhosted.org/packages/ef/55/6dc6d6bb953bdfc52519a0059592c52b6e3b8ad51cf8256dffceb55b0610/setuptools-84.2.1-py3-none-any.whl" +size = 876263 + +[packages.wheels.hashes] +sha256 = "ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2" + +[[packages]] +name = "setuptools-scm" +version = "10.3.4" +marker = "\"bench\" in dependency_groups or \"build\" in dependency_groups or \"dev\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.8" +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, + { name = "vcs-versioning" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "setuptools_scm-10.3.4.tar.gz" +upload-time = 2026-09-23 19:21:42.950372+00:00 +url = "https://files.pythonhosted.org/packages/85/d8/fc143f88819ccf10ba2388ba86732ee2de193e578234e25a783f6cc14bf7/setuptools_scm-10.3.4.tar.gz" +size = 78410 + +[packages.sdist.hashes] +sha256 = "a69f28bfc245608781205e912faae437c2b2165773afa4e7b979d77447a69dd2" + +[[packages.wheels]] +name = "setuptools_scm-10.3.4-py3-none-any.whl" +upload-time = 2026-09-23 19:21:41.607429+00:00 +url = "https://files.pythonhosted.org/packages/da/f5/54538a1f17ea753c42b0928dc2e62986d463f8c393340b9a1e602f2d4dd9/setuptools_scm-10.3.4-py3-none-any.whl" +size = 33366 + +[packages.wheels.hashes] +sha256 = "82f34c3e3084fc2b57d397200637cc13f2338004052d2fee47baa5f0e902464e" + +[[packages]] +name = "snowballstemmer" +version = "3.1.1" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.3" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "snowballstemmer-3.1.1.tar.gz" +upload-time = 2026-06-03 00:56:40.194915+00:00 +url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz" +size = 123314 + +[packages.sdist.hashes] +sha256 = "e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260" + +[[packages.wheels]] +name = "snowballstemmer-3.1.1-py3-none-any.whl" +upload-time = 2026-06-03 00:56:38.614300+00:00 +url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl" +size = 104164 + +[packages.wheels.hashes] +sha256 = "7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752" + +[[packages]] +name = "soupsieve" +version = "3.0.4" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.11.5" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "soupsieve-3.0.4.tar.gz" +upload-time = 2026-10-22 00:45:26.362373+00:00 +url = "https://files.pythonhosted.org/packages/19/63/0b6bae54546aed98d72f12d09df57b19a287f01c84d22047fa2e23772ab9/soupsieve-3.0.4.tar.gz" +size = 119017 + +[packages.sdist.hashes] +sha256 = "924fab84ae21604c19e175e1f39815eb6fa83ba23836e09e9de180925e5bc4c0" + +[[packages.wheels]] +name = "soupsieve-3.0.4-py3-none-any.whl" +upload-time = 2026-10-22 00:45:24.843035+00:00 +url = "https://files.pythonhosted.org/packages/07/66/473f7ca0be0b06e38ee8c85ee2db184e3981bd769fac2e7c19c8a8865211/soupsieve-3.0.4-py3-none-any.whl" +size = 42458 + +[packages.wheels.hashes] +sha256 = "8412826614f9c632227067d0985aeb9f584bd15efc852955430e097a9fe7afbf" + +[[packages]] +name = "spdx-tools" +version = "0.8.5" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.10" +dependencies = [ + { name = "beartype" }, + { name = "click" }, + { name = "license-expression" }, + { name = "ply" }, + { name = "pyyaml" }, + { name = "rdflib" }, + { name = "semantic-version" }, + { name = "uritools" }, + { name = "xmltodict" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "spdx_tools-0.8.5.tar.gz" +upload-time = 2026-03-13 09:29:23.155398+00:00 +url = "https://files.pythonhosted.org/packages/2f/99/33383f587b59cbd191cc1c0fd5408e550b9275e0090d32561cbddc973fdc/spdx_tools-0.8.5.tar.gz" +size = 696323 + +[packages.sdist.hashes] +sha256 = "be600beb2f762f0116025e05490d399e724f668bef84025a7c421bb266688bdb" + +[[packages.wheels]] +name = "spdx_tools-0.8.5-py3-none-any.whl" +upload-time = 2026-03-13 09:29:20.907842+00:00 +url = "https://files.pythonhosted.org/packages/88/f5/1ac402e1f8d253fa60ac167c4a1fb7eeb0f29a04942788e2d43c984cd749/spdx_tools-0.8.5-py3-none-any.whl" +size = 286573 + +[packages.wheels.hashes] +sha256 = "7c2d5865941be9d2e898f5b084e8d5422dd298dc5a29320ddb198fec304f59c4" + +[[packages]] +name = "sphinx" +version = "9.2.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.13" +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinx-9.2.0.tar.gz" +upload-time = 2026-10-21 00:05:15.019079+00:00 +url = "https://files.pythonhosted.org/packages/64/d4/413466f0be8b1cb0e3e43893a148b96d7462d0923d82b4166b21afc5f30c/sphinx-9.2.0.tar.gz" +size = 8782438 + +[packages.sdist.hashes] +sha256 = "90470459f3cab15d9dfed146c71cf77694b0445df17b2e0e69059cb9f7fc9ee4" + +[[packages.wheels]] +name = "sphinx-9.2.0-py3-none-any.whl" +upload-time = 2026-10-21 00:05:12.241734+00:00 +url = "https://files.pythonhosted.org/packages/c4/28/a0c50761cd215b7042f4bc617a6bc5c0cd7be62f75bc908e169e28a4d21e/sphinx-9.2.0-py3-none-any.whl" +size = 3925388 + +[packages.wheels.hashes] +sha256 = "4c3ec13b612712c0d3ce4cbdd9e3eb91e68577e07a8e31f32f21cf178453578c" + +[[packages]] +name = "sphinx-argparse-cli" +version = "1.24.1" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.11" +dependencies = [ + { name = "sphinx" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinx_argparse_cli-1.24.1.tar.gz" +upload-time = 2026-10-27 07:02:09.017213+00:00 +url = "https://files.pythonhosted.org/packages/ec/83/5502c61d82db593198adcb3a376505b767f0f464ebf2db50253cf3c7f319/sphinx_argparse_cli-1.24.1.tar.gz" +size = 17808 + +[packages.sdist.hashes] +sha256 = "9adc72739eb3b099f5de94d8f60af031aaef8355f57398d33ff3e6ea848abd00" + +[[packages.wheels]] +name = "sphinx_argparse_cli-1.24.1-py3-none-any.whl" +upload-time = 2026-10-27 07:02:07.884391+00:00 +url = "https://files.pythonhosted.org/packages/51/c6/16119ac3a8b54b3e92ad668bc6e787cbc79353e7f177f247d4e3f74b3de2/sphinx_argparse_cli-1.24.1-py3-none-any.whl" +size = 12299 + +[packages.wheels.hashes] +sha256 = "6bc2e83f4ecf7c110a91a526ca239465fef6d457c0665d89d5e0a7dc3eb71c73" + +[[packages]] +name = "sphinx-autodoc-typehints" +version = "3.17.4" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.12" +dependencies = [ + { name = "sphinx" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinx_autodoc_typehints-3.17.4.tar.gz" +upload-time = 2026-10-27 19:18:44.225219+00:00 +url = "https://files.pythonhosted.org/packages/9e/a9/cfe13ff89678c6d310a8a9060333b7823bae1dbce9615c7032be89aaec20/sphinx_autodoc_typehints-3.17.4.tar.gz" +size = 511398 + +[packages.sdist.hashes] +sha256 = "a5ddd545192380292f13dd9fc232717a0925fbc66617bc51b3db1e0480db0fec" + +[[packages.wheels]] +name = "sphinx_autodoc_typehints-3.17.4-py3-none-any.whl" +upload-time = 2026-10-27 19:18:42.674074+00:00 +url = "https://files.pythonhosted.org/packages/fa/66/40004fe2aea60edc4800328599dd115b3058f79bf148be1746349281f308/sphinx_autodoc_typehints-3.17.4-py3-none-any.whl" +size = 140603 + +[packages.wheels.hashes] +sha256 = "251271a49e46573c2e752bcc92dae232def822a2c9983272cb04d282eadc2bfa" + +[[packages]] +name = "sphinx-basic-ng" +version = "1.0.0b2" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.7" +dependencies = [ + { name = "sphinx" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinx_basic_ng-1.0.0b2.tar.gz" +upload-time = 2023-07-08 18:40:54.166146+00:00 +url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz" +size = 20736 + +[packages.sdist.hashes] +sha256 = "9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9" + +[[packages.wheels]] +name = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl" +upload-time = 2023-07-08 18:40:52.659879+00:00 +url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl" +size = 22496 + +[packages.wheels.hashes] +sha256 = "eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b" + +[[packages]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinxcontrib_applehelp-2.0.0.tar.gz" +upload-time = 2024-07-29 01:09:00.465812+00:00 +url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz" +size = 20053 + +[packages.sdist.hashes] +sha256 = "2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1" + +[[packages.wheels]] +name = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl" +upload-time = 2024-07-29 01:08:58.990722+00:00 +url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl" +size = 119300 + +[packages.wheels.hashes] +sha256 = "4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5" + +[[packages]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinxcontrib_devhelp-2.0.0.tar.gz" +upload-time = 2024-07-29 01:09:23.417467+00:00 +url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz" +size = 12967 + +[packages.sdist.hashes] +sha256 = "411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad" + +[[packages.wheels]] +name = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl" +upload-time = 2024-07-29 01:09:21.945229+00:00 +url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl" +size = 82530 + +[packages.wheels.hashes] +sha256 = "aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2" + +[[packages]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinxcontrib_htmlhelp-2.1.0.tar.gz" +upload-time = 2024-07-29 01:09:37.889318+00:00 +url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz" +size = 22617 + +[packages.sdist.hashes] +sha256 = "c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9" + +[[packages.wheels]] +name = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl" +upload-time = 2024-07-29 01:09:36.407072+00:00 +url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl" +size = 98705 + +[packages.wheels.hashes] +sha256 = "166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8" + +[[packages]] +name = "sphinxcontrib-jsmath" +version = "2.0.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinxcontrib_jsmath-2.0.0.tar.gz" +upload-time = 2026-10-20 18:22:05.514380+00:00 +url = "https://files.pythonhosted.org/packages/59/02/4f334c7fc3e54c51d2364575a41cd2fae6847e7f53001949f0be5f32e18b/sphinxcontrib_jsmath-2.0.0.tar.gz" +size = 14511 + +[packages.sdist.hashes] +sha256 = "551f780cf9687f9c3c74f0cf9f18aca06e0d6958853453f3803fe4d357c5b924" + +[[packages.wheels]] +name = "sphinxcontrib_jsmath-2.0.0-py3-none-any.whl" +upload-time = 2026-10-20 18:22:04.420584+00:00 +url = "https://files.pythonhosted.org/packages/da/c2/2965c35b39587d4b7a397f65a9c7d4b942b031061e7ac1ce91782f394a0d/sphinxcontrib_jsmath-2.0.0-py3-none-any.whl" +size = 78095 + +[packages.wheels.hashes] +sha256 = "15ca92fe0eec82f0baf91a7efcc51ffacdb605924be6327683a52ebdacb6744c" + +[[packages]] +name = "sphinxcontrib-mermaid" +version = "2.1.1" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "jinja2" }, + { name = "pyyaml" }, + { name = "sphinx" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinxcontrib_mermaid-2.1.1.tar.gz" +upload-time = 2026-09-01 16:10:23.671939+00:00 +url = "https://files.pythonhosted.org/packages/8c/87/06537dff20aa57df2d0f2afa87edff4afa85dec6f57f03f7acc24f83ff88/sphinxcontrib_mermaid-2.1.1.tar.gz" +size = 22353 + +[packages.sdist.hashes] +sha256 = "f8d7f1b2f0ad69b7ebdd5cbf532fb4ee107893b5373bc2d0e9634089c0b2cfee" + +[[packages.wheels]] +name = "sphinxcontrib_mermaid-2.1.1-py3-none-any.whl" +upload-time = 2026-09-01 16:10:22.377054+00:00 +url = "https://files.pythonhosted.org/packages/50/6a/5bf2222b8084c7a7bc3ec23d470c91e3e03e82e4ce0bbacaa61f456ce7c1/sphinxcontrib_mermaid-2.1.1-py3-none-any.whl" +size = 16368 + +[packages.wheels.hashes] +sha256 = "3b9ac1a8e0b5154dec3c0acf9b5c54dd9ba58540e416f20858c300814c25648f" + +[[packages]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinxcontrib_qthelp-2.0.0.tar.gz" +upload-time = 2024-07-29 01:09:56.435212+00:00 +url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz" +size = 17165 + +[packages.sdist.hashes] +sha256 = "4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab" + +[[packages.wheels]] +name = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl" +upload-time = 2024-07-29 01:09:54.885300+00:00 +url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl" +size = 88743 + +[packages.wheels.hashes] +sha256 = "b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb" + +[[packages]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "sphinxcontrib_serializinghtml-2.0.0.tar.gz" +upload-time = 2024-07-29 01:10:09.332393+00:00 +url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz" +size = 16080 + +[packages.sdist.hashes] +sha256 = "e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d" + +[[packages.wheels]] +name = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl" +upload-time = 2024-07-29 01:10:08.203690+00:00 +url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl" +size = 92072 + +[packages.wheels.hashes] +sha256 = "6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331" + +[[packages]] +name = "tomlkit" +version = "0.15.2" +marker = "\"bench\" in dependency_groups or \"build\" in dependency_groups or \"dev\" in dependency_groups or \"docs\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"hatch\" in extras" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "tomlkit-0.15.2.tar.gz" +upload-time = 2026-10-16 04:07:08.166827+00:00 +url = "https://files.pythonhosted.org/packages/08/72/fc01563df95c189a9f65850096bdc205bc2fe37da6c740cd64afaf18d07e/tomlkit-0.15.2.tar.gz" +size = 190522 + +[packages.sdist.hashes] +sha256 = "83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6" + +[[packages.wheels]] +name = "tomlkit-0.15.2-py3-none-any.whl" +upload-time = 2026-10-16 04:07:09.216014+00:00 +url = "https://files.pythonhosted.org/packages/da/46/7d84138a60a8bd796825b2d38ebe92d0d16501ca57d501b7c5d677401bbc/tomlkit-0.15.2-py3-none-any.whl" +size = 52225 + +[packages.wheels.hashes] +sha256 = "05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437" + +[[packages]] +name = "towncrier" +version = "26.9.0" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "click" }, + { name = "jinja2" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "towncrier-26.9.0.tar.gz" +upload-time = 2026-09-04 12:57:25.341701+00:00 +url = "https://files.pythonhosted.org/packages/92/d4/e85ce614bf45b1a7fe13b82cc687819c2eaac5a1dd94b84834322095fd98/towncrier-26.9.0.tar.gz" +size = 79338 + +[packages.sdist.hashes] +sha256 = "ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55" + +[[packages.wheels]] +name = "towncrier-26.9.0-py3-none-any.whl" +upload-time = 2026-09-04 12:57:23.986160+00:00 +url = "https://files.pythonhosted.org/packages/82/88/f9340b545dafa64de053798e760515a2736fbb60092cf9aa6b146ccf3ede/towncrier-26.9.0-py3-none-any.whl" +size = 67030 + +[packages.wheels.hashes] +sha256 = "ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660" + +[[packages]] +name = "trove-classifiers" +version = "2026.9.21.13" +marker = "\"bench\" in dependency_groups or \"build\" in dependency_groups or \"dev\" in dependency_groups or \"docs\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"hatch\" in extras" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "trove_classifiers-2026.9.21.13.tar.gz" +upload-time = 2026-09-21 13:29:07.301512+00:00 +url = "https://files.pythonhosted.org/packages/bf/93/af436dfaa845cab5d96f0adbc1e4f3730532d37fa249e4eb796fb1d7fc82/trove_classifiers-2026.9.21.13.tar.gz" +size = 17085 + +[packages.sdist.hashes] +sha256 = "0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71" + +[[packages.wheels]] +name = "trove_classifiers-2026.9.21.13-py3-none-any.whl" +upload-time = 2026-09-21 13:29:06.123578+00:00 +url = "https://files.pythonhosted.org/packages/30/81/0da8afb52a71d0a4f2bd3152357b1a441e393b286374802b9d3addab4ab5/trove_classifiers-2026.9.21.13-py3-none-any.whl" +size = 14232 + +[packages.wheels.hashes] +sha256 = "8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17" + +[[packages]] +name = "twine" +version = "7.0.0" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "id" }, + { name = "keyring" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "twine-7.0.0.tar.gz" +upload-time = 2026-07-27 15:59:00.825945+00:00 +url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz" +size = 215032 + +[packages.sdist.hashes] +sha256 = "85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177" + +[[packages.wheels]] +name = "twine-7.0.0-py3-none-any.whl" +upload-time = 2026-07-27 15:58:59.260418+00:00 +url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl" +size = 43204 + +[packages.wheels.hashes] +sha256 = "b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7" + +[[packages]] +name = "ty" +version = "0.0.95" +marker = "\"dev\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "ty-0.0.95.tar.gz" +upload-time = 2026-10-29 19:21:51.955948+00:00 +url = "https://files.pythonhosted.org/packages/39/08/d63954788578b9189dbf85081d8cbdba9a32a9a6a29729129a0da09cd3a0/ty-0.0.95.tar.gz" +size = 8115525 + +[packages.sdist.hashes] +sha256 = "a480853a74d43074b2d4b28025d406b9d7223f7fe9f3a6c2adec3eacdbf14ca0" + +[[packages.wheels]] +name = "ty-0.0.95-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-29 19:21:30.801674+00:00 +url = "https://files.pythonhosted.org/packages/90/64/636308717989afa2fe765604ae2a76189f470d566e5e58da4482b625d64e/ty-0.0.95-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +size = 16312046 + +[packages.wheels.hashes] +sha256 = "6d47be2db5db80cd70c582b240a71dd8e9be215ce9288298c863c7c3611ef02b" + +[[packages]] +name = "typing-extensions" +version = "4.16.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "typing_extensions-4.16.0.tar.gz" +upload-time = 2026-07-02 08:40:05.920538+00:00 +url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz" +size = 113555 + +[packages.sdist.hashes] +sha256 = "dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" + +[[packages.wheels]] +name = "typing_extensions-4.16.0-py3-none-any.whl" +upload-time = 2026-07-02 08:40:04.659120+00:00 +url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl" +size = 45571 + +[packages.wheels.hashes] +sha256 = "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" + +[[packages]] +name = "typing-inspection" +version = "0.4.4" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.10" +dependencies = [ + { name = "typing-extensions" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "typing_inspection-0.4.4.tar.gz" +upload-time = 2026-08-12 12:37:25.997329+00:00 +url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz" +size = 76928 + +[packages.sdist.hashes] +sha256 = "547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47" + +[[packages.wheels]] +name = "typing_inspection-0.4.4-py3-none-any.whl" +upload-time = 2026-08-12 12:37:24.648475+00:00 +url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl" +size = 14750 + +[packages.wheels.hashes] +sha256 = "65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147" + +[[packages]] +name = "uritools" +version = "6.1.3" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "uritools-6.1.3.tar.gz" +upload-time = 2026-07-23 23:17:44.612642+00:00 +url = "https://files.pythonhosted.org/packages/a3/0d/20d02264b6682f07e92cbf7ee43e5e803670d101a03ef204ba18368c321f/uritools-6.1.3.tar.gz" +size = 23909 + +[packages.sdist.hashes] +sha256 = "3a498e7e85ef3249343d5710618d641a414da0fbae6d23053ada7976ee83ea5f" + +[[packages.wheels]] +name = "uritools-6.1.3-py3-none-any.whl" +upload-time = 2026-07-23 23:17:43.275613+00:00 +url = "https://files.pythonhosted.org/packages/ac/76/e9bb35862bcc5b82218d2c017b1eacdcfeb3ba46ce0c0ca8ffc4db80ddbe/uritools-6.1.3-py3-none-any.whl" +size = 11981 + +[packages.wheels.hashes] +sha256 = "136f113e76e53f85bf2d9cce0c3d40ceec775d0409e7d6de9b28f046a7d42838" + +[[packages]] +name = "urllib3" +version = "2.8.0" +marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.10" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "urllib3-2.8.0.tar.gz" +upload-time = 2026-09-15 19:29:36.253420+00:00 +url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz" +size = 458972 + +[packages.sdist.hashes] +sha256 = "63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63" + +[[packages.wheels]] +name = "urllib3-2.8.0-py3-none-any.whl" +upload-time = 2026-09-15 19:29:34.577402+00:00 +url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl" +size = 135717 + +[packages.wheels.hashes] +sha256 = "0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3" + +[[packages]] +name = "uv" +version = "0.13.6" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "uv-0.13.6.tar.gz" +upload-time = 2026-10-27 00:45:57.605614+00:00 +url = "https://files.pythonhosted.org/packages/08/d5/88d43310d425f6e3a43997ad771658ded3237d0ed78c16a89b2353eddf56/uv-0.13.6.tar.gz" +size = 7545242 + +[packages.sdist.hashes] +sha256 = "d762215478854f6cf20c3faed8f1eb30c8101d568e7bc32a3abafc063929d5c5" + +[[packages.wheels]] +name = "uv-0.13.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +upload-time = 2026-10-27 00:45:31.291019+00:00 +url = "https://files.pythonhosted.org/packages/5c/43/0688ff5142934238f2812e348f4a24e09b57d48bfb38dd8f850325a0d9de/uv-0.13.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +size = 17333810 + +[packages.wheels.hashes] +sha256 = "22759b8895e1103578de6fb4cd27e94b4399d021023378d29f01333717751fba" + +[[packages]] +name = "vcs-versioning" +version = "2.6.0" +marker = "\"bench\" in dependency_groups or \"build\" in dependency_groups or \"dev\" in dependency_groups or \"type\" in dependency_groups" +requires-python = ">=3.8" +dependencies = [ + { name = "packaging" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "vcs_versioning-2.6.0.tar.gz" +upload-time = 2026-10-06 22:21:55.024903+00:00 +url = "https://files.pythonhosted.org/packages/ad/e7/2a691db7d076b75d099fdba1a14cd966931f2aeb591e77f6d22f15097326/vcs_versioning-2.6.0.tar.gz" +size = 166800 + +[packages.sdist.hashes] +sha256 = "22e9159288e2d8bca2fa6f6c31c34c9c66aec157f60e9e526f5d160d2516d60f" + +[[packages.wheels]] +name = "vcs_versioning-2.6.0-py3-none-any.whl" +upload-time = 2026-10-06 22:21:53.267565+00:00 +url = "https://files.pythonhosted.org/packages/73/1d/ad4064bf12f4503bf61e65d74c93c523672776d25ee0a06aaf42ae09ef3f/vcs_versioning-2.6.0-py3-none-any.whl" +size = 123154 + +[packages.wheels.hashes] +sha256 = "f8bebd1302ebc12e67ceb30fc7dd698ec969414b6fa43b30c3f14ac9b1e72c41" + +[[packages]] +name = "virtualenv" +version = "21.15.2" +marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" +requires-python = ">=3.9" +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "virtualenv-21.15.2.tar.gz" +upload-time = 2026-10-30 19:18:04.759827+00:00 +url = "https://files.pythonhosted.org/packages/38/9e/40c7098240cedd3a2f32e42a74b35c4563c617fdd7e41ae6268d4fdd8915/virtualenv-21.15.2.tar.gz" +size = 5520597 + +[packages.sdist.hashes] +sha256 = "dbd886e6e5689eb4dde6a9bbc478ad14b4fc8ed0852690d8273ccfb8c467f635" + +[[packages.wheels]] +name = "virtualenv-21.15.2-py3-none-any.whl" +upload-time = 2026-10-30 19:18:01.134751+00:00 +url = "https://files.pythonhosted.org/packages/f7/d8/09c9d6df253b2016312dd5ccbb962dfa86a5202f2452604b52e0a9844661/virtualenv-21.15.2-py3-none-any.whl" +size = 5559530 + +[packages.wheels.hashes] +sha256 = "fb8c62c7c328e0238061c721068f06b7bc6bd10f7a0f5987195cef1008837ee6" + +[[packages]] +name = "wcwidth" +version = "0.9.4" +marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "wcwidth-0.9.4.tar.gz" +upload-time = 2026-10-24 18:11:44.954051+00:00 +url = "https://files.pythonhosted.org/packages/ea/01/e5ac23733628a6bbb91913a765fcf55ee8070d8d991bc88fdaaf805052c7/wcwidth-0.9.4.tar.gz" +size = 970051 + +[packages.sdist.hashes] +sha256 = "fd8d57d3cdfe570e3265c8fab1885c24e34ef40889c0759c0c304a19e9835954" + +[[packages.wheels]] +name = "wcwidth-0.9.4-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +upload-time = 2026-10-24 18:11:18.748328+00:00 +url = "https://files.pythonhosted.org/packages/fa/e8/0485850748dfdba9443f4151c7b2442cfa4987ba6cd871038eb5d6b8eca9/wcwidth-0.9.4-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" +size = 785946 + +[packages.wheels.hashes] +sha256 = "b185c8b927b23af2721be1783499d1cfa5c028b098b0858951b4ae224477f1c2" + +[[packages.wheels]] +name = "wcwidth-0.9.4-py3-none-any.whl" +upload-time = 2026-10-24 18:10:52.651030+00:00 +url = "https://files.pythonhosted.org/packages/6f/0c/309b1813b230d1dbbd6f2c2ab2bb21ef68992358977605d9ef289bbfedcc/wcwidth-0.9.4-py3-none-any.whl" +size = 301806 + +[packages.wheels.hashes] +sha256 = "901eace6d2dd25ebb917d0ce5db18e5c537e1504c905cf32f0d006c12098b05d" + +[[packages]] +name = "wheel-filename" +version = "1.4.2" +marker = "\"dev\" in dependency_groups or \"pkg-meta\" in dependency_groups" +requires-python = ">=3.8" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "wheel_filename-1.4.2.tar.gz" +upload-time = 2024-12-01 13:03:16.012739+00:00 +url = "https://files.pythonhosted.org/packages/38/be/726dab762b770d0417e505c58e26d661aac1ec0c831e483cda4817ca2417/wheel_filename-1.4.2.tar.gz" +size = 7911 + +[packages.sdist.hashes] +sha256 = "87891c465dcbb40b40394a906f01a93214bdd51aa5d25e3a9a59cae62bc298fd" + +[[packages.wheels]] +name = "wheel_filename-1.4.2-py3-none-any.whl" +upload-time = 2024-12-01 13:03:00.536117+00:00 +url = "https://files.pythonhosted.org/packages/b4/0f/6e97a3bc38cdde32e3ec49f8c0903fe3559ec9ec9db181782f0bb4417717/wheel_filename-1.4.2-py3-none-any.whl" +size = 6195 + +[packages.wheels.hashes] +sha256 = "3fa599046443d4ca830d06e3d180cd0a675d5871af0a68daa5623318bb4d17e3" + +[[packages]] +name = "xmltodict" +version = "1.0.4" +marker = "\"bench\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"validate\" in extras" +requires-python = ">=3.9" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "xmltodict-1.0.4.tar.gz" +upload-time = 2026-02-22 02:21:22.074263+00:00 +url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz" +size = 26124 + +[packages.sdist.hashes] +sha256 = "6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61" + +[[packages.wheels]] +name = "xmltodict-1.0.4-py3-none-any.whl" +upload-time = 2026-02-22 02:21:21.039836+00:00 +url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl" +size = 13580 + +[packages.wheels.hashes] +sha256 = "a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a" + +[[packages]] +name = "zipremove" +version = "0.10.0" +marker = "\"project-runtime\" in dependency_groups" +requires-python = "~=3.7" +index = "https://pypi.org/simple/" + +[packages.sdist] +name = "zipremove-0.10.0.tar.gz" +upload-time = 2026-07-17 15:50:45.516180+00:00 +url = "https://files.pythonhosted.org/packages/92/30/47d65b9213f52df8a51f603cb0a2e5ef02c22172a8a17805e46c3beabb0d/zipremove-0.10.0.tar.gz" +size = 25448 + +[packages.sdist.hashes] +sha256 = "3c5957c115447b54299d3a23128764b59c4558fc98c4b878ff2dbc5db8a62021" + +[[packages.wheels]] +name = "zipremove-0.10.0-py3-none-any.whl" +upload-time = 2026-07-17 15:50:44.653422+00:00 +url = "https://files.pythonhosted.org/packages/0a/e4/2225c1890ce6e239ca23efabb80a3481229a14a2deaa489f5e89723a5aa6/zipremove-0.10.0-py3-none-any.whl" +size = 11280 + +[packages.wheels.hashes] +sha256 = "04e15df7083642d5b791e51f597cffef8a92d770ecf5160c529b5a7b53b40100" + +[tool.nab] +nab-version = "0.0.23" +created-at = 2026-11-07 05:12:05.951651+00:00 +command-line = [ + "nab", + "lock", + "--all-groups", + "--all-extras", + "--no-cache", +] +input-path = "pyproject.toml" +mode = "universal" +python-specifier = ">=3.15,<3.16" +platforms = [ + "linux_x86_64", +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4a7ace9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,272 @@ +[build-system] +build-backend = "hatchling.build" +requires = [ + "hatch-fancy-pypi-readme>=25.1", + "hatch-vcs>=0.5", + "hatchling>=1.32.4", + "installer>=1.1", + "packageurl-python>=0.11.1", + "zipremove>=0.10", +] + +[project] +name = "python-sbom" +import-names = [ "python_sbom" ] +description = "SBOMs for Python distributions: write PEP 770 CycloneDX SBOMs into wheels." +keywords = [ + "cyclonedx", + "hatch", + "pep770", + "sbom", + "spdx", +] +license = "MIT AND Apache-2.0" +license-files = [ "LICENSE", "src/python_sbom/_validate/schemas/LICENSE" ] +maintainers = [ + { name = "Bernรกt Gรกbor", email = "gaborjbernat@gmail.com" }, +] +requires-python = ">=3.11" +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: Hatch", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Security", + "Topic :: Software Development :: Build Tools", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] +dynamic = [ + "readme", + "version", +] +dependencies = [ + "installer>=1.1", + "packageurl-python>=0.11.1", + "packaging>=26.3", + "zipremove>=0.10", +] +optional-dependencies.hatch = [ + "hatchling>=1.28", +] +optional-dependencies.validate = [ + "jsonschema-rs>=0.60.3", + "license-expression>=30", + "spdx-tools>=0.8.5", +] +urls.Changelog = "https://python-sbom.readthedocs.io/en/latest/changelog.html" +urls.Documentation = "https://python-sbom.readthedocs.io" +urls.Homepage = "https://github.com/tox-dev/python-sbom" +urls.Source = "https://github.com/tox-dev/python-sbom" +urls.Tracker = "https://github.com/tox-dev/python-sbom/issues" +scripts.python-sbom = "python_sbom._cli._main:main" +entry-points.hatch.python-sbom = "python_sbom.hatch.hooks" + +[dependency-groups] +dev = [ + { include-group = "lint" }, + { include-group = "pkg-meta" }, + { include-group = "test" }, + { include-group = "type" }, +] +test = [ + "covdefaults>=2.3", + "hatchling>=1.28", + "installer>=1.1", + "jsonschema-rs>=0.60.3", + "pytest>=9.1.1", + "pytest-cov>=7.1", + "pytest-mock>=3.16", + "spdx-tools>=0.8.5", +] +type = [ + "ty>=0.0.84", + { include-group = "bench" }, + { include-group = "docs" }, + { include-group = "test" }, +] +docs = [ + "furo>=2025.12.19", + "hatchling>=1.28", + "sphinx>=9.0.4", + "sphinx-argparse-cli>=1.23", + "sphinx-autodoc-typehints>=3.17", + "sphinxcontrib-mermaid>=2.1.1", +] +bench = [ + "pyperf>=2.10", + { include-group = "build" }, + { include-group = "test" }, +] +build = [ + "hatch-fancy-pypi-readme>=25.1", + "hatch-vcs>=0.5", + "hatchling>=1.32.4", +] +lint = [ "pre-commit-uv>=4.3" ] +pkg-meta = [ "check-wheel-contents>=0.6.3", "twine>=7", "uv>=0.12.22" ] +release-news = [ + "release-creator>=4.4", + "towncrier>=26.9", +] +release-notes = [ + "release-creator>=4.4", +] +release-prepare = [ + "prek>=0.5", + "release-creator>=4.4", + "towncrier>=26.9", +] + +[tool.hatch] +version.source = "vcs" +metadata.hooks.fancy-pypi-readme.content-type = "text/markdown" +metadata.hooks.fancy-pypi-readme.fragments = [ { path = "README.md" } ] +build.hooks.vcs.version-file = "src/python_sbom/version.py" +# writes python-sbom's own SBOM with the hook it ships, after the vcs hook writes the version +build.hooks.custom.path = "hatch_build.py" + +[tool.uv] +dependency-groups.release-news = { requires-python = ">=3.15" } +dependency-groups.release-notes = { requires-python = ">=3.15" } +dependency-groups.release-prepare = { requires-python = ">=3.15" } + +[tool.ruff] +target-version = "py311" +line-length = 120 +format.preview = true +format.docstring-code-line-length = 100 +format.docstring-code-format = true +lint.select = [ + "ALL", +] +lint.ignore = [ + "CPY", # The project does not add copyright headers to individual files + "DOC", # Public API documentation is generated from signatures + "incorrect-blank-line-before-class", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible + "missing-trailing-comma", # Conflict with formatter + "multi-line-summary-first-line", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible + "single-line-implicit-string-concatenation", # Conflict with formatter + "undocumented-magic-method", # undocumented-public-api (simplint SMP903) covers the public API instead + "undocumented-public-class", # undocumented-public-api (simplint SMP903) covers the public API instead + "undocumented-public-function", # undocumented-public-api (simplint SMP903) covers the public API instead + "undocumented-public-init", # undocumented-public-api (simplint SMP903) covers the public API instead + "undocumented-public-method", # undocumented-public-api (simplint SMP903) covers the public API instead + "undocumented-public-nested-class", # undocumented-public-api (simplint SMP903) covers the public API instead +] +lint.per-file-ignores."bench/**/*.py" = [ + "import-private-name", # Benchmarks time the private entry points the public API wraps + "start-process-with-partial-path", # Fixtures run git from PATH, as a project build does + "subprocess-without-shell-equals-true", # Commands are fixed argument lists the harness builds + "suspicious-subprocess-import", # Fixtures and cold cases run git, uv and Python processes +] +lint.per-file-ignores."tests/**/*.py" = [ + "assert", # Tests use assertions + "D", # Test names document behavior + "FBT", # Boolean parameters keep parameterized cases compact + "implicit-namespace-package", # The tests directory is not a package + "import-private-name", # Unit tests cover the private modules behind the small public API + "magic-value-comparison", # Literal expected values keep assertions local + "subprocess-without-shell-equals-true", # Entry-point arguments are fixed by the tests + "suspicious-subprocess-import", # Entry-point tests invoke a local Python executable +] +# the fixture mirrors virtualenv's seed/wheels/embed/__init__.py, which holds the bundle table +lint.per-file-ignores."tests/_seeder/src/**/__init__.py" = [ "non-empty-init-module" ] +lint.isort = { known-first-party = [ + "python_sbom", +], required-imports = [ + "from __future__ import annotations", +] } +lint.preview = true + +[tool.codespell] +builtin = "clear,usage,en-GB_to_en-US" +ignore-words-list = "acknowledgement" +count = true + +[tool.pyproject-fmt] +max_supported_python = "3.15" + +[tool.ty] +environment.python-version = "3.15" + +[tool.pytest] +ini_options.testpaths = [ "tests" ] +# test modules mirror the source modules, so their names repeat across directories +ini_options.addopts = [ "--import-mode=importlib" ] +ini_options.filterwarnings = [ + "error", +] +ini_options.verbosity_assertions = 2 + +[tool.coverage] +run.parallel = true +run.plugins = [ + "covdefaults", +] +run.core = "ctrace" # sysmon (3.14+ default) cannot record the per-test contexts we collect +paths.source = [ + "src", + ".tox*/*/lib/python*/site-packages", + ".tox*/pypy*/site-packages", + ".tox*\\*\\Lib\\site-packages", + "*/src", + "*\\src", +] +report.fail_under = 100 +html.show_contexts = true +html.skip_covered = false + +[tool.towncrier] +name = "python-sbom" +directory = "docs/changelog.d" +filename = "docs/changelog.rst" +template = "release_creator.fragments:template.rst.jinja2" +title_format = false +underlines = [ "-", "~", "^" ] +type = [ + { directory = "feature", name = "Added", showcontent = true }, + { directory = "change", name = "Changes", showcontent = true }, + { directory = "bugfix", name = "Fixed", showcontent = true }, + { directory = "doc", name = "Documentation", showcontent = true }, + { directory = "breaking", name = "Breaking changes", showcontent = true }, + { directory = "security", name = "Security", showcontent = true }, + { directory = "performance", name = "Performance", showcontent = true }, + { directory = "deprecation", name = "Deprecated", showcontent = true }, + { directory = "removal", name = "Removed", showcontent = true }, + { directory = "packaging", name = "Packaging", showcontent = true }, +] + +[tool.python-sbom] +supplier = { name = "tox-dev", url = [ "https://github.com/tox-dev" ] } + +[tool.simplint] +docstring-style = "rst" +# the fixture mirrors virtualenv's seed/wheels/embed/__init__.py, which holds the bundle table +per-file-ignores."tests/_seeder/src/**/__init__.py" = [ + "init-module-content", + "missing-final", + "module-variable-prefix" +] + +[tool.nab] +mode = "universal" +base-group = "project-runtime" +uploaded-prior-to = "P7D" +decision-order = "stable" +matrix.python = ">=3.15,<3.16" +matrix.platforms = [ "linux_x86_64" ] +# release-creator 4.4.0 needs a seven-day cutoff exception. +# expires 2026-11-09 +packages.release-creator.uploaded-prior-to = 2026-11-02T21:45:00Z +# simplint 0.7.1, the pre-commit hook, needs a seven-day cutoff exception. +# expires 2026-11-13 +packages.simplint.uploaded-prior-to = 2026-11-06T21:30:00Z diff --git a/src/python_sbom/__init__.py b/src/python_sbom/__init__.py new file mode 100644 index 0000000..a71cd89 --- /dev/null +++ b/src/python_sbom/__init__.py @@ -0,0 +1,46 @@ +"""SBOMs for Python distributions: write PEP 770 CycloneDX SBOMs into wheels.""" + +from __future__ import annotations + +from ._errors import SbomError +from ._model._content import BundledWheelInfo, ContentProvider, Contributions, ProviderContext +from ._model._identity import ProjectIdentity +from ._model._inputs import Settings +from ._model._types import ( + Component, + Contact, + Dependency, + Evidence, + ExternalReference, + Hash, + Identity, + IdentityMethod, + License, + LicenseInfo, + OrganizationalEntity, + Property, +) +from .version import __version__ + +__all__ = [ + "BundledWheelInfo", + "Component", + "Contact", + "ContentProvider", + "Contributions", + "Dependency", + "Evidence", + "ExternalReference", + "Hash", + "Identity", + "IdentityMethod", + "License", + "LicenseInfo", + "OrganizationalEntity", + "ProjectIdentity", + "Property", + "ProviderContext", + "SbomError", + "Settings", + "__version__", +] diff --git a/src/python_sbom/__main__.py b/src/python_sbom/__main__.py new file mode 100644 index 0000000..a528915 --- /dev/null +++ b/src/python_sbom/__main__.py @@ -0,0 +1,10 @@ +"""Main entry point.""" + +from __future__ import annotations + +import sys + +from ._cli._main import main + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/src/python_sbom/_cli/__init__.py b/src/python_sbom/_cli/__init__.py new file mode 100644 index 0000000..f60ec27 --- /dev/null +++ b/src/python_sbom/_cli/__init__.py @@ -0,0 +1 @@ +"""The command line: parsing options and running one command per subcommand.""" diff --git a/src/python_sbom/_cli/_archive.py b/src/python_sbom/_cli/_archive.py new file mode 100644 index 0000000..a99bb91 --- /dev/null +++ b/src/python_sbom/_cli/_archive.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import sys +from pathlib import Path, PurePosixPath +from typing import Protocol + +from python_sbom._generate._archive import archive_sbom + +from ._parser import BuildOptions + + +class _Options(BuildOptions, Protocol): + archive: Path + build_scripts: list[str] | None + output: Path | None + embed: bool + + +def run(options: _Options) -> bool: + target, embedded = archive_sbom( + Path.cwd() if options.root is None else options.root, + options.archive, + provider=options.content_provider, + commit=options.commit, + build_env=options.build_env, + build_scripts=tuple(PurePosixPath(script) for script in options.build_scripts or []), + output=options.output, + embed=options.embed, + ) + sys.stdout.write(f"wrote {target}\n") + if embedded is not None: + sys.stdout.write(f"wrote {embedded} into {options.archive.name}\n") + return True + + +__all__ = [ + "run", # simplint: ignore[SMP402] # the parser names it for pkgutil.resolve_name +] diff --git a/src/python_sbom/_cli/_embed.py b/src/python_sbom/_cli/_embed.py new file mode 100644 index 0000000..ec7c1db --- /dev/null +++ b/src/python_sbom/_cli/_embed.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Protocol + +from python_sbom._generate._embed import embed_sbom + +from ._parser import BuildOptions + + +class _Options(BuildOptions, Protocol): + wheels: list[Path] + + +def run(options: _Options) -> bool: + root = Path.cwd() if options.root is None else options.root + for wheel in options.wheels: + written = embed_sbom( + wheel, root, provider=options.content_provider, commit=options.commit, build_env=options.build_env + ) + sys.stdout.write(f"wrote {written} into {wheel.name}\n") + return True + + +__all__ = [ + "run", # simplint: ignore[SMP402] # the parser names it for pkgutil.resolve_name +] diff --git a/src/python_sbom/_cli/_main.py b/src/python_sbom/_cli/_main.py new file mode 100644 index 0000000..8888134 --- /dev/null +++ b/src/python_sbom/_cli/_main.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import pkgutil +import sys +from typing import TYPE_CHECKING + +from python_sbom._errors import SbomError + +from ._parser import build_parser + +if TYPE_CHECKING: + from collections.abc import Sequence + + +def main(args: Sequence[str] | None = None) -> None: + options = build_parser().parse_args(args) + try: + succeeded = pkgutil.resolve_name(options.handler)(options) + except SbomError as error: + sys.stderr.write(f"python-sbom: {error}\n") + succeeded = False + if not succeeded: + raise SystemExit(1) + + +__all__ = [ + "main", +] diff --git a/src/python_sbom/_cli/_parser.py b/src/python_sbom/_cli/_parser.py new file mode 100644 index 0000000..88b09f5 --- /dev/null +++ b/src/python_sbom/_cli/_parser.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from argparse import ArgumentParser, ArgumentTypeError, _SubParsersAction +from pathlib import Path +from typing import Protocol + +# the limits module imports nothing, so --help and --version load none of validation +from python_sbom._validate._limits import MAX_COMPONENTS +from python_sbom.version import __version__ + + +def build_parser() -> ArgumentParser: + parser = ArgumentParser(prog="python-sbom", description="Write, convert and check the CycloneDX SBOMs of wheels.") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + commands = parser.add_subparsers(required=True) + _add_embed(commands) + _add_archive(commands) + _add_spdx(commands) + _add_validate(commands) + return parser + + +def _add_embed(commands: _SubParsersAction[ArgumentParser]) -> None: + parser = commands.add_parser( + "embed", + help="write the SBOM into wheels another build backend built", + description="Write the SBOM the hatchling hook writes into wheels another build backend built, such as" + " meson-python. Options come from the [tool.python-sbom] table of the project's pyproject.toml.", + ) + # a name, not the function, so each command imports only what it runs and --help imports none of it + parser.set_defaults(handler="python_sbom._cli._embed:run") + parser.add_argument("wheels", type=Path, nargs="+", metavar="WHEEL", help="wheel to rewrite in place") + _add_build_options(parser) + + +def _add_build_options(parser: ArgumentParser) -> None: + parser.add_argument( + "--root", type=Path, metavar="DIR", help="project directory of the artifact (default: current directory)" + ) + parser.add_argument( + "--commit", metavar="SHA", help="source revision to record, for an artifact built from an sdist" + ) + parser.add_argument( + "--build-env", + action="append", + metavar="DIR", + help="site-packages directory of the environment that built the artifact; repeat it per directory" + " (default: this interpreter's)", + ) + parser.add_argument( + "--content-provider", + metavar="MODULE:ATTRIBUTE", + help="content provider, overriding the content-provider option", + ) + + +def _add_archive(commands: _SubParsersAction[ArgumentParser]) -> None: + parser = commands.add_parser( + "archive", + help="write the SBOM of a zip archive that is not a wheel, such as a zipapp", + description="Describe a zip archive that holds the project's .dist-info at its top level, such as a zipapp:" + " the archive's own files, the project's distribution, and every wheel it bundles, as a file or unpacked," + " described as the wheel's SBOM describes them. Options come from [tool.python-sbom].", + ) + parser.set_defaults(handler="python_sbom._cli._archive:run") + parser.add_argument("archive", type=Path, metavar="ARCHIVE", help="archive to describe") + _add_build_options(parser) + parser.add_argument( + "--build-script", + dest="build_scripts", + action="append", + metavar="PATH", + help="script, relative to the project, that built the archive; repeat it per script", + ) + parser.add_argument( + "--output", type=Path, metavar="FILE", help="SBOM file to write (default: .cdx.json next to it)" + ) + parser.add_argument( + "--embed", action="store_true", help="also add the SBOM to the archive, at its top level as .cdx.json" + ) + + +def _add_spdx(commands: _SubParsersAction[ArgumentParser]) -> None: + parser = commands.add_parser( + "spdx", + help="render a CycloneDX SBOM as SPDX 2.3 JSON", + description="Render a CycloneDX JSON SBOM, such as one python-sbom wrote, as an SPDX 2.3 JSON document. It" + " checks only the fields the rendering reads, so it needs no extra; python-sbom validate checks the whole" + " document.", + ) + parser.set_defaults(handler="python_sbom._cli._spdx:run") + parser.add_argument("source", type=Path, metavar="SOURCE", help="CycloneDX JSON file to read") + parser.add_argument("target", type=Path, metavar="TARGET", help="SPDX JSON file to write") + + +def _add_validate(commands: _SubParsersAction[ArgumentParser]) -> None: + parser = commands.add_parser( + "validate", + help="validate the SBOMs in wheels and archives", + description="Check every CycloneDX SBOM a wheel or archive holds against the CycloneDX 1.6 schema; for one" + " python-sbom wrote, also its serial number, references, SPDX rendering and, with --allow-license, the licenses" + " of what it ships. Needs the validate extra: python-sbom[validate].", + ) + parser.set_defaults(handler="python_sbom._cli._validate:run") + parser.add_argument( + "paths", type=Path, nargs="+", metavar="PATH", help="wheel, archive, or directory holding wheels" + ) + parser.add_argument( + "--allow-license", + dest="allowed_licenses", + action="append", + metavar="SPDX-ID", + help="SPDX license id shipped components may use; repeat it per id, leave it out to skip the license policy", + ) + parser.add_argument( + "--max-components", + type=_positive_int, + default=MAX_COMPONENTS, + metavar="N", + help="reject an SBOM listing more than N components, nested ones included, or larger than 1 MiB plus 1 KiB per" + " component, without checking it further, which bounds the work an untrusted SBOM can ask for (default:" + f" {MAX_COMPONENTS})", + ) + + +def _positive_int(text: str) -> int: + # argparse reports the error through parser.error, with the usage, like any other bad argument + if text.isdecimal() and (number := int(text)) > 0: + return number + msg = f"must be a positive integer, got {text!r}" + raise ArgumentTypeError(msg) + + +class BuildOptions(Protocol): + root: Path | None + commit: str | None + build_env: list[str] | None + content_provider: str | None + + +__all__ = [ + "BuildOptions", + "build_parser", +] diff --git a/src/python_sbom/_cli/_spdx.py b/src/python_sbom/_cli/_spdx.py new file mode 100644 index 0000000..ad6de8a --- /dev/null +++ b/src/python_sbom/_cli/_spdx.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +from python_sbom._errors import SbomError +from python_sbom._formats._json import dumps +from python_sbom._formats._spdx import render_spdx +from python_sbom._validate._document import is_document, load_json +from python_sbom._validate._limits import MAX_COMPONENTS, byte_limit + +if TYPE_CHECKING: + from pathlib import Path + + from python_sbom._model._types import Document + + +class _Options(Protocol): + source: Path + target: Path + + +def run(options: _Options) -> bool: + document = _read(options.source) + try: + rendered = dumps(render_spdx(document)) + # ValueError: a timestamp fromisoformat rejects; AttributeError: a tools value that is neither an array nor an + # object; RecursionError: nesting json.dumps, a few frames deeper than json.loads, gives up on + except (AttributeError, KeyError, TypeError, ValueError, RecursionError) as error: + # the CLI renders without the schema check, which needs the validate extra + msg = f"{options.source} is not a CycloneDX document python-sbom can render: {error!r}" + raise SbomError(msg) from error + # bytes, so the line endings are the same on every operating system + options.target.write_bytes(rendered) + return True + + +def _read(source: Path) -> Document: + # the size validate accepts at its default component cap, so the parser never holds more than validate lets it + limit = byte_limit(MAX_COMPONENTS) + try: + with source.open("rb") as handle: + raw = handle.read(limit + 1) + except OSError as error: + raise SbomError(str(error)) from error + if len(raw) > limit: + msg = f"{source} is over {limit} bytes, the size limit of an SBOM python-sbom reads" + raise SbomError(msg) + try: + loaded = load_json(raw) + except SbomError as error: + msg = f"{source} {error}" + raise SbomError(msg) from error + if not is_document(loaded): + msg = f"{source} is not a CycloneDX document python-sbom can render" + raise SbomError(msg) + return loaded + + +__all__ = [ + "run", # simplint: ignore[SMP402] # the parser names it for pkgutil.resolve_name +] diff --git a/src/python_sbom/_cli/_validate.py b/src/python_sbom/_cli/_validate.py new file mode 100644 index 0000000..893a27a --- /dev/null +++ b/src/python_sbom/_cli/_validate.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING, Protocol + +from python_sbom._validate._artifact import validate + +if TYPE_CHECKING: + from pathlib import Path + + +class _Options(Protocol): + paths: list[Path] + allowed_licenses: list[str] | None + max_components: int + + +def run(options: _Options) -> bool: + allowed = None if options.allowed_licenses is None else frozenset(options.allowed_licenses) + if not ( + artifacts := sorted( + artifact for path in options.paths for artifact in (path.glob("*.whl") if path.is_dir() else [path]) + ) + ): + sys.stderr.write(f"no wheel found in {', '.join(map(str, options.paths))}\n") + return False + valid = True + for artifact in artifacts: + for problem in (problems := validate(artifact, allowed, options.max_components)): + sys.stderr.write(f"SBOM invalid: {artifact.name}: {problem}\n") + if problems: + valid = False + else: + sys.stdout.write(f"SBOM in {artifact.name} is valid\n") + return valid + + +__all__ = [ + "run", # simplint: ignore[SMP402] # the parser names it for pkgutil.resolve_name +] diff --git a/src/python_sbom/_collect/__init__.py b/src/python_sbom/_collect/__init__.py new file mode 100644 index 0000000..3ee5431 --- /dev/null +++ b/src/python_sbom/_collect/__init__.py @@ -0,0 +1 @@ +"""What the edge reads: settings, build facts, the source revision, the project tree, the artifact and the provider.""" diff --git a/src/python_sbom/_collect/_archive.py b/src/python_sbom/_collect/_archive.py new file mode 100644 index 0000000..f11b47c --- /dev/null +++ b/src/python_sbom/_collect/_archive.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from python_sbom._errors import SbomError +from python_sbom._model._archive import bundled_members +from python_sbom._model._inputs import ArchiveTree, RecordRow +from python_sbom._model._metadata import parse_metadata +from python_sbom._model._names import archive_sbom_file_name + +if TYPE_CHECKING: + from collections.abc import Container + + from python_sbom._model._metadata import CoreMetadata + from python_sbom._wheel._reader import ArtifactReader + + +def read_archive(artifact: ArtifactReader) -> ArchiveTree: + # every member is read from the zip by its name, never from a file system path, so a name such as /etc/hostname or + # ../outside names a member, not a file of the host + where: Final[str] = artifact.path.name + members: Final[list[str]] = sorted( + info.filename + for info in artifact.archive.infolist() + if not info.is_dir() and info.filename != archive_sbom_file_name(where) + ) + metadata: Final[dict[str, CoreMetadata]] = {} + for name in members: + if not name.endswith(".dist-info/METADATA"): + continue + # files belong to a distribution by the directory that holds its .dist-info, which two cannot share + if (directory := _distribution_prefix(name)) in metadata: + msg = ( + f"{where} holds more than one .dist-info in {directory or 'its top level'}, so their files cannot" + " be told apart" + ) + raise SbomError(msg) + metadata[directory] = parse_metadata(artifact.read(name).decode("utf-8")) + if "" not in metadata: + msg = f"{where} holds no top-level .dist-info/METADATA naming the project" + raise SbomError(msg) + files: Final[dict[str, RecordRow]] = { + name: RecordRow( + path=name, + algorithm="SHA-256", + digest=artifact.digest(name), + size=str(artifact.archive.getinfo(name).file_size), + ) + for name in members + if not name.endswith(".whl") + } + owned: Final[dict[str, list[str]]] = {prefix: [] for prefix in metadata} + loose: Final[list[str]] = [] + for name in members: + # the project's own distribution holds every subdirectory no other one does + if owner := _owner(name, metadata): + owned[owner].append(name) + elif "/" in name: + owned[""].append(name) + else: + loose.append(name) + # in the order the components list them, so the first broken wheel is the one reported + wheels: Final[list[str]] = [member for member in bundled_members(loose, owned) if not member.endswith("/")] + return ArchiveTree( + metadata=metadata, + owned=owned, + loose=loose, + files=files, + bundled=dict(zip(wheels, artifact.bundled_wheels(wheels), strict=True)), + ) + + +def _distribution_prefix(name: str) -> str: + # each distribution by the directory that holds its .dist-info, the project's own at the top level + return f"{name.rsplit('/', 2)[0]}/" if name.count("/") > 1 else "" + + +def _owner(name: str, distributions: Container[str]) -> str | None: + # the deepest distribution directory that holds the member, found by looking up each of its ancestor directories, + # deepest first, so the work grows with the member's depth rather than the number of distributions + parents: Final[list[str]] = name.split("/")[:-1] + return next( + ( + prefix + for depth in range(len(parents), 0, -1) + if (prefix := f"{'/'.join(parents[:depth])}/") in distributions + ), + None, + ) + + +__all__ = [ + "read_archive", +] diff --git a/src/python_sbom/_collect/_content.py b/src/python_sbom/_collect/_content.py new file mode 100644 index 0000000..efbcbec --- /dev/null +++ b/src/python_sbom/_collect/_content.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import inspect +import pkgutil +import sys +from importlib.machinery import PathFinder +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Final + +from python_sbom._errors import SbomError +from python_sbom._model._content import ContentProvider + +if TYPE_CHECKING: + from python_sbom._model._inputs import Settings + + +def resolve_provider( + given: ContentProvider | str | None, settings: Settings, root: Path +) -> tuple[ContentProvider, tuple[PurePosixPath, ...]]: + # an instance wins, as a hook subclass builds one; a module:attribute string overrides the option, as the CLI's flag + # does; otherwise the content-provider option, or the provider that adds nothing + if isinstance(given, ContentProvider): + provider = given + elif (spec := settings.content_provider if given is None else given) is None: + provider = ContentProvider() + else: + provider = _load(spec, root) + return provider, _provider_scripts(provider, root) + + +def _load(spec: str, root: Path) -> ContentProvider: + # resolve_name also takes a bare module, which is no provider + module_name, _, attribute = spec.partition(":") + if not (module_name and attribute): + msg = f"content provider {spec!r} must be `module:attribute`" + raise SbomError(msg) + if (imported := _imported_elsewhere(module_name, root)) is not None: + msg = ( + f"content provider {spec!r} cannot load from {root}: this process already imported {imported}, a module of" + " the same name, and would run that instead; build each such project in a process of its own" + ) + raise SbomError(msg) + sys.path.insert(0, str(root)) + try: + provider = pkgutil.resolve_name(spec) + # ValueError: a name resolve_name cannot parse, such as a second colon + except (ImportError, AttributeError, ValueError) as error: + msg = f"content provider {spec!r} does not load from {root}: {error}" + raise SbomError(msg) from error + finally: + sys.path.remove(str(root)) + if isinstance(provider, type) and issubclass(provider, ContentProvider): + provider = provider() + if not isinstance(provider, ContentProvider): + msg = f"content provider {spec!r} is not a ContentProvider subclass or instance" + raise SbomError(msg) + return provider + + +def _imported_elsewhere(module_name: str, root: Path) -> str | None: + # the import cache is per process, so a hatch build of a second project with a provider module of the same name + # would run the first project's; each package on the way down to the module, and the module itself, that the project + # provides is compared with what is already imported under its name; namespace packages merge, so only modules and + # regular packages can clash + search: list[str] | None = [str(root)] + parts: Final[list[str]] = module_name.split(".") + for depth in range(1, len(parts) + 1): + if search is None or (found := PathFinder.find_spec(".".join(parts[:depth]), search)) is None: + return None + imported = getattr(sys.modules.get(found.name), "__file__", None) + if ( + found.origin is not None + and imported is not None + and Path(imported).resolve() != Path(found.origin).resolve() + ): + return imported + search = None if found.submodule_search_locations is None else list(found.submodule_search_locations) + return None + + +def _provider_scripts(provider: ContentProvider, root: Path) -> tuple[PurePosixPath, ...]: + # each class of the provider defined in the project shapes the document, a base class or mixin as much as the class + # itself, so the SBOM lists their modules among the build tools; python-sbom's own ContentProvider adds nothing + found: Final[dict[PurePosixPath, None]] = {} + for cls in type(provider).__mro__: + if cls in {ContentProvider, object}: + continue + if (source := _source(cls)) is None: + continue + resolved, base = Path(source).resolve(), root.resolve() + if resolved.is_relative_to(base): + found[PurePosixPath(resolved.relative_to(base).as_posix())] = None + return tuple(found) + + +def _source(cls: type) -> str | None: + # a method's code names the file even for a module hatchling loaded outside the import system, such as a custom + # hook script + methods = ( + function.__code__.co_filename + for _, function in inspect.getmembers(cls, inspect.isfunction) + if function.__qualname__.startswith(f"{cls.__qualname__}.") + ) + return next(methods, getattr(sys.modules.get(cls.__module__), "__file__", None)) + + +__all__ = [ + "resolve_provider", +] diff --git a/src/python_sbom/_collect/_facts.py b/src/python_sbom/_collect/_facts.py new file mode 100644 index 0000000..89d6823 --- /dev/null +++ b/src/python_sbom/_collect/_facts.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import json +import os +import platform +import sys +import time +import warnings +from functools import cache +from importlib.metadata import distributions +from itertools import takewhile +from typing import TYPE_CHECKING, Final + +from installer.records import InvalidRecordEntry, parse_record_file +from packaging.direct_url import DirectUrl, DirectUrlValidationError +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name + +from python_sbom._errors import SbomError +from python_sbom._model._inputs import BuildFacts, InstalledDistribution, Interpreter, RecordRow +from python_sbom._model._metadata import parse_metadata +from python_sbom._wheel._record import record_rows + +if TYPE_CHECKING: + from collections.abc import Container, Sequence + from importlib.metadata import Distribution + from pathlib import Path + + from python_sbom._model._metadata import CoreMetadata + + from ._revision import RevisionReader + + +def collect_facts( + root: Path, revision: RevisionReader, *, path: Sequence[str] | None = None, default_epoch: int | None = None +) -> BuildFacts: + if from_environment := bool(raw := os.environ.get("SOURCE_DATE_EPOCH", "")): + try: + epoch = int(raw) + except ValueError as error: + msg = f"SOURCE_DATE_EPOCH must be a Unix timestamp, got {raw!r}" + raise SbomError(msg) from error + else: + epoch = int(time.time()) if default_epoch is None else default_epoch + return BuildFacts( + epoch=epoch, + epoch_from_environment=from_environment, + revision=revision(root), + interpreter=Interpreter( + name=sys.implementation.name, + version=platform.python_version(), + implementation=platform.python_implementation(), + ), + distributions=_installed(path), + ) + + +def _installed(path: Sequence[str] | None) -> list[InstalledDistribution]: + found: Final[dict[str, tuple[Distribution, CoreMetadata]]] = {} + for distribution in distributions(path=list(path)) if path is not None else distributions(): + # where Distribution.metadata looks, a .dist-info's METADATA, then an .egg-info's PKG-INFO or the file itself + text = distribution.read_text("METADATA") or distribution.read_text("PKG-INFO") or distribution.read_text("") + # the first on the path is the one imports load, so a shadowed copy further down is left out + if (metadata := parse_metadata(text or "")).name and metadata.version: + found.setdefault(canonicalize_name(metadata.name), (distribution, metadata)) + return [ + InstalledDistribution( + name=name, + metadata=metadata, + files=_recorded_files(distribution), + depends_on=_depends_on(name, distribution, metadata, found), + ) + for name, (distribution, metadata) in found.items() + ] + + +def _recorded_files(distribution: Distribution) -> list[RecordRow]: + # a platform wheel's files are built for this machine's OS and architecture, and an editable install's point at a + # checkout, so listing either would tie the document to one machine + # Root-Is-Purelib as the email parser takes a field, its first occurrence among the header lines, as written after + # the colon, without parsing the rest of WHEEL + headers = takewhile(bool, (distribution.read_text("WHEEL") or "").splitlines()) + fields = (line.partition(":") for line in headers) + purelib = next( + ( + value.lstrip(" \t") == "true" + for field, colon, value in fields + if colon and field.lower() == "root-is-purelib" + ), + False, + ) + if not purelib or _editable(distribution): + return [] + # RECORD read once and as written: Distribution.files would stat every row to drop files missing on disk, and build + # a path object for each, while the SBOM lists what the install recorded + rows: Final = parse_record_file((distribution.read_text("RECORD") or "").splitlines()) + try: + return record_rows((row for row in rows if _describes_distribution(row[0])), distribution.name) + except InvalidRecordEntry as error: + warnings.warn(f"listed no files of {distribution.name}, its RECORD is malformed: {error}", stacklevel=2) + return [] + + +def _editable(distribution: Distribution) -> bool: + if (text := distribution.read_text("direct_url.json")) is None: + return False + try: + dir_info = DirectUrl.from_dict(json.loads(text)).dir_info + # an installer that wrote the file wrong breaks no build: its files get listed, as for any other install + except (ValueError, DirectUrlValidationError) as error: + warnings.warn(f"read the direct_url.json of {distribution.name} as not editable: {error}", stacklevel=2) + return False + return dir_info is not None and dir_info.editable is True + + +def _describes_distribution(path: str) -> bool: + # console-script launchers live outside site-packages and embed the environment's interpreter path; the installer + # metadata names the frontend; uv's cache files hold paths and times; none of them says anything about the package + directory, _, name = path.rpartition("/") + return not ( + path.startswith("../") + or (directory.endswith(".dist-info") and name in {"INSTALLER", "REQUESTED", "direct_url.json"}) + or (name.startswith("uv_") and name.endswith(".json")) + ) + + +def _depends_on(name: str, distribution: Distribution, metadata: CoreMetadata, installed: Container[str]) -> list[str]: + names: Final[set[str]] = set() + # an .egg-info states its requirements in requires.txt, which Distribution.requires reads + for line in metadata.values("requires_dist") or distribution.requires or []: + match _required(line): + case InvalidRequirement() as error: + # old distributions declare requirements, such as "pytz (>dev)", that today's syntax rejects + warnings.warn(f"left out the requirement {line!r} of {name}: {error}", stacklevel=2) + case str() as required if required in installed: + names.add(required) + return sorted(names) + + +@cache +def _required(line: str) -> str | InvalidRequirement | None: + # the normalized name a requirement needs here, None when its marker rules it out, or why it does not parse; the + # same requirements recur across a build environment's distributions, and the verdict holds for the process + try: + requirement = Requirement(line) + except InvalidRequirement as error: + return error + if requirement.marker is not None and not requirement.marker.evaluate(): + return None + return canonicalize_name(requirement.name) + + +__all__ = [ + "collect_facts", +] diff --git a/src/python_sbom/_collect/_revision.py b/src/python_sbom/_collect/_revision.py new file mode 100644 index 0000000..142fb88 --- /dev/null +++ b/src/python_sbom/_collect/_revision.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import os +import re +import shutil +import subprocess # ruff: ignore[suspicious-subprocess-import] # runs git to read the source revision +import warnings +from typing import TYPE_CHECKING, Final, Protocol + +from python_sbom._errors import SbomError +from python_sbom._model._inputs import Revision + +if TYPE_CHECKING: + from pathlib import Path + +#: the file an sdist carries its source revision in, as ``node: ``, for the wheel built from it +REVISION_FILE: Final[str] = ".python-sbom-revision" +_COMMIT: Final[re.Pattern[str]] = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}") + + +class RevisionReader(Protocol): + def __call__(self, root: Path, /) -> Revision | None: ... + + +def revision_reader(commit: str | None) -> RevisionReader: + # a commit given on the command line stands for the checkout, so git and the files go unread + if commit is None: + return default_revision + revision = _commit_revision(commit, "--commit") + return lambda _root: revision + + +def default_revision(root: Path, /) -> Revision | None: + # the variable exists for builds from an sdist, so it wins over whatever checkout the sdist was unpacked into + if commit := os.environ.get("PYTHON_SBOM_COMMIT"): + return _commit_revision(commit, "PYTHON_SBOM_COMMIT") + if (found := _git_revision(root)) is not None: + return found + # the sdist build wrote it from the revision it resolved, so it is at least as exact as an archival file + if (recorded := root / REVISION_FILE).is_file(): + return _commit_revision(recorded.read_text(encoding="utf-8").strip().removeprefix("node: "), REVISION_FILE) + archival = root / ".git_archival.txt" + if archival.is_file() and ( + match := re.search(rf"^node: ({_COMMIT.pattern})$", archival.read_text(encoding="utf-8"), re.MULTILINE) + ): + return Revision(commit=match[1], dirty=False) + return None + + +def _git_revision(root: Path, /) -> Revision | None: + resolved = root.resolve() + if (git := shutil.which("git")) is None or not any( + (directory / ".git").exists() for directory in (resolved, *resolved.parents) + ): + return None + try: + # a project unpacked into another checkout, such as an sdist under its build directory, is not its source + _git(git, root, "ls-files", "--error-unmatch", "--", "pyproject.toml") + commit = _git(git, root, "rev-parse", "--verify", "--quiet", "HEAD") + # untracked files count, since the build packages every file .gitignore lets through + changes = _git(git, root, "status", "--porcelain", "--", ".") + except subprocess.CalledProcessError as error: + if error.returncode != 1: # 1: the project is untracked, or the checkout has no commit yet + warnings.warn(f"git cannot read the source revision in {root}: {error.stderr.strip()}", stacklevel=2) + return None + return Revision(commit=commit, dirty=bool(changes)) + + +def _git(git: str, root: Path, *arguments: str) -> str: + # git first runs in whichever repository encloses the project, whose fsmonitor hook would run a command from its + # config; no optional locks, so status never rewrites the index under another git process + return subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] # git from PATH with fixed arguments + [git, "-c", "core.fsmonitor=false", "--no-optional-locks", "-C", str(root), *arguments], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def _commit_revision(commit: str, origin: str) -> Revision: + # the commit lands in the source link, so anything but a full hash could point the link elsewhere + if not _COMMIT.fullmatch(commit): + msg = f"{origin} must be a full lowercase hex commit hash of 40 or 64 characters, got {commit!r}" + raise SbomError(msg) + return Revision(commit=commit, dirty=False) + + +__all__ = [ + "REVISION_FILE", + "RevisionReader", + "default_revision", + "revision_reader", +] diff --git a/src/python_sbom/_collect/_settings.py b/src/python_sbom/_collect/_settings.py new file mode 100644 index 0000000..751d47f --- /dev/null +++ b/src/python_sbom/_collect/_settings.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import tomllib +from typing import TYPE_CHECKING, TypeAlias + +from python_sbom._errors import SbomError +from python_sbom._model._inputs import Settings + +if TYPE_CHECKING: + from collections.abc import Mapping + from datetime import date, datetime, time + from pathlib import Path + from typing import TypeIs # new in 3.13; annotations here are never evaluated + + from python_sbom._model._types import OrganizationalEntity + +#: a value of a TOML table, as :mod:`tomllib` or hatchling hand it over +Toml: TypeAlias = "str | int | float | bool | datetime | date | time | list[Toml] | dict[str, Toml]" + + +def settings_from_pyproject(root: Path) -> Settings: + pyproject = root / "pyproject.toml" + try: + loaded = tomllib.loads(pyproject.read_text(encoding="utf-8")) + except FileNotFoundError as error: + msg = f"{root} has no pyproject.toml to read [tool.python-sbom] from" + raise SbomError(msg) from error + except tomllib.TOMLDecodeError as error: + msg = f"{pyproject} is not valid TOML: {error}" + raise SbomError(msg) from error + return settings_from_table(loaded.get("tool", {}).get("python-sbom", {}), f"[tool.python-sbom] of {pyproject}") + + +def settings_from_table(table: Mapping[str, Toml], where: str) -> Settings: + known = {"bundled-wheels", "content-provider", "default-branch", "repository", "supplier"} + if unknown := sorted(table.keys() - known): + msg = f"{where} has unknown option(s) {', '.join(unknown)}; known: {', '.join(sorted(known))}" + raise SbomError(msg) + if not isinstance(repository := table.get("repository"), str | None): + msg = f"`repository` in {where} must be a string" + raise SbomError(msg) + if not isinstance(default_branch := table.get("default-branch", "main"), str): + msg = f"`default-branch` in {where} must be a string" + raise SbomError(msg) + if not _strings(patterns := table.get("bundled-wheels", [])): + msg = f"`bundled-wheels` in {where} must be an array of strings" + raise SbomError(msg) + if not isinstance(content_provider := table.get("content-provider"), str | None): + msg = f"`content-provider` in {where} must be a string, `module:attribute`" + raise SbomError(msg) + return Settings( + supplier=_supplier(table.get("supplier"), where), + repository=None if repository is None else repository.rstrip("/"), + default_branch=default_branch, + bundled_wheels=tuple(patterns), + content_provider=content_provider, + ) + + +def _strings(values: object) -> TypeIs[list[str]]: + return isinstance(values, list) and all(isinstance(value, str) for value in values) + + +def _supplier(value: Toml | None, where: str) -> OrganizationalEntity | None: + if value is None: + return None + if ( + isinstance(value, dict) + and value.keys() <= {"name", "url"} + and isinstance(name := value.get("name"), str) + and _strings(urls := value.get("url", [])) + ): + return {"name": name, "url": urls} + msg = f"`supplier` in {where} must be a table with a string `name` and an optional `url` string array" + raise SbomError(msg) + + +__all__ = [ + "Toml", + "settings_from_pyproject", + "settings_from_table", +] diff --git a/src/python_sbom/_collect/_source.py b/src/python_sbom/_collect/_source.py new file mode 100644 index 0000000..a30b16e --- /dev/null +++ b/src/python_sbom/_collect/_source.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from pathlib import PurePath +from typing import TYPE_CHECKING, Final + +from python_sbom._errors import SbomError +from python_sbom._model._inputs import BuildScript +from python_sbom._wheel._zip import sha256_hex + +if TYPE_CHECKING: + from collections.abc import Iterable + from pathlib import Path, PurePosixPath + + from python_sbom._model._inputs import Settings + + +def copyright_notice(root: Path, license_files: list[str]) -> str | None: + # the first Copyright line of the license files the project names, as they sit in the project + for license_file in license_files: + if (path := root / license_file).is_file(): + for line in path.read_text(encoding="utf-8").splitlines(): + if line.startswith("Copyright"): + return line + return None + + +def selected_wheels(root: Path, settings: Settings) -> list[str]: + wheels: Final[set[str]] = set() + for pattern in settings.bundled_wheels: + # a wheel outside the project is none the project ships, and pathlib refuses an absolute pattern outright + if (path := PurePath(pattern)).anchor or ".." in path.parts: + msg = f"`bundled-wheels` pattern {pattern!r} must be relative to the project, without `..`" + raise SbomError(msg) + wheels.update(wheel.relative_to(root).as_posix() for wheel in root.glob(pattern)) + return sorted(wheels) + + +def hash_build_scripts(root: Path, paths: Iterable[PurePosixPath]) -> list[BuildScript]: + # str, so a Windows drive in an absolute path survives the join with the native root; a script named twice, by the + # caller and by the provider, is listed once + return [BuildScript(path=path, sha256=file_sha256(root / str(path))) for path in dict.fromkeys(paths)] + + +def file_sha256(path: Path) -> str: + with path.open("rb") as stream: + return sha256_hex(stream) + + +__all__ = [ + "copyright_notice", + "file_sha256", + "hash_build_scripts", + "selected_wheels", +] diff --git a/src/python_sbom/_collect/_wheel.py b/src/python_sbom/_collect/_wheel.py new file mode 100644 index 0000000..3a0fbd0 --- /dev/null +++ b/src/python_sbom/_collect/_wheel.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from python_sbom._errors import SbomError +from python_sbom._model._inputs import WheelTree +from python_sbom._wheel._zip import read_wheel_metadata + +from ._source import copyright_notice, file_sha256, hash_build_scripts, selected_wheels + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + from pathlib import Path, PurePosixPath + + from python_sbom._model._inputs import Settings + from python_sbom._wheel._reader import ArtifactReader + + +def read_wheel( + artifact: ArtifactReader, root: Path, settings: Settings, build_scripts: Iterable[PurePosixPath] +) -> WheelTree: + _, metadata = read_wheel_metadata(artifact.archive, str(artifact.path)) + return WheelTree( + name=artifact.path.name, + metadata=metadata, + copyright=copyright_notice(root, metadata.values("license_files")), + bundled=artifact.bundled_wheels(_shipped_copies(root, settings, artifact)), + build_scripts=hash_build_scripts(root, build_scripts), + ) + + +def _shipped_copies(root: Path, settings: Settings, artifact: ArtifactReader) -> list[str]: + # the bundled-wheels option selects wheels in the source tree, but the SBOM describes the bytes the artifact ships: + # each selected wheel is found in the artifact by its digest and read from there, so a wheel changed or left out + # since the build is refused rather than described; identical copies take the artifact's matching members in turn + shipped: Final[dict[str, list[str]]] = {} + for name in sorted(artifact.members): + if name.endswith(".whl"): + shipped.setdefault(artifact.digest(name), []).append(name) + copies: Final[dict[str, Iterator[str]]] = {digest: iter(names) for digest, names in shipped.items()} + found: Final[list[str]] = [] + for source in selected_wheels(root, settings): + if (member := next(copies.get(file_sha256(root / source), iter(())), None)) is None: + msg = ( + f"bundled wheel {source} is not in {artifact.path.name} byte for byte: it changed since the build, or" + " the build left it out" + ) + raise SbomError(msg) + found.append(member) + return found + + +__all__ = [ + "read_wheel", +] diff --git a/src/python_sbom/_errors.py b/src/python_sbom/_errors.py new file mode 100644 index 0000000..b6e6ba1 --- /dev/null +++ b/src/python_sbom/_errors.py @@ -0,0 +1,15 @@ +from __future__ import annotations + + +class SbomError(Exception): + """Base of the errors this package raises; the command line reports them without a traceback.""" + + +# validation reports every other error per document, and lets this one through, since it fails every document alike +class MissingExtraError(SbomError): ... # simplint: ignore[SMP704] # see above + + +__all__ = [ + "MissingExtraError", + "SbomError", +] diff --git a/src/python_sbom/_formats/__init__.py b/src/python_sbom/_formats/__init__.py new file mode 100644 index 0000000..539b271 --- /dev/null +++ b/src/python_sbom/_formats/__init__.py @@ -0,0 +1 @@ +"""Serialization of documents: CycloneDX JSON and the SPDX 2.3 rendering.""" diff --git a/src/python_sbom/_formats/_json.py b/src/python_sbom/_formats/_json.py new file mode 100644 index 0000000..4ec244d --- /dev/null +++ b/src/python_sbom/_formats/_json.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from python_sbom._model._types import Document + + from ._spdx import Spdx + + +def dumps(document: Document | Spdx) -> bytes: + return f"{json.dumps(document, indent=2)}\n".encode() + + +__all__ = [ + "dumps", +] diff --git a/src/python_sbom/_formats/_spdx.py b/src/python_sbom/_formats/_spdx.py new file mode 100644 index 0000000..dde2be2 --- /dev/null +++ b/src/python_sbom/_formats/_spdx.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import hashlib +import json +import re +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Final, NotRequired, TypedDict + +from python_sbom._model._graph import walk_components +from python_sbom._model._names import SBOM_FILE, SBOM_REPOSITORY, property_prefix, sbom_property +from python_sbom.version import __version__ + +if TYPE_CHECKING: + from python_sbom._model._types import ( + Component, + Document, + ExternalReference, + License, + Metadata, + OrganizationalEntity, + Tools, + ) + +# SPDX 2.3 JSON names every CycloneDX 1.6 hash algorithm, but by names of its own +_HASH_ALGORITHMS: Final[dict[str, str]] = { + "MD5": "MD5", + "SHA-1": "SHA1", + "SHA-256": "SHA256", + "SHA-384": "SHA384", + "SHA-512": "SHA512", + "SHA3-256": "SHA3-256", + "SHA3-384": "SHA3-384", + "SHA3-512": "SHA3-512", + "BLAKE2b-256": "BLAKE2b-256", + "BLAKE2b-384": "BLAKE2b-384", + "BLAKE2b-512": "BLAKE2b-512", + "BLAKE3": "BLAKE3", +} +_LICENSE_REF: Final[re.Pattern[str]] = re.compile(r"LicenseRef-[A-Za-z0-9.-]+") + + +class _LegacyTool(TypedDict): + vendor: NotRequired[str] + name: NotRequired[str] + version: NotRequired[str] + + +class _Checksum(TypedDict): + algorithm: str + checksumValue: str + + +class _ExternalRef(TypedDict): + referenceCategory: str + referenceType: str + referenceLocator: str + + +class _Package(TypedDict): + SPDXID: str + name: str + downloadLocation: str + filesAnalyzed: bool + licenseConcluded: str + licenseDeclared: str + primaryPackagePurpose: str + versionInfo: NotRequired[str] + supplier: NotRequired[str] + checksums: NotRequired[list[_Checksum]] + homepage: NotRequired[str] + copyrightText: NotRequired[str] + summary: NotRequired[str] + comment: NotRequired[str] + externalRefs: NotRequired[list[_ExternalRef]] + licenseComments: NotRequired[str] + + +class _Relationship(TypedDict): + spdxElementId: str + relationshipType: str + relatedSpdxElement: str + + +class _CreationInfo(TypedDict): + created: str + creators: list[str] + + +class _ExtractedLicense(TypedDict): + licenseId: str + extractedText: str + name: str + + +class Spdx(TypedDict): + spdxVersion: str + dataLicense: str + SPDXID: str + name: str + documentNamespace: str + comment: str + creationInfo: _CreationInfo + packages: list[_Package] + relationships: list[_Relationship] + hasExtractedLicensingInfos: NotRequired[list[_ExtractedLicense]] + + +def render_spdx(document: Document) -> Spdx: + metadata: Final[Metadata] = document["metadata"] + root: Final[Component] = metadata["component"] + project: Final[str] = property_prefix(root["name"]) + recorded: Final[dict[str, str]] = {prop["name"]: prop.get("value", "") for prop in metadata.get("properties", [])} + name: Final[str] = f"{root['name']}-{root['version']}" if "version" in root else root["name"] + # without a serial number the content names the document, so rendering it twice gives the same namespace + source: Final[str] = ( + document["serialNumber"] + if "serialNumber" in document + else f"sha256:{hashlib.sha256(json.dumps(document, sort_keys=True).encode()).hexdigest()}" + ) + repository: Final[str] = recorded.get(sbom_property(project, SBOM_REPOSITORY), "https://spdx.org/spdxdocs") + sbom_file: Final[str] = recorded.get(sbom_property(project, SBOM_FILE), "CycloneDX JSON") + tools, tool_creators = _tools(metadata) + # file components mirror wheel RECORD entries, which carry no SHA-1 and so cannot become SPDX files + components: Final[list[Component]] = [ + component + for component in walk_components([root, *document.get("components", []), *tools]) + if component["type"] != "file" + ] + packages: Final[list[_Package]] = [_package(component, project) for component in components] + spdx: Final[Spdx] = { + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": name, + "documentNamespace": f"{repository}/sboms/{name}-{source.removeprefix('urn:uuid:')}", + "comment": f"Rendered from the CycloneDX SBOM {source} ({sbom_file}), which also carries per-file hashes and" + " the build formulation.", + "creationInfo": _creation_info(metadata, tool_creators), + "packages": packages, + "relationships": _relationships(document, components, tools), + } + # SPDX requires the text of every LicenseRef a package declares; the metadata names them, it holds no text + references: Final[set[str]] = set() + for package in packages: + references.update(_LICENSE_REF.findall(package["licenseDeclared"])) + if references: + spdx["hasExtractedLicensingInfos"] = [ + {"licenseId": token, "extractedText": f"{token}, as named in package metadata", "name": token} + for token in sorted(references) + ] + return spdx + + +def _tools(metadata: Metadata) -> tuple[list[Component], list[str]]: + # CycloneDX 1.5 lists the tools as components; earlier versions as a bare array, whose entries carry no bom-ref, so + # they become creators alone + tools: Final[Tools | list[_LegacyTool] | None] = metadata.get("tools") + if isinstance(tools, list): + return [], [_creator(name, tool.get("version")) for tool in tools if (name := tool.get("name"))] + components: Final[list[Component]] = [] if tools is None else tools.get("components", []) + return components, [ + _creator(tool["name"], tool.get("version")) for tool in components if tool["type"] == "application" + ] + + +def _creator(name: str, version: str | None) -> str: + return f"Tool: {name}" if version is None else f"Tool: {name}-{version}" + + +def _package(component: Component, project: str) -> _Package: + # a component carries either one SPDX expression, or the free-form names from License/classifier metadata plus the + # SPDX ids concluded from them + licenses: Final[list[License]] = component.get("licenses", []) + concluded: Final[list[str]] = [ + entry["license"]["id"] for entry in licenses if "license" in entry and "id" in entry["license"] + ] + package: Final[_Package] = { + "SPDXID": _spdx_id(component["bom-ref"]), + "name": component["name"], + "downloadLocation": "NOASSERTION", + "filesAnalyzed": False, + "licenseConcluded": " AND ".join(concluded) or "NOASSERTION", + "licenseDeclared": next((entry["expression"] for entry in licenses if "expression" in entry), "NOASSERTION"), + "primaryPackagePurpose": {"application": "APPLICATION", "library": "LIBRARY"}.get(component["type"], "OTHER"), + } + if version := component.get("version"): + package["versionInfo"] = version + if (supplier := component.get("supplier")) and (organization := supplier.get("name")): + package["supplier"] = f"Organization: {organization}" + checksums: Final[list[_Checksum]] = [ + {"algorithm": _HASH_ALGORITHMS[entry["alg"]], "checksumValue": entry["content"]} + for entry in component.get("hashes", []) + if entry["alg"] in _HASH_ALGORITHMS + ] + if checksums: + package["checksums"] = checksums + references: Final[list[ExternalReference]] = component.get("externalReferences", []) + if homepage := next((reference["url"] for reference in references if reference["type"] == "website"), None): + package["homepage"] = homepage + if copyright_text := component.get("copyright"): + package["copyrightText"] = copyright_text + if description := component.get("description"): + package["summary"] = description + if comment := "\n".join( + f"{prop['name']}: {prop.get('value', '')}" + for prop in component.get("properties", []) + if prop["name"].startswith(f"{project}:") + ): + package["comment"] = comment + if purl := component.get("purl"): + package["externalRefs"] = [ + {"referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": purl} + ] + if package["licenseDeclared"] == "NOASSERTION" and ( + names := [entry["license"]["name"] for entry in licenses if "license" in entry and "name" in entry["license"]] + ): + package["licenseComments"] = f"Declared in package metadata as: {'; '.join(names)}" + return package + + +def _spdx_id(bom_ref: str) -> str: + # the readable part drops every other character, so refs that differ only in those share it; only the full digest, + # not a prefix of it, keeps their ids apart + readable: Final[str] = re.sub(r"[^A-Za-z0-9.]+", "-", bom_ref).strip("-") + return f"SPDXRef-{readable}-{hashlib.sha256(bom_ref.encode()).hexdigest()}" + + +def _creation_info(metadata: Metadata, tool_creators: list[str]) -> _CreationInfo: + timestamp: Final[str | None] = metadata.get("timestamp") + # SPDX requires a creation time; without the CycloneDX one, the time of this rendering is the true one + created: Final[datetime] = datetime.now(UTC) if timestamp is None else datetime.fromisoformat(timestamp) + supplier: Final[OrganizationalEntity | None] = metadata.get("supplier") + organization: Final[str] = "" if supplier is None else supplier.get("name", "") + creators: Final[list[str]] = [f"Organization: {organization}"] if organization else [] + creators.extend(tool_creators) + creators.append(f"Tool: python-sbom-{__version__}") + return {"created": created.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), "creators": creators} + + +def _relationships(document: Document, components: list[Component], tools: list[Component]) -> list[_Relationship]: + root_id: Final[str] = _spdx_id(document["metadata"]["component"]["bom-ref"]) + relationships: Final[list[_Relationship]] = [_relationship("SPDXRef-DOCUMENT", "DESCRIBES", root_id)] + for parent in components: + parent_id = _spdx_id(parent["bom-ref"]) + children = (child for child in parent.get("components", []) if child["type"] != "file") + relationships.extend(_relationship(parent_id, "CONTAINS", _spdx_id(child["bom-ref"])) for child in children) + for entry in document.get("dependencies", []): + dependent_id = _spdx_id(entry["ref"]) + relationships.extend( + _relationship(dependent_id, "DEPENDS_ON", _spdx_id(dependency)) for dependency in entry.get("dependsOn", []) + ) + relationships.extend(_relationship(_spdx_id(tool["bom-ref"]), "BUILD_TOOL_OF", root_id) for tool in tools) + return relationships + + +def _relationship(element: str, kind: str, related: str) -> _Relationship: + return {"spdxElementId": element, "relationshipType": kind, "relatedSpdxElement": related} + + +__all__ = [ + "Spdx", + "render_spdx", +] diff --git a/src/python_sbom/_generate/__init__.py b/src/python_sbom/_generate/__init__.py new file mode 100644 index 0000000..49187f2 --- /dev/null +++ b/src/python_sbom/_generate/__init__.py @@ -0,0 +1 @@ +"""The pipelines that build a wheel's and an archive's SBOM from what _collect gathers, and write it.""" diff --git a/src/python_sbom/_generate/_archive.py b/src/python_sbom/_generate/_archive.py new file mode 100644 index 0000000..1b14a98 --- /dev/null +++ b/src/python_sbom/_generate/_archive.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from python_sbom._collect._archive import read_archive +from python_sbom._collect._content import resolve_provider +from python_sbom._collect._facts import collect_facts +from python_sbom._collect._revision import revision_reader +from python_sbom._collect._settings import settings_from_pyproject +from python_sbom._collect._source import copyright_notice, hash_build_scripts +from python_sbom._errors import SbomError +from python_sbom._formats._json import dumps +from python_sbom._model._archive import archive_artifact, bundled_paths +from python_sbom._model._bom import assemble +from python_sbom._model._content import ProviderContext +from python_sbom._model._document import checked_contributions +from python_sbom._model._identity import project_identity +from python_sbom._model._names import archive_sbom_file_name +from python_sbom._wheel._reader import open_artifact +from python_sbom._wheel._write import append_member + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path, PurePosixPath + + from python_sbom._model._content import ContentProvider + from python_sbom._model._inputs import BuildFacts + from python_sbom._model._types import Document + + +def archive_sbom( # ruff: ignore[too-many-arguments] # every command-line option reaches the build + root: Path, + archive: Path, + *, + provider: ContentProvider | str | None, + commit: str | None, + build_env: Sequence[str] | None, + build_scripts: Sequence[PurePosixPath], + output: Path | None, + embed: bool, +) -> tuple[Path, str | None]: + name = archive_sbom_file_name(archive.name) + if (target := output or archive.with_name(name)).exists() and target.samefile(archive): + msg = f"{target} is the archive {archive} itself, refusing to overwrite it with the SBOM" + raise SbomError(msg) + facts = collect_facts(root, revision_reader(commit), path=build_env) + document = describe_archive(root, archive, facts=facts, provider=provider, build_scripts=build_scripts) + target.write_bytes(content := dumps(document)) + if not embed: + return target, None + append_member(archive, name, content, facts.epoch) + return target, name + + +def describe_archive( + root: Path, + archive: Path, + *, + facts: BuildFacts, + provider: ContentProvider | str | None, + build_scripts: Sequence[PurePosixPath], +) -> Document: + settings = settings_from_pyproject(root) + content, provider_scripts = resolve_provider(provider, settings, root) + with open_artifact(archive) as artifact: + tree = read_archive(artifact) + project = project_identity(tree.metadata[""], settings) + context = ProviderContext(root=root, settings=settings, identity=project, read=artifact.provider_read) + built = archive_artifact( + archive.name, + tree, + project=project, + bundled_info={str(path): content.bundled_wheel(context, path) for path in bundled_paths(tree)}, + build_scripts=hash_build_scripts(root, [*build_scripts, *provider_scripts]), + copyright_line=copyright_notice(root, tree.metadata[""].values("license_files")), + ) + contributions = checked_contributions(content.contributions(context), content) + return assemble(built, project, settings, facts, contributions) + + +__all__ = [ + "archive_sbom", + "describe_archive", +] diff --git a/src/python_sbom/_generate/_embed.py b/src/python_sbom/_generate/_embed.py new file mode 100644 index 0000000..4f1dcfc --- /dev/null +++ b/src/python_sbom/_generate/_embed.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from pathlib import PurePosixPath +from typing import TYPE_CHECKING + +from python_sbom._collect._content import resolve_provider +from python_sbom._collect._facts import collect_facts +from python_sbom._collect._revision import revision_reader +from python_sbom._collect._settings import settings_from_pyproject +from python_sbom._collect._wheel import read_wheel +from python_sbom._formats._json import dumps +from python_sbom._model._bom import assemble +from python_sbom._model._content import ProviderContext +from python_sbom._model._document import checked_contributions +from python_sbom._model._identity import project_identity +from python_sbom._model._wheel import wheel_artifact +from python_sbom._wheel._reader import open_artifact +from python_sbom._wheel._write import write_sbom + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + + from python_sbom._model._content import ContentProvider + from python_sbom._model._inputs import BuildFacts, Settings + + +def embed_sbom( + wheel: Path, + root: Path, + *, + provider: ContentProvider | str | None, + commit: str | None, + build_env: Sequence[str] | None, +) -> str: + settings = settings_from_pyproject(root) + content, scripts = resolve_provider(provider, settings, root) + facts = collect_facts(root, revision_reader(commit), path=build_env) + return embed_into(wheel, root, settings=settings, content=content, build_scripts=scripts, facts=facts) + + +def embed_into( # ruff: ignore[too-many-arguments] # what the hook and the command line each gather their own way + wheel: Path, + root: Path, + *, + settings: Settings, + content: ContentProvider, + build_scripts: Sequence[PurePosixPath], + facts: BuildFacts, +) -> str: + # the wheel stays open while its document is built, for its metadata, its bundled wheels and the provider to read, + # and is closed before it is rewritten + with open_artifact(wheel) as artifact: + tree = read_wheel(artifact, root, settings, build_scripts) + project = project_identity(tree.metadata, settings) + context = ProviderContext(root=root, settings=settings, identity=project, read=artifact.provider_read) + built = wheel_artifact( + tree, + project=project, + settings=settings, + bundled_info={ + bundled.path: content.bundled_wheel(context, PurePosixPath(bundled.path)) for bundled in tree.bundled + }, + ) + contributions = checked_contributions(content.contributions(context), content) + return write_sbom( + wheel, built.sbom_file, dumps(assemble(built, project, settings, facts, contributions)), facts.epoch + ) + + +__all__ = [ + "embed_into", + "embed_sbom", +] diff --git a/src/python_sbom/_model/__init__.py b/src/python_sbom/_model/__init__.py new file mode 100644 index 0000000..40aeade --- /dev/null +++ b/src/python_sbom/_model/__init__.py @@ -0,0 +1 @@ +"""The CycloneDX document model: its types, its inputs and the pure functions that build it.""" diff --git a/src/python_sbom/_model/_archive.py b/src/python_sbom/_model/_archive.py new file mode 100644 index 0000000..f335220 --- /dev/null +++ b/src/python_sbom/_model/_archive.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import PurePosixPath +from typing import TYPE_CHECKING, Final + +from packageurl import PackageURL + +from ._components import ( + bundled_wheel_component, + component_from_metadata, + file_component, + mark_bundled, + vendored_dependencies, +) +from ._graph import artifact_contents +from ._inputs import Artifact +from ._names import archive_sbom_file_name + +if TYPE_CHECKING: + from collections.abc import Mapping + + from ._content import BundledWheelInfo + from ._identity import ProjectIdentity + from ._inputs import ArchiveTree, BuildScript + from ._types import Component + + +def archive_artifact( # ruff: ignore[too-many-arguments] # keyword-only, each a separate fact the edge read + name: str, + tree: ArchiveTree, + *, + project: ProjectIdentity, + bundled_info: Mapping[str, BundledWheelInfo], + build_scripts: list[BuildScript], + copyright_line: str | None, +) -> Artifact: + # the archive's top-level .dist-info names the project; every other .dist-info marks a wheel bundled unpacked into + # its parent directory and every .whl member a bundled wheel; other files belong to the deepest distribution that + # holds them, or, at the top level, to the archive + version: Final[str] = tree.metadata[""].version + ref: Final[str] = PackageURL(type="generic", name=name, version=version).to_string() + distributions = _Distributions(tree=tree, project=project, bundled_info=bundled_info) + root: Final[Component] = { + "type": "application", + "bom-ref": ref, + "name": name, + "version": version, + "purl": ref, + "components": [ + distributions.wheel(member) if member.endswith(".whl") else file_component(ref, tree.files[member]) + for member in tree.loose + ], + } + components: Final[list[Component]] = [distributions.component(prefix) for prefix in sorted(tree.metadata)] + return Artifact( + root=root, + components=components, + dependencies=vendored_dependencies(artifact_contents(root, components)), + build_scripts=build_scripts, + version=version, + sbom_file=archive_sbom_file_name(name), + scope=( + "Components are the distributions the archive holds, as wheels or unpacked, each with its files; the" + " archive's own top-level files sit under the root component." + ), + workflow=("archive-build", f"build {name} and this SBOM"), + license_file=next(iter(tree.metadata[""].values("license_files")), None), + copyright=copyright_line, + ) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class _Distributions: + tree: ArchiveTree + project: ProjectIdentity + bundled_info: Mapping[str, BundledWheelInfo] + + def component(self, prefix: str) -> Component: + component = component_from_metadata(self.tree.metadata[prefix]) + if prefix: + # a wheel bundled unpacked, named by its directory as a .whl is by its path + directory = prefix.removesuffix("/") + mark_bundled(component, directory, self.project.property_prefix, self.bundled_info[directory]) + component["components"] = [ + self.wheel(name) if name.endswith(".whl") else file_component(component["bom-ref"], self.tree.files[name]) + for name in self.tree.owned[prefix] + ] + return component + + def wheel(self, name: str) -> Component: + return bundled_wheel_component(self.tree.bundled[name], self.project.property_prefix, self.bundled_info[name]) + + +def bundled_paths(tree: ArchiveTree) -> list[PurePosixPath]: + return [PurePosixPath(member.removesuffix("/")) for member in bundled_members(tree.loose, tree.owned)] + + +def bundled_members(loose: list[str], owned: Mapping[str, list[str]]) -> list[str]: + # the bundled wheels in the order the components list them, which is the order they are read and a provider is asked + # about them: the archive's own top-level .whl members, then each wheel bundled unpacked, by its directory ending in + # "/", followed by the .whl members it holds + return [ + *(name for name in loose if name.endswith(".whl")), + *( + member + for prefix in sorted(owned) + for member in ([prefix] if prefix else []) + [name for name in owned[prefix] if name.endswith(".whl")] + ), + ] + + +__all__ = [ + "archive_artifact", + "bundled_members", + "bundled_paths", +] diff --git a/src/python_sbom/_model/_bom.py b/src/python_sbom/_model/_bom.py new file mode 100644 index 0000000..d335732 --- /dev/null +++ b/src/python_sbom/_model/_bom.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from pathlib import PurePosixPath +from typing import TYPE_CHECKING, Final +from urllib.parse import urlsplit + +from ._components import script_tool +from ._document import check_refs, contributed_properties, formulation, timestamp, with_header +from ._names import SBOM_FILE, SBOM_REPOSITORY, sbom_property +from ._tools import tool_components + +if TYPE_CHECKING: + from ._content import Contributions + from ._identity import ProjectIdentity + from ._inputs import Artifact, BuildFacts, Settings + from ._types import Body, Component, Document, ExternalReference, Metadata, Property + + +def assemble( + artifact: Artifact, project: ProjectIdentity, settings: Settings, facts: BuildFacts, contributions: Contributions +) -> Document: + # what every artifact of the project carries alike: who supplies it, where its source lives and at which revision, + # the tools that built it and what the project contributes + root = _provenance(artifact, project, settings, facts, contributions) + components: Final[list[Component]] = [*artifact.components, *contributions.components] + environment, tool_dependencies = tool_components(facts) + tools: Final[list[Component]] = [ + *( + script_tool(script.path, script.sha256, artifact.version, _file_link(project, settings, facts, script.path)) + for script in artifact.build_scripts + ), + *environment, + *contributions.tools, + ] + uid, name = artifact.workflow + body: Final[Body] = { + "metadata": _metadata(tools, artifact, project, settings, facts), + "components": components, + "dependencies": [ + {"ref": root["bom-ref"], "dependsOn": [component["bom-ref"] for component in components]}, + *artifact.dependencies, + *tool_dependencies, + *contributions.dependencies, + ], + # what the artifact needs at run time beyond what it ships, an interpreter and the dependencies an installer + # resolves, is not listed + "compositions": [{"aggregate": "incomplete", "dependencies": [root["bom-ref"]]}], + "formulation": [formulation(uid, name, tools, root["bom-ref"], facts)], + } + check_refs(body) + return with_header(body, project.serial_namespace) + + +def _provenance( + artifact: Artifact, project: ProjectIdentity, settings: Settings, facts: BuildFacts, contributions: Contributions +) -> Component: + root = artifact.root + if settings.supplier is not None: + root["supplier"] = settings.supplier + if artifact.copyright is not None: + root["copyright"] = artifact.copyright + references: Final[list[ExternalReference]] = [*root.get("externalReferences", [])] + if project.repository and urlsplit(project.repository).netloc == "github.com": + references.extend([ + {"type": "security-contact", "url": f"{project.repository}/security/policy"}, + {"type": "advisories", "url": f"{project.repository}/security/advisories"}, + ]) + if artifact.license_file is not None and ( + link := _file_link(project, settings, facts, PurePosixPath(artifact.license_file)) + ): + references.append({"type": "license", "url": link}) + properties: Final[list[Property]] = [ + *root.get("properties", []), + *contributed_properties(contributions.root_properties, project.property_prefix), + ] + if (revision := facts.revision) is not None: + if project.repository: + comment = "source revision, with uncommitted changes" if revision.dirty else "exact source revision" + references.append({ + "type": "vcs", + "url": f"{project.repository}/tree/{revision.commit}", + "comment": comment, + }) + properties.append({"name": sbom_property(project.property_prefix, "vcs-commit"), "value": revision.commit}) + if revision.dirty: + properties.append({"name": sbom_property(project.property_prefix, "vcs-dirty"), "value": "true"}) + root["externalReferences"] = references + root["properties"] = properties + return root + + +def _file_link(project: ProjectIdentity, settings: Settings, facts: BuildFacts, path: PurePosixPath) -> str | None: + if project.repository is None: + return None + # the commit of a clean checkout holds the file as it was built; with no commit, or uncommitted changes, the default + # branch is where the file most likely still lives + revision = facts.revision + tree = settings.default_branch if revision is None or revision.dirty else revision.commit + return f"{project.repository}/blob/{tree}/{path}" + + +def _metadata( + tools: list[Component], + artifact: Artifact, + project: ProjectIdentity, + settings: Settings, + facts: BuildFacts, +) -> Metadata: + metadata: Final[Metadata] = { + "timestamp": timestamp(facts), + "lifecycles": [{"phase": "build"}], + "tools": {"components": tools}, + "component": (root := artifact.root), + } + if settings.supplier is not None: + metadata["manufacturer"] = metadata["supplier"] = settings.supplier + if "authors" in root: + metadata["authors"] = root["authors"] + if "licenses" in root: + metadata["licenses"] = root["licenses"] + properties: Final[list[Property]] = [ + {"name": sbom_property(project.property_prefix, "sbom:scope"), "value": artifact.scope}, + # names the document's file, which marks it as one python-sbom built, so validation checks it in full + {"name": sbom_property(project.property_prefix, SBOM_FILE), "value": artifact.sbom_file}, + ] + if project.repository is not None: + properties.append({ + "name": sbom_property(project.property_prefix, SBOM_REPOSITORY), + "value": project.repository, + }) + metadata["properties"] = properties + return metadata + + +__all__ = [ + "assemble", +] diff --git a/src/python_sbom/_model/_components.py b/src/python_sbom/_model/_components.py new file mode 100644 index 0000000..319b039 --- /dev/null +++ b/src/python_sbom/_model/_components.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import re +from email.utils import getaddresses +from itertools import starmap +from typing import TYPE_CHECKING, Final + +from packaging.utils import canonicalize_name + +from ._graph import walk_components +from ._iri import iri_reference +from ._licenses import licenses +from ._names import purl, pypi_url, reference_type, sbom_property, tool_ref +from ._types import Dependency + +if TYPE_CHECKING: + from pathlib import PurePosixPath + + from ._content import BundledWheelInfo + from ._inputs import BundledWheel, RecordRow + from ._metadata import CoreMetadata + from ._types import Component, Contact, ExternalReference, Identity, Property + + +def bundled_wheel_component(wheel: BundledWheel, property_prefix: str, info: BundledWheelInfo) -> Component: + component = component_from_metadata(wheel.metadata) + mark_bundled(component, wheel.path, property_prefix, info) + component["hashes"] = [{"alg": "SHA-256", "content": wheel.sha256}] + component["externalReferences"] = [ + *component["externalReferences"], + {"type": "distribution", "url": pypi_url(wheel.metadata.name, wheel.metadata.version)}, + ] + component["evidence"] = { + "identity": [ + _evidence("purl", "manifest-analysis", wheel.metadata_name), + _evidence("hash", "hash-comparison", wheel.sha256), + ] + } + vendored: Final[dict[str, Component]] = {} + for source, metadata in wheel.vendored: + child = component_from_metadata(metadata) + child["bom-ref"] = f"{component['bom-ref']}#vendored/{child['bom-ref']}" + child["properties"] = [ + *child["properties"], + {"name": sbom_property(property_prefix, "vendored-manifest"), "value": source}, + ] + child["evidence"] = {"identity": [_evidence("purl", "manifest-analysis", source)]} + # a vendored .dist-info comes after pip's manifest line naming the same package, so its metadata wins; keyed on + # the PEP 503 name, since the manifest and the .dist-info may spell it differently + vendored[f"{canonicalize_name(child['name'])}=={child['version']}"] = child + component["components"] = [ + *(file_component(component["bom-ref"], row) for row in wheel.files), + *(vendored[key] for key in sorted(vendored)), + ] + return component + + +def component_from_metadata(metadata: CoreMetadata) -> Component: + name, version = metadata.name, metadata.version + component: Final[Component] = {"type": "library", "bom-ref": purl(name, version), "name": name, "version": version} + component["purl"] = component["bom-ref"] + if summary := metadata.first("summary"): + component["description"] = summary + # maintainers are who users reach today, so they win over the original authors when both are named + for names, addresses in (("maintainer", "maintainer_email"), ("author", "author_email")): + contacts = [ + *(_contact(name, "") for name in metadata.values(names)), + *starmap(_contact, getaddresses(metadata.values(addresses))), + ] + if people := [contact for contact in contacts if contact]: + component["authors"] = people + break + if declared := licenses(metadata): + component["licenses"] = declared + component["externalReferences"], unlinked = _project_references(metadata) + # core metadata headers copied verbatim, so a reader sees what the distribution declared + headers: Final[list[tuple[str, list[str]]]] = [ + ("requires-python", metadata.values("requires_python")), + ("requires-dist", metadata.values("requires_dist")), + ("provides-extra", metadata.values("provides_extra")), + ("classifier", metadata.values("classifiers")), + ("keywords", metadata.keywords()), + ] + component["properties"] = [] + for header, values in headers: + component["properties"].extend({"name": f"python:{header}", "value": value} for value in values) + component["properties"].extend(unlinked) + return component + + +def _contact(name: str, email: str) -> Contact: + if email and not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email): + # an address the schema rejects as an email, such as "jane at example.com", is kept as the name + return {"name": " ".join(part for part in (name, email) if part)} + contact: Final[Contact] = {} + if name: + contact["name"] = name + if email: + contact["email"] = email + return contact + + +def _project_references(metadata: CoreMetadata) -> tuple[list[ExternalReference], list[Property]]: + # a URL CycloneDX would reject is percent-encoded where that keeps what it points at and left out where it cannot + # be, as an scp-style git address; either way the header's text is kept verbatim as a property + references: Final[list[ExternalReference]] = [] + unlinked: Final[list[Property]] = [] + # a home page that is not an http(s) URL, such as UNKNOWN, is left out + if (home_page := metadata.first("home_page")) and re.match(r"https?:", home_page, re.IGNORECASE): + if (url := iri_reference(home_page)) is not None: + references.append({"type": "website", "url": url}) + if url != home_page: + unlinked.append({"name": "python:home-page", "value": home_page}) + for label, link in metadata.project_urls(): + if (url := iri_reference(link)) is not None: + references.append({"type": reference_type(label), "url": url, "comment": f"Project-URL: {label}"}) + if url != link: + unlinked.append({"name": "python:project-url", "value": f"{label}, {link}"}) + return references, unlinked + + +def mark_bundled(component: Component, path: str, property_prefix: str, info: BundledWheelInfo) -> None: + # the purl names the package; the path, of a .whl or of the directory a wheel is unpacked into, tells apart copies + # of one release bundled in two places + component["bom-ref"] = f"bundled:{path}" + if info.supplier is not None: + component["supplier"] = info.supplier + component["properties"] = [ + {"name": sbom_property(property_prefix, "bundled-wheel"), "value": path}, + *info.properties, + *component["properties"], + ] + + +def _evidence(field: str, technique: str, value: str) -> Identity: + return {"field": field, "confidence": 1, "methods": [{"technique": technique, "confidence": 1, "value": value}]} + + +def file_component(parent_ref: str, row: RecordRow) -> Component: + return { + "type": "file", + "bom-ref": f"{parent_ref}#{row.path}", + "name": row.path, + "hashes": [{"alg": row.algorithm, "content": row.digest}], + "properties": [{"name": "size", "value": row.size}], + } + + +def vendored_dependencies(components: list[Component]) -> list[Dependency]: + # each bundled wheel depends on the packages it vendors, wherever it sits in the document + found: Final[list[Dependency]] = [] + for component in walk_components(components): + if not component["bom-ref"].startswith("bundled:"): + continue + # a file's ref holds its path, which may itself hold "#vendored/" + libraries = (child["bom-ref"] for child in component.get("components", []) if child["type"] == "library") + if vendored := [ref for ref in libraries if "#vendored/" in ref]: + found.append(Dependency(ref=component["bom-ref"], dependsOn=vendored)) + return found + + +def script_tool(path: PurePosixPath, sha256: str, version: str, link: str | None) -> Component: + tool: Final[Component] = { + "type": "application", + "bom-ref": tool_ref(str(path)), + "name": path.name, + "version": version, + "hashes": [{"alg": "SHA-256", "content": sha256}], + } + if link is not None: + tool["externalReferences"] = [{"type": "vcs", "url": link}] + return tool + + +__all__ = [ + "bundled_wheel_component", + "component_from_metadata", + "file_component", + "mark_bundled", + "script_tool", + "vendored_dependencies", +] diff --git a/src/python_sbom/_model/_content.py b/src/python_sbom/_model/_content.py new file mode 100644 index 0000000..cd51765 --- /dev/null +++ b/src/python_sbom/_model/_content.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path, PurePosixPath + + from ._identity import ProjectIdentity + from ._inputs import Settings + from ._types import Component, Dependency, OrganizationalEntity, Property + + +class ProviderContext: + """ + What a content provider may use about the build it contributes to. + + :param root: the project directory + :param settings: the project's options + :param identity: how the project's SBOMs name it, including the prefix of its properties + :param read: reads a member of the artifact being described, for :meth:`read` + + """ + + def __init__( + self, *, root: Path, settings: Settings, identity: ProjectIdentity, read: Callable[[PurePosixPath], bytes] + ) -> None: + self.root = root + self.settings = settings + self.identity = identity + self._read = read + + def read(self, path: PurePosixPath) -> bytes: + """ + Read a file of the artifact being described: a member of the built wheel or of the archive. + + :param path: the file's path inside the artifact, as a bundled wheel's path is given + :returns: the file's bytes + :raises KeyError: when the artifact holds no such file + + """ + return self._read(path) + + +class ContentProvider: + """ + Adds what a project knows beyond its metadata to its SBOM; subclass it and override what the project needs. + + Name a subclass in the ``content-provider`` option, which the hook, ``python-sbom embed`` and ``python-sbom + archive`` load, or return an instance from :meth:`python_sbom.hatch.SbomBuildHook.content_provider`. When the class + lives in the project, the SBOM lists its module among the build tools. + + """ + + # ruff: disable[unused-method-argument, no-self-use] # the defaults add nothing, whatever they are given + def bundled_wheel(self, context: ProviderContext, wheel: PurePosixPath) -> BundledWheelInfo: + """ + Describe what the project does with a wheel it bundles, as a file or unpacked into a directory. + + :param context: the project and the artifact being described + :param wheel: the wheel's path inside the artifact + :returns: its extra properties, and whether the project's supplier supplies it; neither by default + + """ + return BundledWheelInfo() + + def contributions(self, context: ProviderContext) -> Contributions: + """ + Add components, their dependencies, root properties and build tools, before references and the serial derive. + + :param context: the project and the artifact being described + :returns: the contributions; none by default + + """ + return Contributions() + + # ruff: enable[unused-method-argument, no-self-use] + + +@dataclass(frozen=True, slots=True, kw_only=True) +class BundledWheelInfo: + """ + What a project adds to the component of one bundled wheel. + + Both fields default to empty, so a provider names only what it adds. + + :param properties: properties after ``:bundled-wheel``, before those from the wheel's metadata + :param supplier: the wheel's supplier, or ``None`` to name none; only the project decides a bundled wheel's + supplier, never the wheel's own metadata + + """ + + properties: list[Property] = field(default_factory=list) + supplier: OrganizationalEntity | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Contributions: + """ + What a project adds to the document beyond its bundled wheels, merged before references and the serial are derived. + + Each field defaults to empty, so a provider names only what it adds. The build fails when a component or tool lacks + ``type``, ``bom-ref`` or ``name``, a ``bom-ref`` repeats, the ``formula:`` and ``workflow:`` refs of the build's + formulation included, a dependency names one that does not exist, or a ``bom-ref`` gets a second dependency entry, + as the root does if a provider adds one, since python-sbom writes the root's. + + :param components: extra top-level components, such as vendored C libraries; the root depends on each, and a + component's own parts nest under its ``components`` + :param dependencies: extra dependency entries, such as the dependencies of those components + :param root_properties: extra properties of the root component; their names must not start with the project's + prefix, which python-sbom's own properties use + :param tools: extra build tools + + """ + + components: list[Component] = field(default_factory=list) + dependencies: list[Dependency] = field(default_factory=list) + root_properties: list[Property] = field(default_factory=list) + tools: list[Component] = field(default_factory=list) + + +__all__ = [ + "BundledWheelInfo", + "ContentProvider", + "Contributions", + "ProviderContext", +] diff --git a/src/python_sbom/_model/_document.py b/src/python_sbom/_model/_document.py new file mode 100644 index 0000000..42e4d74 --- /dev/null +++ b/src/python_sbom/_model/_document.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import json +import uuid +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Final + +from python_sbom._errors import SbomError + +from ._graph import reference_problems, walk_components + +if TYPE_CHECKING: + from ._content import ContentProvider, Contributions + from ._inputs import BuildFacts + from ._types import Body, Component, Document, Formula, Property + + +def check_refs(body: Body) -> None: + # a provider contributes refs of its own, so the document is checked before it is written rather than by validate + components: Final[list[Component]] = [ + *walk_components([ + body["metadata"]["component"], + *body["components"], + *body["metadata"]["tools"]["components"], + ]) + ] + # formulas and workflows share the bom-ref namespace, so a component cannot take theirs + formulas: Final[list[str]] = [ + ref + for formula in body.get("formulation", []) + for ref in (formula["bom-ref"], *(workflow["bom-ref"] for workflow in formula["workflows"])) + ] + if problems := reference_problems( + [*(component["bom-ref"] for component in components), *formulas], body["dependencies"] + ): + raise SbomError(problems[0]) + + +def with_header(body: Body, serial_namespace: uuid.UUID) -> Document: + return { + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": f"urn:uuid:{uuid.uuid5(serial_namespace, json.dumps(body, sort_keys=True))}", + "version": 1, + **body, + } + + +def checked_contributions(contributions: Contributions, provider: ContentProvider) -> Contributions: + # what a provider adds is checked for the fields everything else here reads, so a mistake names the provider rather + # than surfacing as a KeyError deep in the build + for component in walk_components([*contributions.components, *contributions.tools]): + if missing := [key for key in ("type", "bom-ref", "name") if key not in component]: + name = type(provider).__qualname__ + msg = f"content provider {name} added a component without {', '.join(missing)}: {component!r:.200}" + raise SbomError(msg) + return contributions + + +def contributed_properties(properties: list[Property], prefix: str) -> list[Property]: + if claimed := sorted(prop["name"] for prop in properties if prop["name"].startswith(f"{prefix}:")): + msg = f"contributed root properties {', '.join(claimed)} use the {prefix}: prefix python-sbom writes" + raise SbomError(msg) + return properties + + +def formulation(uid: str, name: str, tools: list[Component], output_ref: str, facts: BuildFacts) -> Formula: + formula: Formula = { + "bom-ref": f"formula:{uid}", + "workflows": [ + { + "bom-ref": f"workflow:{uid}", + "uid": uid, + "name": name, + "taskTypes": ["build"], + "resourceReferences": [{"ref": tool["bom-ref"]} for tool in tools], + "outputs": [{"type": "artifact", "resource": {"ref": output_ref}}], + } + ], + } + if facts.epoch_from_environment: + formula["workflows"][0]["inputs"] = [ + {"environmentVars": [{"name": "SOURCE_DATE_EPOCH", "value": str(facts.epoch)}]} + ] + return formula + + +def timestamp(facts: BuildFacts) -> str: + return datetime.fromtimestamp(facts.epoch, tz=UTC).isoformat() + + +__all__ = [ + "check_refs", + "checked_contributions", + "contributed_properties", + "formulation", + "timestamp", + "with_header", +] diff --git a/src/python_sbom/_model/_graph.py b/src/python_sbom/_model/_graph.py new file mode 100644 index 0000000..79dbff1 --- /dev/null +++ b/src/python_sbom/_model/_graph.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from collections import Counter +from typing import TYPE_CHECKING, Final + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + + from ._types import Component, Dependency + + +def artifact_contents(root: Component, components: list[Component]) -> list[Component]: + # what the artifact holds or declares, wherever the document places it: the files and wheels nested under the root, + # which sit in an archive's top level, and the components; not the root itself, nor the tools that built it, so + # generation, the license policy and validation all judge the same set + return [*root.get("components", []), *components] + + +def shipped_libraries(contents: list[Component]) -> list[Component]: + # what the artifact carries bytes for: a declared requirement resolves at install time, and a library a bundled + # wheel vendors ships under that wheel's terms + return [ + component + for component in walk_components(contents) + if component["type"] == "library" + and ("hashes" in component or any(child["type"] == "file" for child in component.get("components", []))) + ] + + +def walk_components(components: Iterable[Component]) -> Iterator[Component]: + # depth first, each component before the ones it nests, in document order; iterative, so a deeply nested document + # cannot exhaust the stack + pending: Final[list[Component]] = list(reversed(list(components))) + while pending: + component = pending.pop() + yield component + pending.extend(reversed(component.get("components", []))) + + +def reference_problems(refs: list[str], dependencies: list[Dependency]) -> list[str]: + # what generation refuses and validation reports alike: a bom-ref given twice, which formulas and workflows share + # with components; a second dependency entry for one ref, which a reader taking them as a mapping would let a valid + # entry hide an invalid one with, while the SPDX rendering keeps the edges of both; and an entry naming no component + if twice := repeated(refs): + return [f"bom-ref values repeat: {', '.join(twice)}"] + if twice := repeated(entry["ref"] for entry in dependencies): + return [f"dependencies list more than one entry for: {', '.join(twice)}"] + named: Final[set[str]] = {ref for entry in dependencies for ref in (entry["ref"], *entry.get("dependsOn", []))} + if unknown := sorted(named - set(refs)): + return [f"dependencies name bom-refs no component has: {', '.join(unknown)}"] + return [] + + +def repeated(values: Iterable[str]) -> list[str]: + return sorted(value for value, count in Counter(values).items() if count > 1) + + +__all__ = [ + "artifact_contents", + "reference_problems", + "repeated", + "shipped_libraries", + "walk_components", +] diff --git a/src/python_sbom/_model/_identity.py b/src/python_sbom/_model/_identity.py new file mode 100644 index 0000000..732a1f7 --- /dev/null +++ b/src/python_sbom/_model/_identity.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from ._iri import iri_reference +from ._names import property_prefix, reference_type + +if TYPE_CHECKING: + from ._inputs import Settings + from ._metadata import CoreMetadata + + +def project_identity(metadata: CoreMetadata, settings: Settings) -> ProjectIdentity: + project = property_prefix(metadata.name) + repository = settings.repository + # links into the repository extend its URL, so one that cannot be an IRI reference, as an scp-style git address, is + # passed over for the next + for label, url in [] if repository else metadata.project_urls(): + if reference_type(label) == "vcs" and (link := iri_reference(url)) is not None: + repository = link.rstrip("/") + break + base = repository or f"https://pypi.org/project/{project}" + return ProjectIdentity( + property_prefix=project, + repository=repository, + serial_namespace=uuid.uuid5(uuid.NAMESPACE_URL, f"{base}/sboms"), + ) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ProjectIdentity: + """ + How every SBOM of a project names it. + + :param property_prefix: the project's normalized name, which starts the names of the properties python-sbom writes, + such as ``virtualenv`` in ``virtualenv:bundled-wheel`` + :param repository: the repository URL, from the options or the first ``Source``, ``Repository`` or ``GitHub`` + project URL, or ``None`` + :param serial_namespace: the UUID namespace of the serial numbers of the project's SBOMs + + """ + + property_prefix: str + repository: str | None + serial_namespace: uuid.UUID + + +__all__ = [ + "ProjectIdentity", + "project_identity", +] diff --git a/src/python_sbom/_model/_inputs.py b/src/python_sbom/_model/_inputs.py new file mode 100644 index 0000000..e4928f4 --- /dev/null +++ b/src/python_sbom/_model/_inputs.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import PurePosixPath + + from ._metadata import CoreMetadata + from ._types import Component, Dependency, OrganizationalEntity + + +@dataclass(frozen=True, slots=True, kw_only=True) +class RecordRow: + path: str + algorithm: str + digest: str + size: str + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Interpreter: + name: str + version: str + implementation: str + + +@dataclass(frozen=True, slots=True, kw_only=True) +class InstalledDistribution: + name: str + metadata: CoreMetadata + files: list[RecordRow] + depends_on: list[str] + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Revision: + commit: str + dirty: bool + + +@dataclass(frozen=True, slots=True, kw_only=True) +class BuildFacts: + epoch: int + epoch_from_environment: bool + revision: Revision | None + interpreter: Interpreter + distributions: list[InstalledDistribution] + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Settings: + """ + The project's options, from ``[tool.python-sbom]``. + + :param supplier: the organization that supplies the project, or ``None`` to name none + :param repository: the repository URL, or ``None`` to take the first ``Source``, ``Repository`` or ``GitHub`` + project URL + :param default_branch: the branch in links to files in the repository when the build's commit is unknown or has + uncommitted changes + :param bundled_wheels: glob patterns, relative to the project directory, of wheels the project ships as data files + :param content_provider: ``module:attribute`` of the content provider, or ``None`` + + """ + + supplier: OrganizationalEntity | None + repository: str | None + default_branch: str + bundled_wheels: tuple[str, ...] + content_provider: str | None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class BundledWheel: + path: str + sha256: str + metadata_name: str + metadata: CoreMetadata + files: list[RecordRow] + vendored: list[tuple[str, CoreMetadata]] + + +@dataclass(frozen=True, slots=True, kw_only=True) +class BuildScript: + path: PurePosixPath + sha256: str + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ArchiveTree: + # what an archive holds, read from the zip without unpacking it: each distribution's metadata and members by the + # directory that holds its .dist-info, "" for the project's own, the archive's own top-level files, the digest of + # every member but the bundled wheels, and those wheels + metadata: dict[str, CoreMetadata] + owned: dict[str, list[str]] + loose: list[str] + files: dict[str, RecordRow] + bundled: dict[str, BundledWheel] + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Artifact: + # what one artifact, a wheel or an archive, puts in its SBOM; the provenance every artifact of the project shares is + # added around it + root: Component + components: list[Component] + dependencies: list[Dependency] + build_scripts: list[BuildScript] + version: str + sbom_file: str + scope: str + workflow: tuple[str, str] + license_file: str | None + copyright: str | None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class WheelTree: + # what a built wheel holds, read from the wheel without unpacking it: its file name, its core metadata and the + # wheels it bundles; and what the project adds: the copyright line of its license files and the scripts that built + # it + name: str + metadata: CoreMetadata + copyright: str | None + bundled: list[BundledWheel] + build_scripts: list[BuildScript] + + +__all__ = [ + "ArchiveTree", + "Artifact", + "BuildFacts", + "BuildScript", + "BundledWheel", + "InstalledDistribution", + "Interpreter", + "RecordRow", + "Revision", + "Settings", + "WheelTree", +] diff --git a/src/python_sbom/_model/_iri.py b/src/python_sbom/_model/_iri.py new file mode 100644 index 0000000..572226d --- /dev/null +++ b/src/python_sbom/_model/_iri.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import ipaddress +import re +import string +from typing import Final, cast +from urllib.parse import quote + +# RFC 3986 appendix B: splits any text into scheme, authority, path, query and fragment +_PARTS: Final[re.Pattern[str]] = re.compile( + r"(?:([^:/?#]+):)?(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?", re.DOTALL +) +_SCHEME: Final[re.Pattern[str]] = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*") +# what may follow the host: nothing, or a colon and the port's digits +_PORT: Final[re.Pattern[str]] = re.compile(r"(?::[0-9]*)?") +_IP_FUTURE: Final[re.Pattern[str]] = re.compile(r"v[0-9A-Fa-f]+\.[A-Za-z0-9._~!$&'()*+,;=:-]+") +# a valid percent-encoding, or any one character +_TOKEN: Final[re.Pattern[str]] = re.compile(r"%[0-9A-Fa-f]{2}|.", re.DOTALL) +_REG_NAME: Final[str] = f"{string.ascii_letters}{string.digits}-._~!$&'()*+,;=" +_USERINFO: Final[str] = f"{_REG_NAME}:" +_PATH: Final[str] = f"{_USERINFO}@/" +# the query and the fragment allow the same characters +_QUERY: Final[str] = f"{_PATH}?" +# the characters beyond ASCII an IRI holds unencoded, RFC 3987's ucschar +_UCSCHAR: Final[tuple[tuple[int, int], ...]] = ( + (0xA0, 0xD7FF), + (0xF900, 0xFDCF), + (0xFDF0, 0xFFEF), + *((plane << 16, plane << 16 | 0xFFFD) for plane in range(1, 14)), + (0xE1000, 0xEFFFD), +) + + +def iri_reference(text: str) -> str | None: + # the form CycloneDX requires of a URL: a character no part of an IRI reference allows is percent-encoded, which + # keeps what the text points at; a scheme, IP literal or port no encoding mends, as in the scp-style + # git@github.com:org/repo.git, leaves None; the pattern matches any text, every part of it optional + match: Final = cast("re.Match[str]", _PARTS.fullmatch(text)) + scheme, authority, path, query, fragment = match.groups() + if scheme is not None and not _SCHEME.fullmatch(scheme): + return None + parts: Final[list[str]] = [] if scheme is None else [f"{scheme}:"] + if authority is not None: + if (encoded := _authority(authority)) is None: + return None + parts.append(f"//{encoded}") + parts.append(_encoded(path, _PATH)) + if query is not None: + parts.append(f"?{_encoded(query, _QUERY)}") + if fragment is not None: + parts.append(f"#{_encoded(fragment, _QUERY)}") + return "".join(parts) + + +def _authority(authority: str) -> str | None: + userinfo, at, host_port = authority.rpartition("@") + if host_port.startswith("["): + literal, _, port = host_port[1:].partition("]") + if not _ip_literal(literal): + return None + host = f"[{literal}]" + else: + name, colon, number = host_port.partition(":") + host, port = _encoded(name, _REG_NAME), f"{colon}{number}" + if not _PORT.fullmatch(port): + return None + return f"{_encoded(userinfo, _USERINFO)}{at}{host}{port}" + + +def _ip_literal(literal: str) -> bool: + if _IP_FUTURE.fullmatch(literal): + return True + # a zone index has no place in a URI's IP literal + if "%" in literal: + return False + try: + ipaddress.IPv6Address(literal) + except ValueError: + return False + return True + + +def _encoded(text: str, allowed: str) -> str: + return "".join(token if _kept(token, allowed) else quote(token, safe="") for token in _TOKEN.findall(text)) + + +def _kept(token: str, allowed: str) -> bool: + # a token longer than a character is a valid percent-encoding + if len(token) > 1 or token in allowed: + return True + return any(low <= ord(token) <= high for low, high in _UCSCHAR) + + +__all__ = [ + "iri_reference", +] diff --git a/src/python_sbom/_model/_licenses.py b/src/python_sbom/_model/_licenses.py new file mode 100644 index 0000000..9f0248e --- /dev/null +++ b/src/python_sbom/_model/_licenses.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Final + +from packaging.licenses import InvalidLicenseExpression, canonicalize_license_expression + +if TYPE_CHECKING: + from ._metadata import CoreMetadata + from ._types import License + + +def licenses(metadata: CoreMetadata) -> list[License]: + # a License-Expression is taken as declared; otherwise each License field and License classifier name is declared, + # and the SPDX ids they map to are added as concluded + if expression := metadata.first("license_expression"): + try: + return [{"expression": canonicalize_license_expression(expression), "acknowledgement": "declared"}] + except InvalidLicenseExpression: + # an expression SPDX rejects, such as "MIT WITH GPL-3.0-only", stays as the name the metadata gives + return [{"license": {"name": expression, "acknowledgement": "declared"}}] + names = [entry.rsplit(" :: ", 1)[-1] for entry in metadata.values("classifiers") if entry.startswith("License :: ")] + if (declared := metadata.first("license")) and "\n" not in declared: + names.append(declared) + found: Final[list[License]] = [{"license": {"name": name, "acknowledgement": "declared"}} for name in names] + # a license policy judges SPDX ids only, so the names stay bare when any one of them does not resolve, and the + # policy reports that instead of judging a partial set + identifiers = [identifier for name in names if (identifier := _spdx_license(name)) is not None] + if len(identifiers) == len(names): + found.extend( + {"license": {"id": identifier, "acknowledgement": "concluded"}} for identifier in dict.fromkeys(identifiers) + ) + return found + + +def _spdx_license(name: str) -> str | None: + # only the license classifiers that name exactly one SPDX license; "BSD License" or "Apache Software License" leave + # the version or variant open + if identifier := { + "ISC License (ISCL)": "ISC", + "MIT License": "MIT", + "MIT No Attribution License (MIT-0)": "MIT-0", + "Mozilla Public License 2.0 (MPL 2.0)": "MPL-2.0", + "Python Software Foundation License": "PSF-2.0", + "The Unlicense (Unlicense)": "Unlicense", + }.get(name): + return identifier + try: + canonical = canonicalize_license_expression(name) + except InvalidLicenseExpression: + return None + # a CycloneDX license id holds one entry of the SPDX license list, never an expression or a LicenseRef + return canonical if re.fullmatch(r"[A-Za-z0-9.-]+", canonical) and not canonical.startswith("LicenseRef-") else None + + +__all__ = [ + "licenses", +] diff --git a/src/python_sbom/_model/_markers.py b/src/python_sbom/_model/_markers.py new file mode 100644 index 0000000..0c0535d --- /dev/null +++ b/src/python_sbom/_model/_markers.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from packaging.markers import Marker, UndefinedComparison + +if TYPE_CHECKING: + from packaging._parser import MarkerAtom, MarkerList # see _extra_verdict + from packaging.requirements import Requirement + + +def applies_without_extras(requirement: Requirement) -> bool: + # the conditions on extra are decided with no extra requested and every other condition stays unknown, so the + # requirement applies unless its extra conditions alone rule it out; dep-logic reduces markers the same way, but it + # rejects extra comparisons other than == and != through assert, which python -O strips, and on import it replaces + # packaging's marker grammar for the whole build process + if requirement.marker is None: + return True + try: + verdict = _extra_verdict(requirement.marker._markers) # ruff: ignore[private-member-access] # see _extra_verdict + # packaging keeps the parsed tree private, so a release that moves or renames it leaves the requirement listed + # rather than dropping one that applies + except (AttributeError, ImportError): + return True + return verdict is not False + + +def _extra_verdict(markers: MarkerList | MarkerAtom) -> bool | None: + from packaging._parser import Op, Value, Variable # ruff: ignore[import-private-name, import-outside-top-level] # packaging keeps the marker tree private, the caller handles its absence + + # three-valued, with "and" binding tighter than "or", as packaging's own evaluation groups them + groups: list[list[bool | None]] = [[]] + for item in markers: + if item == "or": + groups.append([]) + elif isinstance(item, list): + groups[-1].append(_extra_verdict(item)) + elif isinstance(item, tuple): + match item: + case Variable(value="extra"), Op(value=operator), Value(value=value): + groups[-1].append(_no_extra_verdict(f"extra {operator} {value!r}")) + case Value(value=value), Op(value=operator), Variable(value="extra"): + groups[-1].append(_no_extra_verdict(f"{value!r} {operator} extra")) + case _: + groups[-1].append(None) + # ranked False < unknown < True: "and" takes the lowest verdict of a group, "or" the highest of the groups + verdicts: Final[tuple[bool | None, ...]] = (False, None, True) + return verdicts[max(min(map(verdicts.index, group), default=2) for group in groups)] + + +def _no_extra_verdict(condition: str) -> bool | None: + try: + return Marker(condition).evaluate({"extra": ""}) + # extra compared as a version, such as extra ~= "1.0", has no answer, so it stays unknown + except UndefinedComparison: + return None + + +__all__ = [ + "applies_without_extras", +] diff --git a/src/python_sbom/_model/_metadata.py b/src/python_sbom/_model/_metadata.py new file mode 100644 index 0000000..2fa08d8 --- /dev/null +++ b/src/python_sbom/_model/_metadata.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from email.parser import HeaderParser +from email.policy import compat32 +from typing import TYPE_CHECKING, Final, Literal, TypeAlias + +from packaging.metadata import parse_email + +if TYPE_CHECKING: + from packaging.metadata import RawMetadata + +# the fields python-sbom reads, by their key in packaging's RawMetadata +Field: TypeAlias = Literal[ + "author", + "author_email", + "classifiers", + "home_page", + "license", + "license_expression", + "license_files", + "maintainer", + "maintainer_email", + "name", + "provides_extra", + "requires_dist", + "requires_python", + "summary", + "version", +] + +# the empty line that ends the headers, as the email parser ends them; a line of whitespace continues a header +_BODY: Final[re.Pattern[str]] = re.compile(r"^\r?\n", re.MULTILINE) +# unparsed keys a field by its header name, lowercased, which is the RawMetadata key with dashes except for these +_HEADERS: Final[dict[str, str]] = {"classifiers": "classifier", "license_files": "license-file"} + + +def parse_metadata(text: str) -> CoreMetadata: + # not validated: the SBOM describes a distribution whose metadata breaks the specification as well, as written; the + # body holds the long description, which nothing reads, and parsing it took most of the time + headers: Final = text[: match.start()] if (match := _BODY.search(text)) else text + raw, unparsed = parse_email(headers) + return CoreMetadata(raw=raw, unparsed=unparsed, headers=headers) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class CoreMetadata: + raw: RawMetadata + unparsed: dict[str, list[str]] + # the header block as written, for the values packaging reshapes + headers: str = "" + + @property + def name(self) -> str: + return self.first("name") or "" + + def first(self, field: Field) -> str | None: + return next(iter(self.values(field)), None) + + def values(self, field: Field) -> list[str]: + # packaging keeps a field it cannot take as declared, such as a single-use field given twice, out of raw and its + # values as written in unparsed, from where they are still described + match self.raw.get(field): + case str() as value: + return [value] + case list() as values: + return values + return self.unparsed.get(_HEADERS.get(field, field.replace("_", "-")), []) + + @property + def version(self) -> str: + return self.first("version") or "" + + def keywords(self) -> list[str]: + # packaging splits Keywords at commas and strips each keyword, so the header is read again, as the distribution + # wrote it, only when one is there; packaging parses the block with the same policy + if "keywords" in self.raw: + return HeaderParser(policy=compat32).parsestr(self.headers).get_all("keywords", []) + return self.unparsed.get("keywords", []) + + def project_urls(self) -> list[tuple[str, str]]: + # a label given twice sends every Project-URL to unparsed, where each splits at its first comma as packaging + # splits them; an entry without a URL names nothing to reference + pairs: Final[list[tuple[str, str]]] = ( + list(self.raw["project_urls"].items()) + if "project_urls" in self.raw + else [ + (label.strip(), url.strip()) + for label, _, url in (entry.partition(",") for entry in self.unparsed.get("project-url", [])) + ] + ) + return [(label, url) for label, url in pairs if url] + + +__all__ = [ + "CoreMetadata", + "Field", + "parse_metadata", +] diff --git a/src/python_sbom/_model/_names.py b/src/python_sbom/_model/_names.py new file mode 100644 index 0000000..e9aeb72 --- /dev/null +++ b/src/python_sbom/_model/_names.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import re +from typing import Final + +from packageurl import PackageURL +from packaging.utils import canonicalize_name + +#: the metadata property in which a document names its own file; validation checks such documents in full +SBOM_FILE: Final[str] = "sbom:file" +#: the metadata property in which a document names the project's repository +SBOM_REPOSITORY: Final[str] = "sbom:repository" +# the label normalization and mapping cyclonedx-py applies to Project-URL entries, so the same metadata yields the same +# reference types whichever generator produced the document, plus the well-known labels of PEP 753 it lacks; funding +# labels have no CycloneDX type and fall to "other" +_URL_LABEL_TO_REFERENCE_TYPE: Final[dict[str, str]] = { + "bugtracker": "issue-tracker", + "issuetracker": "issue-tracker", + "issues": "issue-tracker", + "issue": "issue-tracker", + "bugs": "issue-tracker", + "bugreports": "issue-tracker", + "tracker": "issue-tracker", + "home": "website", + "homepage": "website", + "download": "distribution", + "documentation": "documentation", + "docs": "documentation", + "changelog": "release-notes", + "changes": "release-notes", + "whatsnew": "release-notes", + "history": "release-notes", + "releasenotes": "release-notes", + "source": "vcs", + "sourcecode": "vcs", + "repository": "vcs", + "github": "vcs", + "chat": "chat", +} + + +def sbom_property(project: str, key: str) -> str: + return f"{project}:{key}" + + +def sbom_file_name(name: str) -> str: + return f"{property_prefix(name)}.cdx.json" + + +def property_prefix(name: str) -> str: + # the normalized project name, which prefixes the properties this package writes + return canonicalize_name(name) + + +def archive_sbom_file_name(archive: str) -> str: + # next to the archive, and inside it when embedded, where reading the archive again must skip it + return f"{archive}.cdx.json" + + +def purl(name: str, version: str | None = None) -> str: + # normalized as PEP 503 does, so every spelling of a project, jaraco.context or jaraco_context, gets one purl and a + # component's bom-ref agrees with its purl; packageurl only lowercases and turns underscores into dashes + return PackageURL(type="pypi", name=canonicalize_name(name), version=version).to_string() + + +def pypi_url(name: str, version: str | None = None) -> str: + return f"https://pypi.org/project/{name}/{version}/" if version else f"https://pypi.org/project/{name}/" + + +def tool_ref(ref: str) -> str: + # prefixed because the same distribution can be both a build tool and a component of the artifact + return f"tool:{ref}" + + +def reference_type(label: str) -> str: + return _URL_LABEL_TO_REFERENCE_TYPE.get(re.sub(r"[^a-z]", "", label.lower()), "other") + + +__all__ = [ + "SBOM_FILE", + "SBOM_REPOSITORY", + "archive_sbom_file_name", + "property_prefix", + "purl", + "pypi_url", + "reference_type", + "sbom_file_name", + "sbom_property", + "tool_ref", +] diff --git a/src/python_sbom/_model/_tools.py b/src/python_sbom/_model/_tools.py new file mode 100644 index 0000000..870d6a4 --- /dev/null +++ b/src/python_sbom/_model/_tools.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from packageurl import PackageURL + +from ._components import component_from_metadata, file_component +from ._names import purl, tool_ref + +if TYPE_CHECKING: + from ._inputs import BuildFacts + from ._types import Component, Dependency + + +def tool_components(facts: BuildFacts) -> tuple[list[Component], list[Dependency]]: + interpreter = facts.interpreter + package = PackageURL(type="generic", name=interpreter.name, version=interpreter.version).to_string() + tools: Final[list[Component]] = [ + { + "type": "platform", + "bom-ref": tool_ref(package), + "name": interpreter.name, + "version": interpreter.version, + "purl": package, + "properties": [{"name": "python:implementation", "value": interpreter.implementation}], + } + ] + refs: Final[dict[str, str]] = { + distribution.name: tool_ref(purl(distribution.name, distribution.metadata.version)) + for distribution in facts.distributions + } + dependencies: Final[list[Dependency]] = [] + for distribution in sorted(facts.distributions, key=lambda entry: entry.name): + component = component_from_metadata(distribution.metadata) + component["bom-ref"] = refs[distribution.name] + if distribution.files: + component["components"] = [file_component(component["bom-ref"], row) for row in distribution.files] + tools.append(component) + dependencies.append({ + "ref": component["bom-ref"], + "dependsOn": sorted(refs[name] for name in distribution.depends_on), + }) + return tools, dependencies + + +__all__ = [ + "tool_components", +] diff --git a/src/python_sbom/_model/_types.py b/src/python_sbom/_model/_types.py new file mode 100644 index 0000000..c0f524c --- /dev/null +++ b/src/python_sbom/_model/_types.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +from typing import NotRequired, TypedDict + +# the document model is our own: cyclonedx-python-lib models neither formulation nor compositions (its model/bom.py +# holds only commented-out stubs, tracked upstream in CycloneDX/cyclonedx-python-lib#578 and #581), and its SortedSet +# collections re-order lists whose order the document keeps on purpose + + +class OrganizationalEntity(TypedDict): + """ + An organization that supplies or makes a component. + + :ivar name: the organization's name + :ivar url: the organization's web sites + + """ + + name: str + url: list[str] + + +class Contact(TypedDict): + """ + A person who wrote or maintains a component. + + :ivar name: the person's name + :ivar email: the person's email address + + """ + + name: NotRequired[str] + email: NotRequired[str] + + +class Hash(TypedDict): + """ + A digest of a component's bytes. + + :ivar alg: the algorithm as CycloneDX names it, such as ``SHA-256`` + :ivar content: the digest as lowercase hex + + """ + + alg: str + content: str + + +class Property(TypedDict): + """ + A name and value pair attached to a component or the document. + + :ivar name: the property's name; names this package writes start with the project's prefix or ``python:`` + :ivar value: the property's value + + """ + + name: str + value: str + + +class ExternalReference(TypedDict): + """ + A link from a component to something about it. + + :ivar type: the CycloneDX reference type, such as ``vcs`` or ``website`` + :ivar url: the link + :ivar comment: what the link is, such as the Project-URL label it came from + + """ + + type: str + url: str + comment: NotRequired[str] + + +class LicenseInfo(TypedDict): + """ + One license, by SPDX id or by name. + + :ivar id: the SPDX license id + :ivar name: the license's name, when it has no SPDX id + :ivar acknowledgement: ``declared`` when the metadata names it, ``concluded`` when python-sbom resolved it + + """ + + id: NotRequired[str] + name: NotRequired[str] + acknowledgement: str + + +class License(TypedDict): + """ + One license entry of a component: a license or an SPDX expression. + + :ivar license: the license, by SPDX id or by name + :ivar expression: an SPDX license expression + :ivar acknowledgement: ``declared`` or ``concluded``, for an expression + + """ + + license: NotRequired[LicenseInfo] + expression: NotRequired[str] + acknowledgement: NotRequired[str] + + +class IdentityMethod(TypedDict): + """ + How a component's identity was established. + + :ivar technique: the CycloneDX technique, such as ``manifest-analysis`` or ``hash-comparison`` + :ivar confidence: from 0 to 1 + :ivar value: what the technique read, such as the path of a ``METADATA`` file or a digest + + """ + + technique: str + confidence: int + value: str + + +class Identity(TypedDict): + """ + The evidence for one field of a component's identity. + + :ivar field: the field it supports, such as ``purl`` or ``hash`` + :ivar confidence: from 0 to 1 + :ivar methods: how it was established + + """ + + field: str + confidence: int + methods: list[IdentityMethod] + + +class Evidence(TypedDict): + """ + Why a component is what it says it is. + + :ivar identity: the evidence per field + + """ + + identity: list[Identity] + + +#: A CycloneDX 1.6 component: a distribution, an application, the interpreter or a file, as a dictionary with the keys +#: the CycloneDX component object defines, of which python-sbom uses ``type``, ``bom-ref``, ``name``, ``version``, +#: ``purl``, ``supplier``, ``authors``, ``description``, ``licenses``, ``copyright``, ``hashes``, +#: ``externalReferences``, ``properties``, ``evidence`` and the nested ``components``. +Component = TypedDict( + "Component", + { + "type": str, + "bom-ref": str, + "name": str, + "version": NotRequired[str], + "purl": NotRequired[str], + "supplier": NotRequired[OrganizationalEntity], + "authors": NotRequired[list[Contact]], + "description": NotRequired[str], + "licenses": NotRequired[list[License]], + "copyright": NotRequired[str], + "hashes": NotRequired[list[Hash]], + "externalReferences": NotRequired[list[ExternalReference]], + "properties": NotRequired[list[Property]], + "evidence": NotRequired[Evidence], + "components": NotRequired[list["Component"]], + }, +) + + +class Dependency(TypedDict): + """ + The components one component depends on. + + :ivar ref: the ``bom-ref`` of the dependent component + :ivar dependsOn: the ``bom-ref`` values of its dependencies + + """ + + ref: str + dependsOn: NotRequired[list[str]] + + +class _Reference(TypedDict): + ref: str + + +class _Output(TypedDict): + type: str + resource: _Reference + + +class _Input(TypedDict): + environmentVars: list[Property] + + +_Workflow = TypedDict( + "_Workflow", + { + "bom-ref": str, + "uid": str, + "name": str, + "taskTypes": list[str], + "resourceReferences": list[_Reference], + "outputs": list[_Output], + "inputs": NotRequired[list[_Input]], + }, +) + + +#: How an artifact was built: one or more workflows, each with its tools, inputs and outputs. +Formula = TypedDict("Formula", {"bom-ref": str, "workflows": list[_Workflow]}) + + +class Lifecycle(TypedDict): + phase: str + + +class Tools(TypedDict): + components: list[Component] + + +class Metadata(TypedDict): + timestamp: str + lifecycles: list[Lifecycle] + tools: Tools + manufacturer: NotRequired[OrganizationalEntity] + authors: NotRequired[list[Contact]] + supplier: NotRequired[OrganizationalEntity] + component: Component + licenses: NotRequired[list[License]] + properties: NotRequired[list[Property]] + + +class Composition(TypedDict): + aggregate: str + dependencies: list[str] + + +class Body(TypedDict): + metadata: Metadata + components: list[Component] + dependencies: list[Dependency] + compositions: NotRequired[list[Composition]] + formulation: NotRequired[list[Formula]] + + +_Header = TypedDict( + "_Header", + {"$schema": str, "bomFormat": str, "specVersion": str, "serialNumber": str, "version": int}, +) + + +class Document(Body, _Header): + pass + + +__all__ = [ + "Body", + "Component", + "Composition", + "Contact", + "Dependency", + "Document", + "Evidence", + "ExternalReference", + "Formula", + "Hash", + "Identity", + "IdentityMethod", + "License", + "LicenseInfo", + "Lifecycle", + "Metadata", + "OrganizationalEntity", + "Property", + "Tools", +] diff --git a/src/python_sbom/_model/_wheel.py b/src/python_sbom/_model/_wheel.py new file mode 100644 index 0000000..9a46b18 --- /dev/null +++ b/src/python_sbom/_model/_wheel.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from pathlib import PurePosixPath +from typing import TYPE_CHECKING, Final + +from packaging.requirements import Requirement + +from ._components import bundled_wheel_component, component_from_metadata, vendored_dependencies +from ._inputs import Artifact +from ._markers import applies_without_extras +from ._names import purl, pypi_url, sbom_file_name, sbom_property + +if TYPE_CHECKING: + from collections.abc import Mapping + + from ._content import BundledWheelInfo + from ._identity import ProjectIdentity + from ._inputs import Settings, WheelTree + from ._types import Component + + +def wheel_artifact( + tree: WheelTree, *, project: ProjectIdentity, settings: Settings, bundled_info: Mapping[str, BundledWheelInfo] +) -> Artifact: + metadata = tree.metadata + bundled = [ + bundled_wheel_component(wheel, project.property_prefix, bundled_info[wheel.path]) for wheel in tree.bundled + ] + root = component_from_metadata(metadata) + root["type"] = "application" + name, version = metadata.name, metadata.version + root["externalReferences"] = [ + *root["externalReferences"], + {"type": "distribution", "url": pypi_url(name, version)}, + {"type": "attestation", "url": f"https://pypi.org/integrity/{name}/{version}/{tree.name}/provenance"}, + ] + return Artifact( + root=root, + components=[ + *bundled, + # hatchling repeats a Requires-Dist when a hook re-adds a dependency, and each bom-ref must be unique + *( + _declared_dependency(text, requirement, project.property_prefix) + for text in dict.fromkeys(metadata.values("requires_dist")) + if applies_without_extras(requirement := Requirement(text)) + ), + ], + dependencies=vendored_dependencies(bundled), + build_scripts=tree.build_scripts, + version=version, + sbom_file=sbom_file_name(name), + scope=_scope(settings), + workflow=("wheel-build", "build the wheel and this SBOM"), + license_file=next(iter(metadata.values("license_files")), None), + copyright=tree.copyright, + ) + + +def _declared_dependency(text: str, requirement: Requirement, property_prefix: str) -> Component: + component: Final[Component] = { + "type": "library", + # the same distribution can be declared more than once with different markers, so the purl is not unique + "bom-ref": f"requires-dist:{text}", + "name": requirement.name, + "purl": purl(requirement.name), + "externalReferences": [{"type": "distribution", "url": pypi_url(requirement.name)}], + "properties": [{"name": sbom_property(property_prefix, "requires-dist"), "value": text}], + } + if requirement.marker is not None: + component["properties"].append({ + "name": sbom_property(property_prefix, "environment-marker"), + "value": str(requirement.marker), + }) + return component + + +def _scope(settings: Settings) -> str: + declared = ( + "the direct runtime dependencies declared in the wheel's core metadata, which carry no version because the" + " installer resolves them" + ) + if directories := " and ".join(sorted({str(PurePosixPath(pattern).parent) for pattern in settings.bundled_wheels})): + return f"Components are the wheels bundled as data files under {directories} and {declared}." + return f"Components are {declared}." + + +__all__ = [ + "wheel_artifact", +] diff --git a/src/python_sbom/_validate/__init__.py b/src/python_sbom/_validate/__init__.py new file mode 100644 index 0000000..1496c0a --- /dev/null +++ b/src/python_sbom/_validate/__init__.py @@ -0,0 +1 @@ +"""Checks of a document against the schema, its own references, its SPDX rendering and a license policy.""" diff --git a/src/python_sbom/_validate/_artifact.py b/src/python_sbom/_validate/_artifact.py new file mode 100644 index 0000000..02c514b --- /dev/null +++ b/src/python_sbom/_validate/_artifact.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import zipfile +from typing import IO, TYPE_CHECKING, Final + +from python_sbom._wheel._zip import CHUNK, open_archive + +from ._document import validate_document +from ._limits import MAX_COMPONENTS, byte_limit + +if TYPE_CHECKING: + from pathlib import Path + + +def validate(path: Path, allowed_licenses: frozenset[str] | None, max_components: int = MAX_COMPONENTS) -> list[str]: + limit: Final[int] = byte_limit(max_components) + if not zipfile.is_zipfile(path): + with path.open("rb") as file: + return validate_document(_read(file, limit), allowed_licenses, max_components) + problems: Final[list[str]] = [] + with open_archive(path, path.name) as archive: + if not (names := sorted(name for name in archive.namelist() if _is_sbom(name))): + return [f"no SBOM found in {path.name}"] + # one at a time, so memory holds a single SBOM however many the archive packs + for name in names: + with archive.open(name) as member: + raw = _read(member, limit) + problems.extend( + f"{name}: {problem}" for problem in validate_document(raw, allowed_licenses, max_components) + ) + return problems + + +def _read(source: IO[bytes], limit: int) -> bytes: + # stops within a chunk past the limit: enough for validate_document to refuse the SBOM without reading all of it + content: Final[bytearray] = bytearray() + while len(content) <= limit and (chunk := source.read(CHUNK)): + content.extend(chunk) + return bytes(content) + + +def _is_sbom(name: str) -> bool: + # every top-level .dist-info counts, so a decoy one sorting first cannot hide the SBOM of another + match name.split("/"): + case [file]: + return file.endswith(".cdx.json") + case [dist_info, "sboms", file]: + return dist_info.endswith(".dist-info") and file.endswith(".cdx.json") + case _: + return False + + +__all__ = [ + "validate", +] diff --git a/src/python_sbom/_validate/_document.py b/src/python_sbom/_validate/_document.py new file mode 100644 index 0000000..e07e329 --- /dev/null +++ b/src/python_sbom/_validate/_document.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import json +import math +import re +from importlib.util import find_spec +from typing import TYPE_CHECKING, Any, Final + +from python_sbom._errors import MissingExtraError, SbomError +from python_sbom._formats._spdx import render_spdx +from python_sbom._model._graph import artifact_contents, reference_problems, repeated, walk_components +from python_sbom._model._names import SBOM_FILE + +from ._limits import MAX_COMPONENTS, MAX_TEXT, Json, byte_limit + +if TYPE_CHECKING: + from collections.abc import Iterable + from typing import TypeIs # new in 3.13; annotations here are never evaluated + + from python_sbom._model._types import Component, Document + +# the schema leaves these optional, yet every document python-sbom writes carries them and the checks below read them +_REQUIRED: Final[tuple[tuple[str, ...], ...]] = ( + ("metadata", "timestamp"), + ("metadata", "tools", "components"), + ("metadata", "component", "bom-ref"), + ("metadata", "component", "name"), + ("components",), + ("dependencies",), +) +_SERIAL: Final[re.Pattern[str]] = re.compile(r"urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") +# the modules of the validate extra +_EXTRA: Final[tuple[str, ...]] = ("jsonschema_rs", "license_expression", "spdx_tools") +# the schema validator recurses once per level and crashes the process past a few thousand levels on a small thread +# stack; the SBOMs python-sbom and other tools write nest about a dozen levels deep +_MAX_DEPTH: Final[int] = 128 + + +def validate_document( + raw: bytes, allowed_licenses: frozenset[str] | None, max_components: int = MAX_COMPONENTS +) -> list[str]: + if len(raw) > (limit := byte_limit(max_components)): + problem = ( + f"is over {limit} bytes, the size limit for {max_components} components;" + " pass a higher --max-components to check it" + ) + return [problem] + # one document's problem never stops the others from being checked, so whatever the checks raise is reported + try: + return _problems(raw, allowed_licenses, max_components) + except MissingExtraError: + raise + except SbomError as error: + return [str(error)] + except Exception as error: # ruff: ignore[blind-except] # a hostile document may trip any check, see above + return [f"could not be checked, {type(error).__name__}: {str(error)[:MAX_TEXT]}"] + + +def _problems(raw: bytes, allowed_licenses: frozenset[str] | None, max_components: int) -> list[str]: + # every document must conform to the CycloneDX 1.6 schema; one python-sbom wrote must also carry the urn:uuid serial + # actions/attest needs, resolvable and unique bom-refs and a valid SPDX rendering, and meet the license policy. A + # missing extra fails every document alike, while importing it, half a second, waits until the cheap census passes + missing_extra: Final[str] = "validation needs the validate extra: pip install 'python-sbom[validate]'" + if any(find_spec(name) is None for name in _EXTRA): + raise MissingExtraError(missing_extra) + loaded: Final = load_json(raw) + if census := _census_problems(loaded, max_components): + return census + # find_spec sees only the top-level modules, not a dependency of theirs that is missing or broken + try: + from ._schemas import schema_error, spdx_problems # ruff: ignore[import-outside-top-level] # the optional extra, see above + except ImportError as error: + raise MissingExtraError(missing_extra) from error + + if error := schema_error(loaded): + return [f"does not conform to the CycloneDX 1.6 schema: {error}"] + # python-sbom names its file in a metadata property, so documents other tools wrote get the schema check alone; the + # schema already guarantees the shape of what this reads + if not any( + prop.get("name", "").endswith(f":{SBOM_FILE}") for prop in loaded.get("metadata", {}).get("properties", []) + ): + return [] + missing: Final[list[str]] = [".".join(path) for path in _REQUIRED if not _has(loaded, path)] + # is_document holds once nothing is missing; asking it narrows the loaded JSON to a document + if missing or not is_document(loaded): + return [f"lacks {', '.join(missing)}, which every document python-sbom writes carries"] + # optional in the CycloneDX spec itself, but actions/attest's format sniffer requires it to recognize the document + # as CycloneDX at all, and silently rejects anything missing it as an unknown format + if not _SERIAL.fullmatch(serial := loaded.get("serialNumber", "")): + return [f"serialNumber must be a urn:uuid: RFC 4122 UUID, got {serial!r}"] + # the SPDX rendering of a document whose references are broken only repeats those problems + problems: Final[list[str]] = _reference_problems(loaded) or spdx_problems(render_spdx(loaded)) + if allowed_licenses is not None: + # the license parser loads only for a policy to judge against, as most checks have none + try: + from ._licenses import license_problems # ruff: ignore[import-outside-top-level] # see above + except ImportError as error: + raise MissingExtraError(missing_extra) from error + problems.extend( + license_problems(artifact_contents(loaded["metadata"]["component"], loaded["components"]), allowed_licenses) + ) + return problems + + +def load_json(raw: bytes) -> Any: # ruff: ignore[any-type] # json.loads' own type: any shape until checked + # why bytes are no JSON value, worded once for validate and for spdx, which renders a document without validating it + try: + return json.loads(raw.decode("utf-8")) + except UnicodeDecodeError as error: + msg = f"is not UTF-8: {error.reason} at byte {error.start}" + raise SbomError(msg) from error + except json.JSONDecodeError as error: + msg = f"is not JSON: {error}" + raise SbomError(msg) from error + except RecursionError as error: + # the JSON parser recurses once per level and gives up near the recursion limit, long before the depth check + # sees the document + msg = "nests too deep for the JSON parser to read" + raise SbomError(msg) from error + + +def _census_problems(document: Json, max_components: int) -> list[str]: + # counts the components and bom-refs and measures the depth in one linear pass before the schema vouches for any + # shape; iterative, so deep nesting cannot exhaust the stack. Nested components count too: the schema checks every + # components array, and one array may hold them all, so only the total bounds its work + components = 0 + refs: Final[list[str]] = [] + # only lists and objects go on the stack, every other value is judged where its container is read, so a scalar + # nests too deep when its container already sits at the limit; the document starts out as the one item of a list + pending: Final[list[tuple[list[Json] | dict[str, Json], int]]] = [([document], 0)] + while pending: + node, depth = pending.pop() + if isinstance(node, dict): + if isinstance(ref := node.get("bom-ref"), str): + refs.append(ref) + components += len(listed) if isinstance(listed := node.get("components"), list) else 0 + if components > max_components: + return [ + ( + f"lists more than {max_components} components, nested ones included;" + " pass a higher --max-components to check it" + ) + ] + children = node.values() if isinstance(node, dict) else node + if children and depth >= _MAX_DEPTH: + return [f"nests more than {_MAX_DEPTH} levels deep, too deep to check"] + if problem := _push_containers(children, depth + 1, pending): + return problem + # every bom-ref in the document, wherever it sits, as CycloneDX requires them unique and documents other tools wrote + # get no reference check beyond this + if twice := repeated(refs): + return [f"bom-ref values repeat: {', '.join(twice)}"] + return [] + + +def _push_containers( + children: Iterable[Json], depth: int, pending: list[tuple[list[Json] | dict[str, Json], int]] +) -> list[str]: + for child in children: + if isinstance(child, (list, dict)): + pending.append((child, depth)) + elif isinstance(child, float) and not math.isfinite(child): + # json.loads takes NaN and Infinity, which RFC 8259 leaves out of JSON and the schema reads as null + return [f"holds {child}, which is not a JSON number"] + return [] + + +def _has(value: Json, path: tuple[str, ...]) -> bool: + for key in path: + if not isinstance(value, dict) or key not in value: + return False + value = value[key] + return True + + +def is_document(value: object) -> TypeIs[Document]: + # what the SPDX rendering reads without a fallback; the schema leaves even these optional + return isinstance(value, dict) and all(_has(value, ("metadata", "component", key)) for key in ("bom-ref", "name")) + + +def _reference_problems(document: Document) -> list[str]: + root: Final[Component] = document["metadata"]["component"] + tools: Final[list[Component]] = document["metadata"]["tools"]["components"] + components: Final[list[Component]] = list(walk_components([root, *document["components"], *tools])) + if nameless := sorted(component["name"] for component in components if "bom-ref" not in component): + return [f"components lack a bom-ref: {nameless}"] + formulas: Final[list[str]] = [ + ref + for formula in document.get("formulation", []) + for ref in (formula["bom-ref"], *(workflow["bom-ref"] for workflow in formula.get("workflows", []))) + ] + if problems := reference_problems( + [*(component["bom-ref"] for component in components), *formulas], document["dependencies"] + ): + return problems + problems: Final[list[str]] = [] + dependencies: Final[dict[str, set[str]]] = { + entry["ref"]: set(entry.get("dependsOn", [])) for entry in document["dependencies"] + } + direct: Final[set[str]] = {component["bom-ref"] for component in document["components"]} + if dependencies.get(root["bom-ref"]) != direct: + problems.append( + f"root dependsOn {sorted(dependencies.get(root['bom-ref'], []))} != components {sorted(direct)}" + ) + tool_refs: Final[set[str]] = {tool["bom-ref"] for tool in tools} + for formula in document.get("formulation", []): + for workflow in formula.get("workflows", []): + # a workflow may also name an external resource, which no tool can be + referenced = { + reference["ref"] for reference in workflow.get("resourceReferences", []) if "ref" in reference + } + if unknown := referenced - tool_refs: + problems.append(f"workflow {workflow['uid']} references unknown tools: {sorted(unknown)}") + return problems + + +__all__ = [ + "is_document", + "load_json", + "validate_document", +] diff --git a/src/python_sbom/_validate/_licenses.py b/src/python_sbom/_validate/_licenses.py new file mode 100644 index 0000000..af56aac --- /dev/null +++ b/src/python_sbom/_validate/_licenses.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from license_expression import ( + AND, + ExpressionError, + LicenseSymbol, + LicenseWithExceptionSymbol, + Licensing, + get_license_index, +) + +from python_sbom._model._graph import shipped_libraries + +if TYPE_CHECKING: + from license_expression import LicenseExpression + + from python_sbom._model._types import Component, License + +# the SPDX ids alone, without the aliases get_spdx_licensing() adds: those rename ids (LicenseRef-Apache becomes +# Apache-2.0, Net-SNMP becomes LicenseRef-scancode-net-snmp), so a policy would judge an id other than the one declared; +# knowing the ids still lets the parser tell "MIT ISC" apart from a single license +_LICENSING: Final[Licensing] = Licensing( + LicenseSymbol(entry["spdx_license_key"], is_exception=entry["is_exception"]) + for entry in get_license_index() + if entry.get("spdx_license_key") +) + + +def license_problems(contents: list[Component], allowed: frozenset[str]) -> list[str]: + # SPDX matches license ids without regard to case + folded: Final[frozenset[str]] = frozenset(identifier.casefold() for identifier in allowed) + return [ + f"{component.get('bom-ref', component['name'])}: {problem}" + for component in shipped_libraries(contents) + if (problem := _judge(component, allowed, folded)) + ] + + +def _judge(component: Component, allowed: frozenset[str], folded: frozenset[str]) -> str | None: + licenses: Final[list[License]] = component.get("licenses", []) + terms: Final[list[str]] = [ + entry["expression"] if "expression" in entry else entry["license"]["id"] + for entry in licenses + if "expression" in entry or "id" in entry["license"] + ] + if not terms: + if names := [entry["license"]["name"] for entry in licenses if "license" in entry]: + return f"declares license names {names} with no SPDX id" + return "declares no license" + denied: Final[list[str]] = [] + for term in terms: + try: + tree = _parse(term) + except ValueError as error: + return f"license expression {term!r} is malformed: {error}" + if not _allowed(tree, folded): + denied.append(term) + if denied: + return f"license {' AND '.join(denied)} is outside the allowed licenses {sorted(allowed)}" + return None + + +def _parse(term: str) -> LicenseExpression: + try: + tree: Final[LicenseExpression | None] = _LICENSING.parse(term) + except ExpressionError as error: + raise ValueError(str(error)) from error + except (AssertionError, IndexError) as error: + # boolean.py trips over some malformed input, such as "( OR MIT" or "()", before it reports it + msg = "it does not parse" + raise ValueError(msg) from error + if tree is None: + msg = "it is empty" + raise ValueError(msg) + return tree + + +def _allowed(tree: LicenseExpression, folded: frozenset[str]) -> bool: + # the parse tree nests as deep as the expression does, so it is walked with a stack instead of recursion; children + # come after their parent in this order, so the reversed walk judges them first + order: Final[list[LicenseExpression]] = [] + pending: Final[list[LicenseExpression]] = [tree] + while pending: + order.append(node := pending.pop()) + pending.extend(node.args) + verdicts: Final[dict[int, bool]] = {} + for node in reversed(order): + if isinstance(node, LicenseWithExceptionSymbol): + # an exception grants extra permissions on top of the license it modifies, so that license alone decides + verdicts[id(node)] = node.license_symbol.key.casefold() in folded + elif isinstance(node, LicenseSymbol): + verdicts[id(node)] = node.key.casefold() in folded + else: + # the parser builds only AND and OR around the symbols + branches = (verdicts[id(arg)] for arg in node.args) + verdicts[id(node)] = all(branches) if isinstance(node, AND) else any(branches) + return verdicts[id(tree)] + + +__all__ = [ + "license_problems", +] diff --git a/src/python_sbom/_validate/_limits.py b/src/python_sbom/_validate/_limits.py new file mode 100644 index 0000000..4df8753 --- /dev/null +++ b/src/python_sbom/_validate/_limits.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import Final, TypeAlias + +# a JSON value, as json.loads hands it over +Json: TypeAlias = "str | int | float | bool | list[Json] | dict[str, Json] | None" + +# every check takes time in proportion to the count, so the cap bounds the work an untrusted document can ask for, far +# above the few thousand components a wheel with its build tools' files lists +MAX_COMPONENTS: Final[int] = 100_000 +# an exception's message, or a schema error's, can quote the hostile value that caused it, which runs to megabytes +MAX_TEXT: Final[int] = 200 + + +def byte_limit(max_components: int) -> int: + # python-sbom writes about 540 bytes per component, since every file of the wheel and of its build tools is one; a + # KiB each leaves room for documents with fuller components, plus a MiB for the metadata, dependencies and + # formulation beside them, while tying what the JSON parser may be asked to hold to the component cap + return 1024 * 1024 + max_components * 1024 + + +__all__ = [ + "MAX_COMPONENTS", + "MAX_TEXT", + "Json", + "byte_limit", +] diff --git a/src/python_sbom/_validate/_schemas.py b/src/python_sbom/_validate/_schemas.py new file mode 100644 index 0000000..f74ff75 --- /dev/null +++ b/src/python_sbom/_validate/_schemas.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import json +from collections import Counter +from importlib.resources import files +from typing import TYPE_CHECKING, Final + +from jsonschema_rs import Draft7Validator, Registry +from license_expression import ExpressionError, ExpressionParseError, LicenseExpression +from spdx_tools.common.spdx_licensing import spdx_licensing +from spdx_tools.spdx.parser.error import SPDXParsingError +from spdx_tools.spdx.parser.jsonlikedict.json_like_dict_parser import JsonLikeDictParser +from spdx_tools.spdx.validation.creation_info_validator import validate_creation_info +from spdx_tools.spdx.validation.extracted_licensing_info_validator import validate_extracted_licensing_infos +from spdx_tools.spdx.validation.package_validator import validate_package +from spdx_tools.spdx.validation.spdx_id_validators import validate_spdx_id + +from ._limits import MAX_TEXT + +if TYPE_CHECKING: + from importlib.resources.abc import Traversable + + from jsonschema_rs import ValidationError + from spdx_tools.spdx.model import Document, SpdxNoAssertion, SpdxNone + + from python_sbom._formats._spdx import Spdx + + from ._limits import Json + + +def schema_error(document: Json) -> str | None: + detail: Final[ValidationError | None] = next(_VALIDATOR.iter_errors(document), None) + if detail is None: + return None + steps: Final[str] = "".join( + f"[{step}]" if isinstance(step, int) else f".{step}" if step.isidentifier() else f"[{step!r}]" + for step in detail.instance_path + ) + return f"{detail.message[:MAX_TEXT]} at {f'${steps}'[:MAX_TEXT]}" + + +def spdx_problems(spdx: Spdx) -> list[str]: + try: + parsed: Final[Document] = JsonLikeDictParser().parse(dict(spdx)) + except SPDXParsingError as error: + return [f"SPDX rendering does not parse: {message}" for message in error.get_messages()] + return [f"SPDX rendering invalid: {message}" for message in _invalid(parsed)] + + +def _invalid(document: Document) -> list[str]: + # spdx-tools' validate_full_spdx_document looks every relationship end, and every package's own relationships, up + # in lists, so its time grows with the square of the elements: 16,000 took a minute and a half. This runs its + # validators of each element and does the lookups across elements in sets. The rendering writes no files, snippets + # or annotations, and always the DESCRIBES relationship, so their checks are left out + version: Final[str] = document.creation_info.spdx_version + messages: Final[list[str]] = [ + message.validation_message for message in validate_creation_info(document.creation_info, version) + ] + license_refs: Final[frozenset[str | None]] = frozenset( + info.license_id for info in document.extracted_licensing_info + ) + for package in document.packages: + messages.extend(validate_spdx_id(package.spdx_id, document)) + messages.extend(_license_problems(package.license_concluded, license_refs)) + messages.extend(_license_problems(package.license_declared, license_refs)) + messages.extend(message.validation_message for message in validate_package(package, version)) + ids: Final[Counter[str]] = Counter(package.spdx_id for package in document.packages) + ids[document.creation_info.spdx_id] += 1 + for relationship in document.relationships: + ends = (relationship.spdx_element_id, relationship.related_spdx_element_id) + messages.extend( + f'did not find the referenced spdx_id "{end}" in the SPDX document' for end in ends if end not in ids + ) + messages.extend( + message.validation_message for message in validate_extracted_licensing_infos(document.extracted_licensing_info) + ) + if duplicates := sorted(spdx_id for spdx_id, count in ids.items() if count > 1): + messages.append( + f"every spdx_id must be unique within the document, but found the following duplicates: {duplicates}" + ) + return messages + + +def _license_problems( + expression: LicenseExpression | SpdxNoAssertion | SpdxNone | None, license_refs: frozenset[str | None] +) -> list[str]: + # spdx-tools' validate_license_expression, with the LicenseRef ids it lists for every call gathered once + if not isinstance(expression, LicenseExpression): + return [] + problems: Final[list[str]] = [ + f"Unrecognized license reference: {symbol}. license_expression must only use IDs from the license list or" + f" extracted licensing info, but is: {expression}" + for symbol in spdx_licensing.validate(expression).invalid_symbols + if symbol not in license_refs + ] + try: + spdx_licensing.parse(str(expression), validate=True, strict=True) + except ExpressionParseError as error: + # an exception used as a license or the other way around; unknown symbols raise ExpressionError, listed above + problems.append(f"{error}. for license_expression: {expression}") + except ExpressionError: + pass + return problems + + +# the official CycloneDX schema files, unchanged: bom-1.6 and the jsf-0.82 it references from the specification's 1.6.2 +# release, and the spdx license list from its 1.7.2 release, the newest, as the cyclonedx.org URL every schema version +# references serves the newest; Apache-2.0, see the LICENSE beside them. Format assertion is on, as in CycloneDX's own +# Draft 7 validation, and offline, so a reference missing from the registry fails instead of reaching the network +_SCHEMAS: Final[Traversable] = files(__package__) / "schemas" +_REFERENCED: Final[list[dict[str, Json]]] = [ + json.loads((_SCHEMAS / name).read_text(encoding="utf-8")) for name in ("spdx.schema.json", "jsf-0.82.schema.json") +] +_VALIDATOR: Final[Draft7Validator] = Draft7Validator( + json.loads((_SCHEMAS / "bom-1.6.schema.json").read_text(encoding="utf-8")), + validate_formats=True, + offline=True, + registry=Registry([(str(schema["$id"]), schema) for schema in _REFERENCED]), +) + +__all__ = [ + "schema_error", + "spdx_problems", +] diff --git a/src/python_sbom/_validate/schemas/LICENSE b/src/python_sbom/_validate/schemas/LICENSE new file mode 100644 index 0000000..0808751 --- /dev/null +++ b/src/python_sbom/_validate/schemas/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright OWASP Foundation + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/python_sbom/_validate/schemas/bom-1.6.schema.json b/src/python_sbom/_validate/schemas/bom-1.6.schema.json new file mode 100644 index 0000000..b6c096a --- /dev/null +++ b/src/python_sbom/_validate/schemas/bom-1.6.schema.json @@ -0,0 +1,5699 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "type": "object", + "title": "CycloneDX Bill of Materials Standard", + "$comment" : "CycloneDX JSON schema is published under the terms of the Apache License 2.0.", + "required": [ + "bomFormat", + "specVersion" + ], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string" + }, + "bomFormat": { + "type": "string", + "title": "BOM Format", + "description": "Specifies the format of the BOM. This helps to identify the file as CycloneDX since BOMs do not have a filename convention, nor does JSON schema support namespaces. This value must be \"CycloneDX\".", + "enum": [ + "CycloneDX" + ] + }, + "specVersion": { + "type": "string", + "title": "CycloneDX Specification Version", + "description": "The version of the CycloneDX specification the BOM conforms to.", + "examples": ["1.6"] + }, + "serialNumber": { + "type": "string", + "title": "BOM Serial Number", + "description": "Every BOM generated SHOULD have a unique serial number, even if the contents of the BOM have not changed over time. If specified, the serial number must conform to [RFC 4122](https://www.ietf.org/rfc/rfc4122.html). Use of serial numbers is recommended.", + "examples": ["urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79"], + "pattern": "^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "version": { + "type": "integer", + "title": "BOM Version", + "description": "Whenever an existing BOM is modified, either manually or through automated processes, the version of the BOM SHOULD be incremented by 1. When a system is presented with multiple BOMs with identical serial numbers, the system SHOULD use the most recent version of the BOM. The default version is '1'.", + "minimum": 1, + "default": 1, + "examples": [1] + }, + "metadata": { + "$ref": "#/definitions/metadata", + "title": "BOM Metadata", + "description": "Provides additional information about a BOM." + }, + "components": { + "type": "array", + "items": {"$ref": "#/definitions/component"}, + "uniqueItems": true, + "title": "Components", + "description": "A list of software and hardware components." + }, + "services": { + "type": "array", + "items": {"$ref": "#/definitions/service"}, + "uniqueItems": true, + "title": "Services", + "description": "A list of services. This may include microservices, function-as-a-service, and other types of network or intra-process services." + }, + "externalReferences": { + "type": "array", + "items": {"$ref": "#/definitions/externalReference"}, + "title": "External References", + "description": "External references provide a way to document systems, sites, and information that may be relevant but are not included with the BOM. They may also establish specific relationships within or external to the BOM." + }, + "dependencies": { + "type": "array", + "items": {"$ref": "#/definitions/dependency"}, + "uniqueItems": true, + "title": "Dependencies", + "description": "Provides the ability to document dependency relationships including provided & implemented components." + }, + "compositions": { + "type": "array", + "items": {"$ref": "#/definitions/compositions"}, + "uniqueItems": true, + "title": "Compositions", + "description": "Compositions describe constituent parts (including components, services, and dependency relationships) and their completeness. The completeness of vulnerabilities expressed in a BOM may also be described." + }, + "vulnerabilities": { + "type": "array", + "items": {"$ref": "#/definitions/vulnerability"}, + "uniqueItems": true, + "title": "Vulnerabilities", + "description": "Vulnerabilities identified in components or services." + }, + "annotations": { + "type": "array", + "items": {"$ref": "#/definitions/annotations"}, + "uniqueItems": true, + "title": "Annotations", + "description": "Comments made by people, organizations, or tools about any object with a bom-ref, such as components, services, vulnerabilities, or the BOM itself. Unlike inventory information, annotations may contain opinions or commentary from various stakeholders. Annotations may be inline (with inventory) or externalized via BOM-Link and may optionally be signed." + }, + "formulation": { + "type": "array", + "items": {"$ref": "#/definitions/formula"}, + "uniqueItems": true, + "title": "Formulation", + "description": "Describes how a component or service was manufactured or deployed. This is achieved through the use of formulas, workflows, tasks, and steps, which declare the precise steps to reproduce along with the observed formulas describing the steps which transpired in the manufacturing process." + }, + "declarations": { + "type": "object", + "title": "Declarations", + "description": "The list of declarations which describe the conformance to standards. Each declaration may include attestations, claims, and evidence.", + "additionalProperties": false, + "properties": { + "assessors": { + "type": "array", + "title": "Assessors", + "description": "The list of assessors evaluating claims and determining conformance to requirements and confidence in that assessment.", + "items": { + "type": "object", + "title": "Assessor", + "description": "The assessor who evaluates claims and determines conformance to requirements and confidence in that assessment.", + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the object elsewhere in the BOM. Every bom-ref must be unique within the BOM." + }, + "thirdParty": { + "type": "boolean", + "title": "Third Party", + "description": "The boolean indicating if the assessor is outside the organization generating claims. A value of false indicates a self assessor." + }, + "organization": { + "$ref": "#/definitions/organizationalEntity", + "title": "Organization", + "description": "The entity issuing the assessment." + } + } + } + }, + "attestations": { + "type": "array", + "title": "Attestations", + "description": "The list of attestations asserted by an assessor that maps requirements to claims.", + "items": { + "type": "object", + "title": "Attestation", + "additionalProperties": false, + "properties": { + "summary": { + "type": "string", + "title": "Summary", + "description": "The short description explaining the main points of the attestation." + }, + "assessor": { + "$ref": "#/definitions/refLinkType", + "title": "Assessor", + "description": "The `bom-ref` to the assessor asserting the attestation." + }, + "map": { + "type": "array", + "title": "Map", + "description": "The grouping of requirements to claims and the attestors declared conformance and confidence thereof.", + "items": { + "type": "object", + "title": "Map", + "additionalProperties": false, + "properties": { + "requirement": { + "$ref": "#/definitions/refLinkType", + "title": "Requirement", + "description": "The `bom-ref` to the requirement being attested to." + }, + "claims": { + "type": "array", + "title": "Claims", + "description": "The list of `bom-ref` to the claims being attested to.", + "items": { "$ref": "#/definitions/refLinkType" } + }, + "counterClaims": { + "type": "array", + "title": "Counter Claims", + "description": "The list of `bom-ref` to the counter claims being attested to.", + "items": { "$ref": "#/definitions/refLinkType" } + }, + "conformance": { + "type": "object", + "title": "Conformance", + "description": "The conformance of the claim meeting a requirement.", + "additionalProperties": false, + "properties": { + "score": { + "type": "number", + "minimum": 0, + "maximum": 1, + "title": "Score", + "description": "The conformance of the claim between and inclusive of 0 and 1, where 1 is 100% conformance." + }, + "rationale": { + "type": "string", + "title": "Rationale", + "description": "The rationale for the conformance score." + }, + "mitigationStrategies": { + "type": "array", + "title": "Mitigation Strategies", + "description": "The list of `bom-ref` to the evidence provided describing the mitigation strategies.", + "items": { "$ref": "#/definitions/refLinkType" } + } + } + }, + "confidence": { + "type": "object", + "title": "Confidence", + "description": "The confidence of the claim meeting the requirement.", + "additionalProperties": false, + "properties": { + "score": { + "type": "number", + "minimum": 0, + "maximum": 1, + "title": "Score", + "description": "The confidence of the claim between and inclusive of 0 and 1, where 1 is 100% confidence." + }, + "rationale": { + "type": "string", + "title": "Rationale", + "description": "The rationale for the confidence score." + } + } + } + } + } + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + } + }, + "claims": { + "type": "array", + "title": "Claims", + "description": "The list of claims.", + "items": { + "type": "object", + "title": "Claim", + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the object elsewhere in the BOM. Every bom-ref must be unique within the BOM." + }, + "target": { + "$ref": "#/definitions/refLinkType", + "title": "Target", + "description": "The `bom-ref` to a target representing a specific system, application, API, module, team, person, process, business unit, company, etc... that this claim is being applied to." + }, + "predicate": { + "type": "string", + "title": "Predicate", + "description": "The specific statement or assertion about the target." + }, + "mitigationStrategies": { + "type": "array", + "title": "Mitigation Strategies", + "description": "The list of `bom-ref` to the evidence provided describing the mitigation strategies. Each mitigation strategy should include an explanation of how any weaknesses in the evidence will be mitigated.", + "items": { "$ref": "#/definitions/refLinkType" } + }, + "reasoning": { + "type": "string", + "title": "Reasoning", + "description": "The written explanation of why the evidence provided substantiates the claim." + }, + "evidence": { + "type": "array", + "title": "Evidence", + "description": "The list of `bom-ref` to evidence that supports this claim.", + "items": { "$ref": "#/definitions/refLinkType" } + }, + "counterEvidence": { + "type": "array", + "title": "Counter Evidence", + "description": "The list of `bom-ref` to counterEvidence that supports this claim.", + "items": { "$ref": "#/definitions/refLinkType" } + }, + "externalReferences": { + "type": "array", + "items": {"$ref": "#/definitions/externalReference"}, + "title": "External References", + "description": "External references provide a way to document systems, sites, and information that may be relevant but are not included with the BOM. They may also establish specific relationships within or external to the BOM." + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + } + }, + "evidence": { + "type": "array", + "title": "Evidence", + "description": "The list of evidence", + "items": { + "type": "object", + "title": "Evidence", + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the object elsewhere in the BOM. Every bom-ref must be unique within the BOM." + }, + "propertyName": { + "type": "string", + "title": "Property Name", + "description": "The reference to the property name as defined in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy/)." + }, + "description": { + "type": "string", + "title": "Description", + "description": "The written description of what this evidence is and how it was created." + }, + "data": { + "type": "array", + "title": "Data", + "description": "The output or analysis that supports claims.", + "items": { + "type": "object", + "title": "Data", + "additionalProperties": false, + "properties": { + "name": { + "title": "Data Name", + "description": "The name of the data.", + "type": "string" + }, + "contents": { + "type": "object", + "title": "Data Contents", + "description": "The contents or references to the contents of the data being described.", + "additionalProperties": false, + "properties": { + "attachment": { + "title": "Data Attachment", + "description": "An optional way to include textual or encoded data.", + "$ref": "#/definitions/attachment" + }, + "url": { + "type": "string", + "title": "Data URL", + "description": "The URL to where the data can be retrieved.", + "format": "iri-reference" + } + } + }, + "classification": { + "$ref": "#/definitions/dataClassification" + }, + "sensitiveData": { + "type": "array", + "title": "Sensitive Data", + "description": "A description of any sensitive data included.", + "items": { + "type": "string" + } + }, + "governance": { + "title": "Data Governance", + "$ref": "#/definitions/dataGovernance" + } + } + } + }, + "created": { + "type": "string", + "format": "date-time", + "title": "Created", + "description": "The date and time (timestamp) when the evidence was created." + }, + "expires": { + "type": "string", + "format": "date-time", + "title": "Expires", + "description": "The optional date and time (timestamp) when the evidence is no longer valid." + }, + "author": { + "$ref": "#/definitions/organizationalContact", + "title": "Author", + "description": "The author of the evidence." + }, + "reviewer": { + "$ref": "#/definitions/organizationalContact", + "title": "Reviewer", + "description": "The reviewer of the evidence." + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + } + }, + "targets": { + "type": "object", + "title": "Targets", + "description": "The list of targets which claims are made against.", + "additionalProperties": false, + "properties": { + "organizations": { + "type": "array", + "title": "Organizations", + "description": "The list of organizations which claims are made against.", + "items": {"$ref": "#/definitions/organizationalEntity"} + }, + "components": { + "type": "array", + "title": "Components", + "description": "The list of components which claims are made against.", + "items": {"$ref": "#/definitions/component"} + }, + "services": { + "type": "array", + "title": "Services", + "description": "The list of services which claims are made against.", + "items": {"$ref": "#/definitions/service"} + } + } + }, + "affirmation": { + "type": "object", + "title": "Affirmation", + "description": "A concise statement affirmed by an individual regarding all declarations, often used for third-party auditor acceptance or recipient acknowledgment. It includes a list of authorized signatories who assert the validity of the document on behalf of the organization.", + "additionalProperties": false, + "properties": { + "statement": { + "type": "string", + "title": "Statement", + "description": "The brief statement affirmed by an individual regarding all declarations.\n*- Notes This could be an affirmation of acceptance by a third-party auditor or receiving individual of a file.", + "examples": [ "I certify, to the best of my knowledge, that all information is correct." ] + }, + "signatories": { + "type": "array", + "title": "Signatories", + "description": "The list of signatories authorized on behalf of an organization to assert validity of this document.", + "items": { + "type": "object", + "title": "Signatory", + "additionalProperties": false, + "oneOf": [ + { + "required": ["signature"] + }, + { + "required": ["externalReference", "organization"] + } + ], + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The signatory's name." + }, + "role": { + "type": "string", + "title": "Role", + "description": "The signatory's role within an organization." + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + }, + "organization": { + "$ref": "#/definitions/organizationalEntity", + "title": "Organization", + "description": "The signatory's organization." + }, + "externalReference": { + "$ref": "#/definitions/externalReference", + "title": "External Reference", + "description": "External references provide a way to document systems, sites, and information that may be relevant but are not included with the BOM. They may also establish specific relationships within or external to the BOM." + } + } + } + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + }, + "definitions": { + "type": "object", + "title": "Definitions", + "description": "A collection of reusable objects that are defined and may be used elsewhere in the BOM.", + "additionalProperties": false, + "properties": { + "standards": { + "type": "array", + "title": "Standards", + "description": "The list of standards which may consist of regulations, industry or organizational-specific standards, maturity models, best practices, or any other requirements which can be evaluated against or attested to.", + "items": { + "$ref": "#/definitions/standard" + } + } + } + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + }, + "definitions": { + "refType": { + "description": "Identifier for referable and therefore interlinkable elements.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links.", + "type": "string", + "minLength": 1, + "$comment": "TODO (breaking change): add a format constraint that prevents the value from starting with 'urn:cdx:'" + }, + "refLinkType": { + "description": "Descriptor for an element identified by the attribute 'bom-ref' in the same BOM document.\nIn contrast to `bomLinkElementType`.", + "$ref": "#/definitions/refType" + }, + "bomLinkDocumentType": { + "title": "BOM-Link Document", + "description": "Descriptor for another BOM document. See https://cyclonedx.org/capabilities/bomlink/", + "type": "string", + "format": "iri-reference", + "pattern": "^urn:cdx:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/[1-9][0-9]*$", + "$comment": "part of the pattern is based on `bom.serialNumber`'s pattern" + }, + "bomLinkElementType": { + "title": "BOM-Link Element", + "description": "Descriptor for an element in a BOM document. See https://cyclonedx.org/capabilities/bomlink/", + "type": "string", + "format": "iri-reference", + "pattern": "^urn:cdx:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/[1-9][0-9]*#.+$", + "$comment": "part of the pattern is based on `bom.serialNumber`'s pattern" + }, + "bomLink": { + "title": "BOM-Link", + "anyOf": [ + { + "title": "BOM-Link Document", + "$ref": "#/definitions/bomLinkDocumentType" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ] + }, + "metadata": { + "type": "object", + "title": "BOM Metadata", + "additionalProperties": false, + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp", + "description": "The date and time (timestamp) when the BOM was created." + }, + "lifecycles": { + "type": "array", + "title": "Lifecycles", + "description": "Lifecycles communicate the stage(s) in which data in the BOM was captured. Different types of data may be available at various phases of a lifecycle, such as the Software Development Lifecycle (SDLC), IT Asset Management (ITAM), and Software Asset Management (SAM). Thus, a BOM may include data specific to or only obtainable in a given lifecycle.", + "items": { + "type": "object", + "title": "Lifecycle", + "description": "The product lifecycle(s) that this BOM represents.", + "oneOf": [ + { + "title": "Pre-Defined Phase", + "required": ["phase"], + "additionalProperties": false, + "properties": { + "phase": { + "type": "string", + "title": "Phase", + "description": "A pre-defined phase in the product lifecycle.", + "enum": [ + "design", + "pre-build", + "build", + "post-build", + "operations", + "discovery", + "decommission" + ], + "meta:enum": { + "design": "BOM produced early in the development lifecycle containing an inventory of components and services that are proposed or planned to be used. The inventory may need to be procured, retrieved, or resourced prior to use.", + "pre-build": "BOM consisting of information obtained prior to a build process and may contain source files and development artifacts and manifests. The inventory may need to be resolved and retrieved prior to use.", + "build": "BOM consisting of information obtained during a build process where component inventory is available for use. The precise versions of resolved components are usually available at this time as well as the provenance of where the components were retrieved from.", + "post-build": "BOM consisting of information obtained after a build process has completed and the resulting components(s) are available for further analysis. Built components may exist as the result of a CI/CD process, may have been installed or deployed to a system or device, and may need to be retrieved or extracted from the system or device.", + "operations": "BOM produced that represents inventory that is running and operational. This may include staging or production environments and will generally encompass multiple SBOMs describing the applications and operating system, along with HBOMs describing the hardware that makes up the system. Operations Bill of Materials (OBOM) can provide full-stack inventory of runtime environments, configurations, and additional dependencies.", + "discovery": "BOM consisting of information observed through network discovery providing point-in-time enumeration of embedded, on-premise, and cloud-native services such as server applications, connected devices, microservices, and serverless functions.", + "decommission": "BOM containing inventory that will be, or has been retired from operations." + } + } + } + }, + { + "title": "Custom Phase", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The name of the lifecycle phase" + }, + "description": { + "type": "string", + "title": "Description", + "description": "The description of the lifecycle phase" + } + } + } + ] + } + }, + "tools": { + "title": "Tools", + "description": "The tool(s) used in the creation, enrichment, and validation of the BOM.", + "oneOf": [ + { + "type": "object", + "title": "Tools", + "description": "The tool(s) used in the creation, enrichment, and validation of the BOM.", + "additionalProperties": false, + "properties": { + "components": { + "type": "array", + "items": {"$ref": "#/definitions/component"}, + "uniqueItems": true, + "title": "Components", + "description": "A list of software and hardware components used as tools." + }, + "services": { + "type": "array", + "items": {"$ref": "#/definitions/service"}, + "uniqueItems": true, + "title": "Services", + "description": "A list of services used as tools. This may include microservices, function-as-a-service, and other types of network or intra-process services." + } + } + }, + { + "type": "array", + "title": "Tools (legacy)", + "description": "[Deprecated] The tool(s) used in the creation, enrichment, and validation of the BOM.", + "items": {"$ref": "#/definitions/tool"} + } + ] + }, + "manufacturer": { + "title": "BOM Manufacturer", + "description": "The organization that created the BOM.\nManufacturer is common in BOMs created through automated processes. BOMs created through manual means may have `@.authors` instead.", + "$ref": "#/definitions/organizationalEntity" + }, + "authors": { + "type": "array", + "title": "BOM Authors", + "description": "The person(s) who created the BOM.\nAuthors are common in BOMs created through manual processes. BOMs created through automated means may have `@.manufacturer` instead.", + "items": {"$ref": "#/definitions/organizationalContact"} + }, + "component": { + "title": "Component", + "description": "The component that the BOM describes.", + "$ref": "#/definitions/component" + }, + "manufacture": { + "deprecated": true, + "title": "Component Manufacture (legacy)", + "description": "[Deprecated] This will be removed in a future version. Use the `@.component.manufacturer` instead.\nThe organization that manufactured the component that the BOM describes.", + "$ref": "#/definitions/organizationalEntity" + }, + "supplier": { + "title": "Supplier", + "description": " The organization that supplied the component that the BOM describes. The supplier may often be the manufacturer, but may also be a distributor or repackager.", + "$ref": "#/definitions/organizationalEntity" + }, + "licenses": { + "title": "BOM License(s)", + "description": "The license information for the BOM document.\nThis may be different from the license(s) of the component(s) that the BOM describes.", + "$ref": "#/definitions/licenseChoice" + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": {"$ref": "#/definitions/property"} + } + } + }, + "tool": { + "type": "object", + "title": "Tool", + "description": "[Deprecated] This will be removed in a future version. Use component or service instead. Information about the automated or manual tool used", + "additionalProperties": false, + "properties": { + "vendor": { + "type": "string", + "title": "Tool Vendor", + "description": "The name of the vendor who created the tool" + }, + "name": { + "type": "string", + "title": "Tool Name", + "description": "The name of the tool" + }, + "version": { + "$ref": "#/definitions/version", + "title": "Tool Version", + "description": "The version of the tool" + }, + "hashes": { + "type": "array", + "items": {"$ref": "#/definitions/hash"}, + "title": "Hashes", + "description": "The hashes of the tool (if applicable)." + }, + "externalReferences": { + "type": "array", + "items": {"$ref": "#/definitions/externalReference"}, + "title": "External References", + "description": "External references provide a way to document systems, sites, and information that may be relevant, but are not included with the BOM. They may also establish specific relationships within or external to the BOM." + } + } + }, + "organizationalEntity": { + "type": "object", + "title": "Organizational Entity", + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the object elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links." + }, + "name": { + "type": "string", + "title": "Organization Name", + "description": "The name of the organization", + "examples": [ + "Example Inc." + ] + }, + "address": { + "$ref": "#/definitions/postalAddress", + "title": "Organization Address", + "description": "The physical address (location) of the organization" + }, + "url": { + "type": "array", + "items": { + "type": "string", + "format": "iri-reference" + }, + "title": "Organization URL(s)", + "description": "The URL of the organization. Multiple URLs are allowed.", + "examples": ["https://example.com"] + }, + "contact": { + "type": "array", + "title": "Organizational Contact", + "description": "A contact at the organization. Multiple contacts are allowed.", + "items": {"$ref": "#/definitions/organizationalContact"} + } + } + }, + "organizationalContact": { + "type": "object", + "title": "Organizational Contact", + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the object elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links." + }, + "name": { + "type": "string", + "title": "Name", + "description": "The name of a contact", + "examples": ["Contact name"] + }, + "email": { + "type": "string", + "format": "idn-email", + "title": "Email Address", + "description": "The email address of the contact.", + "examples": ["firstname.lastname@example.com"] + }, + "phone": { + "type": "string", + "title": "Phone", + "description": "The phone number of the contact.", + "examples": ["800-555-1212"] + } + } + }, + "component": { + "type": "object", + "title": "Component", + "required": [ + "type", + "name" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "application", + "framework", + "library", + "container", + "platform", + "operating-system", + "device", + "device-driver", + "firmware", + "file", + "machine-learning-model", + "data", + "cryptographic-asset" + ], + "meta:enum": { + "application": "A software application. Refer to [https://en.wikipedia.org/wiki/Application_software](https://en.wikipedia.org/wiki/Application_software) for information about applications.", + "framework": "A software framework. Refer to [https://en.wikipedia.org/wiki/Software_framework](https://en.wikipedia.org/wiki/Software_framework) for information on how frameworks vary slightly from libraries.", + "library": "A software library. Refer to [https://en.wikipedia.org/wiki/Library_(computing)](https://en.wikipedia.org/wiki/Library_(computing)) for information about libraries. All third-party and open source reusable components will likely be a library. If the library also has key features of a framework, then it should be classified as a framework. If not, or is unknown, then specifying library is recommended.", + "container": "A packaging and/or runtime format, not specific to any particular technology, which isolates software inside the container from software outside of a container through virtualization technology. Refer to [https://en.wikipedia.org/wiki/OS-level_virtualization](https://en.wikipedia.org/wiki/OS-level_virtualization).", + "platform": "A runtime environment which interprets or executes software. This may include runtimes such as those that execute bytecode or low-code/no-code application platforms.", + "operating-system": "A software operating system without regard to deployment model (i.e. installed on physical hardware, virtual machine, image, etc) Refer to [https://en.wikipedia.org/wiki/Operating_system](https://en.wikipedia.org/wiki/Operating_system).", + "device": "A hardware device such as a processor or chip-set. A hardware device containing firmware SHOULD include a component for the physical hardware itself and another component of type 'firmware' or 'operating-system' (whichever is relevant), describing information about the software running on the device. See also the list of [known device properties](https://github.com/CycloneDX/cyclonedx-property-taxonomy/blob/main/cdx/device.md).", + "device-driver": "A special type of software that operates or controls a particular type of device. Refer to [https://en.wikipedia.org/wiki/Device_driver](https://en.wikipedia.org/wiki/Device_driver).", + "firmware": "A special type of software that provides low-level control over a device's hardware. Refer to [https://en.wikipedia.org/wiki/Firmware](https://en.wikipedia.org/wiki/Firmware).", + "file": "A computer file. Refer to [https://en.wikipedia.org/wiki/Computer_file](https://en.wikipedia.org/wiki/Computer_file) for information about files.", + "machine-learning-model": "A model based on training data that can make predictions or decisions without being explicitly programmed to do so.", + "data": "A collection of discrete values that convey information.", + "cryptographic-asset": "A cryptographic asset including algorithms, protocols, certificates, keys, tokens, and secrets." + }, + "title": "Component Type", + "description": "Specifies the type of component. For software components, classify as application if no more specific appropriate classification is available or cannot be determined for the component.", + "examples": ["library"] + }, + "mime-type": { + "type": "string", + "title": "Mime-Type", + "description": "The optional mime-type of the component. When used on file components, the mime-type can provide additional context about the kind of file being represented, such as an image, font, or executable. Some library or framework components may also have an associated mime-type.", + "examples": ["image/jpeg"], + "pattern": "^[-+a-z0-9.]+/[-+a-z0-9.]+$" + }, + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the component elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links." + }, + "supplier": { + "title": "Component Supplier", + "description": " The organization that supplied the component. The supplier may often be the manufacturer, but may also be a distributor or repackager.", + "$ref": "#/definitions/organizationalEntity" + }, + "manufacturer": { + "title": "Component Manufacturer", + "description": "The organization that created the component.\nManufacturer is common in components created through automated processes. Components created through manual means may have `@.authors` instead.", + "$ref": "#/definitions/organizationalEntity" + }, + "authors" :{ + "type": "array", + "title": "Component Authors", + "description": "The person(s) who created the component.\nAuthors are common in components created through manual processes. Components created through automated means may have `@.manufacturer` instead.", + "items": {"$ref": "#/definitions/organizationalContact"} + }, + "author": { + "deprecated": true, + "type": "string", + "title": "Component Author (legacy)", + "description": "[Deprecated] This will be removed in a future version. Use `@.authors` or `@.manufacturer` instead.\nThe person(s) or organization(s) that authored the component", + "examples": ["Acme Inc"] + }, + "publisher": { + "type": "string", + "title": "Component Publisher", + "description": "The person(s) or organization(s) that published the component", + "examples": ["Acme Inc"] + }, + "group": { + "type": "string", + "title": "Component Group", + "description": "The grouping name or identifier. This will often be a shortened, single name of the company or project that produced the component, or the source package or domain name. Whitespace and special characters should be avoided. Examples include: apache, org.apache.commons, and apache.org.", + "examples": ["com.acme"] + }, + "name": { + "type": "string", + "title": "Component Name", + "description": "The name of the component. This will often be a shortened, single name of the component. Examples: commons-lang3 and jquery", + "examples": ["tomcat-catalina"] + }, + "version": { + "$ref": "#/definitions/version", + "title": "Component Version", + "description": "The component version. The version should ideally comply with semantic versioning but is not enforced." + }, + "description": { + "type": "string", + "title": "Component Description", + "description": "Specifies a description for the component" + }, + "scope": { + "type": "string", + "enum": [ + "required", + "optional", + "excluded" + ], + "meta:enum": { + "required": "The component is required for runtime", + "optional": "The component is optional at runtime. Optional components are components that are not capable of being called due to them not being installed or otherwise accessible by any means. Components that are installed but due to configuration or other restrictions are prohibited from being called must be scoped as 'required'.", + "excluded": "Components that are excluded provide the ability to document component usage for test and other non-runtime purposes. Excluded components are not reachable within a call graph at runtime." + }, + "title": "Component Scope", + "description": "Specifies the scope of the component. If scope is not specified, 'required' scope SHOULD be assumed by the consumer of the BOM.", + "default": "required" + }, + "hashes": { + "type": "array", + "title": "Component Hashes", + "description": "The hashes of the component.", + "items": {"$ref": "#/definitions/hash"} + }, + "licenses": { + "$ref": "#/definitions/licenseChoice", + "title": "Component License(s)" + }, + "copyright": { + "type": "string", + "title": "Component Copyright", + "description": "A copyright notice informing users of the underlying claims to copyright ownership in a published work.", + "examples": ["Acme Inc"] + }, + "cpe": { + "type": "string", + "title": "Common Platform Enumeration (CPE)", + "description": "Asserts the identity of the component using CPE. The CPE must conform to the CPE 2.2 or 2.3 specification. See [https://nvd.nist.gov/products/cpe](https://nvd.nist.gov/products/cpe). Refer to `@.evidence.identity` to optionally provide evidence that substantiates the assertion of the component's identity.", + "examples": ["cpe:2.3:a:acme:component_framework:-:*:*:*:*:*:*:*"] + }, + "purl": { + "type": "string", + "title": "Package URL (purl)", + "description": "Asserts the identity of the component using package-url (purl). The purl, if specified, must be valid and conform to the specification defined at: [https://github.com/package-url/purl-spec](https://github.com/package-url/purl-spec). Refer to `@.evidence.identity` to optionally provide evidence that substantiates the assertion of the component's identity.", + "examples": ["pkg:maven/com.acme/tomcat-catalina@9.0.14?packaging=jar"] + }, + "omniborId": { + "type": "array", + "title": "OmniBOR Artifact Identifier (gitoid)", + "description": "Asserts the identity of the component using the OmniBOR Artifact ID. The OmniBOR, if specified, must be valid and conform to the specification defined at: [https://www.iana.org/assignments/uri-schemes/prov/gitoid](https://www.iana.org/assignments/uri-schemes/prov/gitoid). Refer to `@.evidence.identity` to optionally provide evidence that substantiates the assertion of the component's identity.", + "items": { "type": "string" }, + "examples": [ + "gitoid:blob:sha1:a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", + "gitoid:blob:sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + ] + }, + "swhid": { + "type": "array", + "title": "Software Heritage Identifier", + "description": "Asserts the identity of the component using the Software Heritage persistent identifier (SWHID). The SWHID, if specified, must be valid and conform to the specification defined at: [https://docs.softwareheritage.org/devel/swh-model/persistent-identifiers.html](https://docs.softwareheritage.org/devel/swh-model/persistent-identifiers.html). Refer to `@.evidence.identity` to optionally provide evidence that substantiates the assertion of the component's identity.", + "items": { "type": "string" }, + "examples": ["swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2"] + }, + "swid": { + "$ref": "#/definitions/swid", + "title": "SWID Tag", + "description": "Asserts the identity of the component using [ISO-IEC 19770-2 Software Identification (SWID) Tags](https://www.iso.org/standard/65666.html). Refer to `@.evidence.identity` to optionally provide evidence that substantiates the assertion of the component's identity." + }, + "modified": { + "type": "boolean", + "title": "Component Modified From Original", + "description": "[Deprecated] This will be removed in a future version. Use the pedigree element instead to supply information on exactly how the component was modified. A boolean value indicating if the component has been modified from the original. A value of true indicates the component is a derivative of the original. A value of false indicates the component has not been modified from the original." + }, + "pedigree": { + "type": "object", + "title": "Component Pedigree", + "description": "Component pedigree is a way to document complex supply chain scenarios where components are created, distributed, modified, redistributed, combined with other components, etc. Pedigree supports viewing this complex chain from the beginning, the end, or anywhere in the middle. It also provides a way to document variants where the exact relation may not be known.", + "additionalProperties": false, + "properties": { + "ancestors": { + "type": "array", + "title": "Ancestors", + "description": "Describes zero or more components in which a component is derived from. This is commonly used to describe forks from existing projects where the forked version contains a ancestor node containing the original component it was forked from. For example, Component A is the original component. Component B is the component being used and documented in the BOM. However, Component B contains a pedigree node with a single ancestor documenting Component A - the original component from which Component B is derived from.", + "items": {"$ref": "#/definitions/component"} + }, + "descendants": { + "type": "array", + "title": "Descendants", + "description": "Descendants are the exact opposite of ancestors. This provides a way to document all forks (and their forks) of an original or root component.", + "items": {"$ref": "#/definitions/component"} + }, + "variants": { + "type": "array", + "title": "Variants", + "description": "Variants describe relations where the relationship between the components is not known. For example, if Component A contains nearly identical code to Component B. They are both related, but it is unclear if one is derived from the other, or if they share a common ancestor.", + "items": {"$ref": "#/definitions/component"} + }, + "commits": { + "type": "array", + "title": "Commits", + "description": "A list of zero or more commits which provide a trail describing how the component deviates from an ancestor, descendant, or variant.", + "items": {"$ref": "#/definitions/commit"} + }, + "patches": { + "type": "array", + "title": "Patches", + "description": ">A list of zero or more patches describing how the component deviates from an ancestor, descendant, or variant. Patches may be complementary to commits or may be used in place of commits.", + "items": {"$ref": "#/definitions/patch"} + }, + "notes": { + "type": "string", + "title": "Notes", + "description": "Notes, observations, and other non-structured commentary describing the components pedigree." + } + } + }, + "externalReferences": { + "type": "array", + "items": {"$ref": "#/definitions/externalReference"}, + "title": "External References", + "description": "External references provide a way to document systems, sites, and information that may be relevant but are not included with the BOM. They may also establish specific relationships within or external to the BOM." + }, + "components": { + "type": "array", + "items": {"$ref": "#/definitions/component"}, + "uniqueItems": true, + "title": "Components", + "description": "A list of software and hardware components included in the parent component. This is not a dependency tree. It provides a way to specify a hierarchical representation of component assemblies, similar to system → subsystem → parts assembly in physical supply chains." + }, + "evidence": { + "$ref": "#/definitions/componentEvidence", + "title": "Evidence", + "description": "Provides the ability to document evidence collected through various forms of extraction or analysis." + }, + "releaseNotes": { + "$ref": "#/definitions/releaseNotes", + "title": "Release notes", + "description": "Specifies optional release notes." + }, + "modelCard": { + "$ref": "#/definitions/modelCard", + "title": "AI/ML Model Card" + }, + "data": { + "type": "array", + "items": {"$ref": "#/definitions/componentData"}, + "title": "Data", + "description": "This object SHOULD be specified for any component of type `data` and must not be specified for other component types." + }, + "cryptoProperties": { + "$ref": "#/definitions/cryptoProperties", + "title": "Cryptographic Properties" + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": {"$ref": "#/definitions/property"} + }, + "tags": { + "$ref": "#/definitions/tags", + "title": "Tags" + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + }, + "swid": { + "type": "object", + "title": "SWID Tag", + "description": "Specifies metadata and content for ISO-IEC 19770-2 Software Identification (SWID) Tags.", + "required": [ + "tagId", + "name" + ], + "additionalProperties": false, + "properties": { + "tagId": { + "type": "string", + "title": "Tag ID", + "description": "Maps to the tagId of a SoftwareIdentity." + }, + "name": { + "type": "string", + "title": "Name", + "description": "Maps to the name of a SoftwareIdentity." + }, + "version": { + "type": "string", + "title": "Version", + "default": "0.0", + "description": "Maps to the version of a SoftwareIdentity." + }, + "tagVersion": { + "type": "integer", + "title": "Tag Version", + "default": 0, + "description": "Maps to the tagVersion of a SoftwareIdentity." + }, + "patch": { + "type": "boolean", + "title": "Patch", + "default": false, + "description": "Maps to the patch of a SoftwareIdentity." + }, + "text": { + "title": "Attachment text", + "description": "Specifies the metadata and content of the SWID tag.", + "$ref": "#/definitions/attachment" + }, + "url": { + "type": "string", + "title": "URL", + "description": "The URL to the SWID file.", + "format": "iri-reference" + } + } + }, + "attachment": { + "type": "object", + "title": "Attachment", + "description": "Specifies the metadata and content for an attachment.", + "required": [ + "content" + ], + "additionalProperties": false, + "properties": { + "contentType": { + "type": "string", + "title": "Content-Type", + "description": "Specifies the format and nature of the data being attached, helping systems correctly interpret and process the content. Common content type examples include `application/json` for JSON data and `text/plain` for plain text documents.\n [RFC 2045 section 5.1](https://www.ietf.org/rfc/rfc2045.html#section-5.1) outlines the structure and use of content types. For a comprehensive list of registered content types, refer to the [IANA media types registry](https://www.iana.org/assignments/media-types/media-types.xhtml).", + "default": "text/plain", + "examples": [ + "text/plain", + "application/json", + "image/png" + ] + }, + "encoding": { + "type": "string", + "title": "Encoding", + "description": "Specifies the optional encoding the text is represented in.", + "enum": [ + "base64" + ], + "meta:enum": { + "base64": "Base64 is a binary-to-text encoding scheme that represents binary data in an ASCII string." + } + }, + "content": { + "type": "string", + "title": "Attachment Text", + "description": "The attachment data. Proactive controls such as input validation and sanitization should be employed to prevent misuse of attachment text." + } + } + }, + "hash": { + "type": "object", + "title": "Hash", + "required": [ + "alg", + "content" + ], + "additionalProperties": false, + "properties": { + "alg": { + "$ref": "#/definitions/hash-alg" + }, + "content": { + "$ref": "#/definitions/hash-content" + } + } + }, + "hash-alg": { + "type": "string", + "title": "Hash Algorithm", + "description": "The algorithm that generated the hash value.", + "enum": [ + "MD5", + "SHA-1", + "SHA-256", + "SHA-384", + "SHA-512", + "SHA3-256", + "SHA3-384", + "SHA3-512", + "BLAKE2b-256", + "BLAKE2b-384", + "BLAKE2b-512", + "BLAKE3" + ] + }, + "hash-content": { + "type": "string", + "title": "Hash Value", + "description": "The value of the hash.", + "examples": ["3942447fac867ae5cdb3229b658f4d48"], + "pattern": "^([a-fA-F0-9]{32}|[a-fA-F0-9]{40}|[a-fA-F0-9]{64}|[a-fA-F0-9]{96}|[a-fA-F0-9]{128})$" + }, + "license": { + "type": "object", + "title": "License", + "description": "Specifies the details and attributes related to a software license. It can either include a valid SPDX license identifier or a named license, along with additional properties such as license acknowledgment, comprehensive commercial licensing information, and the full text of the license.", + "oneOf": [ + { + "required": ["id"] + }, + { + "required": ["name"] + } + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the license elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links." + }, + "id": { + "$ref": "spdx.schema.json", + "title": "License ID (SPDX)", + "description": "A valid SPDX license identifier. If specified, this value must be one of the enumeration of valid SPDX license identifiers defined in the spdx.schema.json (or spdx.xml) subschema which is synchronized with the official SPDX license list.", + "examples": ["Apache-2.0"] + }, + "name": { + "type": "string", + "title": "License Name", + "description": "The name of the license. This may include the name of a commercial or proprietary license or an open source license that may not be defined by SPDX.", + "examples": ["Acme Software License"] + }, + "acknowledgement": { + "$ref": "#/definitions/licenseAcknowledgementEnumeration" + }, + "text": { + "title": "License text", + "description": "An optional way to include the textual content of a license.", + "$ref": "#/definitions/attachment" + }, + "url": { + "type": "string", + "title": "License URL", + "description": "The URL to the license file. If specified, a 'license' externalReference should also be specified for completeness", + "examples": ["https://www.apache.org/licenses/LICENSE-2.0.txt"], + "format": "iri-reference" + }, + "licensing": { + "type": "object", + "title": "Licensing information", + "description": "Licensing details describing the licensor/licensee, license type, renewal and expiration dates, and other important metadata", + "additionalProperties": false, + "properties": { + "altIds": { + "type": "array", + "title": "Alternate License Identifiers", + "description": "License identifiers that may be used to manage licenses and their lifecycle", + "items": { + "type": "string" + } + }, + "licensor": { + "title": "Licensor", + "description": "The individual or organization that grants a license to another individual or organization", + "type": "object", + "additionalProperties": false, + "properties": { + "organization": { + "title": "Licensor (Organization)", + "description": "The organization that granted the license", + "$ref": "#/definitions/organizationalEntity" + }, + "individual": { + "title": "Licensor (Individual)", + "description": "The individual, not associated with an organization, that granted the license", + "$ref": "#/definitions/organizationalContact" + } + }, + "oneOf":[ + { + "required": ["organization"] + }, + { + "required": ["individual"] + } + ] + }, + "licensee": { + "title": "Licensee", + "description": "The individual or organization for which a license was granted to", + "type": "object", + "additionalProperties": false, + "properties": { + "organization": { + "title": "Licensee (Organization)", + "description": "The organization that was granted the license", + "$ref": "#/definitions/organizationalEntity" + }, + "individual": { + "title": "Licensee (Individual)", + "description": "The individual, not associated with an organization, that was granted the license", + "$ref": "#/definitions/organizationalContact" + } + }, + "oneOf":[ + { + "required": ["organization"] + }, + { + "required": ["individual"] + } + ] + }, + "purchaser": { + "title": "Purchaser", + "description": "The individual or organization that purchased the license", + "type": "object", + "additionalProperties": false, + "properties": { + "organization": { + "title": "Purchaser (Organization)", + "description": "The organization that purchased the license", + "$ref": "#/definitions/organizationalEntity" + }, + "individual": { + "title": "Purchaser (Individual)", + "description": "The individual, not associated with an organization, that purchased the license", + "$ref": "#/definitions/organizationalContact" + } + }, + "oneOf":[ + { + "required": ["organization"] + }, + { + "required": ["individual"] + } + ] + }, + "purchaseOrder": { + "type": "string", + "title": "Purchase Order", + "description": "The purchase order identifier the purchaser sent to a supplier or vendor to authorize a purchase" + }, + "licenseTypes": { + "type": "array", + "title": "License Type", + "description": "The type of license(s) that was granted to the licensee.", + "items": { + "type": "string", + "enum": [ + "academic", + "appliance", + "client-access", + "concurrent-user", + "core-points", + "custom-metric", + "device", + "evaluation", + "named-user", + "node-locked", + "oem", + "perpetual", + "processor-points", + "subscription", + "user", + "other" + ], + "meta:enum": { + "academic": "A license that grants use of software solely for the purpose of education or research.", + "appliance": "A license covering use of software embedded in a specific piece of hardware.", + "client-access": "A Client Access License (CAL) allows client computers to access services provided by server software.", + "concurrent-user": "A Concurrent User license (aka floating license) limits the number of licenses for a software application and licenses are shared among a larger number of users.", + "core-points": "A license where the core of a computer's processor is assigned a specific number of points.", + "custom-metric": "A license for which consumption is measured by non-standard metrics.", + "device": "A license that covers a defined number of installations on computers and other types of devices.", + "evaluation": "A license that grants permission to install and use software for trial purposes.", + "named-user": "A license that grants access to the software to one or more pre-defined users.", + "node-locked": "A license that grants access to the software on one or more pre-defined computers or devices.", + "oem": "An Original Equipment Manufacturer license that is delivered with hardware, cannot be transferred to other hardware, and is valid for the life of the hardware.", + "perpetual": "A license where the software is sold on a one-time basis and the licensee can use a copy of the software indefinitely.", + "processor-points": "A license where each installation consumes points per processor.", + "subscription": "A license where the licensee pays a fee to use the software or service.", + "user": "A license that grants access to the software or service by a specified number of users.", + "other": "Another license type." + } + } + }, + "lastRenewal": { + "type": "string", + "format": "date-time", + "title": "Last Renewal", + "description": "The timestamp indicating when the license was last renewed. For new purchases, this is often the purchase or acquisition date. For non-perpetual licenses or subscriptions, this is the timestamp of when the license was last renewed." + }, + "expiration": { + "type": "string", + "format": "date-time", + "title": "Expiration", + "description": "The timestamp indicating when the current license expires (if applicable)." + } + } + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": {"$ref": "#/definitions/property"} + } + } + }, + "licenseAcknowledgementEnumeration": { + "title": "License Acknowledgement", + "description": "Declared licenses and concluded licenses represent two different stages in the licensing process within software development. Declared licenses refer to the initial intention of the software authors regarding the licensing terms under which their code is released. On the other hand, concluded licenses are the result of a comprehensive analysis of the project's codebase to identify and confirm the actual licenses of the components used, which may differ from the initially declared licenses. While declared licenses provide an upfront indication of the licensing intentions, concluded licenses offer a more thorough understanding of the actual licensing within a project, facilitating proper compliance and risk management. Observed licenses are defined in `@.evidence.licenses`. Observed licenses form the evidence necessary to substantiate a concluded license.", + "type": "string", + "enum": [ + "declared", + "concluded" + ], + "meta:enum": { + "declared": "Declared licenses represent the initial intentions of authors regarding the licensing terms of their code.", + "concluded": "Concluded licenses are verified and confirmed." + } + }, + "licenseChoice": { + "title": "License Choice", + "description": "EITHER (list of SPDX licenses and/or named licenses) OR (tuple of one SPDX License Expression)", + "type": "array", + "oneOf": [ + { + "title": "Multiple licenses", + "description": "A list of SPDX licenses and/or named licenses.", + "type": "array", + "items": { + "type": "object", + "title": "License", + "required": ["license"], + "additionalProperties": false, + "properties": { + "license": {"$ref": "#/definitions/license"} + } + } + }, + { + "title": "SPDX License Expression", + "description": "A tuple of exactly one SPDX License Expression.", + "type": "array", + "additionalItems": false, + "minItems": 1, + "maxItems": 1, + "items": [{ + "type": "object", + "additionalProperties": false, + "required": ["expression"], + "properties": { + "expression": { + "type": "string", + "title": "SPDX License Expression", + "description": "A valid SPDX license expression.\nRefer to https://spdx.org/specifications for syntax requirements", + "examples": [ + "Apache-2.0 AND (MIT OR GPL-2.0-only)", + "GPL-3.0-only WITH Classpath-exception-2.0" + ] + }, + "acknowledgement": { + "$ref": "#/definitions/licenseAcknowledgementEnumeration" + }, + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the license elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links." + } + } + }] + } + ] + }, + "commit": { + "type": "object", + "title": "Commit", + "description": "Specifies an individual commit", + "additionalProperties": false, + "properties": { + "uid": { + "type": "string", + "title": "UID", + "description": "A unique identifier of the commit. This may be version control specific. For example, Subversion uses revision numbers whereas git uses commit hashes." + }, + "url": { + "type": "string", + "title": "URL", + "description": "The URL to the commit. This URL will typically point to a commit in a version control system.", + "format": "iri-reference" + }, + "author": { + "title": "Author", + "description": "The author who created the changes in the commit", + "$ref": "#/definitions/identifiableAction" + }, + "committer": { + "title": "Committer", + "description": "The person who committed or pushed the commit", + "$ref": "#/definitions/identifiableAction" + }, + "message": { + "type": "string", + "title": "Message", + "description": "The text description of the contents of the commit" + } + } + }, + "patch": { + "type": "object", + "title": "Patch", + "description": "Specifies an individual patch", + "required": [ + "type" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "unofficial", + "monkey", + "backport", + "cherry-pick" + ], + "meta:enum": { + "unofficial": "A patch which is not developed by the creators or maintainers of the software being patched. Refer to [https://en.wikipedia.org/wiki/Unofficial_patch](https://en.wikipedia.org/wiki/Unofficial_patch).", + "monkey": "A patch which dynamically modifies runtime behavior. Refer to [https://en.wikipedia.org/wiki/Monkey_patch](https://en.wikipedia.org/wiki/Monkey_patch).", + "backport": "A patch which takes code from a newer version of the software and applies it to older versions of the same software. Refer to [https://en.wikipedia.org/wiki/Backporting](https://en.wikipedia.org/wiki/Backporting).", + "cherry-pick": "A patch created by selectively applying commits from other versions or branches of the same software." + }, + "title": "Patch Type", + "description": "Specifies the purpose for the patch including the resolution of defects, security issues, or new behavior or functionality." + }, + "diff": { + "title": "Diff", + "description": "The patch file (or diff) that shows changes. Refer to [https://en.wikipedia.org/wiki/Diff](https://en.wikipedia.org/wiki/Diff)", + "$ref": "#/definitions/diff" + }, + "resolves": { + "type": "array", + "items": {"$ref": "#/definitions/issue"}, + "title": "Resolves", + "description": "A collection of issues the patch resolves" + } + } + }, + "diff": { + "type": "object", + "title": "Diff", + "description": "The patch file (or diff) that shows changes. Refer to https://en.wikipedia.org/wiki/Diff", + "additionalProperties": false, + "properties": { + "text": { + "title": "Diff text", + "description": "Specifies the optional text of the diff", + "$ref": "#/definitions/attachment" + }, + "url": { + "type": "string", + "title": "URL", + "description": "Specifies the URL to the diff", + "format": "iri-reference" + } + } + }, + "issue": { + "type": "object", + "title": "Issue", + "description": "An individual issue that has been resolved.", + "required": [ + "type" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "defect", + "enhancement", + "security" + ], + "meta:enum": { + "defect": "A fault, flaw, or bug in software.", + "enhancement": "A new feature or behavior in software.", + "security": "A special type of defect which impacts security." + }, + "title": "Issue Type", + "description": "Specifies the type of issue" + }, + "id": { + "type": "string", + "title": "Issue ID", + "description": "The identifier of the issue assigned by the source of the issue" + }, + "name": { + "type": "string", + "title": "Issue Name", + "description": "The name of the issue" + }, + "description": { + "type": "string", + "title": "Issue Description", + "description": "A description of the issue" + }, + "source": { + "type": "object", + "title": "Source", + "description": "The source of the issue where it is documented", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The name of the source.", + "examples": [ + "National Vulnerability Database", + "NVD", + "Apache" + ] + }, + "url": { + "type": "string", + "title": "URL", + "description": "The url of the issue documentation as provided by the source", + "format": "iri-reference" + } + } + }, + "references": { + "type": "array", + "items": { + "type": "string", + "format": "iri-reference" + }, + "title": "References", + "description": "A collection of URL's for reference. Multiple URLs are allowed.", + "examples": ["https://example.com"] + } + } + }, + "identifiableAction": { + "type": "object", + "title": "Identifiable Action", + "description": "Specifies an individual commit", + "additionalProperties": false, + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp", + "description": "The timestamp in which the action occurred" + }, + "name": { + "type": "string", + "title": "Name", + "description": "The name of the individual who performed the action" + }, + "email": { + "type": "string", + "format": "idn-email", + "title": "E-mail", + "description": "The email address of the individual who performed the action" + } + } + }, + "externalReference": { + "type": "object", + "title": "External Reference", + "description": "External references provide a way to document systems, sites, and information that may be relevant but are not included with the BOM. They may also establish specific relationships within or external to the BOM.", + "required": [ + "url", + "type" + ], + "additionalProperties": false, + "properties": { + "url": { + "anyOf": [ + { + "title": "URL", + "type": "string", + "format": "iri-reference" + }, + { + "title": "BOM-Link", + "$ref": "#/definitions/bomLink" + } + ], + "title": "URL", + "description": "The URI (URL or URN) to the external reference. External references are URIs and therefore can accept any URL scheme including https ([RFC-7230](https://www.ietf.org/rfc/rfc7230.txt)), mailto ([RFC-2368](https://www.ietf.org/rfc/rfc2368.txt)), tel ([RFC-3966](https://www.ietf.org/rfc/rfc3966.txt)), and dns ([RFC-4501](https://www.ietf.org/rfc/rfc4501.txt)). External references may also include formally registered URNs such as [CycloneDX BOM-Link](https://cyclonedx.org/capabilities/bomlink/) to reference CycloneDX BOMs or any object within a BOM. BOM-Link transforms applicable external references into relationships that can be expressed in a BOM or across BOMs." + }, + "comment": { + "type": "string", + "title": "Comment", + "description": "An optional comment describing the external reference" + }, + "type": { + "type": "string", + "title": "Type", + "description": "Specifies the type of external reference.", + "enum": [ + "vcs", + "issue-tracker", + "website", + "advisories", + "bom", + "mailing-list", + "social", + "chat", + "documentation", + "support", + "source-distribution", + "distribution", + "distribution-intake", + "license", + "build-meta", + "build-system", + "release-notes", + "security-contact", + "model-card", + "log", + "configuration", + "evidence", + "formulation", + "attestation", + "threat-model", + "adversary-model", + "risk-assessment", + "vulnerability-assertion", + "exploitability-statement", + "pentest-report", + "static-analysis-report", + "dynamic-analysis-report", + "runtime-analysis-report", + "component-analysis-report", + "maturity-report", + "certification-report", + "codified-infrastructure", + "quality-metrics", + "poam", + "electronic-signature", + "digital-signature", + "rfc-9116", + "other" + ], + "meta:enum": { + "vcs": "Version Control System", + "issue-tracker": "Issue or defect tracking system, or an Application Lifecycle Management (ALM) system", + "website": "Website", + "advisories": "Security advisories", + "bom": "Bill of Materials (SBOM, OBOM, HBOM, SaaSBOM, etc)", + "mailing-list": "Mailing list or discussion group", + "social": "Social media account", + "chat": "Real-time chat platform", + "documentation": "Documentation, guides, or how-to instructions", + "support": "Community or commercial support", + "source-distribution": "The location where the source code distributable can be obtained. This is often an archive format such as zip or tgz. The source-distribution type complements use of the version control (vcs) type.", + "distribution": "Direct or repository download location", + "distribution-intake": "The location where a component was published to. This is often the same as \"distribution\" but may also include specialized publishing processes that act as an intermediary.", + "license": "The reference to the license file. If a license URL has been defined in the license node, it should also be defined as an external reference for completeness.", + "build-meta": "Build-system specific meta file (i.e. pom.xml, package.json, .nuspec, etc)", + "build-system": "Reference to an automated build system", + "release-notes": "Reference to release notes", + "security-contact": "Specifies a way to contact the maintainer, supplier, or provider in the event of a security incident. Common URIs include links to a disclosure procedure, a mailto (RFC-2368) that specifies an email address, a tel (RFC-3966) that specifies a phone number, or dns (RFC-4501) that specifies the records containing DNS Security TXT.", + "model-card": "A model card describes the intended uses of a machine learning model, potential limitations, biases, ethical considerations, training parameters, datasets used to train the model, performance metrics, and other relevant data useful for ML transparency.", + "log": "A record of events that occurred in a computer system or application, such as problems, errors, or information on current operations.", + "configuration": "Parameters or settings that may be used by other components or services.", + "evidence": "Information used to substantiate a claim.", + "formulation": "Describes how a component or service was manufactured or deployed.", + "attestation": "Human or machine-readable statements containing facts, evidence, or testimony.", + "threat-model": "An enumeration of identified weaknesses, threats, and countermeasures, dataflow diagram (DFD), attack tree, and other supporting documentation in human-readable or machine-readable format.", + "adversary-model": "The defined assumptions, goals, and capabilities of an adversary.", + "risk-assessment": "Identifies and analyzes the potential of future events that may negatively impact individuals, assets, and/or the environment. Risk assessments may also include judgments on the tolerability of each risk.", + "vulnerability-assertion": "A Vulnerability Disclosure Report (VDR) which asserts the known and previously unknown vulnerabilities that affect a component, service, or product including the analysis and findings describing the impact (or lack of impact) that the reported vulnerability has on a component, service, or product.", + "exploitability-statement": "A Vulnerability Exploitability eXchange (VEX) which asserts the known vulnerabilities that do not affect a product, product family, or organization, and optionally the ones that do. The VEX should include the analysis and findings describing the impact (or lack of impact) that the reported vulnerability has on the product, product family, or organization.", + "pentest-report": "Results from an authorized simulated cyberattack on a component or service, otherwise known as a penetration test.", + "static-analysis-report": "SARIF or proprietary machine or human-readable report for which static analysis has identified code quality, security, and other potential issues with the source code.", + "dynamic-analysis-report": "Dynamic analysis report that has identified issues such as vulnerabilities and misconfigurations.", + "runtime-analysis-report": "Report generated by analyzing the call stack of a running application.", + "component-analysis-report": "Report generated by Software Composition Analysis (SCA), container analysis, or other forms of component analysis.", + "maturity-report": "Report containing a formal assessment of an organization, business unit, or team against a maturity model.", + "certification-report": "Industry, regulatory, or other certification from an accredited (if applicable) certification body.", + "codified-infrastructure": "Code or configuration that defines and provisions virtualized infrastructure, commonly referred to as Infrastructure as Code (IaC).", + "quality-metrics": "Report or system in which quality metrics can be obtained.", + "poam": "Plans of Action and Milestones (POA&M) complement an \"attestation\" external reference. POA&M is defined by NIST as a \"document that identifies tasks needing to be accomplished. It details resources required to accomplish the elements of the plan, any milestones in meeting the tasks and scheduled completion dates for the milestones\".", + "electronic-signature": "An e-signature is commonly a scanned representation of a written signature or a stylized script of the person's name.", + "digital-signature": "A signature that leverages cryptography, typically public/private key pairs, which provides strong authenticity verification.", + "rfc-9116": "Document that complies with [RFC 9116](https://www.ietf.org/rfc/rfc9116.html) (A File Format to Aid in Security Vulnerability Disclosure)", + "other": "Use this if no other types accurately describe the purpose of the external reference." + } + }, + "hashes": { + "type": "array", + "items": {"$ref": "#/definitions/hash"}, + "title": "Hashes", + "description": "The hashes of the external reference (if applicable)." + } + } + }, + "dependency": { + "type": "object", + "title": "Dependency", + "description": "Defines the direct dependencies of a component, service, or the components provided/implemented by a given component. Components or services that do not have their own dependencies must be declared as empty elements within the graph. Components or services that are not represented in the dependency graph may have unknown dependencies. It is recommended that implementations assume this to be opaque and not an indicator of an object being dependency-free. It is recommended to leverage compositions to indicate unknown dependency graphs.", + "required": [ + "ref" + ], + "additionalProperties": false, + "properties": { + "ref": { + "$ref": "#/definitions/refLinkType", + "title": "Reference", + "description": "References a component or service by its bom-ref attribute" + }, + "dependsOn": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/refLinkType" + }, + "title": "Depends On", + "description": "The bom-ref identifiers of the components or services that are dependencies of this dependency object." + }, + "provides": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/refLinkType" + }, + "title": "Provides", + "description": "The bom-ref identifiers of the components or services that define a given specification or standard, which are provided or implemented by this dependency object.\nFor example, a cryptographic library which implements a cryptographic algorithm. A component which implements another component does not imply that the implementation is in use." + } + } + }, + "service": { + "type": "object", + "title": "Service", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the service elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links." + }, + "provider": { + "title": "Provider", + "description": "The organization that provides the service.", + "$ref": "#/definitions/organizationalEntity" + }, + "group": { + "type": "string", + "title": "Service Group", + "description": "The grouping name, namespace, or identifier. This will often be a shortened, single name of the company or project that produced the service or domain name. Whitespace and special characters should be avoided.", + "examples": ["com.acme"] + }, + "name": { + "type": "string", + "title": "Service Name", + "description": "The name of the service. This will often be a shortened, single name of the service.", + "examples": ["ticker-service"] + }, + "version": { + "$ref": "#/definitions/version", + "title": "Service Version", + "description": "The service version." + }, + "description": { + "type": "string", + "title": "Service Description", + "description": "Specifies a description for the service" + }, + "endpoints": { + "type": "array", + "items": { + "type": "string", + "format": "iri-reference" + }, + "title": "Endpoints", + "description": "The endpoint URIs of the service. Multiple endpoints are allowed.", + "examples": ["https://example.com/api/v1/ticker"] + }, + "authenticated": { + "type": "boolean", + "title": "Authentication Required", + "description": "A boolean value indicating if the service requires authentication. A value of true indicates the service requires authentication prior to use. A value of false indicates the service does not require authentication." + }, + "x-trust-boundary": { + "type": "boolean", + "title": "Crosses Trust Boundary", + "description": "A boolean value indicating if use of the service crosses a trust zone or boundary. A value of true indicates that by using the service, a trust boundary is crossed. A value of false indicates that by using the service, a trust boundary is not crossed." + }, + "trustZone": { + "type": "string", + "title": "Trust Zone", + "description": "The name of the trust zone the service resides in." + }, + "data": { + "type": "array", + "items": {"$ref": "#/definitions/serviceData"}, + "title": "Data", + "description": "Specifies information about the data including the directional flow of data and the data classification." + }, + "licenses": { + "$ref": "#/definitions/licenseChoice", + "title": "Service License(s)" + }, + "externalReferences": { + "type": "array", + "items": {"$ref": "#/definitions/externalReference"}, + "title": "External References", + "description": "External references provide a way to document systems, sites, and information that may be relevant but are not included with the BOM. They may also establish specific relationships within or external to the BOM." + }, + "services": { + "type": "array", + "items": {"$ref": "#/definitions/service"}, + "uniqueItems": true, + "title": "Services", + "description": "A list of services included or deployed behind the parent service. This is not a dependency tree. It provides a way to specify a hierarchical representation of service assemblies." + }, + "releaseNotes": { + "$ref": "#/definitions/releaseNotes", + "title": "Release notes", + "description": "Specifies optional release notes." + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": {"$ref": "#/definitions/property"} + }, + "tags": { + "$ref": "#/definitions/tags", + "title": "Tags" + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + }, + "serviceData": { + "type": "object", + "title": "Hash Objects", + "required": [ + "flow", + "classification" + ], + "additionalProperties": false, + "properties": { + "flow": { + "$ref": "#/definitions/dataFlowDirection", + "title": "Directional Flow", + "description": "Specifies the flow direction of the data. Direction is relative to the service. Inbound flow states that data enters the service. Outbound flow states that data leaves the service. Bi-directional states that data flows both ways and unknown states that the direction is not known." + }, + "classification": { + "$ref": "#/definitions/dataClassification" + }, + "name": { + "type": "string", + "title": "Name", + "description": "Name for the defined data", + "examples": [ + "Credit card reporting" + ] + }, + "description": { + "type": "string", + "title": "Description", + "description": "Short description of the data content and usage", + "examples": [ + "Credit card information being exchanged in between the web app and the database" + ] + }, + "governance": { + "title": "Data Governance", + "$ref": "#/definitions/dataGovernance" + }, + "source": { + "type": "array", + "items": { + "anyOf": [ + { + "title": "URL", + "type": "string", + "format": "iri-reference" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ] + }, + "title": "Source", + "description": "The URI, URL, or BOM-Link of the components or services the data came in from" + }, + "destination": { + "type": "array", + "items": { + "anyOf": [ + { + "title": "URL", + "type": "string", + "format": "iri-reference" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ] + }, + "title": "Destination", + "description": "The URI, URL, or BOM-Link of the components or services the data is sent to" + } + } + }, + "dataFlowDirection": { + "type": "string", + "enum": [ + "inbound", + "outbound", + "bi-directional", + "unknown" + ], + "meta:enum": { + "inbound": "Data that enters a service.", + "outbound": "Data that exits a service.", + "bi-directional": "Data flows in and out of the service.", + "unknown": "The directional flow of data is not known." + }, + "title": "Data flow direction", + "description": "Specifies the flow direction of the data. Direction is relative to the service." + }, + "copyright": { + "type": "object", + "title": "Copyright", + "description": "A copyright notice informing users of the underlying claims to copyright ownership in a published work.", + "required": [ + "text" + ], + "additionalProperties": false, + "properties": { + "text": { + "type": "string", + "title": "Copyright Text", + "description": "The textual content of the copyright." + } + } + }, + "componentEvidence": { + "type": "object", + "title": "Evidence", + "description": "Provides the ability to document evidence collected through various forms of extraction or analysis.", + "additionalProperties": false, + "properties": { + "identity": { + "title": "Identity Evidence", + "description": "Evidence that substantiates the identity of a component. The identity may be an object or an array of identity objects. Support for specifying identity as a single object was introduced in CycloneDX v1.5. Arrays were introduced in v1.6. It is recommended that all implementations use arrays, even if only one identity object is specified.", + "oneOf" : [ + { + "type": "array", + "title": "Array of Identity Objects", + "items": { "$ref": "#/definitions/componentIdentityEvidence" } + }, + { + "title": "A Single Identity Object", + "description": "[Deprecated]", + "$ref": "#/definitions/componentIdentityEvidence", + "deprecated": true + } + ] + }, + "occurrences": { + "type": "array", + "title": "Occurrences", + "description": "Evidence of individual instances of a component spread across multiple locations.", + "items": { + "type": "object", + "required": [ "location" ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the occurrence elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links." + }, + "location": { + "type": "string", + "title": "Location", + "description": "The location or path to where the component was found." + }, + "line": { + "type": "integer", + "minimum": 0, + "title": "Line Number", + "description": "The line number where the component was found." + }, + "offset": { + "type": "integer", + "minimum": 0, + "title": "Offset", + "description": "The offset where the component was found." + }, + "symbol": { + "type": "string", + "title": "Symbol", + "description": "The symbol name that was found associated with the component." + }, + "additionalContext": { + "type": "string", + "title": "Additional Context", + "description": "Any additional context of the detected component (e.g. a code snippet)." + } + } + } + }, + "callstack": { + "type": "object", + "title": "Call Stack", + "description": "Evidence of the components use through the callstack.", + "additionalProperties": false, + "properties": { + "frames": { + "type": "array", + "title": "Frames", + "description": "Within a call stack, a frame is a discrete unit that encapsulates an execution context, including local variables, parameters, and the return address. As function calls are made, frames are pushed onto the stack, forming an array-like structure that orchestrates the flow of program execution and manages the sequence of function invocations.", + "items": { + "type": "object", + "required": [ + "module" + ], + "additionalProperties": false, + "properties": { + "package": { + "title": "Package", + "description": "A package organizes modules into namespaces, providing a unique namespace for each type it contains.", + "type": "string" + }, + "module": { + "title": "Module", + "description": "A module or class that encloses functions/methods and other code.", + "type": "string" + }, + "function": { + "title": "Function", + "description": "A block of code designed to perform a particular task.", + "type": "string" + }, + "parameters": { + "title": "Parameters", + "description": "Optional arguments that are passed to the module or function.", + "type": "array", + "items": { + "type": "string" + } + }, + "line": { + "title": "Line", + "description": "The line number the code that is called resides on.", + "type": "integer" + }, + "column": { + "title": "Column", + "description": "The column the code that is called resides.", + "type": "integer" + }, + "fullFilename": { + "title": "Full Filename", + "description": "The full path and filename of the module.", + "type": "string" + } + } + } + } + } + }, + "licenses": { + "$ref": "#/definitions/licenseChoice", + "title": "License Evidence" + }, + "copyright": { + "type": "array", + "items": {"$ref": "#/definitions/copyright"}, + "title": "Copyright Evidence", + "description": "Copyright evidence captures intellectual property assertions, providing evidence of possible ownership and legal protection." + } + } + }, + "compositions": { + "type": "object", + "title": "Compositions", + "required": [ + "aggregate" + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the composition elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links." + }, + "aggregate": { + "$ref": "#/definitions/aggregateType", + "title": "Aggregate", + "description": "Specifies an aggregate type that describes how complete a relationship is." + }, + "assemblies": { + "type": "array", + "uniqueItems": true, + "items": { + "anyOf": [ + { + "title": "Ref", + "$ref": "#/definitions/refLinkType" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ] + }, + "title": "BOM references", + "description": "The bom-ref identifiers of the components or services being described. Assemblies refer to nested relationships whereby a constituent part may include other constituent parts. References do not cascade to child parts. References are explicit for the specified constituent part only." + }, + "dependencies": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + }, + "title": "BOM references", + "description": "The bom-ref identifiers of the components or services being described. Dependencies refer to a relationship whereby an independent constituent part requires another independent constituent part. References do not cascade to transitive dependencies. References are explicit for the specified dependency only." + }, + "vulnerabilities": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + }, + "title": "BOM references", + "description": "The bom-ref identifiers of the vulnerabilities being described." + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + }, + "aggregateType": { + "type": "string", + "default": "not_specified", + "enum": [ + "complete", + "incomplete", + "incomplete_first_party_only", + "incomplete_first_party_proprietary_only", + "incomplete_first_party_opensource_only", + "incomplete_third_party_only", + "incomplete_third_party_proprietary_only", + "incomplete_third_party_opensource_only", + "unknown", + "not_specified" + ], + "meta:enum": { + "complete": "The relationship is complete. No further relationships including constituent components, services, or dependencies are known to exist.", + "incomplete": "The relationship is incomplete. Additional relationships exist and may include constituent components, services, or dependencies.", + "incomplete_first_party_only": "The relationship is incomplete. Only relationships for first-party components, services, or their dependencies are represented.", + "incomplete_first_party_proprietary_only": "The relationship is incomplete. Only relationships for first-party components, services, or their dependencies are represented, limited specifically to those that are proprietary.", + "incomplete_first_party_opensource_only": "The relationship is incomplete. Only relationships for first-party components, services, or their dependencies are represented, limited specifically to those that are opensource.", + "incomplete_third_party_only": "The relationship is incomplete. Only relationships for third-party components, services, or their dependencies are represented.", + "incomplete_third_party_proprietary_only": "The relationship is incomplete. Only relationships for third-party components, services, or their dependencies are represented, limited specifically to those that are proprietary.", + "incomplete_third_party_opensource_only": "The relationship is incomplete. Only relationships for third-party components, services, or their dependencies are represented, limited specifically to those that are opensource.", + "unknown": "The relationship may be complete or incomplete. This usually signifies a 'best-effort' to obtain constituent components, services, or dependencies but the completeness is inconclusive.", + "not_specified": "The relationship completeness is not specified." + } + }, + "property": { + "type": "object", + "title": "Lightweight name-value pair", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The name of the property. Duplicate names are allowed, each potentially having a different value." + }, + "value": { + "type": "string", + "title": "Value", + "description": "The value of the property." + } + }, + "additionalProperties": false + }, + "localeType": { + "type": "string", + "pattern": "^([a-z]{2})(-[A-Z]{2})?$", + "title": "Locale", + "description": "Defines a syntax for representing two character language code (ISO-639) followed by an optional two character country code. The language code must be lower case. If the country code is specified, the country code must be upper case. The language code and country code must be separated by a minus sign. Examples: en, en-US, fr, fr-CA" + }, + "releaseType": { + "type": "string", + "examples": [ + "major", + "minor", + "patch", + "pre-release", + "internal" + ], + "description": "The software versioning type. It is recommended that the release type use one of 'major', 'minor', 'patch', 'pre-release', or 'internal'. Representing all possible software release types is not practical, so standardizing on the recommended values, whenever possible, is strongly encouraged.\n\n* __major__ = A major release may contain significant changes or may introduce breaking changes.\n* __minor__ = A minor release, also known as an update, may contain a smaller number of changes than major releases.\n* __patch__ = Patch releases are typically unplanned and may resolve defects or important security issues.\n* __pre-release__ = A pre-release may include alpha, beta, or release candidates and typically have limited support. They provide the ability to preview a release prior to its general availability.\n* __internal__ = Internal releases are not for public consumption and are intended to be used exclusively by the project or manufacturer that produced it." + }, + "note": { + "type": "object", + "title": "Note", + "description": "A note containing the locale and content.", + "required": [ + "text" + ], + "additionalProperties": false, + "properties": { + "locale": { + "$ref": "#/definitions/localeType", + "title": "Locale", + "description": "The ISO-639 (or higher) language code and optional ISO-3166 (or higher) country code. Examples include: \"en\", \"en-US\", \"fr\" and \"fr-CA\"" + }, + "text": { + "title": "Release note content", + "description": "Specifies the full content of the release note.", + "$ref": "#/definitions/attachment" + } + } + }, + "releaseNotes": { + "type": "object", + "title": "Release notes", + "required": [ + "type" + ], + "additionalProperties": false, + "properties": { + "type": { + "$ref": "#/definitions/releaseType", + "title": "Type", + "description": "The software versioning type the release note describes." + }, + "title": { + "type": "string", + "title": "Title", + "description": "The title of the release." + }, + "featuredImage": { + "type": "string", + "format": "iri-reference", + "title": "Featured image", + "description": "The URL to an image that may be prominently displayed with the release note." + }, + "socialImage": { + "type": "string", + "format": "iri-reference", + "title": "Social image", + "description": "The URL to an image that may be used in messaging on social media platforms." + }, + "description": { + "type": "string", + "title": "Description", + "description": "A short description of the release." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp", + "description": "The date and time (timestamp) when the release note was created." + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Aliases", + "description": "One or more alternate names the release may be referred to. This may include unofficial terms used by development and marketing teams (e.g. code names)." + }, + "tags": { + "$ref": "#/definitions/tags", + "title": "Tags" + }, + "resolves": { + "type": "array", + "items": {"$ref": "#/definitions/issue"}, + "title": "Resolves", + "description": "A collection of issues that have been resolved." + }, + "notes": { + "type": "array", + "items": {"$ref": "#/definitions/note"}, + "title": "Notes", + "description": "Zero or more release notes containing the locale and content. Multiple note objects may be specified to support release notes in a wide variety of languages." + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": {"$ref": "#/definitions/property"} + } + } + }, + "advisory": { + "type": "object", + "title": "Advisory", + "description": "Title and location where advisory information can be obtained. An advisory is a notification of a threat to a component, service, or system.", + "required": ["url"], + "additionalProperties": false, + "properties": { + "title": { + "type": "string", + "title": "Title", + "description": "An optional name of the advisory." + }, + "url": { + "type": "string", + "title": "URL", + "format": "iri-reference", + "description": "Location where the advisory can be obtained." + } + } + }, + "cwe": { + "type": "integer", + "minimum": 1, + "title": "CWE", + "description": "Integer representation of a Common Weaknesses Enumerations (CWE). For example 399 (of https://cwe.mitre.org/data/definitions/399.html)" + }, + "severity": { + "type": "string", + "title": "Severity", + "description": "Textual representation of the severity of the vulnerability adopted by the analysis method. If the analysis method uses values other than what is provided, the user is expected to translate appropriately.", + "enum": [ + "critical", + "high", + "medium", + "low", + "info", + "none", + "unknown" + ], + "meta:enum": { + "critical": "Critical severity", + "high": "High severity", + "medium": "Medium severity", + "low": "Low severity", + "info": "Informational warning.", + "none": "None", + "unknown": "The severity is not known" + } + }, + "scoreMethod": { + "type": "string", + "title": "Method", + "description": "Specifies the severity or risk scoring methodology or standard used.", + "enum": [ + "CVSSv2", + "CVSSv3", + "CVSSv31", + "CVSSv4", + "OWASP", + "SSVC", + "other" + ], + "meta:enum": { + "CVSSv2": "Common Vulnerability Scoring System v2.0", + "CVSSv3": "Common Vulnerability Scoring System v3.0", + "CVSSv31": "Common Vulnerability Scoring System v3.1", + "CVSSv4": "Common Vulnerability Scoring System v4.0", + "OWASP": "OWASP Risk Rating Methodology", + "SSVC": "Stakeholder Specific Vulnerability Categorization", + "other": "Another severity or risk scoring methodology" + } + }, + "impactAnalysisState": { + "type": "string", + "title": "Impact Analysis State", + "description": "Declares the current state of an occurrence of a vulnerability, after automated or manual analysis.", + "enum": [ + "resolved", + "resolved_with_pedigree", + "exploitable", + "in_triage", + "false_positive", + "not_affected" + ], + "meta:enum": { + "resolved": "The vulnerability has been remediated.", + "resolved_with_pedigree": "The vulnerability has been remediated and evidence of the changes are provided in the affected components pedigree containing verifiable commit history and/or diff(s).", + "exploitable": "The vulnerability may be directly or indirectly exploitable.", + "in_triage": "The vulnerability is being investigated.", + "false_positive": "The vulnerability is not specific to the component or service and was falsely identified or associated.", + "not_affected": "The component or service is not affected by the vulnerability. Justification should be specified for all not_affected cases." + } + }, + "impactAnalysisJustification": { + "type": "string", + "title": "Impact Analysis Justification", + "description": "The rationale of why the impact analysis state was asserted.", + "enum": [ + "code_not_present", + "code_not_reachable", + "requires_configuration", + "requires_dependency", + "requires_environment", + "protected_by_compiler", + "protected_at_runtime", + "protected_at_perimeter", + "protected_by_mitigating_control" + ], + "meta:enum": { + "code_not_present": "The code has been removed or tree-shaked.", + "code_not_reachable": "The vulnerable code is not invoked at runtime.", + "requires_configuration": "Exploitability requires a configurable option to be set/unset.", + "requires_dependency": "Exploitability requires a dependency that is not present.", + "requires_environment": "Exploitability requires a certain environment which is not present.", + "protected_by_compiler": "Exploitability requires a compiler flag to be set/unset.", + "protected_at_runtime": "Exploits are prevented at runtime.", + "protected_at_perimeter": "Attacks are blocked at physical, logical, or network perimeter.", + "protected_by_mitigating_control": "Preventative measures have been implemented that reduce the likelihood and/or impact of the vulnerability." + } + }, + "rating": { + "type": "object", + "title": "Rating", + "description": "Defines the severity or risk ratings of a vulnerability.", + "additionalProperties": false, + "properties": { + "source": { + "$ref": "#/definitions/vulnerabilitySource", + "description": "The source that calculated the severity or risk rating of the vulnerability." + }, + "score": { + "type": "number", + "title": "Score", + "description": "The numerical score of the rating." + }, + "severity": { + "$ref": "#/definitions/severity", + "description": "Textual representation of the severity that corresponds to the numerical score of the rating." + }, + "method": { + "$ref": "#/definitions/scoreMethod" + }, + "vector": { + "type": "string", + "title": "Vector", + "description": "Textual representation of the metric values used to score the vulnerability" + }, + "justification": { + "type": "string", + "title": "Justification", + "description": "An optional reason for rating the vulnerability as it was" + } + } + }, + "vulnerabilitySource": { + "type": "object", + "title": "Source", + "description": "The source of vulnerability information. This is often the organization that published the vulnerability.", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "title": "URL", + "description": "The url of the vulnerability documentation as provided by the source.", + "examples": [ + "https://nvd.nist.gov/vuln/detail/CVE-2021-39182" + ] + }, + "name": { + "type": "string", + "title": "Name", + "description": "The name of the source.", + "examples": [ + "NVD", + "National Vulnerability Database", + "OSS Index", + "VulnDB", + "GitHub Advisories" + ] + } + } + }, + "vulnerability": { + "type": "object", + "title": "Vulnerability", + "description": "Defines a weakness in a component or service that could be exploited or triggered by a threat source.", + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the vulnerability elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links." + }, + "id": { + "type": "string", + "title": "ID", + "description": "The identifier that uniquely identifies the vulnerability.", + "examples": [ + "CVE-2021-39182", + "GHSA-35m5-8cvj-8783", + "SNYK-PYTHON-ENROCRYPT-1912876" + ] + }, + "source": { + "$ref": "#/definitions/vulnerabilitySource", + "description": "The source that published the vulnerability." + }, + "references": { + "type": "array", + "title": "References", + "description": "Zero or more pointers to vulnerabilities that are the equivalent of the vulnerability specified. Often times, the same vulnerability may exist in multiple sources of vulnerability intelligence, but have different identifiers. References provide a way to correlate vulnerabilities across multiple sources of vulnerability intelligence.", + "items": { + "type": "object", + "required": [ + "id", + "source" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "title": "ID", + "description": "An identifier that uniquely identifies the vulnerability.", + "examples": [ + "CVE-2021-39182", + "GHSA-35m5-8cvj-8783", + "SNYK-PYTHON-ENROCRYPT-1912876" + ] + }, + "source": { + "$ref": "#/definitions/vulnerabilitySource", + "description": "The source that published the vulnerability." + } + } + } + }, + "ratings": { + "type": "array", + "title": "Ratings", + "description": "List of vulnerability ratings. Consumers SHOULD consider ratings in prioritization decisions; source ratings may differ and aid prioritization.", + "items": { + "$ref": "#/definitions/rating" + } + }, + "cwes": { + "type": "array", + "title": "CWEs", + "description": "List of Common Weaknesses Enumerations (CWEs) codes that describes this vulnerability.", + "examples": [399], + "items": { + "$ref": "#/definitions/cwe" + } + }, + "description": { + "type": "string", + "title": "Description", + "description": "A description of the vulnerability as provided by the source." + }, + "detail": { + "type": "string", + "title": "Details", + "description": "If available, an in-depth description of the vulnerability as provided by the source organization. Details often include information useful in understanding root cause." + }, + "recommendation": { + "type": "string", + "title": "Recommendation", + "description": "Recommendations of how the vulnerability can be remediated or mitigated." + }, + "workaround": { + "type": "string", + "title": "Workarounds", + "description": "A bypass, usually temporary, of the vulnerability that reduces its likelihood and/or impact. Workarounds often involve changes to configuration or deployments." + }, + "proofOfConcept": { + "type": "object", + "title": "Proof of Concept", + "description": "Evidence used to reproduce the vulnerability.", + "properties": { + "reproductionSteps": { + "type": "string", + "title": "Steps to Reproduce", + "description": "Precise steps to reproduce the vulnerability." + }, + "environment": { + "type": "string", + "title": "Environment", + "description": "A description of the environment in which reproduction was possible." + }, + "supportingMaterial": { + "type": "array", + "title": "Supporting Material", + "description": "Supporting material that helps in reproducing or understanding how reproduction is possible. This may include screenshots, payloads, and PoC exploit code.", + "items": { "$ref": "#/definitions/attachment" } + } + } + }, + "advisories": { + "type": "array", + "title": "Advisories", + "description": "Published advisories of the vulnerability if provided.", + "items": { + "$ref": "#/definitions/advisory" + } + }, + "created": { + "type": "string", + "format": "date-time", + "title": "Created", + "description": "The date and time (timestamp) when the vulnerability record was created in the vulnerability database." + }, + "published": { + "type": "string", + "format": "date-time", + "title": "Published", + "description": "The date and time (timestamp) when the vulnerability record was first published." + }, + "updated": { + "type": "string", + "format": "date-time", + "title": "Updated", + "description": "The date and time (timestamp) when the vulnerability record was last updated." + }, + "rejected": { + "type": "string", + "format": "date-time", + "title": "Rejected", + "description": "The date and time (timestamp) when the vulnerability record was rejected (if applicable)." + }, + "credits": { + "type": "object", + "title": "Credits", + "description": "Individuals or organizations credited with the discovery of the vulnerability.", + "additionalProperties": false, + "properties": { + "organizations": { + "type": "array", + "title": "Organizations", + "description": "The organizations credited with vulnerability discovery.", + "items": { + "$ref": "#/definitions/organizationalEntity" + } + }, + "individuals": { + "type": "array", + "title": "Individuals", + "description": "The individuals, not associated with organizations, that are credited with vulnerability discovery.", + "items": { + "$ref": "#/definitions/organizationalContact" + } + } + } + }, + "tools": { + "title": "Tools", + "description": "The tool(s) used to identify, confirm, or score the vulnerability.", + "oneOf": [ + { + "type": "object", + "title": "Tools", + "description": "The tool(s) used to identify, confirm, or score the vulnerability.", + "additionalProperties": false, + "properties": { + "components": { + "type": "array", + "items": {"$ref": "#/definitions/component"}, + "uniqueItems": true, + "title": "Components", + "description": "A list of software and hardware components used as tools." + }, + "services": { + "type": "array", + "items": {"$ref": "#/definitions/service"}, + "uniqueItems": true, + "title": "Services", + "description": "A list of services used as tools. This may include microservices, function-as-a-service, and other types of network or intra-process services." + } + } + }, + { + "type": "array", + "title": "Tools (legacy)", + "description": "[Deprecated] The tool(s) used to identify, confirm, or score the vulnerability.", + "items": {"$ref": "#/definitions/tool"} + } + ] + }, + "analysis": { + "type": "object", + "title": "Impact Analysis", + "description": "An assessment of the impact and exploitability of the vulnerability.", + "additionalProperties": false, + "properties": { + "state": { + "$ref": "#/definitions/impactAnalysisState" + }, + "justification": { + "$ref": "#/definitions/impactAnalysisJustification" + }, + "response": { + "type": "array", + "title": "Response", + "description": "A response to the vulnerability by the manufacturer, supplier, or project responsible for the affected component or service. More than one response is allowed. Responses are strongly encouraged for vulnerabilities where the analysis state is exploitable.", + "items": { + "type": "string", + "enum": [ + "can_not_fix", + "will_not_fix", + "update", + "rollback", + "workaround_available" + ], + "meta:enum": { + "can_not_fix": "Can not fix", + "will_not_fix": "Will not fix", + "update": "Update to a different revision or release", + "rollback": "Revert to a previous revision or release", + "workaround_available": "There is a workaround available" + } + } + }, + "detail": { + "type": "string", + "title": "Detail", + "description": "Detailed description of the impact including methods used during assessment. If a vulnerability is not exploitable, this field should include specific details on why the component or service is not impacted by this vulnerability." + }, + "firstIssued": { + "type": "string", + "format": "date-time", + "title": "First Issued", + "description": "The date and time (timestamp) when the analysis was first issued." + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "title": "Last Updated", + "description": "The date and time (timestamp) when the analysis was last updated." + } + } + }, + "affects": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "required": [ + "ref" + ], + "additionalProperties": false, + "properties": { + "ref": { + "anyOf": [ + { + "title": "Ref", + "$ref": "#/definitions/refLinkType" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ], + "title": "Reference", + "description": "References a component or service by the objects bom-ref" + }, + "versions": { + "type": "array", + "title": "Versions", + "description": "Zero or more individual versions or range of versions.", + "items": { + "type": "object", + "oneOf": [ + { + "required": ["version"] + }, + { + "required": ["range"] + } + ], + "additionalProperties": false, + "properties": { + "version": { + "title": "Version", + "description": "A single version of a component or service.", + "$ref": "#/definitions/version" + }, + "range": { + "title": "Version Range", + "description": "A version range specified in Package URL Version Range syntax (vers) which is defined at https://github.com/package-url/vers-spec", + "$ref": "#/definitions/versionRange" + }, + "status": { + "title": "Status", + "description": "The vulnerability status for the version or range of versions.", + "$ref": "#/definitions/affectedStatus", + "default": "affected" + } + } + } + } + } + }, + "title": "Affects", + "description": "The components or services that are affected by the vulnerability." + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "affectedStatus": { + "description": "The vulnerability status of a given version or range of versions of a product. The statuses 'affected' and 'unaffected' indicate that the version is affected or unaffected by the vulnerability. The status 'unknown' indicates that it is unknown or unspecified whether the given version is affected. There can be many reasons for an 'unknown' status, including that an investigation has not been undertaken or that a vendor has not disclosed the status.", + "type": "string", + "enum": [ + "affected", + "unaffected", + "unknown" + ], + "meta:enum": { + "affected": "The version is affected by the vulnerability.", + "unaffected": "The version is not affected by the vulnerability.", + "unknown": "It is unknown (or unspecified) whether the given version is affected." + } + }, + "version": { + "description": "A single disjunctive version identifier, for a component or service.", + "type": "string", + "maxLength": 1024, + "examples": [ + "9.0.14", + "v1.33.7", + "7.0.0-M1", + "2.0pre1", + "1.0.0-beta1", + "0.8.15" + ] + }, + "versionRange": { + "description": "A version range specified in Package URL Version Range syntax (vers) which is defined at https://github.com/package-url/vers-spec", + "type": "string", + "minLength": 1, + "maxLength": 4096, + "examples": [ + "vers:cargo/9.0.14", + "vers:npm/1.2.3|>=2.0.0|<5.0.0", + "vers:pypi/0.0.0|0.0.1|0.0.2|0.0.3|1.0|2.0pre1", + "vers:tomee/>=1.0.0-beta1|<=1.7.5|>=7.0.0-M1|<=7.0.7|>=7.1.0|<=7.1.2|>=8.0.0-M1|<=8.0.1", + "vers:gem/>=2.2.0|!= 2.2.1|<2.3.0" + ] + }, + "range": { + "deprecated": true, + "description": "Deprecated definition. use definition `versionRange` instead.", + "$ref": "#/definitions/versionRange" + }, + "annotations": { + "type": "object", + "title": "Annotations", + "description": "A comment, note, explanation, or similar textual content which provides additional context to the object(s) being annotated.", + "required": [ + "subjects", + "annotator", + "timestamp", + "text" + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the annotation elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links." + }, + "subjects": { + "type": "array", + "uniqueItems": true, + "items": { + "anyOf": [ + { + "title": "Ref", + "$ref": "#/definitions/refLinkType" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ] + }, + "title": "Subjects", + "description": "The object in the BOM identified by its bom-ref. This is often a component or service, but may be any object type supporting bom-refs." + }, + "annotator": { + "type": "object", + "title": "Annotator", + "description": "The organization, person, component, or service which created the textual content of the annotation.", + "oneOf": [ + { + "required": [ + "organization" + ] + }, + { + "required": [ + "individual" + ] + }, + { + "required": [ + "component" + ] + }, + { + "required": [ + "service" + ] + } + ], + "additionalProperties": false, + "properties": { + "organization": { + "description": "The organization that created the annotation", + "$ref": "#/definitions/organizationalEntity" + }, + "individual": { + "description": "The person that created the annotation", + "$ref": "#/definitions/organizationalContact" + }, + "component": { + "description": "The tool or component that created the annotation", + "$ref": "#/definitions/component" + }, + "service": { + "description": "The service that created the annotation", + "$ref": "#/definitions/service" + } + } + }, + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp", + "description": "The date and time (timestamp) when the annotation was created." + }, + "text": { + "type": "string", + "title": "Text", + "description": "The textual content of the annotation." + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + }, + "modelCard": { + "$comment": "Model card support in CycloneDX is derived from TensorFlow Model Card Toolkit released under the Apache 2.0 license and available from https://github.com/tensorflow/model-card-toolkit/blob/main/model_card_toolkit/schema/v0.0.2/model_card.schema.json. In addition, CycloneDX model card support includes portions of VerifyML, also released under the Apache 2.0 license and available from https://github.com/cylynx/verifyml/blob/main/verifyml/model_card_toolkit/schema/v0.0.4/model_card.schema.json.", + "type": "object", + "title": "Model Card", + "description": "A model card describes the intended uses of a machine learning model and potential limitations, including biases and ethical considerations. Model cards typically contain the training parameters, which datasets were used to train the model, performance metrics, and other relevant data useful for ML transparency. This object SHOULD be specified for any component of type `machine-learning-model` and must not be specified for other component types.", + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the model card elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links." + }, + "modelParameters": { + "type": "object", + "title": "Model Parameters", + "description": "Hyper-parameters for construction of the model.", + "additionalProperties": false, + "properties": { + "approach": { + "type": "object", + "title": "Approach", + "description": "The overall approach to learning used by the model for problem solving.", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "title": "Learning Type", + "description": "Learning types describing the learning problem or hybrid learning problem.", + "enum": [ + "supervised", + "unsupervised", + "reinforcement-learning", + "semi-supervised", + "self-supervised" + ], + "meta:enum": { + "supervised": "Supervised machine learning involves training an algorithm on labeled data to predict or classify new data based on the patterns learned from the labeled examples.", + "unsupervised": "Unsupervised machine learning involves training algorithms on unlabeled data to discover patterns, structures, or relationships without explicit guidance, allowing the model to identify inherent structures or clusters within the data.", + "reinforcement-learning": "Reinforcement learning is a type of machine learning where an agent learns to make decisions by interacting with an environment to maximize cumulative rewards, through trial and error.", + "semi-supervised": "Semi-supervised machine learning utilizes a combination of labeled and unlabeled data during training to improve model performance, leveraging the benefits of both supervised and unsupervised learning techniques.", + "self-supervised": "Self-supervised machine learning involves training models to predict parts of the input data from other parts of the same data, without requiring external labels, enabling learning from large amounts of unlabeled data." + } + } + } + }, + "task": { + "type": "string", + "title": "Task", + "description": "Directly influences the input and/or output. Examples include classification, regression, clustering, etc." + }, + "architectureFamily": { + "type": "string", + "title": "Architecture Family", + "description": "The model architecture family such as transformer network, convolutional neural network, residual neural network, LSTM neural network, etc." + }, + "modelArchitecture": { + "type": "string", + "title": "Model Architecture", + "description": "The specific architecture of the model such as GPT-1, ResNet-50, YOLOv3, etc." + }, + "datasets": { + "type": "array", + "title": "Datasets", + "description": "The datasets used to train and evaluate the model.", + "items" : { + "oneOf" : [ + { + "title": "Inline Data Information", + "$ref": "#/definitions/componentData" + }, + { + "type": "object", + "title": "Data Reference", + "additionalProperties": false, + "properties": { + "ref": { + "anyOf": [ + { + "title": "Ref", + "$ref": "#/definitions/refLinkType" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ], + "title": "Reference", + "type": "string", + "description": "References a data component by the components bom-ref attribute" + } + } + } + ] + } + }, + "inputs": { + "type": "array", + "title": "Inputs", + "description": "The input format(s) of the model", + "items": { "$ref": "#/definitions/inputOutputMLParameters" } + }, + "outputs": { + "type": "array", + "title": "Outputs", + "description": "The output format(s) from the model", + "items": { "$ref": "#/definitions/inputOutputMLParameters" } + } + } + }, + "quantitativeAnalysis": { + "type": "object", + "title": "Quantitative Analysis", + "description": "A quantitative analysis of the model", + "additionalProperties": false, + "properties": { + "performanceMetrics": { + "type": "array", + "title": "Performance Metrics", + "description": "The model performance metrics being reported. Examples may include accuracy, F1 score, precision, top-3 error rates, MSC, etc.", + "items": { "$ref": "#/definitions/performanceMetric" } + }, + "graphics": { "$ref": "#/definitions/graphicsCollection" } + } + }, + "considerations": { + "type": "object", + "title": "Considerations", + "description": "What considerations should be taken into account regarding the model's construction, training, and application?", + "additionalProperties": false, + "properties": { + "users": { + "type": "array", + "title": "Users", + "description": "Who are the intended users of the model?", + "items": { + "type": "string" + } + }, + "useCases": { + "type": "array", + "title": "Use Cases", + "description": "What are the intended use cases of the model?", + "items": { + "type": "string" + } + }, + "technicalLimitations": { + "type": "array", + "title": "Technical Limitations", + "description": "What are the known technical limitations of the model? E.g. What kind(s) of data should the model be expected not to perform well on? What are the factors that might degrade model performance?", + "items": { + "type": "string" + } + }, + "performanceTradeoffs": { + "type": "array", + "title": "Performance Tradeoffs", + "description": "What are the known tradeoffs in accuracy/performance of the model?", + "items": { + "type": "string" + } + }, + "ethicalConsiderations": { + "type": "array", + "title": "Ethical Considerations", + "description": "What are the ethical risks involved in the application of this model?", + "items": { "$ref": "#/definitions/risk" } + }, + "environmentalConsiderations":{ + "$ref": "#/definitions/environmentalConsiderations", + "title": "Environmental Considerations", + "description": "What are the various environmental impacts the corresponding machine learning model has exhibited across its lifecycle?" + }, + "fairnessAssessments": { + "type": "array", + "title": "Fairness Assessments", + "description": "How does the model affect groups at risk of being systematically disadvantaged? What are the harms and benefits to the various affected groups?", + "items": { + "$ref": "#/definitions/fairnessAssessment" + } + } + } + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": {"$ref": "#/definitions/property"} + } + } + }, + "inputOutputMLParameters": { + "type": "object", + "title": "Input and Output Parameters", + "additionalProperties": false, + "properties": { + "format": { + "title": "Input/Output Format", + "description": "The data format for input/output to the model.", + "type": "string", + "examples": [ "string", "image", "time-series"] + } + } + }, + "componentData": { + "type": "object", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the dataset elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links." + }, + "type": { + "type": "string", + "title": "Type of Data", + "description": "The general theme or subject matter of the data being specified.", + "enum": [ + "source-code", + "configuration", + "dataset", + "definition", + "other" + ], + "meta:enum": { + "source-code": "Any type of code, code snippet, or data-as-code.", + "configuration": "Parameters or settings that may be used by other components.", + "dataset": "A collection of data.", + "definition": "Data that can be used to create new instances of what the definition defines.", + "other": "Any other type of data that does not fit into existing definitions." + } + }, + "name": { + "title": "Dataset Name", + "description": "The name of the dataset.", + "type": "string" + }, + "contents": { + "type": "object", + "title": "Data Contents", + "description": "The contents or references to the contents of the data being described.", + "additionalProperties": false, + "properties": { + "attachment": { + "title": "Data Attachment", + "description": "An optional way to include textual or encoded data.", + "$ref": "#/definitions/attachment" + }, + "url": { + "type": "string", + "title": "Data URL", + "description": "The URL to where the data can be retrieved.", + "format": "iri-reference" + }, + "properties": { + "type": "array", + "title": "Configuration Properties", + "description": "Provides the ability to document name-value parameters used for configuration.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "classification": { + "$ref": "#/definitions/dataClassification" + }, + "sensitiveData": { + "type": "array", + "title": "Sensitive Data", + "description": "A description of any sensitive data in a dataset.", + "items": { + "type": "string" + } + }, + "graphics": { "$ref": "#/definitions/graphicsCollection" }, + "description": { + "title": "Dataset Description", + "description": "A description of the dataset. Can describe size of dataset, whether it's used for source code, training, testing, or validation, etc.", + "type": "string" + }, + "governance": { + "title": "Data Governance", + "$ref": "#/definitions/dataGovernance" + } + } + }, + "dataGovernance": { + "type": "object", + "title": "Data Governance", + "description": "Data governance captures information regarding data ownership, stewardship, and custodianship, providing insights into the individuals or entities responsible for managing, overseeing, and safeguarding the data throughout its lifecycle.", + "additionalProperties": false, + "properties": { + "custodians": { + "type": "array", + "title": "Data Custodians", + "description": "Data custodians are responsible for the safe custody, transport, and storage of data.", + "items": { "$ref": "#/definitions/dataGovernanceResponsibleParty" } + }, + "stewards": { + "type": "array", + "title": "Data Stewards", + "description": "Data stewards are responsible for data content, context, and associated business rules.", + "items": { "$ref": "#/definitions/dataGovernanceResponsibleParty" } + }, + "owners": { + "type": "array", + "title": "Data Owners", + "description": "Data owners are concerned with risk and appropriate access to data.", + "items": { "$ref": "#/definitions/dataGovernanceResponsibleParty" } + } + } + }, + "dataGovernanceResponsibleParty": { + "type": "object", + "additionalProperties": false, + "properties": { + "organization": { + "title": "Organization", + "description": "The organization that is responsible for specific data governance role(s).", + "$ref": "#/definitions/organizationalEntity" + }, + "contact": { + "title": "Individual", + "description": "The individual that is responsible for specific data governance role(s).", + "$ref": "#/definitions/organizationalContact" + } + }, + "oneOf":[ + { + "required": ["organization"] + }, + { + "required": ["contact"] + } + ] + }, + "graphicsCollection": { + "type": "object", + "title": "Graphics Collection", + "description": "A collection of graphics that represent various measurements.", + "additionalProperties": false, + "properties": { + "description": { + "title": "Description", + "description": "A description of this collection of graphics.", + "type": "string" + }, + "collection": { + "title": "Collection", + "description": "A collection of graphics.", + "type": "array", + "items": { "$ref": "#/definitions/graphic" } + } + } + }, + "graphic": { + "type": "object", + "title": "Graphic", + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "description": "The name of the graphic.", + "type": "string" + }, + "image": { + "title": "Graphic Image", + "description": "The graphic (vector or raster). Base64 encoding must be specified for binary images.", + "$ref": "#/definitions/attachment" + } + } + }, + "performanceMetric": { + "type": "object", + "title": "Performance Metric", + "additionalProperties": false, + "properties": { + "type": { + "title": "Type", + "description": "The type of performance metric.", + "type": "string" + }, + "value": { + "title": "Value", + "description": "The value of the performance metric.", + "type": "string" + }, + "slice": { + "title": "Slice", + "description": "The name of the slice this metric was computed on. By default, assume this metric is not sliced.", + "type": "string" + }, + "confidenceInterval": { + "title": "Confidence Interval", + "description": "The confidence interval of the metric.", + "type": "object", + "additionalProperties": false, + "properties": { + "lowerBound": { + "title": "Lower Bound", + "description": "The lower bound of the confidence interval.", + "type": "string" + }, + "upperBound": { + "title": "Upper Bound", + "description": "The upper bound of the confidence interval.", + "type": "string" + } + } + } + } + }, + "risk": { + "type": "object", + "title": "Risk", + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "description": "The name of the risk.", + "type": "string" + }, + "mitigationStrategy": { + "title": "Mitigation Strategy", + "description": "Strategy used to address this risk.", + "type": "string" + } + } + }, + "fairnessAssessment": { + "type": "object", + "title": "Fairness Assessment", + "description": "Information about the benefits and harms of the model to an identified at risk group.", + "additionalProperties": false, + "properties": { + "groupAtRisk": { + "type": "string", + "title": "Group at Risk", + "description": "The groups or individuals at risk of being systematically disadvantaged by the model." + }, + "benefits": { + "type": "string", + "title": "Benefits", + "description": "Expected benefits to the identified groups." + }, + "harms": { + "type": "string", + "title": "Harms", + "description": "Expected harms to the identified groups." + }, + "mitigationStrategy": { + "type": "string", + "title": "Mitigation Strategy", + "description": "With respect to the benefits and harms outlined, please describe any mitigation strategy implemented." + } + } + }, + "dataClassification": { + "type": "string", + "title": "Data Classification", + "description": "Data classification tags data according to its type, sensitivity, and value if altered, stolen, or destroyed." + }, + "environmentalConsiderations": { + "type": "object", + "title": "Environmental Considerations", + "description": "Describes various environmental impact metrics.", + "additionalProperties": false, + "properties": { + "energyConsumptions": { + "title": "Energy Consumptions", + "description": "Describes energy consumption information incurred for one or more component lifecycle activities.", + "type": "array", + "items": { + "$ref": "#/definitions/energyConsumption" + } + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "energyConsumption": { + "title": "Energy consumption", + "description": "Describes energy consumption information incurred for the specified lifecycle activity.", + "type": "object", + "required": [ + "activity", + "energyProviders", + "activityEnergyCost" + ], + "additionalProperties": false, + "properties": { + "activity": { + "type": "string", + "title": "Activity", + "description": "The type of activity that is part of a machine learning model development or operational lifecycle.", + "enum": [ + "design", + "data-collection", + "data-preparation", + "training", + "fine-tuning", + "validation", + "deployment", + "inference", + "other" + ], + "meta:enum": { + "design": "A model design including problem framing, goal definition and algorithm selection.", + "data-collection": "Model data acquisition including search, selection and transfer.", + "data-preparation": "Model data preparation including data cleaning, labeling and conversion.", + "training": "Model building, training and generalized tuning.", + "fine-tuning": "Refining a trained model to produce desired outputs for a given problem space.", + "validation": "Model validation including model output evaluation and testing.", + "deployment": "Explicit model deployment to a target hosting infrastructure.", + "inference": "Generating an output response from a hosted model from a set of inputs.", + "other": "A lifecycle activity type whose description does not match currently defined values." + } + }, + "energyProviders": { + "title": "Energy Providers", + "description": "The provider(s) of the energy consumed by the associated model development lifecycle activity.", + "type": "array", + "items": { "$ref": "#/definitions/energyProvider" } + }, + "activityEnergyCost": { + "title": "Activity Energy Cost", + "description": "The total energy cost associated with the model lifecycle activity.", + "$ref": "#/definitions/energyMeasure" + }, + "co2CostEquivalent": { + "title": "CO2 Equivalent Cost", + "description": "The CO2 cost (debit) equivalent to the total energy cost.", + "$ref": "#/definitions/co2Measure" + }, + "co2CostOffset": { + "title": "CO2 Cost Offset", + "description": "The CO2 offset (credit) for the CO2 equivalent cost.", + "$ref": "#/definitions/co2Measure" + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "energyMeasure": { + "type": "object", + "title": "Energy Measure", + "description": "A measure of energy.", + "required": [ + "value", + "unit" + ], + "additionalProperties": false, + "properties": { + "value": { + "type": "number", + "title": "Value", + "description": "Quantity of energy." + }, + "unit": { + "type": "string", + "enum": [ "kWh" ], + "title": "Unit", + "description": "Unit of energy.", + "meta:enum": { + "kWh": "Kilowatt-hour (kWh) is the energy delivered by one kilowatt (kW) of power for one hour (h)." + } + } + } + }, + "co2Measure": { + "type": "object", + "title": "CO2 Measure", + "description": "A measure of carbon dioxide (CO2).", + "required": [ + "value", + "unit" + ], + "additionalProperties": false, + "properties": { + "value": { + "type": "number", + "title": "Value", + "description": "Quantity of carbon dioxide (CO2)." + }, + "unit": { + "type": "string", + "enum": [ "tCO2eq" ], + "title": "Unit", + "description": "Unit of carbon dioxide (CO2).", + "meta:enum": { + "tCO2eq": "Tonnes (t) of carbon dioxide (CO2) equivalent (eq)." + } + } + } + }, + "energyProvider": { + "type": "object", + "title": "Energy Provider", + "description": "Describes the physical provider of energy used for model development or operations.", + "required": [ + "organization", + "energySource", + "energyProvided" + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the energy provider elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links.", + "$ref": "#/definitions/refType" + }, + "description": { + "type": "string", + "title": "Description", + "description": "A description of the energy provider." + }, + "organization": { + "type": "object", + "title": "Organization", + "description": "The organization that provides energy.", + "$ref": "#/definitions/organizationalEntity" + }, + "energySource": { + "type": "string", + "enum": [ + "coal", + "oil", + "natural-gas", + "nuclear", + "wind", + "solar", + "geothermal", + "hydropower", + "biofuel", + "unknown", + "other" + ], + "meta:enum": { + "coal": "Energy produced by types of coal.", + "oil": "Petroleum products (primarily crude oil and its derivative fuel oils).", + "natural-gas": "Hydrocarbon gas liquids (HGL) that occur as gases at atmospheric pressure and as liquids under higher pressures including Natural gas (C5H12 and heavier), Ethane (C2H6), Propane (C3H8), etc.", + "nuclear": "Energy produced from the cores of atoms (i.e., through nuclear fission or fusion).", + "wind": "Energy produced from moving air.", + "solar": "Energy produced from the sun (i.e., solar radiation).", + "geothermal": "Energy produced from heat within the earth.", + "hydropower": "Energy produced from flowing water.", + "biofuel": "Liquid fuels produced from biomass feedstocks (i.e., organic materials such as plants or animals).", + "unknown": "The energy source is unknown.", + "other": "An energy source that is not listed." + }, + "title": "Energy Source", + "description": "The energy source for the energy provider." + }, + "energyProvided": { + "$ref": "#/definitions/energyMeasure", + "title": "Energy Provided", + "description": "The energy provided by the energy source for an associated activity." + }, + "externalReferences": { + "type": "array", + "items": {"$ref": "#/definitions/externalReference"}, + "title": "External References", + "description": "External references provide a way to document systems, sites, and information that may be relevant but are not included with the BOM. They may also establish specific relationships within or external to the BOM." + } + } + }, + "postalAddress": { + "type": "object", + "title": "Postal address", + "description": "An address used to identify a contactable location.", + "additionalProperties": false, + "properties": { + "bom-ref": { + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the address elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links.", + "$ref": "#/definitions/refType" + }, + "country": { + "type": "string", + "title": "Country", + "description": "The country name or the two-letter ISO 3166-1 country code." + }, + "region": { + "type": "string", + "title": "Region", + "description": "The region or state in the country.", + "examples": [ "Texas" ] + }, + "locality": { + "type": "string", + "title": "Locality", + "description": "The locality or city within the country.", + "examples": [ "Austin" ] + }, + "postOfficeBoxNumber": { + "type": "string", + "title": "Post Office Box Number", + "description": "The post office box number.", + "examples": [ "901" ] + }, + "postalCode": { + "type": "string", + "title": "Postal Code", + "description": "The postal code.", + "examples": [ "78758" ] + }, + "streetAddress": { + "type": "string", + "title": "Street Address", + "description": "The street address.", + "examples": [ "100 Main Street" ] + } + } + }, + "formula": { + "title": "Formula", + "description": "Describes workflows and resources that captures rules and other aspects of how the associated BOM component or service was formed.", + "type": "object", + "additionalProperties": false, + "properties": { + "bom-ref": { + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the formula elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links.", + "$ref": "#/definitions/refType" + }, + "components": { + "title": "Components", + "description": "Transient components that are used in tasks that constitute one or more of this formula's workflows", + "type": "array", + "items": { + "$ref": "#/definitions/component" + }, + "uniqueItems": true + }, + "services": { + "title": "Services", + "description": "Transient services that are used in tasks that constitute one or more of this formula's workflows", + "type": "array", + "items": { + "$ref": "#/definitions/service" + }, + "uniqueItems": true + }, + "workflows": { + "title": "Workflows", + "description": "List of workflows that can be declared to accomplish specific orchestrated goals and independently triggered.", + "$comment": "Different workflows can be designed to work together to perform end-to-end CI/CD builds and deployments.", + "type": "array", + "items": { + "$ref": "#/definitions/workflow" + }, + "uniqueItems": true + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "workflow": { + "title": "Workflow", + "description": "A specialized orchestration task.", + "$comment": "Workflow are as task themselves and can trigger other workflow tasks. These relationships can be modeled in the taskDependencies graph.", + "type": "object", + "required": [ + "bom-ref", + "uid", + "taskTypes" + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the workflow elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links.", + "$ref": "#/definitions/refType" + }, + "uid": { + "title": "Unique Identifier (UID)", + "description": "The unique identifier for the resource instance within its deployment context.", + "type": "string" + }, + "name": { + "title": "Name", + "description": "The name of the resource instance.", + "type": "string" + }, + "description": { + "title": "Description", + "description": "A description of the resource instance.", + "type": "string" + }, + "resourceReferences": { + "title": "Resource references", + "description": "References to component or service resources that are used to realize the resource instance.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/resourceReferenceChoice" + } + }, + "tasks": { + "title": "Tasks", + "description": "The tasks that comprise the workflow.", + "$comment": "Note that tasks can appear more than once as different instances (by name or UID).", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/task" + } + }, + "taskDependencies": { + "title": "Task dependency graph", + "description": "The graph of dependencies between tasks within the workflow.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/dependency" + } + }, + "taskTypes": { + "title": "Task types", + "description": "Indicates the types of activities performed by the set of workflow tasks.", + "$comment": "Currently, these types reflect common CI/CD actions.", + "type": "array", + "items": { + "$ref": "#/definitions/taskType" + } + }, + "trigger": { + "title": "Trigger", + "description": "The trigger that initiated the task.", + "$ref": "#/definitions/trigger" + }, + "steps": { + "title": "Steps", + "description": "The sequence of steps for the task.", + "type": "array", + "items": { + "$ref": "#/definitions/step" + }, + "uniqueItems": true + }, + "inputs": { + "title": "Inputs", + "description": "Represents resources and data brought into a task at runtime by executor or task commands", + "examples": ["a `configuration` file which was declared as a local `component` or `externalReference`"], + "type": "array", + "items": { + "$ref": "#/definitions/inputType" + }, + "uniqueItems": true + }, + "outputs": { + "title": "Outputs", + "description": "Represents resources and data output from a task at runtime by executor or task commands", + "examples": ["a log file or metrics data produced by the task"], + "type": "array", + "items": { + "$ref": "#/definitions/outputType" + }, + "uniqueItems": true + }, + "timeStart": { + "title": "Time start", + "description": "The date and time (timestamp) when the task started.", + "type": "string", + "format": "date-time" + }, + "timeEnd": { + "title": "Time end", + "description": "The date and time (timestamp) when the task ended.", + "type": "string", + "format": "date-time" + }, + "workspaces": { + "title": "Workspaces", + "description": "A set of named filesystem or data resource shareable by workflow tasks.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/workspace" + } + }, + "runtimeTopology": { + "title": "Runtime topology", + "description": "A graph of the component runtime topology for workflow's instance.", + "$comment": "A description of the runtime component and service topology. This can describe a partial or complete topology used to host and execute the task (e.g., hardware, operating systems, configurations, etc.),", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/dependency" + } + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "task": { + "title": "Task", + "description": "Describes the inputs, sequence of steps and resources used to accomplish a task and its output.", + "$comment": "Tasks are building blocks for constructing assemble CI/CD workflows or pipelines.", + "type": "object", + "required": [ + "bom-ref", + "uid", + "taskTypes" + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the task elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links.", + "$ref": "#/definitions/refType" + }, + "uid": { + "title": "Unique Identifier (UID)", + "description": "The unique identifier for the resource instance within its deployment context.", + "type": "string" + }, + "name": { + "title": "Name", + "description": "The name of the resource instance.", + "type": "string" + }, + "description": { + "title": "Description", + "description": "A description of the resource instance.", + "type": "string" + }, + "resourceReferences": { + "title": "Resource references", + "description": "References to component or service resources that are used to realize the resource instance.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/resourceReferenceChoice" + } + }, + "taskTypes": { + "title": "Task types", + "description": "Indicates the types of activities performed by the set of workflow tasks.", + "$comment": "Currently, these types reflect common CI/CD actions.", + "type": "array", + "items": { + "$ref": "#/definitions/taskType" + } + }, + "trigger": { + "title": "Trigger", + "description": "The trigger that initiated the task.", + "$ref": "#/definitions/trigger" + }, + "steps": { + "title": "Steps", + "description": "The sequence of steps for the task.", + "type": "array", + "items": { + "$ref": "#/definitions/step" + }, + "uniqueItems": true + }, + "inputs": { + "title": "Inputs", + "description": "Represents resources and data brought into a task at runtime by executor or task commands", + "examples": ["a `configuration` file which was declared as a local `component` or `externalReference`"], + "type": "array", + "items": { + "$ref": "#/definitions/inputType" + }, + "uniqueItems": true + }, + "outputs": { + "title": "Outputs", + "description": "Represents resources and data output from a task at runtime by executor or task commands", + "examples": ["a log file or metrics data produced by the task"], + "type": "array", + "items": { + "$ref": "#/definitions/outputType" + }, + "uniqueItems": true + }, + "timeStart": { + "title": "Time start", + "description": "The date and time (timestamp) when the task started.", + "type": "string", + "format": "date-time" + }, + "timeEnd": { + "title": "Time end", + "description": "The date and time (timestamp) when the task ended.", + "type": "string", + "format": "date-time" + }, + "workspaces": { + "title": "Workspaces", + "description": "A set of named filesystem or data resource shareable by workflow tasks.", + "type": "array", + "items": { + "$ref": "#/definitions/workspace" + }, + "uniqueItems": true + }, + "runtimeTopology": { + "title": "Runtime topology", + "description": "A graph of the component runtime topology for task's instance.", + "$comment": "A description of the runtime component and service topology. This can describe a partial or complete topology used to host and execute the task (e.g., hardware, operating systems, configurations, etc.),", + "type": "array", + "items": { + "$ref": "#/definitions/dependency" + }, + "uniqueItems": true + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "step": { + "type": "object", + "description": "Executes specific commands or tools in order to accomplish its owning task as part of a sequence.", + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "description": "A name for the step.", + "type": "string" + }, + "description": { + "title": "Description", + "description": "A description of the step.", + "type": "string" + }, + "commands": { + "title": "Commands", + "description": "Ordered list of commands or directives for the step", + "type": "array", + "items": { + "$ref": "#/definitions/command" + } + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "command": { + "type": "object", + "additionalProperties": false, + "properties": { + "executed": { + "title": "Executed", + "description": "A text representation of the executed command.", + "type": "string" + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "workspace": { + "title": "Workspace", + "description": "A named filesystem or data resource shareable by workflow tasks.", + "type": "object", + "required": [ + "bom-ref", + "uid" + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the workspace elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links.", + "$ref": "#/definitions/refType" + }, + "uid": { + "title": "Unique Identifier (UID)", + "description": "The unique identifier for the resource instance within its deployment context.", + "type": "string" + }, + "name": { + "title": "Name", + "description": "The name of the resource instance.", + "type": "string" + }, + "aliases": { + "title": "Aliases", + "description": "The names for the workspace as referenced by other workflow tasks. Effectively, a name mapping so other tasks can use their own local name in their steps.", + "type": "array", + "items": {"type": "string"} + }, + "description": { + "title": "Description", + "description": "A description of the resource instance.", + "type": "string" + }, + "resourceReferences": { + "title": "Resource references", + "description": "References to component or service resources that are used to realize the resource instance.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/resourceReferenceChoice" + } + }, + "accessMode": { + "title": "Access mode", + "description": "Describes the read-write access control for the workspace relative to the owning resource instance.", + "type": "string", + "enum": [ + "read-only", + "read-write", + "read-write-once", + "write-once", + "write-only" + ] + }, + "mountPath": { + "title": "Mount path", + "description": "A path to a location on disk where the workspace will be available to the associated task's steps.", + "type": "string" + }, + "managedDataType": { + "title": "Managed data type", + "description": "The name of a domain-specific data type the workspace represents.", + "$comment": "This property is for CI/CD frameworks that are able to provide access to structured, managed data at a more granular level than a filesystem.", + "examples": ["ConfigMap","Secret"], + "type": "string" + }, + "volumeRequest": { + "title": "Volume request", + "description": "Identifies the reference to the request for a specific volume type and parameters.", + "examples": ["a kubernetes Persistent Volume Claim (PVC) name"], + "type": "string" + }, + "volume": { + "title": "Volume", + "description": "Information about the actual volume instance allocated to the workspace.", + "$comment": "The actual volume allocated may be different than the request.", + "examples": ["see https://kubernetes.io/docs/concepts/storage/persistent-volumes/"], + "$ref": "#/definitions/volume" + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "volume": { + "title": "Volume", + "description": "An identifiable, logical unit of data storage tied to a physical device.", + "type": "object", + "additionalProperties": false, + "properties": { + "uid": { + "title": "Unique Identifier (UID)", + "description": "The unique identifier for the volume instance within its deployment context.", + "type": "string" + }, + "name": { + "title": "Name", + "description": "The name of the volume instance", + "type": "string" + }, + "mode": { + "title": "Mode", + "description": "The mode for the volume instance.", + "type": "string", + "enum": [ + "filesystem", "block" + ], + "default": "filesystem" + }, + "path": { + "title": "Path", + "description": "The underlying path created from the actual volume.", + "type": "string" + }, + "sizeAllocated": { + "title": "Size allocated", + "description": "The allocated size of the volume accessible to the associated workspace. This should include the scalar size as well as IEC standard unit in either decimal or binary form.", + "examples": ["10GB", "2Ti", "1Pi"], + "type": "string" + }, + "persistent": { + "title": "Persistent", + "description": "Indicates if the volume persists beyond the life of the resource it is associated with.", + "type": "boolean" + }, + "remote": { + "title": "Remote", + "description": "Indicates if the volume is remotely (i.e., network) attached.", + "type": "boolean" + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "trigger": { + "title": "Trigger", + "description": "Represents a resource that can conditionally activate (or fire) tasks based upon associated events and their data.", + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "bom-ref", + "uid" + ], + "properties": { + "bom-ref": { + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the trigger elsewhere in the BOM. Every bom-ref must be unique within the BOM.\nValue SHOULD not start with the BOM-Link intro 'urn:cdx:' to avoid conflicts with BOM-Links.", + "$ref": "#/definitions/refType" + }, + "uid": { + "title": "Unique Identifier (UID)", + "description": "The unique identifier for the resource instance within its deployment context.", + "type": "string" + }, + "name": { + "title": "Name", + "description": "The name of the resource instance.", + "type": "string" + }, + "description": { + "title": "Description", + "description": "A description of the resource instance.", + "type": "string" + }, + "resourceReferences": { + "title": "Resource references", + "description": "References to component or service resources that are used to realize the resource instance.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/resourceReferenceChoice" + } + }, + "type": { + "title": "Type", + "description": "The source type of event which caused the trigger to fire.", + "type": "string", + "enum": [ + "manual", + "api", + "webhook", + "scheduled" + ] + }, + "event": { + "title": "Event", + "description": "The event data that caused the associated trigger to activate.", + "$ref": "#/definitions/event" + }, + "conditions": { + "type": "array", + "title": "Conditions", + "description": "A list of conditions used to determine if a trigger should be activated.", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/condition" + } + }, + "timeActivated": { + "title": "Time activated", + "description": "The date and time (timestamp) when the trigger was activated.", + "type": "string", + "format": "date-time" + }, + "inputs": { + "title": "Inputs", + "description": "Represents resources and data brought into a task at runtime by executor or task commands", + "examples": ["a `configuration` file which was declared as a local `component` or `externalReference`"], + "type": "array", + "items": { + "$ref": "#/definitions/inputType" + }, + "uniqueItems": true + }, + "outputs": { + "title": "Outputs", + "description": "Represents resources and data output from a task at runtime by executor or task commands", + "examples": ["a log file or metrics data produced by the task"], + "type": "array", + "items": { + "$ref": "#/definitions/outputType" + }, + "uniqueItems": true + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "event": { + "title": "Event", + "description": "Represents something that happened that may trigger a response.", + "type": "object", + "additionalProperties": false, + "properties": { + "uid": { + "title": "Unique Identifier (UID)", + "description": "The unique identifier of the event.", + "type": "string" + }, + "description": { + "title": "Description", + "description": "A description of the event.", + "type": "string" + }, + "timeReceived": { + "title": "Time Received", + "description": "The date and time (timestamp) when the event was received.", + "type": "string", + "format": "date-time" + }, + "data": { + "title": "Data", + "description": "Encoding of the raw event data.", + "$ref": "#/definitions/attachment" + }, + "source": { + "title": "Source", + "description": "References the component or service that was the source of the event", + "$ref": "#/definitions/resourceReferenceChoice" + }, + "target": { + "title": "Target", + "description": "References the component or service that was the target of the event", + "$ref": "#/definitions/resourceReferenceChoice" + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "inputType": { + "title": "Input type", + "description": "Type that represents various input data types and formats.", + "type": "object", + "oneOf": [ + { + "required": [ + "resource" + ] + }, + { + "required": [ + "parameters" + ] + }, + { + "required": [ + "environmentVars" + ] + }, + { + "required": [ + "data" + ] + } + ], + "additionalProperties": false, + "properties": { + "source": { + "title": "Source", + "description": "A reference to the component or service that provided the input to the task (e.g., reference to a service with data flow value of `inbound`)", + "examples": [ + "source code repository", + "database" + ], + "$ref": "#/definitions/resourceReferenceChoice" + }, + "target": { + "title": "Target", + "description": "A reference to the component or service that received or stored the input if not the task itself (e.g., a local, named storage workspace)", + "examples": [ + "workspace", + "directory" + ], + "$ref": "#/definitions/resourceReferenceChoice" + }, + "resource": { + "title": "Resource", + "description": "A reference to an independent resource provided as an input to a task by the workflow runtime.", + "examples": [ + "a reference to a configuration file in a repository (i.e., a bom-ref)", + "a reference to a scanning service used in a task (i.e., a bom-ref)" + ], + "$ref": "#/definitions/resourceReferenceChoice" + }, + "parameters": { + "title": "Parameters", + "description": "Inputs that have the form of parameters with names and values.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/parameter" + } + }, + "environmentVars": { + "title": "Environment variables", + "description": "Inputs that have the form of parameters with names and values.", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/definitions/property" + }, + { + "type": "string" + } + ] + } + }, + "data": { + "title": "Data", + "description": "Inputs that have the form of data.", + "$ref": "#/definitions/attachment" + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "outputType": { + "type": "object", + "oneOf": [ + { + "required": [ + "resource" + ] + }, + { + "required": [ + "environmentVars" + ] + }, + { + "required": [ + "data" + ] + } + ], + "additionalProperties": false, + "properties": { + "type": { + "title": "Type", + "description": "Describes the type of data output.", + "type": "string", + "enum": [ + "artifact", + "attestation", + "log", + "evidence", + "metrics", + "other" + ] + }, + "source": { + "title": "Source", + "description": "Component or service that generated or provided the output from the task (e.g., a build tool)", + "$ref": "#/definitions/resourceReferenceChoice" + }, + "target": { + "title": "Target", + "description": "Component or service that received the output from the task (e.g., reference to an artifactory service with data flow value of `outbound`)", + "examples": ["a log file described as an `externalReference` within its target domain."], + "$ref": "#/definitions/resourceReferenceChoice" + }, + "resource": { + "title": "Resource", + "description": "A reference to an independent resource generated as output by the task.", + "examples": [ + "configuration file", + "source code", + "scanning service" + ], + "$ref": "#/definitions/resourceReferenceChoice" + }, + "data": { + "title": "Data", + "description": "Outputs that have the form of data.", + "$ref": "#/definitions/attachment" + }, + "environmentVars": { + "title": "Environment variables", + "description": "Outputs that have the form of environment variables.", + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/property" + }, + { + "type": "string" + } + ] + }, + "uniqueItems": true + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "resourceReferenceChoice": { + "title": "Resource reference choice", + "description": "A reference to a locally defined resource (e.g., a bom-ref) or an externally accessible resource.", + "$comment": "Enables reference to a resource that participates in a workflow; using either internal (bom-ref) or external (externalReference) types.", + "type": "object", + "additionalProperties": false, + "properties": { + "ref": { + "title": "BOM Reference", + "description": "References an object by its bom-ref attribute", + "anyOf": [ + { + "title": "Ref", + "$ref": "#/definitions/refLinkType" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ] + }, + "externalReference": { + "title": "External reference", + "description": "Reference to an externally accessible resource.", + "$ref": "#/definitions/externalReference" + } + }, + "oneOf": [ + { + "required": [ + "ref" + ] + }, + { + "required": [ + "externalReference" + ] + } + ] + }, + "condition": { + "title": "Condition", + "description": "A condition that was used to determine a trigger should be activated.", + "type": "object", + "additionalProperties": false, + "properties": { + "description": { + "title": "Description", + "description": "Describes the set of conditions which cause the trigger to activate.", + "type": "string" + }, + "expression": { + "title": "Expression", + "description": "The logical expression that was evaluated that determined the trigger should be fired.", + "type": "string" + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "taskType": { + "type": "string", + "enum": [ + "copy", + "clone", + "lint", + "scan", + "merge", + "build", + "test", + "deliver", + "deploy", + "release", + "clean", + "other" + ], + "meta:enum": { + "copy": "A task that copies software or data used to accomplish other tasks in the workflow.", + "clone": "A task that clones a software repository into the workflow in order to retrieve its source code or data for use in a build step.", + "lint": "A task that checks source code for programmatic and stylistic errors.", + "scan": "A task that performs a scan against source code, or built or deployed components and services. Scans are typically run to gather or test for security vulnerabilities or policy compliance.", + "merge": "A task that merges changes or fixes into source code prior to a build step in the workflow.", + "build": "A task that builds the source code, dependencies and/or data into an artifact that can be deployed to and executed on target systems.", + "test": "A task that verifies the functionality of a component or service.", + "deliver": "A task that delivers a built artifact to one or more target repositories or storage systems.", + "deploy": "A task that deploys a built artifact for execution on one or more target systems.", + "release": "A task that releases a built, versioned artifact to a target repository or distribution system.", + "clean": "A task that cleans unnecessary tools, build artifacts and/or data from workflow storage.", + "other": "A workflow task that does not match current task type definitions." + } + }, + "parameter": { + "title": "Parameter", + "description": "A representation of a functional parameter.", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "description": "The name of the parameter.", + "type": "string" + }, + "value": { + "title": "Value", + "description": "The value of the parameter.", + "type": "string" + }, + "dataType": { + "title": "Data type", + "description": "The data type of the parameter.", + "type": "string" + } + } + }, + "componentIdentityEvidence": { + "type": "object", + "title": "Identity Evidence", + "description": "Evidence that substantiates the identity of a component.", + "required": [ "field" ], + "additionalProperties": false, + "properties": { + "field": { + "type": "string", + "enum": [ + "group", "name", "version", "purl", "cpe", "omniborId", "swhid", "swid", "hash" + ], + "title": "Field", + "description": "The identity field of the component which the evidence describes." + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1, + "title": "Confidence", + "description": "The overall confidence of the evidence from 0 - 1, where 1 is 100% confidence." + }, + "concludedValue": { + "type": "string", + "title": "Concluded Value", + "description": "The value of the field (cpe, purl, etc) that has been concluded based on the aggregate of all methods (if available)." + }, + "methods": { + "type": "array", + "title": "Methods", + "description": "The methods used to extract and/or analyze the evidence.", + "items": { + "type": "object", + "required": [ + "technique" , + "confidence" + ], + "additionalProperties": false, + "properties": { + "technique": { + "title": "Technique", + "description": "The technique used in this method of analysis.", + "type": "string", + "enum": [ + "source-code-analysis", + "binary-analysis", + "manifest-analysis", + "ast-fingerprint", + "hash-comparison", + "instrumentation", + "dynamic-analysis", + "filename", + "attestation", + "other" + ] + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1, + "title": "Confidence", + "description": "The confidence of the evidence from 0 - 1, where 1 is 100% confidence. Confidence is specific to the technique used. Each technique of analysis can have independent confidence." + }, + "value": { + "type": "string", + "title": "Value", + "description": "The value or contents of the evidence." + } + } + } + }, + "tools": { + "type": "array", + "uniqueItems": true, + "items": { + "anyOf": [ + { + "title": "Ref", + "$ref": "#/definitions/refLinkType" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ] + }, + "title": "BOM References", + "description": "The object in the BOM identified by its bom-ref. This is often a component or service but may be any object type supporting bom-refs. Tools used for analysis should already be defined in the BOM, either in the metadata/tools, components, or formulation." + } + } + }, + "standard": { + "type": "object", + "title": "Standard", + "description": "A standard may consist of regulations, industry or organizational-specific standards, maturity models, best practices, or any other requirements which can be evaluated against or attested to.", + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the object elsewhere in the BOM. Every bom-ref must be unique within the BOM." + }, + "name": { + "type": "string", + "title": "Name", + "description": "The name of the standard. This will often be a shortened, single name of the standard." + }, + "version": { + "type": "string", + "title": "Version", + "description": "The version of the standard." + }, + "description": { + "type": "string", + "title": "Description", + "description": "The description of the standard." + }, + "owner": { + "type": "string", + "title": "Owner", + "description": "The owner of the standard, often the entity responsible for its release." + }, + "requirements": { + "type": "array", + "title": "Requirements", + "description": "The list of requirements comprising the standard.", + "items": { + "type": "object", + "title": "Requirement", + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the object elsewhere in the BOM. Every bom-ref must be unique within the BOM." + }, + "identifier": { + "type": "string", + "title": "Identifier", + "description": "The unique identifier used in the standard to identify a specific requirement. This should match what is in the standard and should not be the requirements bom-ref." + }, + "title": { + "type": "string", + "title": "Title", + "description": "The title of the requirement." + }, + "text": { + "type": "string", + "title": "Text", + "description": "The textual content of the requirement." + }, + "descriptions": { + "type": "array", + "title": "Descriptions", + "description": "The supplemental text that provides additional guidance or context to the requirement, but is not directly part of the requirement.", + "items": { "type": "string" } + }, + "openCre": { + "type": "array", + "title": "OWASP OpenCRE Identifier(s)", + "description": "The Common Requirements Enumeration (CRE) identifier(s). CRE is a structured and standardized framework for uniting security standards and guidelines. CRE links each section of a resource to a shared topic identifier (a Common Requirement). Through this shared topic link, all resources map to each other. Use of CRE promotes clear and unambiguous communication among stakeholders.", + "items": { + "type": "string", + "pattern": "^CRE:[0-9]+-[0-9]+$", + "examples": [ "CRE:764-507" ] + } + }, + "parent": { + "$ref": "#/definitions/refLinkType", + "title": "Parent BOM Reference", + "description": "The optional `bom-ref` to a parent requirement. This establishes a hierarchy of requirements. Top-level requirements must not define a parent. Only child requirements should define parents." + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is optional.", + "items": { + "$ref": "#/definitions/property" + } + }, + "externalReferences": { + "type": "array", + "items": {"$ref": "#/definitions/externalReference"}, + "title": "External References", + "description": "External references provide a way to document systems, sites, and information that may be relevant, but are not included with the BOM. They may also establish specific relationships within or external to the BOM." + } + } + } + }, + "levels": { + "type": "array", + "title": "Levels", + "description": "The list of levels associated with the standard. Some standards have different levels of compliance.", + "items": { + "type": "object", + "title": "Level", + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the object elsewhere in the BOM. Every bom-ref must be unique within the BOM." + }, + "identifier": { + "type": "string", + "title": "Identifier", + "description": "The identifier used in the standard to identify a specific level." + }, + "title": { + "type": "string", + "title": "Title", + "description": "The title of the level." + }, + "description": { + "type": "string", + "title": "Description", + "description": "The description of the level." + }, + "requirements": { + "type": "array", + "title": "Requirements", + "description": "The list of requirement `bom-ref`s that comprise the level.", + "items": { "$ref": "#/definitions/refLinkType" } + } + } + } + }, + "externalReferences": { + "type": "array", + "items": {"$ref": "#/definitions/externalReference"}, + "title": "External References", + "description": "External references provide a way to document systems, sites, and information that may be relevant but are not included with the BOM. They may also establish specific relationships within or external to the BOM." + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + }, + "signature": { + "$ref": "jsf-0.82.schema.json#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + }, + "cryptoProperties": { + "type": "object", + "title": "Cryptographic Properties", + "description": "Cryptographic assets have properties that uniquely define them and that make them actionable for further reasoning. As an example, it makes a difference if one knows the algorithm family (e.g. AES) or the specific variant or instantiation (e.g. AES-128-GCM). This is because the security level and the algorithm primitive (authenticated encryption) are only defined by the definition of the algorithm variant. The presence of a weak cryptographic algorithm like SHA1 vs. HMAC-SHA1 also makes a difference.", + "additionalProperties": false, + "required": [ + "assetType" + ], + "properties": { + "assetType": { + "type": "string", + "title": "Asset Type", + "description": "Cryptographic assets occur in several forms. Algorithms and protocols are most commonly implemented in specialized cryptographic libraries. They may, however, also be 'hardcoded' in software components. Certificates and related cryptographic material like keys, tokens, secrets or passwords are other cryptographic assets to be modelled.", + "enum": [ + "algorithm", + "certificate", + "protocol", + "related-crypto-material" + ], + "meta:enum": { + "algorithm": "Mathematical function commonly used for data encryption, authentication, and digital signatures.", + "certificate": "An electronic document that is used to provide the identity or validate a public key.", + "protocol": "A set of rules and guidelines that govern the behavior and communication with each other.", + "related-crypto-material": "Other cryptographic assets related to algorithms, certificates, and protocols such as keys and tokens." + } + }, + "algorithmProperties": { + "type": "object", + "title": "Algorithm Properties", + "description": "Additional properties specific to a cryptographic algorithm.", + "additionalProperties": false, + "properties": { + "primitive": { + "type": "string", + "title": "primitive", + "description": "Cryptographic building blocks used in higher-level cryptographic systems and protocols. Primitives represent different cryptographic routines: deterministic random bit generators (drbg, e.g. CTR_DRBG from NIST SP800-90A-r1), message authentication codes (mac, e.g. HMAC-SHA-256), blockciphers (e.g. AES), streamciphers (e.g. Salsa20), signatures (e.g. ECDSA), hash functions (e.g. SHA-256), public-key encryption schemes (pke, e.g. RSA), extended output functions (xof, e.g. SHAKE256), key derivation functions (e.g. pbkdf2), key agreement algorithms (e.g. ECDH), key encapsulation mechanisms (e.g. ML-KEM), authenticated encryption (ae, e.g. AES-GCM) and the combination of multiple algorithms (combiner, e.g. SP800-56Cr2).", + "enum": [ + "drbg", + "mac", + "block-cipher", + "stream-cipher", + "signature", + "hash", + "pke", + "xof", + "kdf", + "key-agree", + "kem", + "ae", + "combiner", + "other", + "unknown" + ], + "meta:enum": { + "drbg": "Deterministic Random Bit Generator (DRBG) is a type of pseudorandom number generator designed to produce a sequence of bits from an initial seed value. DRBGs are commonly used in cryptographic applications where reproducibility of random values is important.", + "mac": "In cryptography, a Message Authentication Code (MAC) is information used for authenticating and integrity-checking a message.", + "block-cipher": "A block cipher is a symmetric key algorithm that operates on fixed-size blocks of data. It encrypts or decrypts the data in block units, providing confidentiality. Block ciphers are widely used in various cryptographic modes and protocols for secure data transmission.", + "stream-cipher": "A stream cipher is a symmetric key cipher where plaintext digits are combined with a pseudorandom cipher digit stream (keystream).", + "signature": "In cryptography, a signature is a digital representation of a message or data that proves its origin, identity, and integrity. Digital signatures are generated using cryptographic algorithms and are widely used for authentication and verification in secure communication.", + "hash": "A hash function is a mathematical algorithm that takes an input (or 'message') and produces a fixed-size string of characters, which is typically a hash value. Hash functions are commonly used in various cryptographic applications, including data integrity verification and password hashing.", + "pke": "Public Key Encryption (PKE) is a type of encryption that uses a pair of public and private keys for secure communication. The public key is used for encryption, while the private key is used for decryption. PKE is a fundamental component of public-key cryptography.", + "xof": "An XOF is an extendable output function that can take arbitrary input and creates a stream of output, up to a limit determined by the size of the internal state of the hash function that underlies the XOF.", + "kdf": "A Key Derivation Function (KDF) derives key material from another source of entropy while preserving the entropy of the input.", + "key-agree": "In cryptography, a key-agreement is a protocol whereby two or more parties agree on a cryptographic key in such a way that both influence the outcome.", + "kem": "A Key Encapsulation Mechanism (KEM) algorithm is a mechanism for transporting random keying material to a recipient using the recipient's public key.", + "ae": "Authenticated Encryption (AE) is a cryptographic process that provides both confidentiality and data integrity. It ensures that the encrypted data has not been tampered with and comes from a legitimate source. AE is commonly used in secure communication protocols.", + "combiner": "A combiner aggregates many candidates for a cryptographic primitive and generates a new candidate for the same primitive.", + "other": "Another primitive type.", + "unknown": "The primitive is not known." + } + }, + "parameterSetIdentifier": { + "type": "string", + "title": "Parameter Set Identifier", + "description": "An identifier for the parameter set of the cryptographic algorithm. Examples: in AES128, '128' identifies the key length in bits, in SHA256, '256' identifies the digest length, '128' in SHAKE128 identifies its maximum security level in bits, and 'SHA2-128s' identifies a parameter set used in SLH-DSA (FIPS205)." + }, + "curve": { + "type": "string", + "title": "Elliptic Curve", + "description": "The specific underlying Elliptic Curve (EC) definition employed which is an indicator of the level of security strength, performance and complexity. Absent an authoritative source of curve names, CycloneDX recommends using curve names as defined at [https://neuromancer.sk/std/](https://neuromancer.sk/std/), the source of which can be found at [https://github.com/J08nY/std-curves](https://github.com/J08nY/std-curves)." + }, + "executionEnvironment": { + "type": "string", + "title": "Execution Environment", + "description": "The target and execution environment in which the algorithm is implemented in.", + "enum": [ + "software-plain-ram", + "software-encrypted-ram", + "software-tee", + "hardware", + "other", + "unknown" + ], + "meta:enum": { + "software-plain-ram": "A software implementation running in plain unencrypted RAM.", + "software-encrypted-ram": "A software implementation running in encrypted RAM.", + "software-tee": "A software implementation running in a trusted execution environment.", + "hardware": "A hardware implementation.", + "other": "Another implementation environment.", + "unknown": "The execution environment is not known." + } + }, + "implementationPlatform": { + "type": "string", + "title": "Implementation platform", + "description": "The target platform for which the algorithm is implemented. The implementation can be 'generic', running on any platform or for a specific platform.", + "enum": [ + "generic", + "x86_32", + "x86_64", + "armv7-a", + "armv7-m", + "armv8-a", + "armv8-m", + "armv9-a", + "armv9-m", + "s390x", + "ppc64", + "ppc64le", + "other", + "unknown" + ] + }, + "certificationLevel": { + "type": "array", + "title": "Certification Level", + "description": "The certification that the implementation of the cryptographic algorithm has received, if any. Certifications include revisions and levels of FIPS 140 or Common Criteria of different Extended Assurance Levels (CC-EAL).", + "items": { + "type": "string", + "enum": [ + "none", + "fips140-1-l1", + "fips140-1-l2", + "fips140-1-l3", + "fips140-1-l4", + "fips140-2-l1", + "fips140-2-l2", + "fips140-2-l3", + "fips140-2-l4", + "fips140-3-l1", + "fips140-3-l2", + "fips140-3-l3", + "fips140-3-l4", + "cc-eal1", + "cc-eal1+", + "cc-eal2", + "cc-eal2+", + "cc-eal3", + "cc-eal3+", + "cc-eal4", + "cc-eal4+", + "cc-eal5", + "cc-eal5+", + "cc-eal6", + "cc-eal6+", + "cc-eal7", + "cc-eal7+", + "other", + "unknown" + ], + "meta:enum": { + "none": "No certification obtained", + "fips140-1-l1": "FIPS 140-1 Level 1", + "fips140-1-l2": "FIPS 140-1 Level 2", + "fips140-1-l3": "FIPS 140-1 Level 3", + "fips140-1-l4": "FIPS 140-1 Level 4", + "fips140-2-l1": "FIPS 140-2 Level 1", + "fips140-2-l2": "FIPS 140-2 Level 2", + "fips140-2-l3": "FIPS 140-2 Level 3", + "fips140-2-l4": "FIPS 140-2 Level 4", + "fips140-3-l1": "FIPS 140-3 Level 1", + "fips140-3-l2": "FIPS 140-3 Level 2", + "fips140-3-l3": "FIPS 140-3 Level 3", + "fips140-3-l4": "FIPS 140-3 Level 4", + "cc-eal1": "Common Criteria - Evaluation Assurance Level 1", + "cc-eal1+": "Common Criteria - Evaluation Assurance Level 1 (Augmented)", + "cc-eal2": "Common Criteria - Evaluation Assurance Level 2", + "cc-eal2+": "Common Criteria - Evaluation Assurance Level 2 (Augmented)", + "cc-eal3": "Common Criteria - Evaluation Assurance Level 3", + "cc-eal3+": "Common Criteria - Evaluation Assurance Level 3 (Augmented)", + "cc-eal4": "Common Criteria - Evaluation Assurance Level 4", + "cc-eal4+": "Common Criteria - Evaluation Assurance Level 4 (Augmented)", + "cc-eal5": "Common Criteria - Evaluation Assurance Level 5", + "cc-eal5+": "Common Criteria - Evaluation Assurance Level 5 (Augmented)", + "cc-eal6": "Common Criteria - Evaluation Assurance Level 6", + "cc-eal6+": "Common Criteria - Evaluation Assurance Level 6 (Augmented)", + "cc-eal7": "Common Criteria - Evaluation Assurance Level 7", + "cc-eal7+": "Common Criteria - Evaluation Assurance Level 7 (Augmented)", + "other": "Another certification", + "unknown": "The certification level is not known" + } + } + }, + "mode": { + "type": "string", + "title": "Mode", + "description": "The mode of operation in which the cryptographic algorithm (block cipher) is used.", + "enum": [ + "cbc", + "ecb", + "ccm", + "gcm", + "cfb", + "ofb", + "ctr", + "other", + "unknown" + ], + "meta:enum": { + "cbc": "Cipher block chaining", + "ecb": "Electronic codebook", + "ccm": "Counter with cipher block chaining message authentication code", + "gcm": "Galois/counter", + "cfb": "Cipher feedback", + "ofb": "Output feedback", + "ctr": "Counter", + "other": "Another mode of operation", + "unknown": "The mode of operation is not known" + } + }, + "padding": { + "type": "string", + "title": "Padding", + "description": "The padding scheme that is used for the cryptographic algorithm.", + "enum": [ + "pkcs5", + "pkcs7", + "pkcs1v15", + "oaep", + "raw", + "other", + "unknown" + ], + "meta:enum": { + "pkcs5": "Public Key Cryptography Standard: Password-Based Cryptography", + "pkcs7": "Public Key Cryptography Standard: Cryptographic Message Syntax", + "pkcs1v15": "Public Key Cryptography Standard: RSA Cryptography v1.5", + "oaep": "Optimal asymmetric encryption padding", + "raw": "Raw", + "other": "Another padding scheme", + "unknown": "The padding scheme is not known" + } + }, + "cryptoFunctions": { + "type": "array", + "title": "Cryptographic functions", + "description": "The cryptographic functions implemented by the cryptographic algorithm.", + "items": { + "type": "string", + "enum": [ + "generate", + "keygen", + "encrypt", + "decrypt", + "digest", + "tag", + "keyderive", + "sign", + "verify", + "encapsulate", + "decapsulate", + "other", + "unknown" + ] + } + }, + "classicalSecurityLevel": { + "type": "integer", + "title": "classical security level", + "description": "The classical security level that a cryptographic algorithm provides (in bits).", + "minimum": 0 + }, + "nistQuantumSecurityLevel": { + "type": "integer", + "title": "NIST security strength category", + "description": "The NIST security strength category as defined in https://csrc.nist.gov/projects/post-quantum-cryptography/post-quantum-cryptography-standardization/evaluation-criteria/security-(evaluation-criteria). A value of 0 indicates that none of the categories are met.", + "minimum": 0, + "maximum": 6 + } + } + }, + "certificateProperties": { + "type": "object", + "title": "Certificate Properties", + "description": "Properties for cryptographic assets of asset type 'certificate'", + "additionalProperties": false, + "properties": { + "subjectName": { + "type": "string", + "title": "Subject Name", + "description": "The subject name for the certificate" + }, + "issuerName": { + "type": "string", + "title": "Issuer Name", + "description": "The issuer name for the certificate" + }, + "notValidBefore": { + "type": "string", + "format": "date-time", + "title": "Not Valid Before", + "description": "The date and time according to ISO-8601 standard from which the certificate is valid" + }, + "notValidAfter": { + "type": "string", + "format": "date-time", + "title": "Not Valid After", + "description": "The date and time according to ISO-8601 standard from which the certificate is not valid anymore" + }, + "signatureAlgorithmRef": { + "$ref": "#/definitions/refType", + "title": "Algorithm Reference", + "description": "The bom-ref to signature algorithm used by the certificate" + }, + "subjectPublicKeyRef": { + "$ref": "#/definitions/refType", + "title": "Key reference", + "description": "The bom-ref to the public key of the subject" + }, + "certificateFormat": { + "type": "string", + "title": "Certificate Format", + "description": "The format of the certificate", + "examples": [ + "X.509", + "PEM", + "DER", + "CVC" + ] + }, + "certificateExtension": { + "type": "string", + "title": "Certificate File Extension", + "description": "The file extension of the certificate", + "examples": [ + "crt", + "pem", + "cer", + "der", + "p12" + ] + } + } + }, + "relatedCryptoMaterialProperties": { + "type": "object", + "title": "Related Cryptographic Material Properties", + "description": "Properties for cryptographic assets of asset type: `related-crypto-material`", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "title": "relatedCryptoMaterialType", + "description": "The type for the related cryptographic material", + "enum": [ + "private-key", + "public-key", + "secret-key", + "key", + "ciphertext", + "signature", + "digest", + "initialization-vector", + "nonce", + "seed", + "salt", + "shared-secret", + "tag", + "additional-data", + "password", + "credential", + "token", + "other", + "unknown" + ], + "meta:enum": { + "private-key": "The confidential key of a key pair used in asymmetric cryptography.", + "public-key": "The non-confidential key of a key pair used in asymmetric cryptography.", + "secret-key": "A key used to encrypt and decrypt messages in symmetric cryptography.", + "key": "A piece of information, usually an octet string, which, when processed through a cryptographic algorithm, processes cryptographic data.", + "ciphertext": "The result of encryption performed on plaintext using an algorithm (or cipher).", + "signature": "A cryptographic value that is calculated from the data and a key known only by the signer.", + "digest": "The output of the hash function.", + "initialization-vector": "A fixed-size random or pseudo-random value used as an input parameter for cryptographic algorithms.", + "nonce": "A random or pseudo-random number that can only be used once in a cryptographic communication.", + "seed": "The input to a pseudo-random number generator. Different seeds generate different pseudo-random sequences.", + "salt": "A value used in a cryptographic process, usually to ensure that the results of computations for one instance cannot be reused by an attacker.", + "shared-secret": "A piece of data known only to the parties involved, in a secure communication.", + "tag": "A message authentication code (MAC), sometimes known as an authentication tag, is a short piece of information used for authenticating and integrity-checking a message.", + "additional-data": "An unspecified collection of data with relevance to cryptographic activity.", + "password": "A secret word, phrase, or sequence of characters used during authentication or authorization.", + "credential": "Establishes the identity of a party to communication, usually in the form of cryptographic keys or passwords.", + "token": "An object encapsulating a security identity.", + "other": "Another type of cryptographic asset.", + "unknown": "The type of cryptographic asset is not known." + } + }, + "id": { + "type": "string", + "title": "ID", + "description": "The optional unique identifier for the related cryptographic material." + }, + "state": { + "type": "string", + "title": "State", + "description": "The key state as defined by NIST SP 800-57.", + "enum": [ + "pre-activation", + "active", + "suspended", + "deactivated", + "compromised", + "destroyed" + ] + }, + "algorithmRef": { + "$ref": "#/definitions/refType", + "title": "Algorithm Reference", + "description": "The bom-ref to the algorithm used to generate the related cryptographic material." + }, + "creationDate": { + "type": "string", + "format": "date-time", + "title": "Creation Date", + "description": "The date and time (timestamp) when the related cryptographic material was created." + }, + "activationDate": { + "type": "string", + "format": "date-time", + "title": "Activation Date", + "description": "The date and time (timestamp) when the related cryptographic material was activated." + }, + "updateDate": { + "type": "string", + "format": "date-time", + "title": "Update Date", + "description": "The date and time (timestamp) when the related cryptographic material was updated." + }, + "expirationDate": { + "type": "string", + "format": "date-time", + "title": "Expiration Date", + "description": "The date and time (timestamp) when the related cryptographic material expires." + }, + "value": { + "type": "string", + "title": "Value", + "description": "The associated value of the cryptographic material." + }, + "size": { + "type": "integer", + "title": "Size", + "description": "The size of the cryptographic asset (in bits)." + }, + "format": { + "type": "string", + "title": "Format", + "description": "The format of the related cryptographic material (e.g. P8, PEM, DER)." + }, + "securedBy": { + "$ref": "#/definitions/securedBy", + "title": "Secured By", + "description": "The mechanism by which the cryptographic asset is secured by." + } + } + }, + "protocolProperties": { + "type": "object", + "title": "Protocol Properties", + "description": "Properties specific to cryptographic assets of type: `protocol`.", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "title": "Type", + "description": "The concrete protocol type.", + "enum": [ + "tls", + "ssh", + "ipsec", + "ike", + "sstp", + "wpa", + "other", + "unknown" + ], + "meta:enum": { + "tls": "Transport Layer Security", + "ssh": "Secure Shell", + "ipsec": "Internet Protocol Security", + "ike": "Internet Key Exchange", + "sstp": "Secure Socket Tunneling Protocol", + "wpa": "Wi-Fi Protected Access", + "other": "Another protocol type", + "unknown": "The protocol type is not known" + } + }, + "version": { + "type": "string", + "title": "Protocol Version", + "description": "The version of the protocol.", + "examples": [ + "1.0", + "1.2", + "1.99" + ] + }, + "cipherSuites": { + "type": "array", + "title": "Cipher Suites", + "description": "A list of cipher suites related to the protocol.", + "items": { + "$ref": "#/definitions/cipherSuite", + "title": "Cipher Suite" + } + }, + "ikev2TransformTypes": { + "type": "object", + "title": "IKEv2 Transform Types", + "description": "The IKEv2 transform types supported (types 1-4), defined in [RFC 7296 section 3.3.2](https://www.ietf.org/rfc/rfc7296.html#section-3.3.2), and additional properties.", + "additionalProperties": false, + "properties": { + "encr": { + "$ref": "#/definitions/cryptoRefArray", + "title": "Encryption Algorithm (ENCR)", + "description": "Transform Type 1: encryption algorithms" + }, + "prf": { + "$ref": "#/definitions/cryptoRefArray", + "title": "Pseudorandom Function (PRF)", + "description": "Transform Type 2: pseudorandom functions" + }, + "integ": { + "$ref": "#/definitions/cryptoRefArray", + "title": "Integrity Algorithm (INTEG)", + "description": "Transform Type 3: integrity algorithms" + }, + "ke": { + "$ref": "#/definitions/cryptoRefArray", + "title": "Key Exchange Method (KE)", + "description": "Transform Type 4: Key Exchange Method (KE) per [RFC 9370](https://www.ietf.org/rfc/rfc9370.html), formerly called Diffie-Hellman Group (D-H)." + }, + "esn": { + "type": "boolean", + "title": "Extended Sequence Numbers (ESN)", + "description": "Specifies if an Extended Sequence Number (ESN) is used." + }, + "auth": { + "$ref": "#/definitions/cryptoRefArray", + "title": "IKEv2 Authentication method", + "description": "IKEv2 Authentication method" + } + } + }, + "cryptoRefArray": { + "$ref": "#/definitions/cryptoRefArray", + "title": "Cryptographic References", + "description": "A list of protocol-related cryptographic assets" + } + } + }, + "oid": { + "type": "string", + "title": "OID", + "description": "The object identifier (OID) of the cryptographic asset." + } + } + }, + "cipherSuite": { + "type": "object", + "title": "Cipher Suite", + "description": "Object representing a cipher suite", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "Common Name", + "description": "A common name for the cipher suite.", + "examples": [ + "TLS_DHE_RSA_WITH_AES_128_CCM" + ] + }, + "algorithms": { + "type": "array", + "title": "Related Algorithms", + "description": "A list of algorithms related to the cipher suite.", + "items": { + "$ref": "#/definitions/refType", + "title": "Algorithm reference", + "description": "The bom-ref to algorithm cryptographic asset." + } + }, + "identifiers": { + "type": "array", + "title": "Cipher Suite Identifiers", + "description": "A list of common identifiers for the cipher suite.", + "items": { + "type": "string", + "title": "identifier", + "description": "Cipher suite identifier", + "examples": [ + "0xC0", + "0x9E" + ] + } + } + } + }, + "cryptoRefArray" : { + "type": "array", + "items": { + "$ref": "#/definitions/refType" + } + }, + "securedBy": { + "type": "object", + "title": "Secured By", + "description": "Specifies the mechanism by which the cryptographic asset is secured by", + "additionalProperties": false, + "properties": { + "mechanism": { + "type": "string", + "title": "Mechanism", + "description": "Specifies the mechanism by which the cryptographic asset is secured by.", + "examples": [ + "HSM", + "TPM", + "SGX", + "Software", + "None" + ] + }, + "algorithmRef": { + "$ref": "#/definitions/refType", + "title": "Algorithm Reference", + "description": "The bom-ref to the algorithm." + } + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Tags", + "description": "Textual strings that aid in discovery, search, and retrieval of the associated object. Tags often serve as a way to group or categorize similar or related objects by various attributes.", + "examples": [ + "json-parser", + "object-persistence", + "text-to-image", + "translation", + "object-detection" + ] + } + } +} diff --git a/src/python_sbom/_validate/schemas/jsf-0.82.schema.json b/src/python_sbom/_validate/schemas/jsf-0.82.schema.json new file mode 100644 index 0000000..f46bfb1 --- /dev/null +++ b/src/python_sbom/_validate/schemas/jsf-0.82.schema.json @@ -0,0 +1,240 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://cyclonedx.org/schema/jsf-0.82.schema.json", + "type": "object", + "title": "JSON Signature Format (JSF) standard", + "$comment" : "JSON Signature Format schema is published under the terms of the Apache License 2.0. JSF was developed by Anders Rundgren (anders.rundgren.net@gmail.com) as a part of the OpenKeyStore project. This schema supports the entirely of the JSF standard excluding 'extensions'.", + "definitions": { + "signature": { + "type": "object", + "title": "Signature", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "signers": { + "type": "array", + "title": "Signature", + "description": "Unique top level property for Multiple Signatures. (multisignature)", + "items": {"$ref": "#/definitions/signer"} + } + } + }, + { + "additionalProperties": false, + "properties": { + "chain": { + "type": "array", + "title": "Signature", + "description": "Unique top level property for Signature Chains. (signaturechain)", + "items": {"$ref": "#/definitions/signer"} + } + } + }, + { + "title": "Signature", + "description": "Unique top level property for simple signatures. (signaturecore)", + "$ref": "#/definitions/signer" + } + ] + }, + "signer": { + "type": "object", + "title": "Signature", + "required": [ + "algorithm", + "value" + ], + "additionalProperties": false, + "properties": { + "algorithm": { + "oneOf": [ + { + "type": "string", + "title": "Algorithm", + "description": "Signature algorithm. The currently recognized JWA [RFC7518] and RFC8037 [RFC8037] asymmetric key algorithms. Note: Unlike RFC8037 [RFC8037] JSF requires explicit Ed* algorithm names instead of \"EdDSA\".", + "enum": [ + "RS256", + "RS384", + "RS512", + "PS256", + "PS384", + "PS512", + "ES256", + "ES384", + "ES512", + "Ed25519", + "Ed448", + "HS256", + "HS384", + "HS512" + ] + }, + { + "type": "string", + "title": "Algorithm", + "description": "Signature algorithm. Note: If proprietary signature algorithms are added, they must be expressed as URIs.", + "format": "uri" + } + ] + }, + "keyId": { + "type": "string", + "title": "Key ID", + "description": "Optional. Application specific string identifying the signature key." + }, + "publicKey": { + "title": "Public key", + "description": "Optional. Public key object.", + "$ref": "#/definitions/publicKey" + }, + "certificatePath": { + "type": "array", + "title": "Certificate path", + "description": "Optional. Sorted array of X.509 [RFC5280] certificates, where the first element must contain the signature certificate. The certificate path must be contiguous but is not required to be complete.", + "items": { + "type": "string" + } + }, + "excludes": { + "type": "array", + "title": "Excludes", + "description": "Optional. Array holding the names of one or more application level properties that must be excluded from the signature process. Note that the \"excludes\" property itself, must also be excluded from the signature process. Since both the \"excludes\" property and the associated data it points to are unsigned, a conforming JSF implementation must provide options for specifying which properties to accept.", + "items": { + "type": "string" + } + }, + "value": { + "type": "string", + "title": "Signature", + "description": "The signature data. Note that the binary representation must follow the JWA [RFC7518] specifications." + } + } + }, + "keyType": { + "type": "string", + "title": "Key type", + "description": "Key type indicator.", + "enum": [ + "EC", + "OKP", + "RSA" + ] + }, + "publicKey": { + "title": "Public key", + "description": "Optional. Public key object.", + "type": "object", + "required": [ + "kty" + ], + "additionalProperties": true, + "properties": { + "kty": { + "$ref": "#/definitions/keyType" + } + }, + "allOf": [ + { + "if": { + "properties": { "kty": { "const": "EC" } } + }, + "then": { + "required": [ + "kty", + "crv", + "x", + "y" + ], + "additionalProperties": false, + "properties": { + "kty": { + "$ref": "#/definitions/keyType" + }, + "crv": { + "type": "string", + "title": "Curve name", + "description": "EC curve name.", + "enum": [ + "P-256", + "P-384", + "P-521" + ] + }, + "x": { + "type": "string", + "title": "Coordinate", + "description": "EC curve point X. The length of this field must be the full size of a coordinate for the curve specified in the \"crv\" parameter. For example, if the value of \"crv\" is \"P-521\", the decoded argument must be 66 bytes." + }, + "y": { + "type": "string", + "title": "Coordinate", + "description": "EC curve point Y. The length of this field must be the full size of a coordinate for the curve specified in the \"crv\" parameter. For example, if the value of \"crv\" is \"P-256\", the decoded argument must be 32 bytes." + } + } + } + }, + { + "if": { + "properties": { "kty": { "const": "OKP" } } + }, + "then": { + "required": [ + "kty", + "crv", + "x" + ], + "additionalProperties": false, + "properties": { + "kty": { + "$ref": "#/definitions/keyType" + }, + "crv": { + "type": "string", + "title": "Curve name", + "description": "EdDSA curve name.", + "enum": [ + "Ed25519", + "Ed448" + ] + }, + "x": { + "type": "string", + "title": "Coordinate", + "description": "EdDSA curve point X. The length of this field must be the full size of a coordinate for the curve specified in the \"crv\" parameter. For example, if the value of \"crv\" is \"Ed25519\", the decoded argument must be 32 bytes." + } + } + } + }, + { + "if": { + "properties": { "kty": { "const": "RSA" } } + }, + "then": { + "required": [ + "kty", + "n", + "e" + ], + "additionalProperties": false, + "properties": { + "kty": { + "$ref": "#/definitions/keyType" + }, + "n": { + "type": "string", + "title": "Modulus", + "description": "RSA modulus." + }, + "e": { + "type": "string", + "title": "Exponent", + "description": "RSA exponent." + } + } + } + } + ] + } + } +} diff --git a/src/python_sbom/_validate/schemas/spdx.schema.json b/src/python_sbom/_validate/schemas/spdx.schema.json new file mode 100644 index 0000000..566c4a3 --- /dev/null +++ b/src/python_sbom/_validate/schemas/spdx.schema.json @@ -0,0 +1,1662 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://cyclonedx.org/schema/spdx.schema.json", + "$comment": "v1.1-3.29.0", + "type": "string", + "enum": [ + "0BSD", + "3D-Slicer-1.0", + "AAL", + "Abstyles", + "AdaCore-doc", + "Adobe-2006", + "Adobe-Display-PostScript", + "Adobe-Glyph", + "Adobe-Utopia", + "ADSL", + "Advanced-Cryptics-Dictionary", + "AFL-1.1", + "AFL-1.2", + "AFL-2.0", + "AFL-2.1", + "AFL-3.0", + "Afmparse", + "AGPL-1.0", + "AGPL-1.0-only", + "AGPL-1.0-or-later", + "AGPL-3.0", + "AGPL-3.0-only", + "AGPL-3.0-or-later", + "Aladdin", + "ALGLIB-Documentation", + "AMD-newlib", + "AMDPLPA", + "AML", + "AML-glslang", + "AMPAS", + "ANTLR-PD", + "ANTLR-PD-fallback", + "any-OSI", + "any-OSI-perl-modules", + "Apache-1.0", + "Apache-1.1", + "Apache-2.0", + "APAFML", + "APL-1.0", + "App-s2p", + "APSL-1.0", + "APSL-1.1", + "APSL-1.2", + "APSL-2.0", + "Arphic-1999", + "Artistic-1.0", + "Artistic-1.0-cl8", + "Artistic-1.0-Perl", + "Artistic-2.0", + "Artistic-dist", + "Aspell-RU", + "ASWF-Digital-Assets-1.0", + "ASWF-Digital-Assets-1.1", + "atc-game", + "Baekmuk", + "Bahyph", + "Barr", + "bcrypt-Solar-Designer", + "Beerware", + "Bitstream-Charter", + "Bitstream-Vera", + "BitTorrent-1.0", + "BitTorrent-1.1", + "blessing", + "BlueOak-1.0.0", + "Boehm-GC", + "Boehm-GC-without-fee", + "BOLA-1.1", + "Borceux", + "Brian-Gladman-2-Clause", + "Brian-Gladman-3-Clause", + "Brian-Gladman-3-Clause-no-conversion", + "BSD-1-Clause", + "BSD-2-Clause", + "BSD-2-Clause-Darwin", + "BSD-2-Clause-first-lines", + "BSD-2-Clause-FreeBSD", + "BSD-2-Clause-NetBSD", + "BSD-2-Clause-Patent", + "BSD-2-Clause-pkgconf-disclaimer", + "BSD-2-Clause-pos-unchanged", + "BSD-2-Clause-Views", + "BSD-3-Clause", + "BSD-3-Clause-acpica", + "BSD-3-Clause-Attribution", + "BSD-3-Clause-Clear", + "BSD-3-Clause-flex", + "BSD-3-Clause-HP", + "BSD-3-Clause-LBNL", + "BSD-3-Clause-Modification", + "BSD-3-Clause-No-Military-License", + "BSD-3-Clause-No-Nuclear-License", + "BSD-3-Clause-No-Nuclear-License-2014", + "BSD-3-Clause-No-Nuclear-Warranty", + "BSD-3-Clause-Open-MPI", + "BSD-3-Clause-OpenWebUI", + "BSD-3-Clause-Sun", + "BSD-3-Clause-Tso", + "BSD-4-Clause", + "BSD-4-Clause-Shortened", + "BSD-4-Clause-UC", + "BSD-4.3RENO", + "BSD-4.3TAHOE", + "BSD-Advertising-Acknowledgement", + "BSD-ask-to-endorse", + "BSD-Attribution-HPND-disclaimer", + "BSD-Inferno-Nettverk", + "BSD-Mark-Modifications", + "BSD-Protection", + "BSD-Source-alt-GPL", + "BSD-Source-beginning-file", + "BSD-Source-Code", + "BSD-Source-Code-no-disclaimer", + "BSD-Systemics", + "BSD-Systemics-W3Works", + "BSL-1.0", + "Buddy", + "Bugroff", + "BUSL-1.1", + "bzip2-1.0.5", + "bzip2-1.0.6", + "C-UDA-1.0", + "CAL-1.0", + "CAL-1.0-Combined-Work-Exception", + "Caldera", + "Caldera-no-preamble", + "CAPEC-tou", + "Catharon", + "CATOSL-1.1", + "CC-BY-1.0", + "CC-BY-2.0", + "CC-BY-2.5", + "CC-BY-2.5-AU", + "CC-BY-3.0", + "CC-BY-3.0-AT", + "CC-BY-3.0-AU", + "CC-BY-3.0-DE", + "CC-BY-3.0-IGO", + "CC-BY-3.0-NL", + "CC-BY-3.0-US", + "CC-BY-4.0", + "CC-BY-NC-1.0", + "CC-BY-NC-2.0", + "CC-BY-NC-2.5", + "CC-BY-NC-3.0", + "CC-BY-NC-3.0-DE", + "CC-BY-NC-3.0-IGO", + "CC-BY-NC-4.0", + "CC-BY-NC-ND-1.0", + "CC-BY-NC-ND-2.0", + "CC-BY-NC-ND-2.5", + "CC-BY-NC-ND-3.0", + "CC-BY-NC-ND-3.0-DE", + "CC-BY-NC-ND-3.0-IGO", + "CC-BY-NC-ND-4.0", + "CC-BY-NC-SA-1.0", + "CC-BY-NC-SA-2.0", + "CC-BY-NC-SA-2.0-DE", + "CC-BY-NC-SA-2.0-FR", + "CC-BY-NC-SA-2.0-UK", + "CC-BY-NC-SA-2.5", + "CC-BY-NC-SA-3.0", + "CC-BY-NC-SA-3.0-DE", + "CC-BY-NC-SA-3.0-IGO", + "CC-BY-NC-SA-4.0", + "CC-BY-ND-1.0", + "CC-BY-ND-2.0", + "CC-BY-ND-2.5", + "CC-BY-ND-3.0", + "CC-BY-ND-3.0-DE", + "CC-BY-ND-4.0", + "CC-BY-SA-1.0", + "CC-BY-SA-2.0", + "CC-BY-SA-2.0-UK", + "CC-BY-SA-2.1-JP", + "CC-BY-SA-2.5", + "CC-BY-SA-3.0", + "CC-BY-SA-3.0-AT", + "CC-BY-SA-3.0-DE", + "CC-BY-SA-3.0-IGO", + "CC-BY-SA-4.0", + "CC-PDDC", + "CC-PDM-1.0", + "CC-SA-1.0", + "CC0-1.0", + "CDDL-1.0", + "CDDL-1.1", + "CDL-1.0", + "CDLA-Permissive-1.0", + "CDLA-Permissive-2.0", + "CDLA-Sharing-1.0", + "CECILL-1.0", + "CECILL-1.1", + "CECILL-2.0", + "CECILL-2.1", + "CECILL-B", + "CECILL-C", + "CERN-OHL-1.1", + "CERN-OHL-1.2", + "CERN-OHL-P-2.0", + "CERN-OHL-S-2.0", + "CERN-OHL-W-2.0", + "CFITSIO", + "check-cvs", + "checkmk", + "ClArtistic", + "Clips", + "CMU-Mach", + "CMU-Mach-nodoc", + "CNRI-Jython", + "CNRI-Python", + "CNRI-Python-GPL-Compatible", + "COIL-1.0", + "Community-Spec-1.0", + "Condor-1.1", + "copyleft-next-0.3.0", + "copyleft-next-0.3.1", + "Cornell-Lossless-JPEG", + "CPAL-1.0", + "CPL-1.0", + "CPOL-1.02", + "Cronyx", + "Crossword", + "CryptoSwift", + "CrystalStacker", + "CUA-OPL-1.0", + "Cube", + "curl", + "cve-tou", + "D-FSL-1.0", + "DEC-3-Clause", + "diffmark", + "DL-DE-BY-2.0", + "DL-DE-ZERO-2.0", + "DOC", + "DocBook-DTD", + "DocBook-Schema", + "DocBook-Stylesheet", + "DocBook-XML", + "Dotseqn", + "DRL-1.0", + "DRL-1.1", + "DSDP", + "dtoa", + "dvipdfm", + "ECL-1.0", + "ECL-2.0", + "eCos-2.0", + "EFL-1.0", + "EFL-2.0", + "eGenix", + "Elastic-2.0", + "Entessa", + "EPICS", + "EPL-1.0", + "EPL-2.0", + "ErlPL-1.1", + "ESA-PL-permissive-2.4", + "ESA-PL-strong-copyleft-2.4", + "ESA-PL-weak-copyleft-2.4", + "etalab-2.0", + "EUDatagrid", + "EUPL-1.0", + "EUPL-1.1", + "EUPL-1.2", + "Eurosym", + "Fair", + "FBM", + "FDK-AAC", + "FDK-MPEG-H", + "Ferguson-Twofish", + "Frameworx-1.0", + "FreeBSD-DOC", + "FreeImage", + "FSFAP", + "FSFAP-no-warranty-disclaimer", + "FSFUL", + "FSFULLR", + "FSFULLRSD", + "FSFULLRWD", + "FSL-1.1-ALv2", + "FSL-1.1-MIT", + "FTL", + "Furuseth", + "fwlw", + "Game-Programming-Gems", + "GCR-docs", + "GD", + "generic-xts", + "GFDL-1.1", + "GFDL-1.1-invariants-only", + "GFDL-1.1-invariants-or-later", + "GFDL-1.1-no-invariants-only", + "GFDL-1.1-no-invariants-or-later", + "GFDL-1.1-only", + "GFDL-1.1-or-later", + "GFDL-1.2", + "GFDL-1.2-invariants-only", + "GFDL-1.2-invariants-or-later", + "GFDL-1.2-no-invariants-only", + "GFDL-1.2-no-invariants-or-later", + "GFDL-1.2-only", + "GFDL-1.2-or-later", + "GFDL-1.3", + "GFDL-1.3-invariants-only", + "GFDL-1.3-invariants-or-later", + "GFDL-1.3-no-invariants-only", + "GFDL-1.3-no-invariants-or-later", + "GFDL-1.3-only", + "GFDL-1.3-or-later", + "Giftware", + "GL2PS", + "Glide", + "Glulxe", + "GLWTPL", + "gnuplot", + "GPL-1.0", + "GPL-1.0+", + "GPL-1.0-only", + "GPL-1.0-or-later", + "GPL-2.0", + "GPL-2.0+", + "GPL-2.0-only", + "GPL-2.0-or-later", + "GPL-2.0-with-autoconf-exception", + "GPL-2.0-with-bison-exception", + "GPL-2.0-with-classpath-exception", + "GPL-2.0-with-font-exception", + "GPL-2.0-with-GCC-exception", + "GPL-3.0", + "GPL-3.0+", + "GPL-3.0-only", + "GPL-3.0-or-later", + "GPL-3.0-with-autoconf-exception", + "GPL-3.0-with-GCC-exception", + "Graphics-Gems", + "gSOAP-1.3b", + "gtkbook", + "Gutmann", + "HaskellReport", + "HDF5", + "hdparm", + "HIDAPI", + "Hippocratic-2.1", + "Hippocratic-3.0-core", + "HP-1986", + "HP-1989", + "HPND", + "HPND-DEC", + "HPND-doc", + "HPND-doc-sell", + "HPND-export-US", + "HPND-export-US-acknowledgement", + "HPND-export-US-modify", + "HPND-export2-US", + "HPND-Fenneberg-Livingston", + "HPND-INRIA-IMAG", + "HPND-Intel", + "HPND-Kevlin-Henney", + "HPND-Markus-Kuhn", + "HPND-merchantability-variant", + "HPND-MIT-disclaimer", + "HPND-Netrek", + "HPND-Pbmplus", + "HPND-sell-MIT-disclaimer-xserver", + "HPND-sell-regexpr", + "HPND-sell-variant", + "HPND-sell-variant-critical-systems", + "HPND-sell-variant-MIT-disclaimer", + "HPND-sell-variant-MIT-disclaimer-rev", + "HPND-SMC", + "HPND-UC", + "HPND-UC-export-US", + "HTMLTIDY", + "hyphen-bulgarian", + "IBM-pibs", + "ICU", + "IEC-Code-Components-EULA", + "IJG", + "IJG-short", + "ImageMagick", + "iMatix", + "Imlib2", + "Info-ZIP", + "Informatica", + "Inner-Net-2.0", + "InnoSetup", + "Intel", + "Intel-ACPI", + "Interbase-1.0", + "IPA", + "IPL-1.0", + "ISC", + "ISC-Veillard", + "ISO-permission", + "Jam", + "JasPer-2.0", + "jove", + "JPL-image", + "JPNIC", + "JSON", + "Kastrup", + "Kazlib", + "Knuth-CTAN", + "LAL-1.2", + "LAL-1.3", + "Latex2e", + "Latex2e-translated-notice", + "Leptonica", + "LGPL-2.0", + "LGPL-2.0+", + "LGPL-2.0-only", + "LGPL-2.0-or-later", + "LGPL-2.1", + "LGPL-2.1+", + "LGPL-2.1-only", + "LGPL-2.1-or-later", + "LGPL-3.0", + "LGPL-3.0+", + "LGPL-3.0-only", + "LGPL-3.0-or-later", + "LGPLLR", + "Libpng", + "libpng-1.6.35", + "libpng-2.0", + "libselinux-1.0", + "libtiff", + "libutil-David-Nugent", + "LiLiQ-P-1.1", + "LiLiQ-R-1.1", + "LiLiQ-Rplus-1.1", + "Linux-man-pages-1-para", + "Linux-man-pages-copyleft", + "Linux-man-pages-copyleft-2-para", + "Linux-man-pages-copyleft-var", + "Linux-OpenIB", + "LOOP", + "LPD-document", + "LPL-1.0", + "LPL-1.02", + "LPPL-1.0", + "LPPL-1.1", + "LPPL-1.2", + "LPPL-1.3a", + "LPPL-1.3c", + "lsof", + "Lucida-Bitmap-Fonts", + "LZMA-SDK-9.11-to-9.20", + "LZMA-SDK-9.22", + "Mackerras-3-Clause", + "Mackerras-3-Clause-acknowledgment", + "magaz", + "mailprio", + "MakeIndex", + "man2html", + "Martin-Birgmeier", + "McPhee-slideshow", + "metamail", + "Minpack", + "MIPS", + "MirOS", + "MIT", + "MIT-0", + "MIT-advertising", + "MIT-Click", + "MIT-CMU", + "MIT-enna", + "MIT-feh", + "MIT-Festival", + "MIT-Khronos-old", + "MIT-Modern-Variant", + "MIT-open-group", + "MIT-STK", + "MIT-testregex", + "MIT-Wu", + "MITNFA", + "MMIXware", + "MMPL-1.0.1", + "Motosoto", + "MPEG-SSG", + "mpi-permissive", + "mpich2", + "MPL-1.0", + "MPL-1.1", + "MPL-2.0", + "MPL-2.0-no-copyleft-exception", + "mplus", + "MS-LPL", + "MS-PL", + "MS-RL", + "MTLL", + "MulanPSL-1.0", + "MulanPSL-2.0", + "Multics", + "Mup", + "MVT-1.1", + "NAIST-2003", + "NASA-1.3", + "Naumen", + "NBPL-1.0", + "NCBI-PD", + "NCGL-UK-2.0", + "NCL", + "NCSA", + "Net-SNMP", + "NetCDF", + "Newsletr", + "NGPL", + "ngrep", + "NICTA-1.0", + "NIST-PD", + "NIST-PD-fallback", + "NIST-PD-TNT", + "NIST-Software", + "NLOD-1.0", + "NLOD-2.0", + "NLPL", + "Nokia", + "NOSL", + "Noweb", + "NPL-1.0", + "NPL-1.1", + "NPOSL-3.0", + "NRL", + "NTIA-PD", + "NTP", + "NTP-0", + "Nunit", + "O-UDA-1.0", + "OAR", + "OCCT-PL", + "OCLC-2.0", + "ODbL-1.0", + "ODC-By-1.0", + "OFFIS", + "OFL-1.0", + "OFL-1.0-no-RFN", + "OFL-1.0-RFN", + "OFL-1.1", + "OFL-1.1-no-RFN", + "OFL-1.1-RFN", + "OGC-1.0", + "OGDL-Taiwan-1.0", + "OGL-Canada-2.0", + "OGL-UK-1.0", + "OGL-UK-2.0", + "OGL-UK-3.0", + "OGTSL", + "OLDAP-1.1", + "OLDAP-1.2", + "OLDAP-1.3", + "OLDAP-1.4", + "OLDAP-2.0", + "OLDAP-2.0.1", + "OLDAP-2.1", + "OLDAP-2.2", + "OLDAP-2.2.1", + "OLDAP-2.2.2", + "OLDAP-2.3", + "OLDAP-2.4", + "OLDAP-2.5", + "OLDAP-2.6", + "OLDAP-2.7", + "OLDAP-2.8", + "OLFL-1.3", + "OML", + "OpenMDW-1.0", + "OpenPBS-2.3", + "OpenSSL", + "OpenSSL-standalone", + "OpenVision", + "OPL-1.0", + "OPL-UK-3.0", + "OPUBL-1.0", + "OSC-1.0", + "OSET-PL-2.1", + "OSL-1.0", + "OSL-1.1", + "OSL-2.0", + "OSL-2.1", + "OSL-3.0", + "OSSP", + "PADL", + "ParaType-Free-Font-1.3", + "Parity-6.0.0", + "Parity-7.0.0", + "PDDL-1.0", + "PHP-3.0", + "PHP-3.01", + "Pixar", + "pkgconf", + "Plexus", + "pnmstitch", + "PolyForm-Noncommercial-1.0.0", + "PolyForm-Small-Business-1.0.0", + "PostgreSQL", + "PPL", + "PSF-2.0", + "psfrag", + "psutils", + "Python-2.0", + "Python-2.0.1", + "python-ldap", + "Qhull", + "QPL-1.0", + "QPL-1.0-INRIA-2004", + "radvd", + "Rdisc", + "RHeCos-1.1", + "RPL-1.1", + "RPL-1.5", + "RPSL-1.0", + "RSA-MD", + "RSCPL", + "Ruby", + "Ruby-pty", + "SAX-PD", + "SAX-PD-2.0", + "Saxpath", + "SCEA", + "SchemeReport", + "Sendmail", + "Sendmail-8.23", + "Sendmail-Open-Source-1.1", + "SGI-B-1.0", + "SGI-B-1.1", + "SGI-B-2.0", + "SGI-OpenGL", + "SGMLUG-PM", + "SGP4", + "SHL-0.5", + "SHL-0.51", + "SimPL-2.0", + "SISSL", + "SISSL-1.2", + "SL", + "Sleepycat", + "SMAIL-GPL", + "SMLNJ", + "SMPPL", + "SNIA", + "snprintf", + "SOFA", + "softSurfer", + "Soundex", + "Spencer-86", + "Spencer-94", + "Spencer-99", + "SPL-1.0", + "ssh-keyscan", + "SSH-OpenSSH", + "SSH-short", + "SSLeay-standalone", + "SSPL-1.0", + "StandardML-NJ", + "SugarCRM-1.1.3", + "SUL-1.0", + "Sun-PPP", + "Sun-PPP-2000", + "SunPro", + "SWL", + "swrule", + "Symlinks", + "TAPR-OHL-1.0", + "TCL", + "TCP-wrappers", + "TekHVC", + "TermReadKey", + "TGPPL-1.0", + "ThirdEye", + "threeparttable", + "TMate", + "TORQUE-1.1", + "TOSL", + "TPDL", + "TPL-1.0", + "TrustedQSL", + "TTWL", + "TTYP0", + "TU-Berlin-1.0", + "TU-Berlin-2.0", + "Ubuntu-font-1.0", + "UCAR", + "UCL-1.0", + "ulem", + "UMich-Merit", + "Unicode-3.0", + "Unicode-DFS-2015", + "Unicode-DFS-2016", + "Unicode-TOU", + "UnixCrypt", + "Unlicense", + "Unlicense-libtelnet", + "Unlicense-libwhirlpool", + "UnRAR", + "UPL-1.0", + "URT-RLE", + "Vim", + "Vixie-Cron", + "VOSTROM", + "VSL-1.0", + "W3C", + "W3C-19980720", + "W3C-20150513", + "w3m", + "Watcom-1.0", + "Widget-Workshop", + "WordNet", + "Wsuipa", + "WTFNMFPL", + "WTFPL", + "wwl", + "wxWindows", + "X11", + "X11-distribute-modifications-variant", + "X11-no-permit-persons", + "X11-swapped", + "Xdebug-1.03", + "Xerox", + "Xfig", + "XFree86-1.1", + "xinetd", + "xkeyboard-config-Zinoviev", + "xlock", + "Xnet", + "xpp", + "XSkat", + "xzoom", + "YPL-1.0", + "YPL-1.1", + "Zed", + "Zeeff", + "Zend-2.0", + "Zimbra-1.3", + "Zimbra-1.4", + "Zlib", + "zlib-acknowledgement", + "ZPL-1.1", + "ZPL-2.0", + "ZPL-2.1", + "389-exception", + "Asterisk-exception", + "Asterisk-linking-protocols-exception", + "Autoconf-exception-2.0", + "Autoconf-exception-3.0", + "Autoconf-exception-generic", + "Autoconf-exception-generic-3.0", + "Autoconf-exception-macro", + "Bison-exception-1.24", + "Bison-exception-2.2", + "Bootloader-exception", + "CGAL-linking-exception", + "Classpath-exception-2.0", + "Classpath-exception-2.0-short", + "CLISP-exception-2.0", + "cryptsetup-OpenSSL-exception", + "Digia-Qt-LGPL-exception-1.1", + "DigiRule-FOSS-exception", + "eCos-exception-2.0", + "erlang-otp-linking-exception", + "Fawkes-Runtime-exception", + "FLTK-exception", + "fmt-exception", + "Font-exception-2.0", + "freertos-exception-2.0", + "GCC-exception-2.0", + "GCC-exception-2.0-note", + "GCC-exception-3.1", + "Gmsh-exception", + "GNAT-exception", + "GNOME-examples-exception", + "GNU-compiler-exception", + "gnu-javamail-exception", + "Google-Patent-WebM", + "GPL-3.0-389-ds-base-exception", + "GPL-3.0-interface-exception", + "GPL-3.0-linking-exception", + "GPL-3.0-linking-source-exception", + "GPL-CC-1.0", + "GStreamer-exception-2005", + "GStreamer-exception-2008", + "harbour-exception", + "i2p-gpl-java-exception", + "Independent-modules-exception", + "KiCad-libraries-exception", + "kvirc-openssl-exception", + "LGPL-3.0-linking-exception", + "libpri-OpenH323-exception", + "Libtool-exception", + "Linux-syscall-note", + "LLGPL", + "LLVM-exception", + "LZMA-exception", + "mif-exception", + "mxml-exception", + "Nokia-Qt-exception-1.1", + "OCaml-LGPL-linking-exception", + "OCCT-exception-1.0", + "OpenJDK-assembly-exception-1.0", + "openvpn-openssl-exception", + "PCRE2-exception", + "polyparse-exception", + "PS-or-PDF-font-exception-20170817", + "QPL-1.0-INRIA-2004-exception", + "Qt-GPL-exception-1.0", + "Qt-LGPL-exception-1.1", + "Qwt-exception-1.0", + "romic-exception", + "RRDtool-FLOSS-exception-2.0", + "rsync-linking-exception", + "SANE-exception", + "SHL-2.0", + "SHL-2.1", + "Simple-Library-Usage-exception", + "Spelling-Provider-LGPL-exception", + "sqlitestudio-OpenSSL-exception", + "stunnel-exception", + "SWI-exception", + "Swift-exception", + "Texinfo-exception", + "u-boot-exception-2.0", + "UBDL-exception", + "Universal-FOSS-exception-1.0", + "vsftpd-openssl-exception", + "WxWindows-exception-3.1", + "x11vnc-openssl-exception" + ], + "meta:enum": { + "0BSD": "BSD Zero Clause License", + "3D-Slicer-1.0": "3D Slicer License v1.0", + "AAL": "Attribution Assurance License", + "Abstyles": "Abstyles License", + "AdaCore-doc": "AdaCore Doc License", + "Adobe-2006": "Adobe Systems Incorporated Source Code License Agreement", + "Adobe-Display-PostScript": "Adobe Display PostScript License", + "Adobe-Glyph": "Adobe Glyph List License", + "Adobe-Utopia": "Adobe Utopia Font License", + "ADSL": "Amazon Digital Services License", + "Advanced-Cryptics-Dictionary": "Advanced Cryptics Dictionary License", + "AFL-1.1": "Academic Free License v1.1", + "AFL-1.2": "Academic Free License v1.2", + "AFL-2.0": "Academic Free License v2.0", + "AFL-2.1": "Academic Free License v2.1", + "AFL-3.0": "Academic Free License v3.0", + "Afmparse": "Afmparse License", + "AGPL-1.0": "Affero General Public License v1.0", + "AGPL-1.0-only": "Affero General Public License v1.0 only", + "AGPL-1.0-or-later": "Affero General Public License v1.0 or later", + "AGPL-3.0": "GNU Affero General Public License v3.0", + "AGPL-3.0-only": "GNU Affero General Public License v3.0 only", + "AGPL-3.0-or-later": "GNU Affero General Public License v3.0 or later", + "Aladdin": "Aladdin Free Public License", + "ALGLIB-Documentation": "ALGLIB Documentation License", + "AMD-newlib": "AMD newlib License", + "AMDPLPA": "AMD's plpa_map.c License", + "AML": "Apple MIT License", + "AML-glslang": "AML glslang variant License", + "AMPAS": "Academy of Motion Picture Arts and Sciences BSD", + "ANTLR-PD": "ANTLR Software Rights Notice", + "ANTLR-PD-fallback": "ANTLR Software Rights Notice with license fallback", + "any-OSI": "Any OSI License", + "any-OSI-perl-modules": "Any OSI License - Perl Modules", + "Apache-1.0": "Apache License 1.0", + "Apache-1.1": "Apache License 1.1", + "Apache-2.0": "Apache License 2.0", + "APAFML": "Adobe Postscript AFM License", + "APL-1.0": "Adaptive Public License 1.0", + "App-s2p": "App::s2p License", + "APSL-1.0": "Apple Public Source License 1.0", + "APSL-1.1": "Apple Public Source License 1.1", + "APSL-1.2": "Apple Public Source License 1.2", + "APSL-2.0": "Apple Public Source License 2.0", + "Arphic-1999": "Arphic Public License", + "Artistic-1.0": "Artistic License 1.0", + "Artistic-1.0-cl8": "Artistic License 1.0 w\/clause 8", + "Artistic-1.0-Perl": "Artistic License 1.0 (Perl)", + "Artistic-2.0": "Artistic License 2.0", + "Artistic-dist": "Artistic License 1.0 (dist)", + "Aspell-RU": "Aspell Russian License", + "ASWF-Digital-Assets-1.0": "ASWF Digital Assets License version 1.0", + "ASWF-Digital-Assets-1.1": "ASWF Digital Assets License 1.1", + "atc-game": "atc Game License", + "Baekmuk": "Baekmuk License", + "Bahyph": "Bahyph License", + "Barr": "Barr License", + "bcrypt-Solar-Designer": "bcrypt Solar Designer License", + "Beerware": "Beerware License", + "Bitstream-Charter": "Bitstream Charter Font License", + "Bitstream-Vera": "Bitstream Vera Font License", + "BitTorrent-1.0": "BitTorrent Open Source License v1.0", + "BitTorrent-1.1": "BitTorrent Open Source License v1.1", + "blessing": "SQLite Blessing", + "BlueOak-1.0.0": "Blue Oak Model License 1.0.0", + "Boehm-GC": "Boehm-Demers-Weiser GC License", + "Boehm-GC-without-fee": "Boehm-Demers-Weiser GC License (without fee)", + "BOLA-1.1": "Buena Onda License Agreement v1.1", + "Borceux": "Borceux license", + "Brian-Gladman-2-Clause": "Brian Gladman 2-Clause License", + "Brian-Gladman-3-Clause": "Brian Gladman 3-Clause License", + "Brian-Gladman-3-Clause-no-conversion": "Brian Gladman 3-Clause License (no conversion clause)", + "BSD-1-Clause": "BSD 1-Clause License", + "BSD-2-Clause": "BSD 2-Clause \"Simplified\" License", + "BSD-2-Clause-Darwin": "BSD 2-Clause - Ian Darwin variant", + "BSD-2-Clause-first-lines": "BSD 2-Clause - first lines requirement", + "BSD-2-Clause-FreeBSD": "BSD 2-Clause FreeBSD License", + "BSD-2-Clause-NetBSD": "BSD 2-Clause NetBSD License", + "BSD-2-Clause-Patent": "BSD-2-Clause Plus Patent License", + "BSD-2-Clause-pkgconf-disclaimer": "BSD 2-Clause pkgconf disclaimer variant", + "BSD-2-Clause-pos-unchanged": "BSD 2-Clause - position unchanged variant", + "BSD-2-Clause-Views": "BSD 2-Clause with views sentence", + "BSD-3-Clause": "BSD 3-Clause \"New\" or \"Revised\" License", + "BSD-3-Clause-acpica": "BSD 3-Clause acpica variant", + "BSD-3-Clause-Attribution": "BSD with attribution", + "BSD-3-Clause-Clear": "BSD 3-Clause Clear License", + "BSD-3-Clause-flex": "BSD 3-Clause Flex variant", + "BSD-3-Clause-HP": "Hewlett-Packard BSD variant license", + "BSD-3-Clause-LBNL": "Lawrence Berkeley National Labs BSD variant license", + "BSD-3-Clause-Modification": "BSD 3-Clause Modification", + "BSD-3-Clause-No-Military-License": "BSD 3-Clause No Military License", + "BSD-3-Clause-No-Nuclear-License": "BSD 3-Clause No Nuclear License", + "BSD-3-Clause-No-Nuclear-License-2014": "BSD 3-Clause No Nuclear License 2014", + "BSD-3-Clause-No-Nuclear-Warranty": "BSD 3-Clause No Nuclear Warranty", + "BSD-3-Clause-Open-MPI": "BSD 3-Clause Open MPI variant", + "BSD-3-Clause-OpenWebUI": "BSD 3-Clause - OpenWebUI variant", + "BSD-3-Clause-Sun": "BSD 3-Clause Sun Microsystems", + "BSD-3-Clause-Tso": "BSD 3-Clause Tso variant", + "BSD-4-Clause": "BSD 4-Clause \"Original\" or \"Old\" License", + "BSD-4-Clause-Shortened": "BSD 4 Clause Shortened", + "BSD-4-Clause-UC": "BSD-4-Clause (University of California-Specific)", + "BSD-4.3RENO": "BSD 4.3 RENO License", + "BSD-4.3TAHOE": "BSD 4.3 TAHOE License", + "BSD-Advertising-Acknowledgement": "BSD Advertising Acknowledgement License", + "BSD-ask-to-endorse": "BSD - ask to endorse", + "BSD-Attribution-HPND-disclaimer": "BSD with Attribution and HPND disclaimer", + "BSD-Inferno-Nettverk": "BSD-Inferno-Nettverk", + "BSD-Mark-Modifications": "BSD Mark Modifications License", + "BSD-Protection": "BSD Protection License", + "BSD-Source-alt-GPL": "BSD Source Code Attribution - GPL alternative", + "BSD-Source-beginning-file": "BSD Source Code Attribution - beginning of file variant", + "BSD-Source-Code": "BSD Source Code Attribution", + "BSD-Source-Code-no-disclaimer": "BSD Source Code Attribution - no disclaimer", + "BSD-Systemics": "Systemics BSD variant license", + "BSD-Systemics-W3Works": "Systemics W3Works BSD variant license", + "BSL-1.0": "Boost Software License 1.0", + "Buddy": "Buddy License", + "Bugroff": "Bugroff License", + "BUSL-1.1": "Business Source License 1.1", + "bzip2-1.0.5": "bzip2 and libbzip2 License v1.0.5", + "bzip2-1.0.6": "bzip2 and libbzip2 License v1.0.6", + "C-UDA-1.0": "Computational Use of Data Agreement v1.0", + "CAL-1.0": "Cryptographic Autonomy License 1.0", + "CAL-1.0-Combined-Work-Exception": "Cryptographic Autonomy License 1.0 (Combined Work Exception)", + "Caldera": "Caldera License", + "Caldera-no-preamble": "Caldera License (without preamble)", + "CAPEC-tou": "Common Attack Pattern Enumeration and Classification License", + "Catharon": "Catharon License", + "CATOSL-1.1": "Computer Associates Trusted Open Source License 1.1", + "CC-BY-1.0": "Creative Commons Attribution 1.0 Generic", + "CC-BY-2.0": "Creative Commons Attribution 2.0 Generic", + "CC-BY-2.5": "Creative Commons Attribution 2.5 Generic", + "CC-BY-2.5-AU": "Creative Commons Attribution 2.5 Australia", + "CC-BY-3.0": "Creative Commons Attribution 3.0 Unported", + "CC-BY-3.0-AT": "Creative Commons Attribution 3.0 Austria", + "CC-BY-3.0-AU": "Creative Commons Attribution 3.0 Australia", + "CC-BY-3.0-DE": "Creative Commons Attribution 3.0 Germany", + "CC-BY-3.0-IGO": "Creative Commons Attribution 3.0 IGO", + "CC-BY-3.0-NL": "Creative Commons Attribution 3.0 Netherlands", + "CC-BY-3.0-US": "Creative Commons Attribution 3.0 United States", + "CC-BY-4.0": "Creative Commons Attribution 4.0 International", + "CC-BY-NC-1.0": "Creative Commons Attribution Non Commercial 1.0 Generic", + "CC-BY-NC-2.0": "Creative Commons Attribution Non Commercial 2.0 Generic", + "CC-BY-NC-2.5": "Creative Commons Attribution Non Commercial 2.5 Generic", + "CC-BY-NC-3.0": "Creative Commons Attribution Non Commercial 3.0 Unported", + "CC-BY-NC-3.0-DE": "Creative Commons Attribution Non Commercial 3.0 Germany", + "CC-BY-NC-3.0-IGO": "Creative Commons Attribution Non Commercial 3.0 IGO", + "CC-BY-NC-4.0": "Creative Commons Attribution Non Commercial 4.0 International", + "CC-BY-NC-ND-1.0": "Creative Commons Attribution Non Commercial No Derivatives 1.0 Generic", + "CC-BY-NC-ND-2.0": "Creative Commons Attribution Non Commercial No Derivatives 2.0 Generic", + "CC-BY-NC-ND-2.5": "Creative Commons Attribution Non Commercial No Derivatives 2.5 Generic", + "CC-BY-NC-ND-3.0": "Creative Commons Attribution Non Commercial No Derivatives 3.0 Unported", + "CC-BY-NC-ND-3.0-DE": "Creative Commons Attribution Non Commercial No Derivatives 3.0 Germany", + "CC-BY-NC-ND-3.0-IGO": "Creative Commons Attribution Non Commercial No Derivatives 3.0 IGO", + "CC-BY-NC-ND-4.0": "Creative Commons Attribution Non Commercial No Derivatives 4.0 International", + "CC-BY-NC-SA-1.0": "Creative Commons Attribution Non Commercial Share Alike 1.0 Generic", + "CC-BY-NC-SA-2.0": "Creative Commons Attribution Non Commercial Share Alike 2.0 Generic", + "CC-BY-NC-SA-2.0-DE": "Creative Commons Attribution Non Commercial Share Alike 2.0 Germany", + "CC-BY-NC-SA-2.0-FR": "Creative Commons Attribution-NonCommercial-ShareAlike 2.0 France", + "CC-BY-NC-SA-2.0-UK": "Creative Commons Attribution Non Commercial Share Alike 2.0 England and Wales", + "CC-BY-NC-SA-2.5": "Creative Commons Attribution Non Commercial Share Alike 2.5 Generic", + "CC-BY-NC-SA-3.0": "Creative Commons Attribution Non Commercial Share Alike 3.0 Unported", + "CC-BY-NC-SA-3.0-DE": "Creative Commons Attribution Non Commercial Share Alike 3.0 Germany", + "CC-BY-NC-SA-3.0-IGO": "Creative Commons Attribution Non Commercial Share Alike 3.0 IGO", + "CC-BY-NC-SA-4.0": "Creative Commons Attribution Non Commercial Share Alike 4.0 International", + "CC-BY-ND-1.0": "Creative Commons Attribution No Derivatives 1.0 Generic", + "CC-BY-ND-2.0": "Creative Commons Attribution No Derivatives 2.0 Generic", + "CC-BY-ND-2.5": "Creative Commons Attribution No Derivatives 2.5 Generic", + "CC-BY-ND-3.0": "Creative Commons Attribution No Derivatives 3.0 Unported", + "CC-BY-ND-3.0-DE": "Creative Commons Attribution No Derivatives 3.0 Germany", + "CC-BY-ND-4.0": "Creative Commons Attribution No Derivatives 4.0 International", + "CC-BY-SA-1.0": "Creative Commons Attribution Share Alike 1.0 Generic", + "CC-BY-SA-2.0": "Creative Commons Attribution Share Alike 2.0 Generic", + "CC-BY-SA-2.0-UK": "Creative Commons Attribution Share Alike 2.0 England and Wales", + "CC-BY-SA-2.1-JP": "Creative Commons Attribution Share Alike 2.1 Japan", + "CC-BY-SA-2.5": "Creative Commons Attribution Share Alike 2.5 Generic", + "CC-BY-SA-3.0": "Creative Commons Attribution Share Alike 3.0 Unported", + "CC-BY-SA-3.0-AT": "Creative Commons Attribution Share Alike 3.0 Austria", + "CC-BY-SA-3.0-DE": "Creative Commons Attribution Share Alike 3.0 Germany", + "CC-BY-SA-3.0-IGO": "Creative Commons Attribution-ShareAlike 3.0 IGO", + "CC-BY-SA-4.0": "Creative Commons Attribution Share Alike 4.0 International", + "CC-PDDC": "Creative Commons Public Domain Dedication and Certification", + "CC-PDM-1.0": "Creative Commons Public Domain Mark 1.0 Universal", + "CC-SA-1.0": "Creative Commons Share Alike 1.0 Generic", + "CC0-1.0": "Creative Commons Zero v1.0 Universal", + "CDDL-1.0": "Common Development and Distribution License 1.0", + "CDDL-1.1": "Common Development and Distribution License 1.1", + "CDL-1.0": "Common Documentation License 1.0", + "CDLA-Permissive-1.0": "Community Data License Agreement Permissive 1.0", + "CDLA-Permissive-2.0": "Community Data License Agreement Permissive 2.0", + "CDLA-Sharing-1.0": "Community Data License Agreement Sharing 1.0", + "CECILL-1.0": "CeCILL Free Software License Agreement v1.0", + "CECILL-1.1": "CeCILL Free Software License Agreement v1.1", + "CECILL-2.0": "CeCILL Free Software License Agreement v2.0", + "CECILL-2.1": "CeCILL Free Software License Agreement v2.1", + "CECILL-B": "CeCILL-B Free Software License Agreement", + "CECILL-C": "CeCILL-C Free Software License Agreement", + "CERN-OHL-1.1": "CERN Open Hardware Licence v1.1", + "CERN-OHL-1.2": "CERN Open Hardware Licence v1.2", + "CERN-OHL-P-2.0": "CERN Open Hardware Licence Version 2 - Permissive", + "CERN-OHL-S-2.0": "CERN Open Hardware Licence Version 2 - Strongly Reciprocal", + "CERN-OHL-W-2.0": "CERN Open Hardware Licence Version 2 - Weakly Reciprocal", + "CFITSIO": "CFITSIO License", + "check-cvs": "check-cvs License", + "checkmk": "Checkmk License", + "ClArtistic": "Clarified Artistic License", + "Clips": "Clips License", + "CMU-Mach": "CMU Mach License", + "CMU-Mach-nodoc": "CMU Mach - no notices-in-documentation variant", + "CNRI-Jython": "CNRI Jython License", + "CNRI-Python": "CNRI Python License", + "CNRI-Python-GPL-Compatible": "CNRI Python Open Source GPL Compatible License Agreement", + "COIL-1.0": "Copyfree Open Innovation License", + "Community-Spec-1.0": "Community Specification License 1.0", + "Condor-1.1": "Condor Public License v1.1", + "copyleft-next-0.3.0": "copyleft-next 0.3.0", + "copyleft-next-0.3.1": "copyleft-next 0.3.1", + "Cornell-Lossless-JPEG": "Cornell Lossless JPEG License", + "CPAL-1.0": "Common Public Attribution License 1.0", + "CPL-1.0": "Common Public License 1.0", + "CPOL-1.02": "Code Project Open License 1.02", + "Cronyx": "Cronyx License", + "Crossword": "Crossword License", + "CryptoSwift": "CryptoSwift License", + "CrystalStacker": "CrystalStacker License", + "CUA-OPL-1.0": "CUA Office Public License v1.0", + "Cube": "Cube License", + "curl": "curl License", + "cve-tou": "Common Vulnerability Enumeration ToU License", + "D-FSL-1.0": "Deutsche Freie Software Lizenz", + "DEC-3-Clause": "DEC 3-Clause License", + "diffmark": "diffmark license", + "DL-DE-BY-2.0": "Data licence Germany \u2013 attribution \u2013 version 2.0", + "DL-DE-ZERO-2.0": "Data licence Germany \u2013 zero \u2013 version 2.0", + "DOC": "DOC License", + "DocBook-DTD": "DocBook DTD License", + "DocBook-Schema": "DocBook Schema License", + "DocBook-Stylesheet": "DocBook Stylesheet License", + "DocBook-XML": "DocBook XML License", + "Dotseqn": "Dotseqn License", + "DRL-1.0": "Detection Rule License 1.0", + "DRL-1.1": "Detection Rule License 1.1", + "DSDP": "DSDP License", + "dtoa": "David M. Gay dtoa License", + "dvipdfm": "dvipdfm License", + "ECL-1.0": "Educational Community License v1.0", + "ECL-2.0": "Educational Community License v2.0", + "eCos-2.0": "eCos license version 2.0", + "EFL-1.0": "Eiffel Forum License v1.0", + "EFL-2.0": "Eiffel Forum License v2.0", + "eGenix": "eGenix.com Public License 1.1.0", + "Elastic-2.0": "Elastic License 2.0", + "Entessa": "Entessa Public License v1.0", + "EPICS": "EPICS Open License", + "EPL-1.0": "Eclipse Public License 1.0", + "EPL-2.0": "Eclipse Public License 2.0", + "ErlPL-1.1": "Erlang Public License v1.1", + "ESA-PL-permissive-2.4": "European Space Agency Public License \u2013 v2.4 \u2013 Permissive (Type 3)", + "ESA-PL-strong-copyleft-2.4": "European Space Agency Public License (ESA-PL) - V2.4 - Strong Copyleft (Type 1)", + "ESA-PL-weak-copyleft-2.4": "European Space Agency Public License \u2013 v2.4 \u2013 Weak Copyleft (Type 2)", + "etalab-2.0": "Etalab Open License 2.0", + "EUDatagrid": "EU DataGrid Software License", + "EUPL-1.0": "European Union Public License 1.0", + "EUPL-1.1": "European Union Public License 1.1", + "EUPL-1.2": "European Union Public License 1.2", + "Eurosym": "Eurosym License", + "Fair": "Fair License", + "FBM": "Fuzzy Bitmap License", + "FDK-AAC": "Fraunhofer FDK AAC Codec Library", + "FDK-MPEG-H": "Fraunhofer FDK MPEG-H Software", + "Ferguson-Twofish": "Ferguson Twofish License", + "Frameworx-1.0": "Frameworx Open License 1.0", + "FreeBSD-DOC": "FreeBSD Documentation License", + "FreeImage": "FreeImage Public License v1.0", + "FSFAP": "FSF All Permissive License", + "FSFAP-no-warranty-disclaimer": "FSF All Permissive License (without Warranty)", + "FSFUL": "FSF Unlimited License", + "FSFULLR": "FSF Unlimited License (with License Retention)", + "FSFULLRSD": "FSF Unlimited License (with License Retention and Short Disclaimer)", + "FSFULLRWD": "FSF Unlimited License (With License Retention and Warranty Disclaimer)", + "FSL-1.1-ALv2": "Functional Source License, Version 1.1, ALv2 Future License", + "FSL-1.1-MIT": "Functional Source License, Version 1.1, MIT Future License", + "FTL": "Freetype Project License", + "Furuseth": "Furuseth License", + "fwlw": "fwlw License", + "Game-Programming-Gems": "Game Programming Gems License", + "GCR-docs": "Gnome GCR Documentation License", + "GD": "GD License", + "generic-xts": "Generic XTS License", + "GFDL-1.1": "GNU Free Documentation License v1.1", + "GFDL-1.1-invariants-only": "GNU Free Documentation License v1.1 only - invariants", + "GFDL-1.1-invariants-or-later": "GNU Free Documentation License v1.1 or later - invariants", + "GFDL-1.1-no-invariants-only": "GNU Free Documentation License v1.1 only - no invariants", + "GFDL-1.1-no-invariants-or-later": "GNU Free Documentation License v1.1 or later - no invariants", + "GFDL-1.1-only": "GNU Free Documentation License v1.1 only", + "GFDL-1.1-or-later": "GNU Free Documentation License v1.1 or later", + "GFDL-1.2": "GNU Free Documentation License v1.2", + "GFDL-1.2-invariants-only": "GNU Free Documentation License v1.2 only - invariants", + "GFDL-1.2-invariants-or-later": "GNU Free Documentation License v1.2 or later - invariants", + "GFDL-1.2-no-invariants-only": "GNU Free Documentation License v1.2 only - no invariants", + "GFDL-1.2-no-invariants-or-later": "GNU Free Documentation License v1.2 or later - no invariants", + "GFDL-1.2-only": "GNU Free Documentation License v1.2 only", + "GFDL-1.2-or-later": "GNU Free Documentation License v1.2 or later", + "GFDL-1.3": "GNU Free Documentation License v1.3", + "GFDL-1.3-invariants-only": "GNU Free Documentation License v1.3 only - invariants", + "GFDL-1.3-invariants-or-later": "GNU Free Documentation License v1.3 or later - invariants", + "GFDL-1.3-no-invariants-only": "GNU Free Documentation License v1.3 only - no invariants", + "GFDL-1.3-no-invariants-or-later": "GNU Free Documentation License v1.3 or later - no invariants", + "GFDL-1.3-only": "GNU Free Documentation License v1.3 only", + "GFDL-1.3-or-later": "GNU Free Documentation License v1.3 or later", + "Giftware": "Giftware License", + "GL2PS": "GL2PS License", + "Glide": "3dfx Glide License", + "Glulxe": "Glulxe License", + "GLWTPL": "Good Luck With That Public License", + "gnuplot": "gnuplot License", + "GPL-1.0": "GNU General Public License v1.0 only", + "GPL-1.0+": "GNU General Public License v1.0 or later", + "GPL-1.0-only": "GNU General Public License v1.0 only", + "GPL-1.0-or-later": "GNU General Public License v1.0 or later", + "GPL-2.0": "GNU General Public License v2.0 only", + "GPL-2.0+": "GNU General Public License v2.0 or later", + "GPL-2.0-only": "GNU General Public License v2.0 only", + "GPL-2.0-or-later": "GNU General Public License v2.0 or later", + "GPL-2.0-with-autoconf-exception": "GNU General Public License v2.0 w\/Autoconf exception", + "GPL-2.0-with-bison-exception": "GNU General Public License v2.0 w\/Bison exception", + "GPL-2.0-with-classpath-exception": "GNU General Public License v2.0 w\/Classpath exception", + "GPL-2.0-with-font-exception": "GNU General Public License v2.0 w\/Font exception", + "GPL-2.0-with-GCC-exception": "GNU General Public License v2.0 w\/GCC Runtime Library exception", + "GPL-3.0": "GNU General Public License v3.0 only", + "GPL-3.0+": "GNU General Public License v3.0 or later", + "GPL-3.0-only": "GNU General Public License v3.0 only", + "GPL-3.0-or-later": "GNU General Public License v3.0 or later", + "GPL-3.0-with-autoconf-exception": "GNU General Public License v3.0 w\/Autoconf exception", + "GPL-3.0-with-GCC-exception": "GNU General Public License v3.0 w\/GCC Runtime Library exception", + "Graphics-Gems": "Graphics Gems License", + "gSOAP-1.3b": "gSOAP Public License v1.3b", + "gtkbook": "gtkbook License", + "Gutmann": "Gutmann License", + "HaskellReport": "Haskell Language Report License", + "HDF5": "HDF5 License", + "hdparm": "hdparm License", + "HIDAPI": "HIDAPI License", + "Hippocratic-2.1": "Hippocratic License 2.1", + "Hippocratic-3.0-core": "Hippocratic License 3.0", + "HP-1986": "Hewlett-Packard 1986 License", + "HP-1989": "Hewlett-Packard 1989 License", + "HPND": "Historical Permission Notice and Disclaimer", + "HPND-DEC": "Historical Permission Notice and Disclaimer - DEC variant", + "HPND-doc": "Historical Permission Notice and Disclaimer - documentation variant", + "HPND-doc-sell": "Historical Permission Notice and Disclaimer - documentation sell variant", + "HPND-export-US": "HPND with US Government export control warning", + "HPND-export-US-acknowledgement": "HPND with US Government export control warning and acknowledgment", + "HPND-export-US-modify": "HPND with US Government export control warning and modification rqmt", + "HPND-export2-US": "HPND with US Government export control and 2 disclaimers", + "HPND-Fenneberg-Livingston": "Historical Permission Notice and Disclaimer - Fenneberg-Livingston variant", + "HPND-INRIA-IMAG": "Historical Permission Notice and Disclaimer - INRIA-IMAG variant", + "HPND-Intel": "Historical Permission Notice and Disclaimer - Intel variant", + "HPND-Kevlin-Henney": "Historical Permission Notice and Disclaimer - Kevlin Henney variant", + "HPND-Markus-Kuhn": "Historical Permission Notice and Disclaimer - Markus Kuhn variant", + "HPND-merchantability-variant": "Historical Permission Notice and Disclaimer - merchantability variant", + "HPND-MIT-disclaimer": "Historical Permission Notice and Disclaimer with MIT disclaimer", + "HPND-Netrek": "Historical Permission Notice and Disclaimer - Netrek variant", + "HPND-Pbmplus": "Historical Permission Notice and Disclaimer - Pbmplus variant", + "HPND-sell-MIT-disclaimer-xserver": "Historical Permission Notice and Disclaimer - sell xserver variant with MIT disclaimer", + "HPND-sell-regexpr": "Historical Permission Notice and Disclaimer - sell regexpr variant", + "HPND-sell-variant": "Historical Permission Notice and Disclaimer - sell variant", + "HPND-sell-variant-critical-systems": "HPND - sell variant with safety critical systems clause", + "HPND-sell-variant-MIT-disclaimer": "HPND sell variant with MIT disclaimer", + "HPND-sell-variant-MIT-disclaimer-rev": "HPND sell variant with MIT disclaimer - reverse", + "HPND-SMC": "Historical Permission Notice and Disclaimer - SMC variant", + "HPND-UC": "Historical Permission Notice and Disclaimer - University of California variant", + "HPND-UC-export-US": "Historical Permission Notice and Disclaimer - University of California, US export warning", + "HTMLTIDY": "HTML Tidy License", + "hyphen-bulgarian": "hyphen-bulgarian License", + "IBM-pibs": "IBM PowerPC Initialization and Boot Software", + "ICU": "ICU License", + "IEC-Code-Components-EULA": "IEC Code Components End-user licence agreement", + "IJG": "Independent JPEG Group License", + "IJG-short": "Independent JPEG Group License - short", + "ImageMagick": "ImageMagick License", + "iMatix": "iMatix Standard Function Library Agreement", + "Imlib2": "Imlib2 License", + "Info-ZIP": "Info-ZIP License", + "Informatica": "Informatica License", + "Inner-Net-2.0": "Inner Net License v2.0", + "InnoSetup": "Inno Setup License", + "Intel": "Intel Open Source License", + "Intel-ACPI": "Intel ACPI Software License Agreement", + "Interbase-1.0": "Interbase Public License v1.0", + "IPA": "IPA Font License", + "IPL-1.0": "IBM Public License v1.0", + "ISC": "ISC License", + "ISC-Veillard": "ISC Veillard variant", + "ISO-permission": "ISO permission notice", + "Jam": "Jam License", + "JasPer-2.0": "JasPer License", + "jove": "Jove License", + "JPL-image": "JPL Image Use Policy", + "JPNIC": "Japan Network Information Center License", + "JSON": "JSON License", + "Kastrup": "Kastrup License", + "Kazlib": "Kazlib License", + "Knuth-CTAN": "Knuth CTAN License", + "LAL-1.2": "Licence Art Libre 1.2", + "LAL-1.3": "Licence Art Libre 1.3", + "Latex2e": "Latex2e License", + "Latex2e-translated-notice": "Latex2e with translated notice permission", + "Leptonica": "Leptonica License", + "LGPL-2.0": "GNU Library General Public License v2 only", + "LGPL-2.0+": "GNU Library General Public License v2 or later", + "LGPL-2.0-only": "GNU Library General Public License v2 only", + "LGPL-2.0-or-later": "GNU Library General Public License v2 or later", + "LGPL-2.1": "GNU Lesser General Public License v2.1 only", + "LGPL-2.1+": "GNU Lesser General Public License v2.1 or later", + "LGPL-2.1-only": "GNU Lesser General Public License v2.1 only", + "LGPL-2.1-or-later": "GNU Lesser General Public License v2.1 or later", + "LGPL-3.0": "GNU Lesser General Public License v3.0 only", + "LGPL-3.0+": "GNU Lesser General Public License v3.0 or later", + "LGPL-3.0-only": "GNU Lesser General Public License v3.0 only", + "LGPL-3.0-or-later": "GNU Lesser General Public License v3.0 or later", + "LGPLLR": "Lesser General Public License For Linguistic Resources", + "Libpng": "libpng License", + "libpng-1.6.35": "PNG Reference Library License v1 (for libpng 0.5 through 1.6.35)", + "libpng-2.0": "PNG Reference Library version 2", + "libselinux-1.0": "libselinux public domain notice", + "libtiff": "libtiff License", + "libutil-David-Nugent": "libutil David Nugent License", + "LiLiQ-P-1.1": "Licence Libre du Qu\u00E9bec \u2013 Permissive version 1.1", + "LiLiQ-R-1.1": "Licence Libre du Qu\u00E9bec \u2013 R\u00E9ciprocit\u00E9 version 1.1", + "LiLiQ-Rplus-1.1": "Licence Libre du Qu\u00E9bec \u2013 R\u00E9ciprocit\u00E9 forte version 1.1", + "Linux-man-pages-1-para": "Linux man-pages - 1 paragraph", + "Linux-man-pages-copyleft": "Linux man-pages Copyleft", + "Linux-man-pages-copyleft-2-para": "Linux man-pages Copyleft - 2 paragraphs", + "Linux-man-pages-copyleft-var": "Linux man-pages Copyleft Variant", + "Linux-OpenIB": "Linux Kernel Variant of OpenIB.org license", + "LOOP": "Common Lisp LOOP License", + "LPD-document": "LPD Documentation License", + "LPL-1.0": "Lucent Public License Version 1.0", + "LPL-1.02": "Lucent Public License v1.02", + "LPPL-1.0": "LaTeX Project Public License v1.0", + "LPPL-1.1": "LaTeX Project Public License v1.1", + "LPPL-1.2": "LaTeX Project Public License v1.2", + "LPPL-1.3a": "LaTeX Project Public License v1.3a", + "LPPL-1.3c": "LaTeX Project Public License v1.3c", + "lsof": "lsof License", + "Lucida-Bitmap-Fonts": "Lucida Bitmap Fonts License", + "LZMA-SDK-9.11-to-9.20": "LZMA SDK License (versions 9.11 to 9.20)", + "LZMA-SDK-9.22": "LZMA SDK License (versions 9.22 and beyond)", + "Mackerras-3-Clause": "Mackerras 3-Clause License", + "Mackerras-3-Clause-acknowledgment": "Mackerras 3-Clause - acknowledgment variant", + "magaz": "magaz License", + "mailprio": "mailprio License", + "MakeIndex": "MakeIndex License", + "man2html": "man2html License", + "Martin-Birgmeier": "Martin Birgmeier License", + "McPhee-slideshow": "McPhee Slideshow License", + "metamail": "metamail License", + "Minpack": "Minpack License", + "MIPS": "MIPS License", + "MirOS": "The MirOS Licence", + "MIT": "MIT License", + "MIT-0": "MIT No Attribution", + "MIT-advertising": "Enlightenment License (e16)", + "MIT-Click": "MIT Click License", + "MIT-CMU": "CMU License", + "MIT-enna": "enna License", + "MIT-feh": "feh License", + "MIT-Festival": "MIT Festival Variant", + "MIT-Khronos-old": "MIT Khronos - old variant", + "MIT-Modern-Variant": "MIT License Modern Variant", + "MIT-open-group": "MIT Open Group variant", + "MIT-STK": "MIT-STK License", + "MIT-testregex": "MIT testregex Variant", + "MIT-Wu": "MIT Tom Wu Variant", + "MITNFA": "MIT +no-false-attribs license", + "MMIXware": "MMIXware License", + "MMPL-1.0.1": "Minecraft Mod Public License v1.0.1", + "Motosoto": "Motosoto License", + "MPEG-SSG": "MPEG Software Simulation", + "mpi-permissive": "mpi Permissive License", + "mpich2": "mpich2 License", + "MPL-1.0": "Mozilla Public License 1.0", + "MPL-1.1": "Mozilla Public License 1.1", + "MPL-2.0": "Mozilla Public License 2.0", + "MPL-2.0-no-copyleft-exception": "Mozilla Public License 2.0 (no copyleft exception)", + "mplus": "mplus Font License", + "MS-LPL": "Microsoft Limited Public License", + "MS-PL": "Microsoft Public License", + "MS-RL": "Microsoft Reciprocal License", + "MTLL": "Matrix Template Library License", + "MulanPSL-1.0": "Mulan Permissive Software License, Version 1", + "MulanPSL-2.0": "Mulan Permissive Software License, Version 2", + "Multics": "Multics License", + "Mup": "Mup License", + "MVT-1.1": "MVT License 1.1", + "NAIST-2003": "Nara Institute of Science and Technology License (2003)", + "NASA-1.3": "NASA Open Source Agreement 1.3", + "Naumen": "Naumen Public License", + "NBPL-1.0": "Net Boolean Public License v1", + "NCBI-PD": "NCBI Public Domain Notice", + "NCGL-UK-2.0": "Non-Commercial Government Licence", + "NCL": "NCL Source Code License", + "NCSA": "University of Illinois\/NCSA Open Source License", + "Net-SNMP": "Net-SNMP License", + "NetCDF": "NetCDF license", + "Newsletr": "Newsletr License", + "NGPL": "Nethack General Public License", + "ngrep": "ngrep License", + "NICTA-1.0": "NICTA Public Software License, Version 1.0", + "NIST-PD": "NIST Public Domain Notice", + "NIST-PD-fallback": "NIST Public Domain Notice with license fallback", + "NIST-PD-TNT": "NIST Public Domain Notice TNT variant", + "NIST-Software": "NIST Software License", + "NLOD-1.0": "Norwegian Licence for Open Government Data (NLOD) 1.0", + "NLOD-2.0": "Norwegian Licence for Open Government Data (NLOD) 2.0", + "NLPL": "No Limit Public License", + "Nokia": "Nokia Open Source License", + "NOSL": "Netizen Open Source License", + "Noweb": "Noweb License", + "NPL-1.0": "Netscape Public License v1.0", + "NPL-1.1": "Netscape Public License v1.1", + "NPOSL-3.0": "Non-Profit Open Software License 3.0", + "NRL": "NRL License", + "NTIA-PD": "NTIA Public Domain Notice", + "NTP": "NTP License", + "NTP-0": "NTP No Attribution", + "Nunit": "Nunit License", + "O-UDA-1.0": "Open Use of Data Agreement v1.0", + "OAR": "OAR License", + "OCCT-PL": "Open CASCADE Technology Public License", + "OCLC-2.0": "OCLC Research Public License 2.0", + "ODbL-1.0": "Open Data Commons Open Database License v1.0", + "ODC-By-1.0": "Open Data Commons Attribution License v1.0", + "OFFIS": "OFFIS License", + "OFL-1.0": "SIL Open Font License 1.0", + "OFL-1.0-no-RFN": "SIL Open Font License 1.0 with no Reserved Font Name", + "OFL-1.0-RFN": "SIL Open Font License 1.0 with Reserved Font Name", + "OFL-1.1": "SIL Open Font License 1.1", + "OFL-1.1-no-RFN": "SIL Open Font License 1.1 with no Reserved Font Name", + "OFL-1.1-RFN": "SIL Open Font License 1.1 with Reserved Font Name", + "OGC-1.0": "OGC Software License, Version 1.0", + "OGDL-Taiwan-1.0": "Taiwan Open Government Data License, version 1.0", + "OGL-Canada-2.0": "Open Government Licence - Canada", + "OGL-UK-1.0": "Open Government Licence v1.0", + "OGL-UK-2.0": "Open Government Licence v2.0", + "OGL-UK-3.0": "Open Government Licence v3.0", + "OGTSL": "Open Group Test Suite License", + "OLDAP-1.1": "Open LDAP Public License v1.1", + "OLDAP-1.2": "Open LDAP Public License v1.2", + "OLDAP-1.3": "Open LDAP Public License v1.3", + "OLDAP-1.4": "Open LDAP Public License v1.4", + "OLDAP-2.0": "Open LDAP Public License v2.0 (or possibly 2.0A and 2.0B)", + "OLDAP-2.0.1": "Open LDAP Public License v2.0.1", + "OLDAP-2.1": "Open LDAP Public License v2.1", + "OLDAP-2.2": "Open LDAP Public License v2.2", + "OLDAP-2.2.1": "Open LDAP Public License v2.2.1", + "OLDAP-2.2.2": "Open LDAP Public License 2.2.2", + "OLDAP-2.3": "Open LDAP Public License v2.3", + "OLDAP-2.4": "Open LDAP Public License v2.4", + "OLDAP-2.5": "Open LDAP Public License v2.5", + "OLDAP-2.6": "Open LDAP Public License v2.6", + "OLDAP-2.7": "Open LDAP Public License v2.7", + "OLDAP-2.8": "Open LDAP Public License v2.8", + "OLFL-1.3": "Open Logistics Foundation License Version 1.3", + "OML": "Open Market License", + "OpenMDW-1.0": "OpenMDW License Agreement v1.0", + "OpenPBS-2.3": "OpenPBS v2.3 Software License", + "OpenSSL": "OpenSSL License", + "OpenSSL-standalone": "OpenSSL License - standalone", + "OpenVision": "OpenVision License", + "OPL-1.0": "Open Public License v1.0", + "OPL-UK-3.0": "United Kingdom Open Parliament Licence v3.0", + "OPUBL-1.0": "Open Publication License v1.0", + "OSC-1.0": "OSC License 1.0", + "OSET-PL-2.1": "OSET Public License version 2.1", + "OSL-1.0": "Open Software License 1.0", + "OSL-1.1": "Open Software License 1.1", + "OSL-2.0": "Open Software License 2.0", + "OSL-2.1": "Open Software License 2.1", + "OSL-3.0": "Open Software License 3.0", + "OSSP": "OSSP License", + "PADL": "PADL License", + "ParaType-Free-Font-1.3": "ParaType Free Font Licensing Agreement v1.3", + "Parity-6.0.0": "The Parity Public License 6.0.0", + "Parity-7.0.0": "The Parity Public License 7.0.0", + "PDDL-1.0": "Open Data Commons Public Domain Dedication & License 1.0", + "PHP-3.0": "PHP License v3.0", + "PHP-3.01": "PHP License v3.01", + "Pixar": "Pixar License", + "pkgconf": "pkgconf License", + "Plexus": "Plexus Classworlds License", + "pnmstitch": "pnmstitch License", + "PolyForm-Noncommercial-1.0.0": "PolyForm Noncommercial License 1.0.0", + "PolyForm-Small-Business-1.0.0": "PolyForm Small Business License 1.0.0", + "PostgreSQL": "PostgreSQL License", + "PPL": "Peer Production License", + "PSF-2.0": "Python Software Foundation License 2.0", + "psfrag": "psfrag License", + "psutils": "psutils License", + "Python-2.0": "Python License 2.0", + "Python-2.0.1": "Python License 2.0.1", + "python-ldap": "Python ldap License", + "Qhull": "Qhull License", + "QPL-1.0": "Q Public License 1.0", + "QPL-1.0-INRIA-2004": "Q Public License 1.0 - INRIA 2004 variant", + "radvd": "radvd License", + "Rdisc": "Rdisc License", + "RHeCos-1.1": "Red Hat eCos Public License v1.1", + "RPL-1.1": "Reciprocal Public License 1.1", + "RPL-1.5": "Reciprocal Public License 1.5", + "RPSL-1.0": "RealNetworks Public Source License v1.0", + "RSA-MD": "RSA Message-Digest License", + "RSCPL": "Ricoh Source Code Public License", + "Ruby": "Ruby License", + "Ruby-pty": "Ruby pty extension license", + "SAX-PD": "Sax Public Domain Notice", + "SAX-PD-2.0": "Sax Public Domain Notice 2.0", + "Saxpath": "Saxpath License", + "SCEA": "SCEA Shared Source License", + "SchemeReport": "Scheme Language Report License", + "Sendmail": "Sendmail License", + "Sendmail-8.23": "Sendmail License 8.23", + "Sendmail-Open-Source-1.1": "Sendmail Open Source License v1.1", + "SGI-B-1.0": "SGI Free Software License B v1.0", + "SGI-B-1.1": "SGI Free Software License B v1.1", + "SGI-B-2.0": "SGI Free Software License B v2.0", + "SGI-OpenGL": "SGI OpenGL License", + "SGMLUG-PM": "SGMLUG Parser Materials License", + "SGP4": "SGP4 Permission Notice", + "SHL-0.5": "Solderpad Hardware License v0.5", + "SHL-0.51": "Solderpad Hardware License, Version 0.51", + "SimPL-2.0": "Simple Public License 2.0", + "SISSL": "Sun Industry Standards Source License v1.1", + "SISSL-1.2": "Sun Industry Standards Source License v1.2", + "SL": "SL License", + "Sleepycat": "Sleepycat License", + "SMAIL-GPL": "SMAIL General Public License", + "SMLNJ": "Standard ML of New Jersey License", + "SMPPL": "Secure Messaging Protocol Public License", + "SNIA": "SNIA Public License 1.1", + "snprintf": "snprintf License", + "SOFA": "SOFA Software License", + "softSurfer": "softSurfer License", + "Soundex": "Soundex License", + "Spencer-86": "Spencer License 86", + "Spencer-94": "Spencer License 94", + "Spencer-99": "Spencer License 99", + "SPL-1.0": "Sun Public License v1.0", + "ssh-keyscan": "ssh-keyscan License", + "SSH-OpenSSH": "SSH OpenSSH license", + "SSH-short": "SSH short notice", + "SSLeay-standalone": "SSLeay License - standalone", + "SSPL-1.0": "Server Side Public License, v 1", + "StandardML-NJ": "Standard ML of New Jersey License", + "SugarCRM-1.1.3": "SugarCRM Public License v1.1.3", + "SUL-1.0": "Sustainable Use License v1.0", + "Sun-PPP": "Sun PPP License", + "Sun-PPP-2000": "Sun PPP License (2000)", + "SunPro": "SunPro License", + "SWL": "Scheme Widget Library (SWL) Software License Agreement", + "swrule": "swrule License", + "Symlinks": "Symlinks License", + "TAPR-OHL-1.0": "TAPR Open Hardware License v1.0", + "TCL": "TCL\/TK License", + "TCP-wrappers": "TCP Wrappers License", + "TekHVC": "TekHVC License", + "TermReadKey": "TermReadKey License", + "TGPPL-1.0": "Transitive Grace Period Public Licence 1.0", + "ThirdEye": "ThirdEye License", + "threeparttable": "threeparttable License", + "TMate": "TMate Open Source License", + "TORQUE-1.1": "TORQUE v2.5+ Software License v1.1", + "TOSL": "Trusster Open Source License", + "TPDL": "Time::ParseDate License", + "TPL-1.0": "THOR Public License 1.0", + "TrustedQSL": "TrustedQSL License", + "TTWL": "Text-Tabs+Wrap License", + "TTYP0": "TTYP0 License", + "TU-Berlin-1.0": "Technische Universitaet Berlin License 1.0", + "TU-Berlin-2.0": "Technische Universitaet Berlin License 2.0", + "Ubuntu-font-1.0": "Ubuntu Font Licence v1.0", + "UCAR": "UCAR License", + "UCL-1.0": "Upstream Compatibility License v1.0", + "ulem": "ulem License", + "UMich-Merit": "Michigan\/Merit Networks License", + "Unicode-3.0": "Unicode License v3", + "Unicode-DFS-2015": "Unicode License Agreement - Data Files and Software (2015)", + "Unicode-DFS-2016": "Unicode License Agreement - Data Files and Software (2016)", + "Unicode-TOU": "Unicode Terms of Use", + "UnixCrypt": "UnixCrypt License", + "Unlicense": "The Unlicense", + "Unlicense-libtelnet": "Unlicense - libtelnet variant", + "Unlicense-libwhirlpool": "Unlicense - libwhirlpool variant", + "UnRAR": "UnRAR License", + "UPL-1.0": "Universal Permissive License v1.0", + "URT-RLE": "Utah Raster Toolkit Run Length Encoded License", + "Vim": "Vim License", + "Vixie-Cron": "Vixie Cron License", + "VOSTROM": "VOSTROM Public License for Open Source", + "VSL-1.0": "Vovida Software License v1.0", + "W3C": "W3C Software Notice and License (2002-12-31)", + "W3C-19980720": "W3C Software Notice and License (1998-07-20)", + "W3C-20150513": "W3C Software Notice and Document License (2015-05-13)", + "w3m": "w3m License", + "Watcom-1.0": "Sybase Open Watcom Public License 1.0", + "Widget-Workshop": "Widget Workshop License", + "WordNet": "WordNet License", + "Wsuipa": "Wsuipa License", + "WTFNMFPL": "Do What The F*ck You Want To But It's Not My Fault Public License", + "WTFPL": "Do What The F*ck You Want To Public License", + "wwl": "WWL License", + "wxWindows": "wxWindows Library License", + "X11": "X11 License", + "X11-distribute-modifications-variant": "X11 License Distribution Modification Variant", + "X11-no-permit-persons": "X11 no permit persons clause", + "X11-swapped": "X11 swapped final paragraphs", + "Xdebug-1.03": "Xdebug License v 1.03", + "Xerox": "Xerox License", + "Xfig": "Xfig License", + "XFree86-1.1": "XFree86 License 1.1", + "xinetd": "xinetd License", + "xkeyboard-config-Zinoviev": "xkeyboard-config Zinoviev License", + "xlock": "xlock License", + "Xnet": "X.Net License", + "xpp": "XPP License", + "XSkat": "XSkat License", + "xzoom": "xzoom License", + "YPL-1.0": "Yahoo! Public License v1.0", + "YPL-1.1": "Yahoo! Public License v1.1", + "Zed": "Zed License", + "Zeeff": "Zeeff License", + "Zend-2.0": "Zend License v2.0", + "Zimbra-1.3": "Zimbra Public License v1.3", + "Zimbra-1.4": "Zimbra Public License v1.4", + "Zlib": "zlib License", + "zlib-acknowledgement": "zlib\/libpng License with Acknowledgement", + "ZPL-1.1": "Zope Public License 1.1", + "ZPL-2.0": "Zope Public License 2.0", + "ZPL-2.1": "Zope Public License 2.1", + "389-exception": "389 Directory Server Exception", + "Asterisk-exception": "Asterisk exception", + "Asterisk-linking-protocols-exception": "Asterisk linking protocols exception", + "Autoconf-exception-2.0": "Autoconf exception 2.0", + "Autoconf-exception-3.0": "Autoconf exception 3.0", + "Autoconf-exception-generic": "Autoconf generic exception", + "Autoconf-exception-generic-3.0": "Autoconf generic exception for GPL-3.0", + "Autoconf-exception-macro": "Autoconf macro exception", + "Bison-exception-1.24": "Bison exception 1.24", + "Bison-exception-2.2": "Bison exception 2.2", + "Bootloader-exception": "Bootloader Distribution Exception", + "CGAL-linking-exception": "CGAL Linking Exception", + "Classpath-exception-2.0": "Classpath exception 2.0", + "Classpath-exception-2.0-short": "Classpath exception 2.0 - short", + "CLISP-exception-2.0": "CLISP exception 2.0", + "cryptsetup-OpenSSL-exception": "cryptsetup OpenSSL exception", + "Digia-Qt-LGPL-exception-1.1": "Digia Qt LGPL Exception version 1.1", + "DigiRule-FOSS-exception": "DigiRule FOSS License Exception", + "eCos-exception-2.0": "eCos exception 2.0", + "erlang-otp-linking-exception": "Erlang\/OTP Linking Exception", + "Fawkes-Runtime-exception": "Fawkes Runtime Exception", + "FLTK-exception": "FLTK exception", + "fmt-exception": "fmt exception", + "Font-exception-2.0": "Font exception 2.0", + "freertos-exception-2.0": "FreeRTOS Exception 2.0", + "GCC-exception-2.0": "GCC Runtime Library exception 2.0", + "GCC-exception-2.0-note": "GCC Runtime Library exception 2.0 - note variant", + "GCC-exception-3.1": "GCC Runtime Library exception 3.1", + "Gmsh-exception": "Gmsh exception", + "GNAT-exception": "GNAT exception", + "GNOME-examples-exception": "GNOME examples exception", + "GNU-compiler-exception": "GNU Compiler Exception", + "gnu-javamail-exception": "GNU JavaMail exception", + "Google-Patent-WebM": "Google Additional IP Rights Grant (Patents) - WebM", + "GPL-3.0-389-ds-base-exception": "GPL-3.0 389 DS Base Exception", + "GPL-3.0-interface-exception": "GPL-3.0 Interface Exception", + "GPL-3.0-linking-exception": "GPL-3.0 Linking Exception", + "GPL-3.0-linking-source-exception": "GPL-3.0 Linking Exception (with Corresponding Source)", + "GPL-CC-1.0": "GPL Cooperation Commitment 1.0", + "GStreamer-exception-2005": "GStreamer Exception (2005)", + "GStreamer-exception-2008": "GStreamer Exception (2008)", + "harbour-exception": "harbour exception", + "i2p-gpl-java-exception": "i2p GPL+Java Exception", + "Independent-modules-exception": "Independent Module Linking exception", + "KiCad-libraries-exception": "KiCad Libraries Exception", + "kvirc-openssl-exception": "kvirc OpenSSL Exception", + "LGPL-3.0-linking-exception": "LGPL-3.0 Linking Exception", + "libpri-OpenH323-exception": "libpri OpenH323 exception", + "Libtool-exception": "Libtool Exception", + "Linux-syscall-note": "Linux Syscall Note", + "LLGPL": "LLGPL Preamble", + "LLVM-exception": "LLVM Exception", + "LZMA-exception": "LZMA exception", + "mif-exception": "Macros and Inline Functions Exception", + "mxml-exception": "mxml Exception", + "Nokia-Qt-exception-1.1": "Nokia Qt LGPL exception 1.1", + "OCaml-LGPL-linking-exception": "OCaml LGPL Linking Exception", + "OCCT-exception-1.0": "Open CASCADE Exception 1.0", + "OpenJDK-assembly-exception-1.0": "OpenJDK Assembly exception 1.0", + "openvpn-openssl-exception": "OpenVPN OpenSSL Exception", + "PCRE2-exception": "PCRE2 exception", + "polyparse-exception": "Polyparse Exception", + "PS-or-PDF-font-exception-20170817": "PS\/PDF font exception (2017-08-17)", + "QPL-1.0-INRIA-2004-exception": "INRIA QPL 1.0 2004 variant exception", + "Qt-GPL-exception-1.0": "Qt GPL exception 1.0", + "Qt-LGPL-exception-1.1": "Qt LGPL exception 1.1", + "Qwt-exception-1.0": "Qwt exception 1.0", + "romic-exception": "Romic Exception", + "RRDtool-FLOSS-exception-2.0": "RRDtool FLOSS exception 2.0", + "rsync-linking-exception": "rsync Linking Exception", + "SANE-exception": "SANE Exception", + "SHL-2.0": "Solderpad Hardware License v2.0", + "SHL-2.1": "Solderpad Hardware License v2.1", + "Simple-Library-Usage-exception": "Simple Library Usage Exception", + "Spelling-Provider-LGPL-exception": "Spelling Provider LGPL exception", + "sqlitestudio-OpenSSL-exception": "sqlitestudio OpenSSL exception", + "stunnel-exception": "stunnel Exception", + "SWI-exception": "SWI exception", + "Swift-exception": "Swift Exception", + "Texinfo-exception": "Texinfo exception", + "u-boot-exception-2.0": "U-Boot exception 2.0", + "UBDL-exception": "Unmodified Binary Distribution exception", + "Universal-FOSS-exception-1.0": "Universal FOSS Exception, Version 1.0", + "vsftpd-openssl-exception": "vsftpd OpenSSL exception", + "WxWindows-exception-3.1": "WxWindows Library Exception 3.1", + "x11vnc-openssl-exception": "x11vnc OpenSSL Exception" + } +} diff --git a/src/python_sbom/_wheel/__init__.py b/src/python_sbom/_wheel/__init__.py new file mode 100644 index 0000000..24df4da --- /dev/null +++ b/src/python_sbom/_wheel/__init__.py @@ -0,0 +1 @@ +"""Zip artifacts: one checked handle per build, verifying the wheels they bundle, and writing an SBOM in.""" diff --git a/src/python_sbom/_wheel/_bundled.py b/src/python_sbom/_wheel/_bundled.py new file mode 100644 index 0000000..06d005b --- /dev/null +++ b/src/python_sbom/_wheel/_bundled.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from collections import Counter +from concurrent.futures import ThreadPoolExecutor +from typing import TYPE_CHECKING, Final + +from installer.records import InvalidRecordEntry, parse_record_file +from installer.sources import WheelFile +from packaging.requirements import InvalidRequirement, Requirement + +from python_sbom._errors import SbomError +from python_sbom._model._inputs import BundledWheel, RecordRow +from python_sbom._model._metadata import CoreMetadata, parse_metadata + +from ._record import record_rows +from ._zip import open_archive, read_member, read_wheel_metadata, sha256_hex + +if TYPE_CHECKING: + import zipfile + from collections.abc import Callable + from typing import IO + + +# wheels verified at once, each perhaps in memory whole +_WORKERS: Final[int] = 4 + + +def read_bundled_wheels(paths: list[str], open_wheel: Callable[[str], IO[bytes]]) -> list[BundledWheel]: + # inflating and hashing release the GIL, so the wheels verify side by side, a few at a time, as each may be held in + # memory whole; results and the first error come in the order given + def read(path: str) -> BundledWheel: + with open_wheel(path) as stream: + return _read_bundled_wheel(stream, path) + + with ThreadPoolExecutor(max_workers=_WORKERS) as pool: + return list(pool.map(read, paths)) + + +def _read_bundled_wheel(stream: IO[bytes], path: str) -> BundledWheel: + where: Final[str] = f"bundled wheel {path}" + sha256: Final[str] = sha256_hex(stream) + stream.seek(0) + with open_archive(stream, where) as archive: + # installer takes the project name from the archive's file name, which an archive read from memory lacks + archive.filename = path + members: Final[list[str]] = sorted(info.filename for info in archive.infolist() if not info.is_dir()) + wheel, metadata = read_wheel_metadata(archive, where) + dist_info: Final[str] = wheel.dist_info_dir + _check_named(metadata, where, f"{dist_info}/METADATA") + files: Final[list[RecordRow]] = _verified_record(archive, wheel, where) + vendored: Final[list[tuple[str, CoreMetadata]]] = [ + *_vendor_manifest(archive, where, members), + *( + (name, _vendored_metadata(archive, where, name)) + for name in members + if "/_vendor/" in name and name.endswith(".dist-info/METADATA") + ), + ] + return BundledWheel( + path=path, + sha256=sha256, + metadata_name=f"{dist_info}/METADATA", + metadata=metadata, + files=files, + vendored=vendored, + ) + + +def _check_named(metadata: CoreMetadata, where: str, name: str) -> None: + if not (metadata.name and metadata.version): + msg = f"{where}: {name} lacks Name or Version" + raise SbomError(msg) + + +def _verified_record(archive: zipfile.ZipFile, wheel: WheelFile, where: str) -> list[RecordRow]: + # the SBOM copies these rows, so a RECORD that misstates the wheel would make the SBOM misstate it too + dist_info: Final[str] = wheel.dist_info_dir + # installer decompresses RECORD whole, so a capped read comes first and bounds installer's read of the same bytes + record: Final[str] = read_member(archive, f"{dist_info}/RECORD", where).decode("utf-8") + try: + rows: Final[list[tuple[str, str, str]]] = list(parse_record_file(record.splitlines())) + # read before installer's check, which takes any hashlib name and fails on one needing a length, such as + # shake_128, with a TypeError + found: Final[list[RecordRow]] = record_rows(rows, where) + except InvalidRecordEntry as error: + msg = f"{where}: RECORD does not parse: {error}" + raise SbomError(msg) from error + # installer checks the last row of a path only, skips rows naming no member, and lets a RECORD.jws or RECORD.p7s + # anywhere under the .dist-info, "../" included, go unlisted, so each of these checks is ours + if repeated := sorted(path for path, count in Counter(path for path, _, _ in rows).items() if count > 1): + msg = f"{where}: RECORD lists {', '.join(repeated)} more than once" + raise SbomError(msg) + members: Final[set[str]] = {info.filename for info in archive.infolist() if not info.is_dir()} + recorded: Final[set[str]] = {path for path, _, _ in rows} + # installer checks the row of RECORD itself, the one row without a digest + if missing := sorted( + members - recorded - {f"{dist_info}/{name}" for name in ("RECORD", "RECORD.jws", "RECORD.p7s")} + ): + msg = f"{where}: RECORD lacks a digest for {', '.join(missing)}" + raise SbomError(msg) + if extra := sorted(recorded - members): + msg = f"{where}: RECORD lists {', '.join(extra)}, which the wheel does not hold" + raise SbomError(msg) + try: + wheel.validate_record() + except WheelFile.validation_error as error: + # installer documents the exception class only, not the list of issues it carries + issues: Final[list[str]] = getattr(error, "issues", [str(error)]) + prefix: Final[str] = f"In {archive.filename}, " + msg = f"{where}: RECORD disagrees with the wheel: {'; '.join(issue.removeprefix(prefix) for issue in issues)}" + raise SbomError(msg) from error + return found + + +def _vendor_manifest(archive: zipfile.ZipFile, where: str, members: list[str]) -> list[tuple[str, CoreMetadata]]: + # pip lists what it vendors in _vendor/vendor.txt instead of shipping each vendored .dist-info + found: Final[list[tuple[str, CoreMetadata]]] = [] + for name in (member for member in members if member.endswith("/_vendor/vendor.txt")): + for line in read_member(archive, name, where).decode("utf-8").splitlines(): + if not (text := line.partition("#")[0].strip()): + continue + try: + pins = list((requirement := Requirement(text)).specifier) + except InvalidRequirement as error: + msg = f"{where}: vendored requirement in {name} does not parse: {text}" + raise SbomError(msg) from error + if len(pins) != 1 or pins[0].operator != "==" or "*" in pins[0].version: + msg = f"{where}: vendored requirement in {name} pins no one exact version: {text}" + raise SbomError(msg) + found.append((name, CoreMetadata(raw={"name": requirement.name, "version": pins[0].version}, unparsed={}))) + return found + + +def _vendored_metadata(archive: zipfile.ZipFile, where: str, name: str) -> CoreMetadata: + metadata: Final[CoreMetadata] = parse_metadata(read_member(archive, name, where).decode("utf-8")) + _check_named(metadata, where, name) + return metadata + + +__all__ = [ + "read_bundled_wheels", +] diff --git a/src/python_sbom/_wheel/_reader.py b/src/python_sbom/_wheel/_reader.py new file mode 100644 index 0000000..4891971 --- /dev/null +++ b/src/python_sbom/_wheel/_reader.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass +from io import BytesIO +from typing import TYPE_CHECKING, Final + +from ._bundled import read_bundled_wheels +from ._zip import open_archive, read_member, sha256_hex + +if TYPE_CHECKING: + import zipfile + from collections.abc import Generator + from pathlib import Path, PurePosixPath + + from python_sbom._model._inputs import BundledWheel + +# a bundled wheel is read whole, to hash it and open it as a zip, so its size is capped as metadata's is +_WHEEL_LIMIT: Final[int] = 512 * 1024 * 1024 + + +@contextmanager +def open_artifact(path: Path) -> Generator[ArtifactReader, None, None]: + # one open zip serves every read of a build, the provider's included, so the archive is checked and indexed once + with open_archive(path, str(path)) as archive: + yield ArtifactReader(path=path, archive=archive, members=frozenset(archive.namelist())) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ArtifactReader: + path: Path + archive: zipfile.ZipFile + members: frozenset[str] + + def digest(self, name: str) -> str: + with self.archive.open(name) as member: + return sha256_hex(member) + + def provider_read(self, path: PurePosixPath) -> bytes: + # a provider asking for a member the artifact lacks gets the KeyError a mapping raises, which it may expect + if (name := str(path)) not in self.members: + raise KeyError(name) + return self.read(name) + + def read(self, name: str) -> bytes: + return read_member(self.archive, name, str(self.path)) + + def bundled_wheels(self, names: list[str]) -> list[BundledWheel]: + # zipfile serializes the seeks of concurrent member reads on its own lock, so the verifying threads share this + # handle and inflate side by side + return read_bundled_wheels( + names, lambda name: BytesIO(read_member(self.archive, name, str(self.path), _WHEEL_LIMIT)) + ) + + +__all__ = [ + "ArtifactReader", + "open_artifact", +] diff --git a/src/python_sbom/_wheel/_record.py b/src/python_sbom/_wheel/_record.py new file mode 100644 index 0000000..25d9260 --- /dev/null +++ b/src/python_sbom/_wheel/_record.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import base64 +import binascii +from itertools import starmap +from typing import TYPE_CHECKING, Final + +from installer.records import RecordEntry + +from python_sbom._errors import SbomError +from python_sbom._model._inputs import RecordRow + +if TYPE_CHECKING: + from collections.abc import Iterable + +# the algorithms a wheel RECORD may name, as CycloneDX spells them +_ALGORITHMS: Final[dict[str, str]] = { + "md5": "MD5", + "sha1": "SHA-1", + "sha256": "SHA-256", + "sha384": "SHA-384", + "sha512": "SHA-512", +} + + +def record_rows(rows: Iterable[tuple[str, str, str]], where: str) -> list[RecordRow]: + # the rows with a digest, as the SBOM lists them: RECORD's own row has none, nor has a file an installer left + # unhashed; installer's InvalidRecordEntry passes to the caller, which knows what a malformed row means there + found: Final[list[RecordRow]] = [] + for entry in starmap(RecordEntry.from_elements, rows): + if entry.hash_ is None: + continue + algorithm, digest = _decode_digest(str(entry.hash_), f"{where}: RECORD row for {entry.path}") + found.append(RecordRow(path=entry.path, algorithm=algorithm, digest=digest, size=str(entry.size))) + return found + + +def _decode_digest(digest: str, where: str) -> tuple[str, str]: + # installer checks RECORD digests as urlsafe base64 under any hashlib name, while CycloneDX takes hex under its own + # names for a few algorithms + name, _, encoded = digest.partition("=") + if (algorithm := _ALGORITHMS.get(name)) is None: + msg = f"{where}: unknown hash algorithm {name!r}" + raise SbomError(msg) + try: + raw = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)) + except binascii.Error as error: + msg = f"{where}: digest {encoded!r} is not urlsafe base64" + raise SbomError(msg) from error + return algorithm, raw.hex() + + +__all__ = [ + "record_rows", +] diff --git a/src/python_sbom/_wheel/_write.py b/src/python_sbom/_wheel/_write.py new file mode 100644 index 0000000..fca97bf --- /dev/null +++ b/src/python_sbom/_wheel/_write.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import csv +import os +import shutil +import stat +import tempfile +import time +import zipfile +from io import BytesIO, StringIO +from pathlib import Path +from typing import Final + +import zipremove +from installer.records import Hash, RecordEntry +from installer.utils import copyfileobj_with_hashing + +from python_sbom._errors import SbomError + +from ._zip import open_archive, wheel_source + +# 1980-01-01, the earliest time a zip entry holds +_ZIP_EPOCH: Final[int] = 315532800 + + +def write_sbom(wheel: Path, name: str, content: bytes, epoch: int) -> str: + with open_archive(wheel, str(wheel)) as archive: + _, dist_info = wheel_source(archive, str(wheel)) + record: Final[str] = f"{dist_info}/RECORD" + if missing := sorted({f"{dist_info}/METADATA", record} - set(archive.namelist())): + msg = f"{wheel} has no {', '.join(missing)}" + raise SbomError(msg) + record_info: Final[zipfile.ZipInfo] = archive.getinfo(record) + record_data: Final[bytes] = archive.read(record_info) + sbom: Final[str] = f"{dist_info}/sboms/{name}" + entry: Final[zipfile.ZipInfo] = _entry(sbom, epoch) + entry.compress_type = zipfile.ZIP_DEFLATED + rewritten: Final[bytes] = _rewritten_record(record_data, record, sbom, content) + # a name no "dist/*.whl" glob matches, in the wheel's directory so the final rename stays on one file system + descriptor, temporary = tempfile.mkstemp(dir=wheel.parent, prefix=".python-sbom-", suffix=".tmp") + os.close(descriptor) + try: # ruff: ignore[too-many-statements-in-try-clause] # whichever step fails, the temporary file goes + # copied through the process: copy_file_range makes some file systems first flush the wheel just written + with wheel.open("rb") as source, Path(temporary).open("wb") as target: + shutil.copyfileobj(source, target) + # the other members keep their bytes as stored, still compressed, so only the SBOM and RECORD get compressed + # here; both go last, RECORD after the SBOM + with zipremove.ZipFile(temporary, "a") as archive: + stale = [info for info in archive.infolist() if info.filename in {record, sbom}] + archive.repack(list(map(archive.remove, stale))) # ty: ignore[unresolved-attribute] # zipremove adds both + archive.writestr(entry, content) + archive.writestr(record_info, rewritten) + Path(temporary).chmod(stat.S_IMODE(wheel.stat().st_mode)) + Path(temporary).replace(wheel) + except BaseException: + Path(temporary).unlink(missing_ok=True) + raise + return sbom + + +def _entry(name: str, epoch: int) -> zipfile.ZipInfo: + # zip times start at 1980; Unix attributes whichever system writes the entry, so the bytes match across systems + entry: Final[zipfile.ZipInfo] = zipfile.ZipInfo(name, time.gmtime(max(epoch, _ZIP_EPOCH))[:6]) + entry.create_system = 3 + entry.external_attr = (stat.S_IFREG | 0o644) << 16 + return entry + + +def _rewritten_record(record: bytes, name: str, sbom: str, content: bytes) -> bytes: + lines: Final[list[str]] = [line for line in record.decode("utf-8").splitlines(keepends=True) if _path(line) != sbom] + newline: Final[str] = "\r\n" if lines and lines[0].endswith("\r\n") else "\n" + # a last row without its line end would take the SBOM's row onto its own line + if lines and not lines[-1].endswith(("\n", "\r")): + lines[-1] += newline + # the row of RECORD itself stays last + at: Final[int] = next((index for index, line in enumerate(lines) if _path(line) == name), len(lines)) + # installer's helper hashes as it copies; the copy is thrown away + digest, size = copyfileobj_with_hashing(BytesIO(content), BytesIO(), "sha256") + row: Final[StringIO] = StringIO() + csv.writer(row, lineterminator=newline).writerow(RecordEntry(sbom, Hash("sha256", digest), size).to_row()) + lines.insert(at, row.getvalue()) + return "".join(lines).encode() + + +def _path(line: str) -> str: + # RECORD quotes a path that holds a comma, a quote or a line end, so the raw start of a line misses its row + return next(csv.reader([line]), [""])[0] + + +def append_member(archive: Path, name: str, content: bytes, epoch: int) -> None: + with zipfile.ZipFile(archive, "a") as handle: + if name in handle.namelist(): + msg = f"{archive.name} already holds {name}" + raise SbomError(msg) + # deflated at the highest level whatever the other members use, since a zipapp may store them all and the SBOM + # then adds megabytes; a fixed level, time and attributes keep the bytes the same for one input on every system + handle.writestr(_entry(name, epoch), content, compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) + + +__all__ = [ + "append_member", + "write_sbom", +] diff --git a/src/python_sbom/_wheel/_zip.py b/src/python_sbom/_wheel/_zip.py new file mode 100644 index 0000000..322f0f0 --- /dev/null +++ b/src/python_sbom/_wheel/_zip.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import hashlib +import zipfile +import zlib +from collections import Counter +from contextlib import contextmanager +from typing import IO, TYPE_CHECKING, Final + +from installer.sources import WheelFile + +from python_sbom._errors import SbomError +from python_sbom._model._metadata import CoreMetadata, parse_metadata + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + +# metadata files beyond this size are refused, so a hostile wheel cannot exhaust memory +_METADATA_LIMIT: Final[int] = 16 * 1024 * 1024 +# members inflate a chunk at a time, so a size the archive states falsely is caught before it is all in memory +CHUNK: Final[int] = 1024 * 1024 + + +@contextmanager +def open_archive(file: Path | IO[bytes], where: str) -> Generator[zipfile.ZipFile, None, None]: + try: + with zipfile.ZipFile(file) as archive: + # installers extract the first of two same-named members and zipfile reads the last, and a file system that + # ignores case merges names that differ only by case, so such names would hide what gets installed; + # installer's WheelFile accepts names that differ only by case + if clashes := sorted( + name for name, count in Counter(name.casefold() for name in archive.namelist()).items() if count > 1 + ): + msg = f"{where} holds more than one member named {', '.join(clashes)}, ignoring case" + raise SbomError(msg) + yield archive + # zipfile raises these as late as a member's last read, inside installer's checks too, which let them through + except (zipfile.BadZipFile, zlib.error) as error: + msg = f"{where} is not a valid zip archive: {error}" + raise SbomError(msg) from error + + +def read_wheel_metadata(archive: zipfile.ZipFile, where: str) -> tuple[WheelFile, CoreMetadata]: + wheel, dist_info = wheel_source(archive, where) + # read capped, as installer's read_dist_info decompresses the member whole + return wheel, parse_metadata(read_member(archive, f"{dist_info}/METADATA", where).decode("utf-8")) + + +def wheel_source(archive: zipfile.ZipFile, where: str) -> tuple[WheelFile, str]: + # installer refuses a wheel whose file name does not parse, or that holds other than one top-level .dist-info, since + # a second one is a decoy that could decide whose metadata counts + try: + wheel = WheelFile(archive) + dist_info = wheel.dist_info_dir + except ValueError as error: + msg = f"{where} is not a wheel: {error}" + raise SbomError(msg) from error + return wheel, dist_info + + +def read_member(archive: zipfile.ZipFile, name: str, where: str, limit: int = _METADATA_LIMIT) -> bytes: + try: + info = archive.getinfo(name) + except KeyError as error: + msg = f"{where} has no {name}" + raise SbomError(msg) from error + # counted as it decompresses, since the size the archive states may lie and one read decompresses it all at once + content: Final[bytearray] = bytearray() + with archive.open(info) as member: + while chunk := member.read(CHUNK): + content.extend(chunk) + if len(content) > limit: + msg = f"{where}: {name} is over the {limit} byte limit" + raise SbomError(msg) + return bytes(content) + + +def sha256_hex(stream: IO[bytes]) -> str: + # hashlib.file_digest wants readinto, which a zip member stream typed as IO[bytes] does not promise + digest: Final = hashlib.sha256() + while chunk := stream.read(CHUNK): + digest.update(chunk) + return digest.hexdigest() + + +__all__ = [ + "CHUNK", + "open_archive", + "read_member", + "read_wheel_metadata", + "sha256_hex", + "wheel_source", +] diff --git a/src/python_sbom/hatch/__init__.py b/src/python_sbom/hatch/__init__.py new file mode 100644 index 0000000..4e6ce53 --- /dev/null +++ b/src/python_sbom/hatch/__init__.py @@ -0,0 +1,9 @@ +"""The hatchling build hook, which writes the SBOM into every wheel hatchling builds.""" + +from __future__ import annotations + +from ._hook import SbomBuildHook + +__all__ = [ + "SbomBuildHook", +] diff --git a/src/python_sbom/hatch/_hook.py b/src/python_sbom/hatch/_hook.py new file mode 100644 index 0000000..455412e --- /dev/null +++ b/src/python_sbom/hatch/_hook.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import shutil +import tempfile +import weakref +from functools import cached_property +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Any, Final + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from hatchling.builders.utils import get_reproducible_timestamp +from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT + +from python_sbom._collect._content import resolve_provider +from python_sbom._collect._revision import REVISION_FILE, default_revision +from python_sbom._collect._settings import settings_from_table +from python_sbom._errors import SbomError + +if TYPE_CHECKING: + from collections.abc import Callable + + from python_sbom._model._content import ContentProvider + from python_sbom._model._inputs import Settings + +# options of every hatchling hook table, which hatchling itself reads +_HATCHLING_OPTIONS: Final[frozenset[str]] = frozenset({ + "dependencies", + "enable-by-default", + "path", + "require-runtime-dependencies", + "require-runtime-features", +}) + + +class SbomBuildHook(BuildHookInterface): + """ + Hatchling build hook that writes a CycloneDX SBOM into the wheel's ``.dist-info/sboms/``, as PEP 770 describes. + + It runs once hatchling has written the wheel, so the SBOM describes the wheel's final metadata and file name + whatever other hooks changed. Options come from ``[tool.python-sbom]``, the one place ``python-sbom embed`` and + ``python-sbom archive`` read them too. For an sdist it records the source revision, so a wheel built from that sdist + still names its commit. + + :cvar PLUGIN_NAME: the hook's name in ``[tool.hatch.build.hooks]`` + + """ + + PLUGIN_NAME = "python-sbom" + _remove_revision_file: Callable[[], None] | None = None + + def initialize( + self, + version: str, + build_data: dict[str, Any], # simplint: ignore[SMP602] # hatchling's signature + ) -> None: + """ + Read the source revision, and record it in an sdist. + + :param version: hatchling's build variant + :param build_data: hatchling's build data, which receives the revision file + + """ + if self.target_name not in {"sdist", "wheel"} or version == "editable": + return + # once per build, before it writes the artifact into a checkout that may not ignore it; a value kept for the + # process would name an older commit in the next build after a commit + self._revision = revision = default_revision(Path(self.root)) + # a dirty checkout has no commit that reproduces the sdist, so a wheel built from it names none + if self.target_name != "sdist" or revision is None or revision.dirty: + return + # outside the project, so the build leaves nothing behind in it + directory = tempfile.mkdtemp() + # hatchling reads the file only while it writes the sdist, and skips finalize when the build fails, so the + # directory then goes with the hook, at the latest when the interpreter exits + self._remove_revision_file = weakref.finalize(self, shutil.rmtree, directory) + # bytes, so the sdist holds the same LF line on every OS + (written := Path(directory) / REVISION_FILE).write_bytes(f"node: {revision.commit}\n".encode()) + build_data["force_include"][str(written)] = REVISION_FILE + + def finalize( + self, + version: str, + build_data: ( # ruff: ignore[unused-method-argument] # hatchling's signature + dict[str, Any] # simplint: ignore[SMP602] # see above + ), + artifact_path: str, + ) -> None: + """ + Write the SBOM into the wheel hatchling built, and remove the revision file an sdist build wrote. + + :param version: hatchling's build variant; an editable wheel gets no SBOM, since it carries no code + :param build_data: hatchling's build data + :param artifact_path: the built wheel + + """ + if self._remove_revision_file is not None: + self._remove_revision_file() + if self.target_name != "wheel" or version == "editable": + return + # what a wheel needs loads only when one is built, so an sdist build stays light + from python_sbom._collect._facts import collect_facts # ruff: ignore[import-outside-top-level] # see above + from python_sbom._generate._embed import embed_into # ruff: ignore[import-outside-top-level] # see above + + root = Path(self.root) + # content_provider() may be a subclass's override, so its result goes through resolve_provider again, which + # returns a given instance as is, for the provider's source files to be listed among the build tools + content, provider_scripts = resolve_provider(self.content_provider(), self._settings, root) + # a custom hook script shapes the document, as a provider defined in the project does, so the SBOM lists both + # among the build tools + own = [PurePosixPath(self.config.get("path", DEFAULT_BUILD_SCRIPT))] if self.PLUGIN_NAME == "custom" else [] + scripts = tuple(dict.fromkeys([*own, *provider_scripts])) + # hatchling's own fixed time for a reproducible build when SOURCE_DATE_EPOCH is unset, so the SBOM agrees with + # the zip entries hatchling wrote + facts = collect_facts( + root, + lambda _root: self._revision, + default_epoch=get_reproducible_timestamp() if self.build_config.reproducible else None, + ) + embed_into( + Path(artifact_path), root, settings=self._settings, content=content, build_scripts=scripts, facts=facts + ) + + def content_provider(self) -> ContentProvider: + """ + Return what the project adds to its SBOM. + + Override it in a hatchling custom build hook to build the provider with what the hook knows; the + ``content-provider`` option is then ignored. + + :returns: the ``content-provider`` option's provider, or one that adds nothing + + """ + return resolve_provider(None, self._settings, Path(self.root))[0] + + @cached_property + def _settings(self) -> Settings: + # hatchling has parsed pyproject.toml already; the hook's own table holds only hatchling's options, so every + # entry point reads the same [tool.python-sbom] + if unknown := sorted(self.config.keys() - _HATCHLING_OPTIONS): + msg = ( + f"the {self.PLUGIN_NAME} hook's table holds {', '.join(unknown)}; move python-sbom's options to" + " [tool.python-sbom], where python-sbom embed and python-sbom archive read them too" + ) + raise SbomError(msg) + return settings_from_table(self.metadata.config.get("tool", {}).get("python-sbom", {}), "[tool.python-sbom]") + + +__all__ = [ + "SbomBuildHook", +] diff --git a/src/python_sbom/hatch/hooks.py b/src/python_sbom/hatch/hooks.py new file mode 100644 index 0000000..ed86134 --- /dev/null +++ b/src/python_sbom/hatch/hooks.py @@ -0,0 +1,18 @@ +"""Register the build hook with hatchling.""" + +from __future__ import annotations + +from hatchling.plugin import hookimpl + +from ._hook import SbomBuildHook + + +@hookimpl +def hatch_register_build_hook() -> type[SbomBuildHook]: + """ + Return the build hook hatchling loads for ``[tool.hatch.build.hooks.python-sbom]``. + + :returns: the build hook class + + """ + return SbomBuildHook diff --git a/src/python_sbom/py.typed b/src/python_sbom/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/_cli/conftest.py b/tests/_cli/conftest.py new file mode 100644 index 0000000..30836ed --- /dev/null +++ b/tests/_cli/conftest.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import tempfile +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from pathlib import Path + + from tests.conftest import WriteWheel + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + (root := tmp_path / "project").mkdir() + (root / "pyproject.toml").write_text("[tool.python-sbom]\ndefault-branch = 'trunk'\n", encoding="utf-8") + return root + + +@pytest.fixture +def wheel(tmp_path: Path, write_wheel: WriteWheel) -> Path: + return write_wheel( + tmp_path / "dist" / "native-1.0-py3-none-any.whl", + {"native/__init__.py": b"", "native-1.0.dist-info/METADATA": b"Name: native\nVersion: 1.0\n"}, + ) + + +@pytest.fixture +def temp_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + (directory := tmp_path / "temp").mkdir() + monkeypatch.setattr(tempfile, "tempdir", str(directory)) + monkeypatch.setenv("TMPDIR", str(directory)) + return directory diff --git a/tests/_cli/test_archive.py b/tests/_cli/test_archive.py new file mode 100644 index 0000000..23e3f97 --- /dev/null +++ b/tests/_cli/test_archive.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import json +import stat +import zipfile +from typing import TYPE_CHECKING + +import pytest + +from python_sbom._cli._main import main + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + +@pytest.fixture +def zipapp(tmp_path: Path) -> Path: + with zipfile.ZipFile(target := tmp_path / "app.pyz", "w") as handle: + handle.writestr("__main__.py", "import app\n") + handle.writestr("app-1.0.dist-info/METADATA", "Name: app\nVersion: 1.0\nLicense-Expression: MIT\n") + handle.writestr("app/__init__.py", "") + return target + + +def test_archive_writes_sbom_next_to_archive(project: Path, zipapp: Path, capsys: pytest.CaptureFixture[str]) -> None: + main(["archive", str(zipapp), "--root", str(project), "--build-env", str(project)]) + assert ( + capsys.readouterr().out, + json.loads(zipapp.with_name("app.pyz.cdx.json").read_text())["metadata"]["component"]["name"], + ) == ( + f"wrote {zipapp.with_name('app.pyz.cdx.json')}\n", + "app.pyz", + ) + + +def test_archive_embeds_sbom(project: Path, zipapp: Path, tmp_path: Path) -> None: + (project / "build_app.py").write_text("", encoding="utf-8") + main([ + "archive", + str(zipapp), + "--root", + str(project), + "--build-env", + str(project), + "--build-script", + "build_app.py", + "--output", + str(tmp_path / "out.json"), + "--embed", + ]) + entry = zipfile.ZipFile(zipapp).getinfo("app.pyz.cdx.json") + assert (entry.create_system, entry.external_attr, entry.compress_type, zipfile.ZipFile(zipapp).read(entry)) == ( + 3, + (stat.S_IFREG | 0o644) << 16, + zipfile.ZIP_DEFLATED, + (tmp_path / "out.json").read_bytes(), + ) + + +def test_archive_refuses_second_embed(project: Path, zipapp: Path, capsys: pytest.CaptureFixture[str]) -> None: + arguments = ["archive", str(zipapp), "--root", str(project), "--build-env", str(project), "--embed"] + main(arguments) + capsys.readouterr() + with pytest.raises(SystemExit): + main(arguments) + assert capsys.readouterr().err == "python-sbom: app.pyz already holds app.pyz.cdx.json\n" + + +def test_archive_embed_leaves_no_temporary_files(project: Path, zipapp: Path, temp_dir: Path) -> None: + main(["archive", str(zipapp), "--root", str(project), "--build-env", str(project), "--embed"]) + # the rewritten archive is staged next to the original + assert (list(temp_dir.iterdir()), sorted(path.name for path in zipapp.parent.iterdir())) == ( + [], + ["app.pyz", "app.pyz.cdx.json", "project", "temp"], + ) + + +def direct(archive: Path) -> Path: + return archive + + +def hardlink(archive: Path) -> Path: + (alias := archive.with_name("alias.pyz")).hardlink_to(archive) + return alias + + +def symlink(archive: Path) -> Path: + alias = archive.with_name("alias.pyz") + try: + alias.symlink_to(archive) + except OSError: # pragma: no cover # only without the privilege to create symbolic links + pytest.skip("symbolic links need privileges on this platform") + return alias + + +@pytest.mark.parametrize( + "alias", + [ + pytest.param(direct, id="direct"), + pytest.param(hardlink, id="hardlink"), + pytest.param(symlink, id="symlink"), + ], +) +@pytest.mark.parametrize( + "flags", + [pytest.param([], id="sbom"), pytest.param(["--embed"], id="embed")], +) +def test_archive_refuses_output_over_archive( + project: Path, + zipapp: Path, + alias: Callable[[Path], Path], + flags: list[str], + capsys: pytest.CaptureFixture[str], +) -> None: + output = alias(zipapp) + before = zipapp.read_bytes() + arguments = [ + "archive", + str(zipapp), + "--root", + str(project), + "--build-env", + str(project), + "--output", + str(output), + *flags, + ] + with pytest.raises(SystemExit): + main(arguments) + assert (zipapp.read_bytes(), capsys.readouterr().err) == ( + before, + f"python-sbom: {output} is the archive {zipapp} itself, refusing to overwrite it with the SBOM\n", + ) diff --git a/tests/_cli/test_embed.py b/tests/_cli/test_embed.py new file mode 100644 index 0000000..f713682 --- /dev/null +++ b/tests/_cli/test_embed.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json +import zipfile +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom._cli._main import main + +if TYPE_CHECKING: + from pathlib import Path + + from python_sbom._model._types import Document + from tests.conftest import Install + +_SBOM: Final[str] = "native-1.0.dist-info/sboms/native.cdx.json" + + +_COMMIT: Final[str] = "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff" + + +@pytest.fixture +def embed_output( + project: Path, wheel: Path, tmp_path: Path, install: Install, capsys: pytest.CaptureFixture[str] +) -> str: + install(site := tmp_path / "site", "Name: meson-python\nVersion: 0.18\n", {}) + main(["embed", str(wheel), "--root", str(project), "--commit", _COMMIT, "--build-env", str(site)]) + return capsys.readouterr().out + + +def test_embed_reports_the_sbom(embed_output: str, wheel: Path) -> None: + assert embed_output == f"wrote {_SBOM} into {wheel.name}\n" + + +@pytest.mark.usefixtures("embed_output") +def test_embed_records_the_commit(wheel: Path) -> None: + assert {"name": "native:vcs-commit", "value": _COMMIT} in embedded(wheel)["metadata"]["component"]["properties"] + + +def embedded(wheel: Path) -> Document: + return json.loads(zipfile.ZipFile(wheel).read(_SBOM)) + + +@pytest.mark.usefixtures("embed_output") +def test_embed_lists_the_build_environment(wheel: Path) -> None: + assert [tool["name"] for tool in embedded(wheel)["metadata"]["tools"]["components"][1:]] == ["meson-python"] + + +def test_embed_defaults_to_current_directory(project: Path, wheel: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(project) + monkeypatch.setenv("PYTHON_SBOM_COMMIT", "cafecafecafecafecafecafecafecafecafecafe") + main(["embed", str(wheel), "--build-env", str(project)]) + assert {"name": "native:vcs-commit", "value": "cafecafecafecafecafecafecafecafecafecafe"} in embedded(wheel)[ + "metadata" + ]["component"]["properties"] + + +@pytest.mark.parametrize( + ("option", "table"), + [ + pytest.param(["--content-provider", "MODULE:Native"], "", id="option"), + pytest.param([], "content-provider = 'MODULE:Native'\n", id="setting"), + ], +) +def test_embed_with_content_provider(tmp_path: Path, project: Path, wheel: Path, option: list[str], table: str) -> None: + # a module name unique to the test, so the import cache never hands over another test's module + provider = """\ +from python_sbom import ContentProvider, Contributions + + +class Native(ContentProvider): + def contributions(self, context): + return Contributions( + root_properties=[{"name": "extra:by", "value": "cli"}] + ) +""" + (project / f"{tmp_path.name}.py").write_text(provider, encoding="utf-8") + (project / "pyproject.toml").write_text(f"[tool.python-sbom]\n{table.replace('MODULE', tmp_path.name)}") + main([ + "embed", + str(wheel), + "--root", + str(project), + "--build-env", + str(project), + *(argument.replace("MODULE", tmp_path.name) for argument in option), + ]) + document = embedded(wheel) + assert {"name": "extra:by", "value": "cli"} in document["metadata"]["component"]["properties"] + assert document["metadata"]["tools"]["components"][0]["name"] == f"{tmp_path.name}.py" + + +def test_embed_leaves_no_temporary_files(project: Path, wheel: Path, temp_dir: Path) -> None: + main(["embed", str(wheel), "--root", str(project), "--build-env", str(project)]) + # the rewritten wheel is staged next to the original + assert (list(temp_dir.iterdir()), list(wheel.parent.iterdir())) == ([], [wheel]) diff --git a/tests/_cli/test_main.py b/tests/_cli/test_main.py new file mode 100644 index 0000000..a2677e5 --- /dev/null +++ b/tests/_cli/test_main.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import runpy +import subprocess +import sys +from pathlib import Path + +import pytest + +from python_sbom._cli._main import main +from python_sbom.version import __version__ + + +def test_error_without_traceback(tmp_path: Path, wheel: Path, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exit_info: + main(["embed", str(wheel), "--root", str(tmp_path)]) + assert exit_info.value.code == 1 + assert capsys.readouterr().err == f"python-sbom: {tmp_path} has no pyproject.toml to read [tool.python-sbom] from\n" + + +def test_module_entry_point(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + monkeypatch.setattr(sys, "argv", ["python-sbom", "--help"]) + with pytest.raises(SystemExit): + runpy.run_module("python_sbom", run_name="__main__") + assert capsys.readouterr().out.startswith("usage: python-sbom ") + + +def test_console_script() -> None: + script = Path(sys.executable).parent / "python-sbom" + assert subprocess.check_output([script, "--help"], text=True).startswith("usage: python-sbom ") + + +def test_version(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit): + main(["--version"]) + assert capsys.readouterr().out == f"python-sbom {__version__}\n" + + +@pytest.mark.parametrize( + "arguments", [pytest.param("--version", id="version"), pytest.param("embed --help", id="help")] +) +def test_parsing_loads_no_command(arguments: str) -> None: + code = ( + "import sys\nfrom python_sbom._cli._main import main\ntry:\n" + f" main({arguments.split()!r})\nexcept SystemExit:\n" + " print(sorted(name for name in sys.modules if name.startswith('python_sbom._cli.')))\n" + ) + loaded = subprocess.check_output([sys.executable, "-c", code], text=True).splitlines()[-1] + assert loaded == "['python_sbom._cli._main', 'python_sbom._cli._parser']" diff --git a/tests/_cli/test_parser.py b/tests/_cli/test_parser.py new file mode 100644 index 0000000..fee73ac --- /dev/null +++ b/tests/_cli/test_parser.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from python_sbom._cli._parser import build_parser + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.mark.parametrize( + "value", + [pytest.param("0", id="zero"), pytest.param("-1", id="negative"), pytest.param("many", id="not-a-number")], +) +def test_max_components_must_be_positive(value: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exit_info: + build_parser().parse_args(["validate", str(tmp_path), "--max-components", value]) + assert (exit_info.value.code, capsys.readouterr().err.splitlines()[-1]) == ( + 2, + f"python-sbom validate: error: argument --max-components: must be a positive integer, got {value!r}", + ) + + +def test_max_components_takes_positive(tmp_path: Path) -> None: + assert build_parser().parse_args(["validate", str(tmp_path), "--max-components", "1"]).max_components == 1 diff --git a/tests/_cli/test_spdx.py b/tests/_cli/test_spdx.py new file mode 100644 index 0000000..fde3b1b --- /dev/null +++ b/tests/_cli/test_spdx.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest + +from python_sbom._cli._main import main +from python_sbom._formats._json import dumps +from python_sbom._validate._limits import MAX_COMPONENTS, byte_limit + +if TYPE_CHECKING: + from pathlib import Path + + from tests.conftest import Describe + + +def test_spdx(tmp_path: Path, describe: Describe) -> None: + (source := tmp_path / "example.cdx.json").write_bytes(dumps(describe())) + main(["spdx", str(source), str(target := tmp_path / "example.spdx.json")]) + text = target.read_bytes() + assert (text.endswith(b"}\n"), b"\r\n" in text, json.loads(text)["spdxVersion"]) == (True, False, "SPDX-2.3") + + +def test_spdx_refuses_non_document(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + (source := tmp_path / "other.json").write_text("[]", encoding="utf-8") + with pytest.raises(SystemExit): + main(["spdx", str(source), str(tmp_path / "out.json")]) + assert capsys.readouterr().err == f"python-sbom: {source} is not a CycloneDX document python-sbom can render\n" + + +@pytest.mark.parametrize( + "document", + [ + pytest.param( + json.dumps({"metadata": {"component": {"bom-ref": "app", "name": "app"}}, "components": [{"name": "x"}]}), + id="component-missing-fields", + ), + pytest.param( + json.dumps({"metadata": {"component": {"bom-ref": "app", "name": "app"}, "tools": 1}, "components": []}), + id="tools-not-object", + ), + ], +) +def test_spdx_refuses_malformed_document(document: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + (source := tmp_path / "broken.json").write_text(document, encoding="utf-8") + with pytest.raises(SystemExit): + main(["spdx", str(source), str(tmp_path / "out.json")]) + assert capsys.readouterr().err.startswith( + f"python-sbom: {source} is not a CycloneDX document python-sbom can render: " + ) + + +@pytest.mark.parametrize( + ("content", "problem"), + [ + pytest.param( + b"{", "is not JSON: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)", id="json" + ), + pytest.param(b'{"name": "\xff"}', "is not UTF-8: invalid start byte at byte 10", id="utf-8"), + pytest.param(b"[" * 1_000_000 + b"]" * 1_000_000, "nests too deep for the JSON parser to read", id="nesting"), + ], +) +def test_spdx_refuses_unreadable_source( + content: bytes, problem: str, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (source := tmp_path / "source.json").write_bytes(content) + with pytest.raises(SystemExit): + main(["spdx", str(source), str(tmp_path / "out.json")]) + assert capsys.readouterr().err == f"python-sbom: {source} {problem}\n" + + +def test_spdx_refuses_missing_source(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit): + main(["spdx", str(source := tmp_path / "missing.json"), str(tmp_path / "out.json")]) + assert capsys.readouterr().err == f"python-sbom: [Errno 2] No such file or directory: {str(source)!r}\n" + + +def test_spdx_refuses_oversized_source(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + limit = byte_limit(MAX_COMPONENTS) + # sparse, so the test writes nothing; the size alone is refused, before any parsing + with (source := tmp_path / "large.json").open("wb") as handle: + handle.truncate(limit + 1) + with pytest.raises(SystemExit): + main(["spdx", str(source), str(tmp_path / "out.json")]) + assert capsys.readouterr().err == ( + f"python-sbom: {source} is over {limit} bytes, the size limit of an SBOM python-sbom reads\n" + ) + + +def test_spdx_refuses_unparsable_timestamp( + tmp_path: Path, describe: Describe, capsys: pytest.CaptureFixture[str] +) -> None: + document = describe() + document["metadata"]["timestamp"] = "yesterday" + (source := tmp_path / "example.cdx.json").write_bytes(dumps(document)) + with pytest.raises(SystemExit): + main(["spdx", str(source), str(tmp_path / "out.json")]) + assert capsys.readouterr().err == ( + f"python-sbom: {source} is not a CycloneDX document python-sbom can render:" + " ValueError(\"Invalid isoformat string: 'yesterday'\")\n" + ) diff --git a/tests/_cli/test_validate.py b/tests/_cli/test_validate.py new file mode 100644 index 0000000..bea3bd7 --- /dev/null +++ b/tests/_cli/test_validate.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom._cli._main import main + +if TYPE_CHECKING: + from pathlib import Path + + from tests.conftest import WriteWheel + +_SBOM: Final[str] = "native-1.0.dist-info/sboms/native.cdx.json" + + +def test_validate_directory(project: Path, wheel: Path, capsys: pytest.CaptureFixture[str]) -> None: + main(["embed", str(wheel), "--root", str(project), "--build-env", str(project)]) + capsys.readouterr() + main(["validate", str(wheel.parent), "--allow-license", "MIT"]) + assert capsys.readouterr().out == f"SBOM in {wheel.name} is valid\n" + + +def test_validate_reports_problems(wheel: Path, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit): + main(["validate", str(wheel)]) + assert capsys.readouterr().err == f"SBOM invalid: {wheel.name}: no SBOM found in {wheel.name}\n" + + +@pytest.fixture +def crowded_wheel(tmp_path: Path, write_wheel: WriteWheel) -> Path: + # 100 groups of 50 parts, 5,100 components in all, yet no array long enough to slow the schema check + groups = [ + { + "type": "library", + "name": f"g{group}", + "components": [{"type": "library", "name": f"p{part}"} for part in range(50)], + } + for group in range(100) + ] + sbom = json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, "components": groups}).encode() + return write_wheel(tmp_path / "native-1.0-py3-none-any.whl", {"native-1.0.dist-info/METADATA": b"", _SBOM: sbom}) + + +def test_validate_max_components_sets_the_cap(crowded_wheel: Path, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit): + main(["validate", str(crowded_wheel), "--max-components", "5000"]) + assert capsys.readouterr().err == ( + f"SBOM invalid: {crowded_wheel.name}: {_SBOM}: lists more than 5000 components, nested ones included;" + " pass a higher --max-components to check it\n" + ) + + +def test_validate_default_cap_admits_thousands(crowded_wheel: Path, capsys: pytest.CaptureFixture[str]) -> None: + main(["validate", str(crowded_wheel)]) + assert capsys.readouterr().out == f"SBOM in {crowded_wheel.name} is valid\n" + + +def test_validate_without_wheels(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit): + main(["validate", str(tmp_path)]) + assert capsys.readouterr().err == f"no wheel found in {tmp_path}\n" diff --git a/tests/_collect/test_archive.py b/tests/_collect/test_archive.py new file mode 100644 index 0000000..ff8aab8 --- /dev/null +++ b/tests/_collect/test_archive.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import hashlib +import zipfile +from typing import TYPE_CHECKING + +import pytest + +from python_sbom._collect._archive import read_archive +from python_sbom._wheel._reader import open_artifact + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.mark.parametrize( + "name", + [ + pytest.param("/etc/hostname", id="absolute"), + pytest.param("lib/../../../../etc/os-release", id="parent"), + pytest.param("sub/../../outside.txt", id="outside"), + ], +) +def test_archive_member_names_never_reach_the_host(tmp_path: Path, name: str) -> None: + # each member is hashed from the zip, so a name that would point outside an unpacked copy names the member alone + with zipfile.ZipFile(archive := tmp_path / "app.pyz", "w") as handle: + handle.writestr("app-1.0.dist-info/METADATA", "Name: app\nVersion: 1.0\n") + handle.writestr(zipfile.ZipInfo(name), b"inside the archive") + with open_artifact(archive) as opened: + row = read_archive(opened).files[name] + assert (row.digest, row.size) == (hashlib.sha256(b"inside the archive").hexdigest(), "18") diff --git a/tests/_collect/test_content.py b/tests/_collect/test_content.py new file mode 100644 index 0000000..71b06f5 --- /dev/null +++ b/tests/_collect/test_content.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import dataclasses +import sys +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING + +import pytest + +import python_sbom +from python_sbom import ContentProvider +from python_sbom._collect._content import resolve_provider +from python_sbom._errors import SbomError + +if TYPE_CHECKING: + from python_sbom import Settings + + +@pytest.fixture +def module(tmp_path: Path) -> str: + # a name unique to the test, so the import cache never hands over another test's module + (tmp_path / "tools").mkdir() + provider = """\ +from python_sbom import ContentProvider + + +class Content(ContentProvider): + pass + + +instance = Content() +other = object() + + +class Holder: + nested = instance +""" + (tmp_path / "tools" / f"{tmp_path.name}.py").write_text(provider, encoding="utf-8") + return f"tools.{tmp_path.name}" + + +@pytest.mark.parametrize( + "attribute", + [ + pytest.param("Content", id="class"), + pytest.param("instance", id="instance"), + pytest.param("Holder.nested", id="dotted-attribute"), + ], +) +def test_load_from_project(tmp_path: Path, module: str, attribute: str, settings: Settings) -> None: + provider, scripts = resolve_provider(f"{module}:{attribute}", settings, tmp_path) + assert (type(provider).__name__, scripts) == ("Content", (PurePosixPath(f"tools/{tmp_path.name}.py"),)) + + +def test_option_names_the_provider(tmp_path: Path, module: str, settings: Settings) -> None: + configured = dataclasses.replace(settings, content_provider=f"{module}:Content") + assert type(resolve_provider(None, configured, tmp_path)[0]).__name__ == "Content" + + +def test_given_name_wins_over_option(tmp_path: Path, module: str, settings: Settings) -> None: + configured = dataclasses.replace(settings, content_provider="not_a_module_anywhere:Content") + assert type(resolve_provider(f"{module}:Content", configured, tmp_path)[0]).__name__ == "Content" + + +def test_given_instance_wins_over_option(tmp_path: Path, settings: Settings) -> None: + # even one that is falsy, as a provider defining __len__ may be + class Empty(ContentProvider): + def __len__(self) -> int: + return 0 + + given = Empty() + assert not given + configured = dataclasses.replace(settings, content_provider="not_a_module_anywhere:Content") + assert resolve_provider(given, configured, tmp_path) == (given, ()) + + +def test_no_provider_adds_nothing_and_is_no_tool(settings: Settings) -> None: + # python-sbom's own no-op provider is no build tool, not even when python-sbom builds itself + provider, scripts = resolve_provider(None, settings, Path(python_sbom.__file__).parents[2]) + assert (type(provider), scripts) == (ContentProvider, ()) + + +def test_project_base_classes_are_tools(tmp_path: Path, settings: Settings) -> None: + # a base class in the project shapes the document as much as the provider class itself + (package := tmp_path / f"bases_{tmp_path.name}").mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "base.py").write_text( + "from python_sbom import ContentProvider\n\n\nclass Base(ContentProvider):\n pass\n", encoding="utf-8" + ) + (package / "leaf.py").write_text("from .base import Base\n\n\nclass Leaf(Base):\n pass\n", encoding="utf-8") + _, scripts = resolve_provider(f"{package.name}.leaf:Leaf", settings, tmp_path) + assert scripts == (PurePosixPath(f"{package.name}/leaf.py"), PurePosixPath(f"{package.name}/base.py")) + + +def test_provider_class_outside_project_is_no_tool(tmp_path: Path, settings: Settings) -> None: + class Local(ContentProvider): + pass + + assert resolve_provider(Local(), settings, tmp_path)[1] == () + + +@pytest.mark.parametrize( + ("spec", "message"), + [ + pytest.param("tools", "must be `module:attribute`", id="no-attribute"), + pytest.param(":Content", "must be `module:attribute`", id="no-module"), + pytest.param("tools:Content:x", "does not load from .*: invalid format", id="two-colons"), + pytest.param("not_a_module_anywhere:Content", "does not load from .*: No module named", id="import-fails"), + pytest.param("MODULE:other", "is not a ContentProvider subclass or instance", id="not-a-provider"), + pytest.param("MODULE:missing", "does not load from .*: .* has no attribute 'missing'", id="missing-attribute"), + ], +) +def test_load_refuses(tmp_path: Path, module: str, spec: str, message: str, settings: Settings) -> None: + with pytest.raises(SbomError, match=message): + resolve_provider(spec.replace("MODULE", module), settings, tmp_path) + + +def test_provider_without_source_is_no_tool(tmp_path: Path, settings: Settings) -> None: + provider = type("Generated", (ContentProvider,), {"__module__": "not_a_loaded_module"})() + assert resolve_provider(provider, settings, tmp_path)[1] == () + + +def test_provider_of_another_project_is_not_reused(tmp_path: Path, settings: Settings) -> None: + # two projects whose providers share a module name, built in one process as hatch builds them + name = f"provider_{tmp_path.name}" + for project, value in (("first", "1"), ("second", "2")): + (tmp_path / project).mkdir() + (tmp_path / project / f"{name}.py").write_text( + f"from python_sbom import ContentProvider\n\n\nclass Content(ContentProvider):\n value = {value}\n", + encoding="utf-8", + ) + try: + resolve_provider(f"{name}:Content", settings, tmp_path / "first") + with pytest.raises( + SbomError, match=rf"this process already imported .*first.*{name}\.py, a module of the same" + ): + resolve_provider(f"{name}:Content", settings, tmp_path / "second") + finally: + sys.modules.pop(name, None) + + +def test_provider_of_the_same_project_loads_again(tmp_path: Path, module: str, settings: Settings) -> None: + first, _ = resolve_provider(f"{module}:Content", settings, tmp_path) + second, _ = resolve_provider(f"{module}:Content", settings, tmp_path) + assert type(first) is type(second) diff --git a/tests/_collect/test_facts.py b/tests/_collect/test_facts.py new file mode 100644 index 0000000..ac54dc6 --- /dev/null +++ b/tests/_collect/test_facts.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import hashlib +import platform +import re +import sys +import time +from typing import TYPE_CHECKING + +import pytest + +from python_sbom._collect._facts import collect_facts +from python_sbom._errors import SbomError +from python_sbom._model._inputs import Interpreter, RecordRow, Revision + +if TYPE_CHECKING: + from pathlib import Path + + from conftest import Install + + from python_sbom._model._inputs import BuildFacts + + +def test_epoch_from_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SOURCE_DATE_EPOCH", "1700000000") + facts = collect_facts(tmp_path, no_revision, path=[], default_epoch=0) + assert facts.epoch == 1_700_000_000 + assert facts.epoch_from_environment + + +def no_revision(_root: Path, /) -> None: + return None + + +def test_epoch_default(tmp_path: Path) -> None: + facts = collect_facts(tmp_path, no_revision, path=[], default_epoch=42) + assert (facts.epoch, facts.epoch_from_environment) == (42, False) + + +def test_empty_epoch_is_unset(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SOURCE_DATE_EPOCH", "") + facts = collect_facts(tmp_path, no_revision, path=[], default_epoch=42) + assert (facts.epoch, facts.epoch_from_environment) == (42, False) + + +def test_epoch_without_default_is_now(tmp_path: Path) -> None: + before = int(time.time()) + assert before <= collect_facts(tmp_path, no_revision, path=[]).epoch <= time.time() + + +def test_invalid_epoch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SOURCE_DATE_EPOCH", "yesterday") + with pytest.raises(SbomError, match="SOURCE_DATE_EPOCH must be a Unix timestamp, got 'yesterday'"): + collect_facts(tmp_path, no_revision, path=[]) + + +def test_interpreter(tmp_path: Path) -> None: + assert collect_facts(tmp_path, no_revision, path=[], default_epoch=0).interpreter == Interpreter( + name=sys.implementation.name, + version=platform.python_version(), + implementation=platform.python_implementation(), + ) + + +def test_revision_reads_root(tmp_path: Path) -> None: + def reader(root: Path, /) -> Revision: + return Revision(commit=root.name, dirty=False) + + assert collect_facts(tmp_path, reader, path=[], default_epoch=0).revision == Revision( + commit=tmp_path.name, dirty=False + ) + + +def test_default_path_is_this_interpreter(tmp_path: Path) -> None: + names = { + distribution.metadata.name + for distribution in collect_facts(tmp_path, no_revision, default_epoch=0).distributions + } + assert "pytest" in names + + +def test_pure_distribution_files(tmp_path: Path, install: Install) -> None: + # nested, so the console script, two levels up as installers write it, stays inside the test's directory + install( + site := tmp_path / "lib" / "site-packages", + "Name: alpha\nVersion: 1.0\n", + { + "alpha/__init__.py": b"x = 1\n", + "../../bin/alpha": b"#!/usr/bin/python\n", + "alpha-1.0.dist-info/INSTALLER": b"uv\n", + "alpha-1.0.dist-info/REQUESTED": b"", + "alpha-1.0.dist-info/direct_url.json": b'{"url": "file:///tmp/alpha.whl", "archive_info": {}}', + "alpha-1.0.dist-info/uv_cache.json": b'{"timestamp": 1}', + }, + ) + (distribution,) = facts_from(site).distributions + assert distribution.files == [ + RecordRow( + path="alpha/__init__.py", algorithm="SHA-256", digest=hashlib.sha256(b"x = 1\n").hexdigest(), size="6" + ), + RecordRow( + path="alpha-1.0.dist-info/METADATA", + algorithm="SHA-256", + digest=hashlib.sha256(b"Name: alpha\nVersion: 1.0\n").hexdigest(), + size="25", + ), + RecordRow( + path="alpha-1.0.dist-info/WHEEL", + algorithm="SHA-256", + digest=hashlib.sha256(b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\n").hexdigest(), + size="41", + ), + ] + + +def facts_from(site: Path) -> BuildFacts: + return collect_facts(site, no_revision, path=[str(site)], default_epoch=0) + + +def test_platform_distribution_lists_no_files(tmp_path: Path, install: Install) -> None: + install(tmp_path, "Name: native\nVersion: 1.0\n", {"native.so": b"\x7fELF"}, purelib=False) + assert facts_from(tmp_path).distributions[0].files == [] + + +@pytest.mark.parametrize( + ("wheel", "listed"), + [ + pytest.param("Wheel-Version: 1.0\nroot-is-purelib:\ttrue\n", True, id="any-case-and-blank"), + pytest.param("Wheel-Version: 1.0\nRoot-Is-Purelib: true\nRoot-Is-Purelib: false\n", True, id="first-wins"), + pytest.param("Wheel-Version: 1.0\n", False, id="absent"), + pytest.param("Wheel-Version: 1.0\n\nRoot-Is-Purelib: true\n", False, id="after-the-headers"), + pytest.param("Root-Is-Purelib: true \n", False, id="trailing-space"), + ], +) +def test_wheel_file_decides_purelib(tmp_path: Path, install: Install, wheel: str, listed: bool) -> None: + # read as the email parser reads the field: its first occurrence, among the headers, as written after the colon + install(tmp_path, "Name: alpha\nVersion: 1.0\n", {}) + (tmp_path / "alpha-1.0.dist-info" / "WHEEL").write_text(wheel, encoding="utf-8") + assert bool(facts_from(tmp_path).distributions[0].files) is listed + + +def test_editable_distribution_lists_no_files(tmp_path: Path, install: Install) -> None: + editable = '{"url": "file:///checkout", "dir_info": {"editable": true}}' + install(tmp_path, "Name: local\nVersion: 1.0\n", {"local.pth": b"/checkout/src\n"}, direct_url=editable) + assert facts_from(tmp_path).distributions[0].files == [] + + +def test_directory_install_not_editable_lists_files(tmp_path: Path, install: Install) -> None: + install(tmp_path, "Name: local\nVersion: 1.0\n", {}, direct_url='{"url": "file:///checkout", "dir_info": {}}') + assert [row.path for row in facts_from(tmp_path).distributions[0].files] == [ + "local-1.0.dist-info/METADATA", + "local-1.0.dist-info/WHEEL", + ] + + +@pytest.mark.parametrize( + ("direct_url", "message"), + [ + pytest.param("{", "Expecting property name", id="not-json"), + pytest.param('{"dir_info": {"editable": true}}', "Missing required value in 'url'", id="no-url"), + pytest.param( + '{"url": "file:///checkout", "dir_info": {"editable": "yes"}}', + r"Unexpected type str \(expected bool\) in 'dir_info.editable'", + id="editable-not-bool", + ), + ], +) +def test_malformed_direct_url_reads_as_not_editable( + tmp_path: Path, install: Install, direct_url: str, message: str +) -> None: + install(tmp_path, "Name: local\nVersion: 1.0\n", {}, direct_url=direct_url) + with pytest.warns(UserWarning, match=f"read the direct_url.json of local as not editable: {message}"): + files = facts_from(tmp_path).distributions[0].files + assert len(files) == 2 + + +def test_first_distribution_on_path_wins(tmp_path: Path, install: Install) -> None: + install(first := tmp_path / "first", "Name: alpha\nVersion: 1.0\n", {}) + install(second := tmp_path / "second", "Name: Alpha\nVersion: 2.0\n", {}) + (distribution,) = collect_facts( + tmp_path, no_revision, path=[str(first), str(second)], default_epoch=0 + ).distributions + assert distribution.metadata.version == "1.0" + + +def test_depends_on_installed_requirements_that_apply(tmp_path: Path, install: Install) -> None: + install( + tmp_path, + "Name: alpha\nVersion: 1.0\nRequires-Dist: Beta>=1\nRequires-Dist: gamma; python_version < '3'\n" + "Requires-Dist: delta; extra == 'cli'\nRequires-Dist: missing\n", + {}, + ) + for name in ("beta", "gamma", "delta"): + install(tmp_path, f"Name: {name}\nVersion: 1.0\n", {}) + by_name = {distribution.metadata.name: distribution for distribution in facts_from(tmp_path).distributions} + assert by_name["alpha"].depends_on == ["beta"] + + +def test_egg_info_distribution(tmp_path: Path) -> None: + (egg_info := tmp_path / "legacy-1.0-py3.14.egg-info").mkdir() + (egg_info / "PKG-INFO").write_text("Metadata-Version: 2.1\nName: Legacy\nVersion: 1.0\n", encoding="utf-8") + (distribution,) = facts_from(tmp_path).distributions + assert (distribution.name, distribution.metadata.version, distribution.files) == ("legacy", "1.0", []) + + +def test_recorded_file_missing_on_disk_is_listed(tmp_path: Path, install: Install) -> None: + # the install recorded it, and Python 3.11 lists such a file too, where later versions' Distribution.files drop it + install(tmp_path, "Name: alpha\nVersion: 1.0\n", {"alpha.py": b"x = 1\n"}) + (tmp_path / "alpha.py").unlink() + assert [row.path for row in facts_from(tmp_path).distributions[0].files] == [ + "alpha.py", + "alpha-1.0.dist-info/METADATA", + "alpha-1.0.dist-info/WHEEL", + ] + + +@pytest.mark.parametrize( + ("row", "issue"), + [ + pytest.param("alpha.py,sha256=x", "expected 3 elements, got 2", id="two-columns"), + pytest.param("alpha.py,sha256=x,big", "`size` cannot be non-integer", id="size-not-integer"), + pytest.param("alpha.py,nohash,1", "`hash` does not follow the required format", id="hash-without-name"), + ], +) +def test_malformed_record_lists_no_files(tmp_path: Path, install: Install, row: str, issue: str) -> None: + install(tmp_path, "Name: alpha\nVersion: 1.0\n", {}) + (tmp_path / "alpha-1.0.dist-info" / "RECORD").write_bytes(f"{row}\n".encode()) + with pytest.warns(UserWarning, match=f"listed no files of alpha, its RECORD is malformed: .*{re.escape(issue)}"): + assert facts_from(tmp_path).distributions[0].files == [] + + +@pytest.mark.parametrize( + "files", + [ + pytest.param({}, id="without-metadata"), + pytest.param({"METADATA": "Name: broken\n"}, id="without-version"), + ], +) +def test_distribution_without_name_and_version_left_out( + tmp_path: Path, install: Install, files: dict[str, str] +) -> None: + install(tmp_path, "Name: ok\nVersion: 1.0\n", {}) + (broken := tmp_path / "broken-1.0.dist-info").mkdir() + for name, text in files.items(): + (broken / name).write_text(text, encoding="utf-8") + assert [distribution.name for distribution in facts_from(tmp_path).distributions] == ["ok"] + + +def test_invalid_requirement_left_out(tmp_path: Path, install: Install) -> None: + install(tmp_path, "Name: old\nVersion: 1.0\nRequires-Dist: pytz (>dev)\nRequires-Dist: pytz\n", {}) + install(tmp_path, "Name: pytz\nVersion: 1.0\n", {}) + with pytest.warns(UserWarning, match=r"left out the requirement 'pytz \(>dev\)' of old: "): + by_name = {distribution.name: distribution for distribution in facts_from(tmp_path).distributions} + assert by_name["old"].depends_on == ["pytz"] diff --git a/tests/_collect/test_revision.py b/tests/_collect/test_revision.py new file mode 100644 index 0000000..e6f23ed --- /dev/null +++ b/tests/_collect/test_revision.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from python_sbom._collect._revision import REVISION_FILE, default_revision, revision_reader +from python_sbom._errors import SbomError +from python_sbom._model._inputs import Revision + +if TYPE_CHECKING: + from pathlib import Path + + from conftest import Git + + +@pytest.mark.parametrize("commit", [pytest.param("a" * 40, id="sha1"), pytest.param("b" * 64, id="sha256")]) +def test_commit_from_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, commit: str) -> None: + monkeypatch.setenv("PYTHON_SBOM_COMMIT", commit) + assert default_revision(tmp_path) == Revision(commit=commit, dirty=False) + + +@pytest.mark.parametrize( + "commit", + [ + pytest.param("c0ffee", id="abbreviated"), + pytest.param("A" * 40, id="uppercase"), + pytest.param("a" * 41, id="between-lengths"), + pytest.param(f"{'a' * 40}/../../evil", id="path"), + ], +) +def test_commit_from_environment_must_be_full_hash( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, commit: str +) -> None: + monkeypatch.setenv("PYTHON_SBOM_COMMIT", commit) + with pytest.raises(SbomError, match="PYTHON_SBOM_COMMIT must be a full lowercase hex commit hash"): + default_revision(tmp_path) + + +def test_commit_from_environment_wins_over_git(checkout: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PYTHON_SBOM_COMMIT", "a" * 40) + assert default_revision(checkout) == Revision(commit="a" * 40, dirty=False) + + +@pytest.fixture +def checkout(tmp_path: Path, git: Git) -> Path: + (root := tmp_path / "checkout").joinpath("pyproject.toml").write_text("[project]\n", encoding="utf-8") + (root / ".gitignore").write_text("build/\n", encoding="utf-8") + git("add", ".") + git("commit", "--quiet", "--message", "first") + return root + + +def test_revision_file(tmp_path: Path) -> None: + (tmp_path / REVISION_FILE).write_text(f"node: {'d' * 40}\n", encoding="utf-8") + assert default_revision(tmp_path) == Revision(commit="d" * 40, dirty=False) + + +def test_invalid_revision_file(tmp_path: Path) -> None: + (tmp_path / REVISION_FILE).write_text("node: d\n", encoding="utf-8") + with pytest.raises(SbomError, match=r"\.python-sbom-revision must be a full lowercase hex commit hash"): + default_revision(tmp_path) + + +def test_git_revision(checkout: Path, git: Git) -> None: + assert default_revision(checkout) == Revision(commit=git("rev-parse", "HEAD"), dirty=False) + + +def test_git_revision_dirty(checkout: Path, git: Git) -> None: + (checkout / "pyproject.toml").write_text("[project]\nname = 'changed'\n", encoding="utf-8") + assert default_revision(checkout) == Revision(commit=git("rev-parse", "HEAD"), dirty=True) + + +def test_untracked_file_marks_revision_dirty(checkout: Path) -> None: + # the build packages it, so the commit alone no longer describes the source + (checkout / "backdoor.py").write_text("import os\n", encoding="utf-8") + assert (revision := default_revision(checkout)) is not None + assert revision.dirty + + +def test_ignored_file_leaves_revision_clean(checkout: Path) -> None: + (checkout / "build").mkdir() + (checkout / "build" / "output.txt").write_text("new\n", encoding="utf-8") + assert (revision := default_revision(checkout)) is not None + assert not revision.dirty + + +@pytest.fixture +def monorepo(tmp_path: Path, git: Git) -> Path: + for project in ("one", "two"): + (root := tmp_path / "checkout" / project).mkdir() + (root / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + git("add", ".") + git("commit", "--quiet", "--message", "first") + return tmp_path / "checkout" + + +def test_git_revision_of_project_in_subdirectory(monorepo: Path, git: Git) -> None: + assert default_revision(monorepo / "one") == Revision(commit=git("rev-parse", "HEAD"), dirty=False) + + +def test_sibling_changes_leave_revision_clean(monorepo: Path) -> None: + (monorepo / "two" / "pyproject.toml").write_text("[project]\nname = 'changed'\n", encoding="utf-8") + (monorepo / "two" / "new.py").write_text("", encoding="utf-8") + assert (revision := default_revision(monorepo / "one")) is not None + assert not revision.dirty + + +def test_project_untracked_by_enclosing_checkout_uses_archival(checkout: Path) -> None: + # an sdist unpacked under another project's build directory is not that project's commit + (project := checkout / "build" / "project").mkdir(parents=True) + (project / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + (project / ".git_archival.txt").write_text(f"node: {'e' * 40}\n", encoding="utf-8") + assert default_revision(project) == Revision(commit="e" * 40, dirty=False) + + +@pytest.mark.parametrize( + "project", + [pytest.param(".", id="own-checkout"), pytest.param("build/project", id="enclosing-checkout")], +) +def test_git_runs_no_fsmonitor_command(tmp_path: Path, checkout: Path, git: Git, project: str) -> None: + git("config", "core.fsmonitor", f"touch {(marker := tmp_path / 'ran').as_posix()}; false") + (root := checkout / project).mkdir(parents=True, exist_ok=True) + (root / "pyproject.toml").touch() + default_revision(root) + assert not marker.exists() + + +def test_git_revision_before_first_commit(tmp_path: Path, git: Git) -> None: + (tmp_path / "checkout" / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + git("add", "pyproject.toml") + assert default_revision(tmp_path / "checkout") is None + + +def test_git_revision_outside_checkout(tmp_path: Path) -> None: + assert default_revision(tmp_path) is None + + +def test_git_revision_without_git(checkout: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PATH", "") + assert default_revision(checkout) is None + + +@pytest.mark.parametrize( + "git_file", + [ + # a worktree copied without the main checkout, as into a container + pytest.param("gitdir: {root}/missing\n", id="missing-gitdir"), + # the marker uv leaves in an unpacked sdist it builds from + pytest.param("", id="empty"), + ], +) +def test_git_failure_warns_and_falls_back(tmp_path: Path, git_file: str) -> None: + (tmp_path / ".git").write_text(git_file.format(root=tmp_path.as_posix()), encoding="utf-8") + (tmp_path / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + (tmp_path / ".git_archival.txt").write_text(f"node: {'f' * 40}\n", encoding="utf-8") + with pytest.warns(UserWarning, match="git cannot read the source revision in .*: fatal: "): + assert default_revision(tmp_path) == Revision(commit="f" * 40, dirty=False) + + +def test_git_status_failure_leaves_revision_unknown(checkout: Path, git: Git) -> None: + # a tree object lost from the store, so git knows HEAD but cannot compare the checkout with it + tree = git("rev-parse", "HEAD^{tree}") + (stored := checkout / ".git" / "objects" / tree[:2] / tree[2:]).chmod(0o644) + stored.unlink() + with pytest.warns(UserWarning, match="git cannot read the source revision in .*: error: bad tree object HEAD"): + assert default_revision(checkout) is None + + +@pytest.mark.parametrize( + ("archival", "expected"), + [ + pytest.param(f"node: {'a' * 40}\n", Revision(commit="a" * 40, dirty=False), id="expanded"), + pytest.param("node: $Format:%H$\n", None, id="unexpanded"), + pytest.param(f"node: {'a' * 41}\n", None, id="not-a-commit"), + ], +) +def test_git_archival(tmp_path: Path, archival: str, expected: Revision | None) -> None: + (tmp_path / ".git_archival.txt").write_text(archival, encoding="utf-8") + assert default_revision(tmp_path) == expected + + +def test_no_revision_source(tmp_path: Path) -> None: + assert default_revision(tmp_path) is None + + +def test_given_commit_wins_over_checkout(checkout: Path) -> None: + assert revision_reader("a" * 40)(checkout) == Revision(commit="a" * 40, dirty=False) + + +def test_given_commit_must_be_full_hash() -> None: + with pytest.raises(SbomError, match="--commit must be a full lowercase hex commit hash"): + revision_reader("c0ffee") + + +def test_no_given_commit_reads_checkout(checkout: Path, git: Git) -> None: + assert revision_reader(None)(checkout) == Revision(commit=git("rev-parse", "HEAD"), dirty=False) diff --git a/tests/_collect/test_settings.py b/tests/_collect/test_settings.py new file mode 100644 index 0000000..a069ab3 --- /dev/null +++ b/tests/_collect/test_settings.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from python_sbom._collect._settings import settings_from_pyproject, settings_from_table +from python_sbom._errors import SbomError +from python_sbom._model._inputs import Settings + +if TYPE_CHECKING: + from pathlib import Path + + from python_sbom._collect._settings import Toml + + +def test_missing_pyproject(tmp_path: Path) -> None: + with pytest.raises(SbomError, match=r"has no pyproject\.toml to read"): + settings_from_pyproject(tmp_path) + + +def test_invalid_pyproject(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("[tool\n", encoding="utf-8") + with pytest.raises(SbomError, match=r"pyproject\.toml is not valid TOML"): + settings_from_pyproject(tmp_path) + + +def test_defaults(tmp_path: Path, settings: Settings) -> None: + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'a'\n", encoding="utf-8") + assert settings_from_pyproject(tmp_path) == settings + + +def test_table(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text( + "[tool.python-sbom]\n" + "repository = 'https://github.com/example/example/'\n" + "default-branch = 'dev'\n" + "bundled-wheels = ['src/*.whl']\n" + "supplier = { name = 'Example Org' }\n" + "content-provider = 'sbom:Content'\n", + encoding="utf-8", + ) + assert settings_from_pyproject(tmp_path) == Settings( + supplier={"name": "Example Org", "url": []}, + repository="https://github.com/example/example", + default_branch="dev", + bundled_wheels=("src/*.whl",), + content_provider="sbom:Content", + ) + + +def test_unknown_option() -> None: + with pytest.raises(SbomError, match=r"here has unknown option\(s\) bundled, suplier; known: bundled-wheels"): + settings_from_table({"suplier": {}, "bundled": []}, "here") + + +@pytest.mark.parametrize( + ("table", "message"), + [ + pytest.param({"repository": 1}, "`repository` in here must be a string", id="repository"), + pytest.param({"default-branch": 1}, "`default-branch` in here must be a string", id="default-branch"), + pytest.param({"bundled-wheels": "*.whl"}, "`bundled-wheels` in here must be an array", id="bundled-not-list"), + pytest.param({"bundled-wheels": [1]}, "`bundled-wheels` in here must be an array", id="bundled-not-strings"), + pytest.param({"content-provider": 1}, "`content-provider` in here must be a string", id="content-provider"), + pytest.param({"supplier": "Example"}, "`supplier` in here must be a table", id="supplier-not-table"), + pytest.param({"supplier": {"name": 1}}, "`supplier` in here must be a table", id="supplier-name"), + pytest.param({"supplier": {"name": "a", "url": "x"}}, "`supplier` in here must be a table", id="supplier-url"), + pytest.param( + {"supplier": {"name": "a", "email": "x"}}, "`supplier` in here must be a table", id="supplier-key" + ), + ], +) +def test_wrong_type(table: dict[str, Toml], message: str) -> None: + with pytest.raises(SbomError, match=message): + settings_from_table(table, "here") + + +def test_supplier_with_urls() -> None: + settings = settings_from_table({"supplier": {"name": "Example Org", "url": ["https://example.org"]}}, "here") + assert settings.supplier == {"name": "Example Org", "url": ["https://example.org"]} diff --git a/tests/_collect/test_source.py b/tests/_collect/test_source.py new file mode 100644 index 0000000..5968cad --- /dev/null +++ b/tests/_collect/test_source.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import dataclasses +import hashlib +import re +from pathlib import PurePosixPath +from typing import TYPE_CHECKING + +import pytest + +from python_sbom._collect._source import copyright_notice, hash_build_scripts, selected_wheels +from python_sbom._errors import SbomError +from python_sbom._model._inputs import BuildScript + +if TYPE_CHECKING: + from collections.abc import Mapping + from pathlib import Path + + from python_sbom._model._inputs import Settings + + +@pytest.mark.parametrize( + ("files", "expected"), + [ + pytest.param({}, None, id="no-license-file"), + pytest.param({"LICENSE": "MIT terms\n"}, None, id="no-copyright-line"), + pytest.param( + {"LICENSE": "MIT\n\nCopyright (c) 2026 Example\nCopyright (c) 2025 Other\n"}, + "Copyright (c) 2026 Example", + id="first-copyright-line", + ), + ], +) +def test_copyright(tmp_path: Path, files: Mapping[str, str], expected: str | None) -> None: + for name, text in files.items(): + (tmp_path / name).write_text(text, encoding="utf-8") + assert copyright_notice(tmp_path, ["LICENSE", "NOTICE"]) == expected + + +def test_selected_wheels_sorted_once(tmp_path: Path, settings: Settings) -> None: + for directory in ("b", "a"): + (tmp_path / "src" / directory).mkdir(parents=True) + (tmp_path / "src" / directory / "pip-1.0-py3-none-any.whl").write_bytes(b"") + settings = dataclasses.replace(settings, bundled_wheels=("src/*/*.whl", "src/a/*.whl")) + assert selected_wheels(tmp_path, settings) == ["src/a/pip-1.0-py3-none-any.whl", "src/b/pip-1.0-py3-none-any.whl"] + + +@pytest.mark.parametrize( + "pattern", + [ + pytest.param("../outside/*.whl", id="parent"), + pytest.param("src/../../outside/*.whl", id="parent-after-directory"), + pytest.param("ROOT/outside/*.whl", id="absolute"), + ], +) +def test_bundled_wheel_pattern_outside_project(tmp_path: Path, settings: Settings, pattern: str) -> None: + (tmp_path / "outside").mkdir() + (tmp_path / "outside" / "pip-1.0-py3-none-any.whl").write_bytes(b"") + (project := tmp_path / "project" / "src").mkdir(parents=True) + pattern = pattern.replace("ROOT", tmp_path.as_posix()) + settings = dataclasses.replace(settings, bundled_wheels=(pattern,)) + with pytest.raises(SbomError, match=f"`bundled-wheels` pattern {re.escape(repr(pattern))} must be relative"): + selected_wheels(project.parent, settings) + + +def test_build_scripts_hashed_once(tmp_path: Path) -> None: + (tmp_path / "hatch_build.py").write_bytes(b"hook = 1\n") + script = PurePosixPath("hatch_build.py") + assert hash_build_scripts(tmp_path, [script, script]) == [ + BuildScript(path=script, sha256=hashlib.sha256(b"hook = 1\n").hexdigest()) + ] + + +def test_build_script_outside_project(tmp_path: Path) -> None: + # a custom hook path may be absolute, which on Windows carries a drive the join with the root must keep + (tmp_path / "hatch_build.py").write_bytes(b"hook = 1\n") + script = PurePosixPath((tmp_path / "hatch_build.py").as_posix()) + assert hash_build_scripts(tmp_path / "project", [script]) == [ + BuildScript(path=script, sha256=hashlib.sha256(b"hook = 1\n").hexdigest()) + ] diff --git a/tests/_collect/test_wheel.py b/tests/_collect/test_wheel.py new file mode 100644 index 0000000..cd3dd19 --- /dev/null +++ b/tests/_collect/test_wheel.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import dataclasses +import hashlib +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom._collect._wheel import read_wheel +from python_sbom._errors import SbomError +from python_sbom._model._inputs import BuildScript, WheelTree +from python_sbom._wheel._reader import open_artifact + +if TYPE_CHECKING: + from collections.abc import Mapping + + from conftest import WriteWheel + + from python_sbom._model._inputs import Settings + +_PIP: Final[dict[str, bytes]] = {"pip/__init__.py": b"", "pip-1.0.dist-info/METADATA": b"Name: pip\nVersion: 1.0\n"} +_NATIVE: Final[dict[str, bytes]] = { + "native-1.0.dist-info/METADATA": b"Name: native\nVersion: 1.0\nLicense-File: LICENSE\n" +} + + +def test_read_wheel(tmp_path: Path, settings: Settings, write_wheel: WriteWheel) -> None: + (tmp_path / "LICENSE").write_text("MIT\n\nCopyright (c) 2026 Example\n", encoding="utf-8") + (tmp_path / "hatch_build.py").write_bytes(b"hook = 1\n") + tree = tree_of( + write_wheel(tmp_path / "native-1.0-py3-none-any.whl", _NATIVE), + tmp_path, + settings, + PurePosixPath("hatch_build.py"), + ) + assert (tree.name, tree.metadata.name, tree.copyright, tree.bundled, tree.build_scripts) == ( + "native-1.0-py3-none-any.whl", + "native", + "Copyright (c) 2026 Example", + [], + [BuildScript(path=PurePosixPath("hatch_build.py"), sha256=hashlib.sha256(b"hook = 1\n").hexdigest())], + ) + + +def tree_of(wheel: Path, root: Path, settings: Settings, *scripts: PurePosixPath) -> WheelTree: + with open_artifact(wheel) as artifact: + return read_wheel(artifact, root, settings, scripts) + + +def test_bundled_wheels_read_from_the_wheel(tmp_path: Path, settings: Settings, write_wheel: WriteWheel) -> None: + # identical copies, selected twice over, take the wheel's copies in turn, each keeping an identity of its own + for directory in ("b", "a"): + content = write_wheel(tmp_path / "src" / directory / "pip-1.0-py3-none-any.whl", _PIP).read_bytes() + wheel = built( + write_wheel, tmp_path, {"pkg/b/pip-1.0-py3-none-any.whl": content, "pkg/a/pip-1.0-py3-none-any.whl": content} + ) + settings = dataclasses.replace(settings, bundled_wheels=("src/*/*.whl", "src/a/*.whl")) + assert [bundled.path for bundled in tree_of(wheel, tmp_path, settings).bundled] == [ + "pkg/a/pip-1.0-py3-none-any.whl", + "pkg/b/pip-1.0-py3-none-any.whl", + ] + + +def built(write_wheel: WriteWheel, root: Path, bundled: Mapping[str, bytes]) -> Path: + return write_wheel(root / "dist" / "native-1.0-py3-none-any.whl", {**_NATIVE, **bundled}) + + +@pytest.mark.parametrize( + "shipped", + [ + pytest.param({"pkg/pip-1.0-py3-none-any.whl": b"the bytes of the build"}, id="changed-since-the-build"), + pytest.param({}, id="left-out-of-the-build"), + ], +) +def test_bundled_wheel_not_shipped_is_refused( + tmp_path: Path, settings: Settings, write_wheel: WriteWheel, shipped: dict[str, bytes] +) -> None: + write_wheel(tmp_path / "src" / "pip-1.0-py3-none-any.whl", _PIP) + settings = dataclasses.replace(settings, bundled_wheels=("src/*.whl",)) + message = r"bundled wheel src/pip-1\.0-py3-none-any\.whl is not in native-1\.0-py3-none-any\.whl byte for byte" + with pytest.raises(SbomError, match=message): + tree_of(built(write_wheel, tmp_path, shipped), tmp_path, settings) + + +def test_more_bundled_copies_than_shipped_is_refused( + tmp_path: Path, settings: Settings, write_wheel: WriteWheel +) -> None: + for directory in ("a", "b"): + content = write_wheel(tmp_path / "src" / directory / "pip-1.0-py3-none-any.whl", _PIP).read_bytes() + settings = dataclasses.replace(settings, bundled_wheels=("src/*/*.whl",)) + with pytest.raises(SbomError, match=r"bundled wheel src/b/pip-1\.0-py3-none-any\.whl is not in native"): + tree_of(built(write_wheel, tmp_path, {"pkg/pip-1.0-py3-none-any.whl": content}), tmp_path, settings) + + +@pytest.mark.parametrize( + ("name", "message"), + [ + pytest.param("native.whl", "is not a wheel: Not a valid wheel filename", id="file-name"), + pytest.param( + "native-1.0-py3-none-any.whl", + r"is not a wheel: Wheel doesn't contain exactly one \.dist-info directory", + id="no-dist-info", + ), + ], +) +def test_read_wheel_needs_a_wheel( + tmp_path: Path, settings: Settings, write_wheel: WriteWheel, name: str, message: str +) -> None: + archive = write_wheel(tmp_path / name, {"native/__init__.py": b""}, record=False) + with pytest.raises(SbomError, match=message): + tree_of(archive, Path(), settings) diff --git a/tests/_formats/test_spdx.py b/tests/_formats/test_spdx.py new file mode 100644 index 0000000..31bd2e7 --- /dev/null +++ b/tests/_formats/test_spdx.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import dataclasses +import json +import re +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Final, TypeAlias + +import pytest + +from python_sbom._formats._json import dumps +from python_sbom._formats._spdx import render_spdx +from python_sbom._validate._schemas import spdx_problems +from python_sbom.version import __version__ + +if TYPE_CHECKING: + from conftest import Describe + + from python_sbom import Component + from python_sbom._formats._spdx import _Package + from python_sbom._model._inputs import Settings + +# a JSON value, as json.loads hands it over +_Json: TypeAlias = "str | int | float | bool | list[_Json] | dict[str, _Json] | None" + +# what auditwheel writes: CycloneDX 1.4 conventions, a legacy tools array, no timestamp and no serial number +_FOREIGN: Final[str] = json.dumps({ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "component": {"type": "library", "bom-ref": "pkg:pypi/plain@1.0", "name": "plain", "version": "1.0"}, + "tools": [{"name": "auditwheel", "version": "6.4.0"}, {"name": "repair"}, {"vendor": "PyPA"}], + }, + "components": [{"type": "library", "bom-ref": "pkg:rpm/almalinux/libfoo@1", "name": "libfoo", "version": "1"}], + "dependencies": [{"ref": "pkg:pypi/plain@1.0", "dependsOn": ["pkg:rpm/almalinux/libfoo@1"]}], +}) + + +def test_minimal(describe: Describe) -> None: + document = describe() + spdx = render_spdx(document) + ids = {package["name"]: package["SPDXID"] for package in spdx["packages"]} + serial = document["serialNumber"] + assert spdx == { + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "Example-1.0", + "documentNamespace": f"https://spdx.org/spdxdocs/sboms/Example-1.0-{serial.removeprefix('urn:uuid:')}", + "comment": f"Rendered from the CycloneDX SBOM {serial} (example.cdx.json), which also carries per-file hashes" + " and the build formulation.", + "creationInfo": {"created": "2023-11-14T22:13:20Z", "creators": [f"Tool: python-sbom-{__version__}"]}, + "packages": [ + { + "SPDXID": ids["Example"], + "name": "Example", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": False, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "primaryPackagePurpose": "APPLICATION", + "versionInfo": "1.0", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:pypi/example@1.0", + } + ], + }, + { + "SPDXID": ids["cpython"], + "name": "cpython", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": False, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "primaryPackagePurpose": "OTHER", + "versionInfo": "3.15.0", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:generic/cpython@3.15.0", + } + ], + }, + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": ids["Example"], + }, + { + "spdxElementId": ids["cpython"], + "relationshipType": "BUILD_TOOL_OF", + "relatedSpdxElement": ids["Example"], + }, + ], + } + + +def test_spdx_ids_are_valid_and_tell_similar_refs_apart(describe: Describe) -> None: + document = describe("Requires-Dist: click; os_name == 'nt'\nRequires-Dist: click; os_name != 'nt'\n") + ids = [package["SPDXID"] for package in render_spdx(document)["packages"]] + assert all(re.fullmatch(r"SPDXRef-[A-Za-z0-9.-]+", spdx_id) for spdx_id in ids) + assert len(set(ids)) == len(ids) + + +def test_spdx_ids_of_refs_differing_in_dropped_characters_stay_distinct(describe: Describe) -> None: + document = describe() + document["components"] = [ + {"type": "library", "bom-ref": ref, "name": "pip", "version": "1.0"} + for ref in [ + "bundled:foo---__-__-__---____--/pip-1.0-py3-none-any.whl", + "bundled:foo---___-_-_--___---_-/pip-1.0-py3-none-any.whl", + ] + ] + ids = [package["SPDXID"] for package in render_spdx(document)["packages"] if package["name"] == "pip"] + assert len(set(ids)) == 2 + + +def test_repository_names_the_namespace(describe: Describe, settings: Settings) -> None: + document = describe(settings=dataclasses.replace(settings, repository="https://github.com/example/example")) + namespace = render_spdx(document)["documentNamespace"] + assert namespace.startswith("https://github.com/example/example/sboms/Example-1.0-") + + +def test_supplier_and_application_tools_are_creators(describe: Describe, settings: Settings) -> None: + document = describe(settings=dataclasses.replace(settings, supplier={"name": "Example Org", "url": []})) + document["metadata"]["tools"]["components"].extend([ + {"type": "application", "bom-ref": "tool:hatchling", "name": "hatchling", "version": "1.28"}, + {"type": "application", "bom-ref": "tool:script", "name": "script"}, + ]) + assert render_spdx(document)["creationInfo"]["creators"] == [ + "Organization: Example Org", + "Tool: hatchling-1.28", + "Tool: script", + f"Tool: python-sbom-{__version__}", + ] + + +def test_versionless_root(describe: Describe) -> None: + document = describe() + del document["metadata"]["component"]["version"] + spdx = render_spdx(document) + assert spdx["name"] == "Example" + assert "versionInfo" not in spdx["packages"][0] + + +def test_package_fields(describe: Describe) -> None: + document = describe() + component: Component = { + "type": "library", + "bom-ref": "bundled:a.whl", + "name": "a", + "supplier": {"name": "PyPA", "url": []}, + "hashes": [{"alg": "SHA-256", "content": "ab" * 32}], + "externalReferences": [{"type": "website", "url": "https://example.org"}], + "copyright": "Copyright (c) PyPA", + "description": "a library", + "licenses": [ + {"license": {"name": "BSD License", "acknowledgement": "declared"}}, + {"license": {"id": "MIT", "acknowledgement": "concluded"}}, + ], + "properties": [{"name": "example:bundled-wheel", "value": "a.whl"}, {"name": "python:keywords", "value": "a"}], + "components": [{"type": "file", "bom-ref": "bundled:a.whl#a.py", "name": "a.py"}], + } + document["components"].append(component) + (package,) = (package for package in render_spdx(document)["packages"] if package["name"] == "a") + assert {key: value for key, value in package.items() if key != "SPDXID"} == { + "name": "a", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": False, + "licenseConcluded": "MIT", + "licenseDeclared": "NOASSERTION", + "licenseComments": "Declared in package metadata as: BSD License", + "primaryPackagePurpose": "LIBRARY", + "supplier": "Organization: PyPA", + "checksums": [{"algorithm": "SHA256", "checksumValue": "ab" * 32}], + "homepage": "https://example.org", + "copyrightText": "Copyright (c) PyPA", + "summary": "a library", + "comment": "example:bundled-wheel: a.whl", + } + + +def test_license_refs_carry_extracted_text(describe: Describe) -> None: + document = describe("License-Expression: MIT AND LicenseRef-Vendor\n") + assert render_spdx(document).get("hasExtractedLicensingInfos") == [ + { + "licenseId": "LicenseRef-Vendor", + "extractedText": "LicenseRef-Vendor, as named in package metadata", + "name": "LicenseRef-Vendor", + } + ] + + +def test_nested_libraries_are_contained(describe: Describe) -> None: + document = describe() + document["components"].append({ + "type": "library", + "bom-ref": "outer", + "name": "outer", + "components": [{"type": "library", "bom-ref": "inner", "name": "inner"}], + }) + spdx = render_spdx(document) + ids = {package["name"]: package["SPDXID"] for package in spdx["packages"]} + assert {"spdxElementId": ids["outer"], "relationshipType": "CONTAINS", "relatedSpdxElement": ids["inner"]} in spdx[ + "relationships" + ] + + +@pytest.mark.parametrize( + ("kind", "purpose"), + [ + pytest.param("application", "APPLICATION", id="application"), + pytest.param("library", "LIBRARY", id="library"), + pytest.param("framework", "OTHER", id="other"), + ], +) +def test_package_purpose(describe: Describe, kind: str, purpose: str) -> None: + document = describe() + document["components"].append({"type": kind, "bom-ref": "extra", "name": "extra"}) + (package,) = (package for package in render_spdx(document)["packages"] if package["name"] == "extra") + assert package["primaryPackagePurpose"] == purpose + + +def test_other_tools_document_renders_valid_spdx() -> None: + assert spdx_problems(render_spdx(json.loads(_FOREIGN))) == [] + + +def test_legacy_tools_become_creators() -> None: + assert render_spdx(json.loads(_FOREIGN))["creationInfo"]["creators"] == [ + "Tool: auditwheel-6.4.0", + "Tool: repair", + f"Tool: python-sbom-{__version__}", + ] + + +def test_document_without_tools() -> None: + foreign = json.loads(_FOREIGN) + del foreign["metadata"]["tools"] + assert render_spdx(foreign)["creationInfo"]["creators"] == [f"Tool: python-sbom-{__version__}"] + + +def test_namespace_without_serial_names_the_content() -> None: + changed = json.loads(_FOREIGN) + changed["version"] = 2 + namespaces = [render_spdx(document)["documentNamespace"] for document in (json.loads(_FOREIGN),) * 2] + assert (namespaces[0] == namespaces[1], namespaces[0] == render_spdx(changed)["documentNamespace"]) == ( + True, + False, + ) + + +def test_creation_time_without_timestamp_is_the_rendering_time() -> None: + before = datetime.now(UTC).replace(microsecond=0) + created = datetime.fromisoformat(render_spdx(json.loads(_FOREIGN))["creationInfo"]["created"]) + assert before <= created <= datetime.now(UTC) + + +def test_supplier_without_name_is_no_creator(describe: Describe) -> None: + # the schema makes the name optional, so the loaded JSON, not the typed document, loses it + loaded = json.loads(dumps(describe())) + loaded["metadata"]["supplier"] = {"url": ["https://example.org"]} + assert render_spdx(loaded)["creationInfo"]["creators"] == [f"Tool: python-sbom-{__version__}"] + + +@pytest.mark.parametrize( + ("algorithm", "expected"), + [ + pytest.param("SHA-1", "SHA1", id="sha1"), + pytest.param("SHA-512", "SHA512", id="sha512"), + pytest.param("SHA3-256", "SHA3-256", id="sha3"), + pytest.param("BLAKE2b-256", "BLAKE2b-256", id="blake2b"), + pytest.param("BLAKE3", "BLAKE3", id="blake3"), + ], +) +def test_hash_algorithms_take_spdx_names(algorithm: str, expected: str) -> None: + (package,) = packages_of({"hashes": [{"alg": algorithm, "content": "ab" * 32}]}) + assert package.get("checksums") == [{"algorithm": expected, "checksumValue": "ab" * 32}] + + +def packages_of(fields: dict[str, _Json]) -> list[_Package]: + document = json.loads(_FOREIGN) + document["components"] = [{"type": "library", "bom-ref": "lib", "name": "lib", **fields}] + return [package for package in render_spdx(document)["packages"] if package["name"] == "lib"] + + +def test_hash_algorithm_spdx_lacks_is_dropped() -> None: + (package,) = packages_of({"hashes": [{"alg": "SHA-224", "content": "ab" * 28}]}) + assert "checksums" not in package + + +def test_component_supplier_without_name_is_dropped() -> None: + (package,) = packages_of({"supplier": {"url": ["https://example.org"]}}) + assert "supplier" not in package + + +def test_property_without_value() -> None: + (package,) = packages_of({"properties": [{"name": "plain:extra"}]}) + assert package["comment"] == "plain:extra: " diff --git a/tests/_generate/pinned/embed-members.json b/tests/_generate/pinned/embed-members.json new file mode 100644 index 0000000..20926ad --- /dev/null +++ b/tests/_generate/pinned/embed-members.json @@ -0,0 +1,100 @@ +[ + [ + "pkg_name/__init__.py", + [ + 2024, + 1, + 1, + 0, + 0, + 0 + ], + 0, + 25165824, + "9e26bf369911c45c243c684147b23fc9e1dcfcf257d299a1c632016a6fcd33f4" + ], + [ + "pkg_name/wheels/pip-1.0-py3-none-any.whl", + [ + 2024, + 1, + 1, + 0, + 0, + 0 + ], + 0, + 25165824, + "a64ff1b762e23c4eb0a3b4a8fb85fe12d18de3dce86ea6471f3f546285976734" + ], + [ + "pkg_name-1.0.dist-info/METADATA", + [ + 2024, + 1, + 1, + 0, + 0, + 0 + ], + 0, + 25165824, + "f18fd332176c30ba5df08ce78bd605a6616ed6e7e5e165a4a0b05e6db880b0a2" + ], + [ + "pkg_name-1.0.dist-info/licenses/LICENSE", + [ + 2024, + 1, + 1, + 0, + 0, + 0 + ], + 0, + 25165824, + "d0febe6f2e066329c487c2621cd02a4eddce97b51cdacb22600dd5181d843c08" + ], + [ + "pkg_name-1.0.dist-info/WHEEL", + [ + 2024, + 1, + 1, + 0, + 0, + 0 + ], + 0, + 25165824, + "242557f73f15fa3b36695e6a9904761377e20acf98bb7bcf3bdd5c08198ed493" + ], + [ + "pkg_name-1.0.dist-info/sboms/pkg-name.cdx.json", + [ + 2023, + 11, + 14, + 22, + 13, + 20 + ], + 8, + 2175008768, + "f4a42ed807c0f5cca7212382f6c68426e945baff4ca5f6ee4c15e0cba3081802" + ], + [ + "pkg_name-1.0.dist-info/RECORD", + [ + 2024, + 1, + 1, + 0, + 0, + 0 + ], + 0, + 25165824, + "5ff2e7e7932db80e36379c90b7f157c7d17b4c912d9d98ce7876cc151fa8b58c" + ] +] diff --git a/tests/_generate/pinned/embed.cdx.json b/tests/_generate/pinned/embed.cdx.json new file mode 100644 index 0000000..b4d5fd1 --- /dev/null +++ b/tests/_generate/pinned/embed.cdx.json @@ -0,0 +1,583 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": "urn:uuid:cc128def-101f-5a39-891f-b23b5e129f55", + "version": 1, + "metadata": { + "timestamp": "2023-11-14T22:13:20+00:00", + "lifecycles": [ + { + "phase": "build" + } + ], + "tools": { + "components": [ + { + "type": "platform", + "bom-ref": "tool:pkg:generic/cpython@3.14.0", + "name": "cpython", + "version": "3.14.0", + "purl": "pkg:generic/cpython@3.14.0", + "properties": [ + { + "name": "python:implementation", + "value": "CPython" + } + ] + }, + { + "type": "library", + "bom-ref": "tool:pkg:pypi/alpha@1.0", + "name": "alpha", + "version": "1.0", + "purl": "pkg:pypi/alpha@1.0", + "externalReferences": [], + "properties": [ + { + "name": "python:requires-dist", + "value": "beta" + } + ], + "components": [ + { + "type": "file", + "bom-ref": "tool:pkg:pypi/alpha@1.0#alpha.py", + "name": "alpha.py", + "hashes": [ + { + "alg": "SHA-256", + "content": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ], + "properties": [ + { + "name": "size", + "value": "0" + } + ] + }, + { + "type": "file", + "bom-ref": "tool:pkg:pypi/alpha@1.0#alpha-1.0.dist-info/METADATA", + "name": "alpha-1.0.dist-info/METADATA", + "hashes": [ + { + "alg": "SHA-256", + "content": "84fff1197cd817e092c798eaadecbb239d6076bda206362533d29a5151059eb1" + } + ], + "properties": [ + { + "name": "size", + "value": "45" + } + ] + }, + { + "type": "file", + "bom-ref": "tool:pkg:pypi/alpha@1.0#alpha-1.0.dist-info/WHEEL", + "name": "alpha-1.0.dist-info/WHEEL", + "hashes": [ + { + "alg": "SHA-256", + "content": "a4e0b653593a8427ffbe465eee1dac9c6456c7e0b6d178ee7376b4d8a427d8e6" + } + ], + "properties": [ + { + "name": "size", + "value": "41" + } + ] + } + ] + }, + { + "type": "library", + "bom-ref": "tool:pkg:pypi/beta@2.0", + "name": "beta", + "version": "2.0", + "purl": "pkg:pypi/beta@2.0", + "externalReferences": [], + "properties": [] + }, + { + "type": "library", + "bom-ref": "tool:pkg:pypi/gamma@3.0", + "name": "gamma", + "version": "3.0", + "purl": "pkg:pypi/gamma@3.0", + "externalReferences": [], + "properties": [] + } + ] + }, + "component": { + "type": "application", + "bom-ref": "pkg:pypi/pkg-name@1.0", + "name": "Pkg.Name", + "version": "1.0", + "purl": "pkg:pypi/pkg-name@1.0", + "description": "a pinned example", + "authors": [ + { + "name": "not-an-email" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0", + "acknowledgement": "declared" + } + ], + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/example/pkg", + "comment": "Project-URL: Source" + }, + { + "type": "vcs", + "url": "https://gitlab.com/example/pkg", + "comment": "Project-URL: Source" + }, + { + "type": "documentation", + "url": "https://pkg.readthedocs.io", + "comment": "Project-URL: Documentation" + }, + { + "type": "issue-tracker", + "url": "https://github.com/example/pkg/issues", + "comment": "Project-URL: Bug Tracker" + }, + { + "type": "distribution", + "url": "https://pypi.org/project/Pkg.Name/1.0/" + }, + { + "type": "attestation", + "url": "https://pypi.org/integrity/Pkg.Name/1.0/pkg_name-1.0-py3-none-any.whl/provenance" + }, + { + "type": "security-contact", + "url": "https://github.com/example/pkg/security/policy" + }, + { + "type": "advisories", + "url": "https://github.com/example/pkg/security/advisories" + }, + { + "type": "license", + "url": "https://github.com/example/pkg/blob/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/LICENSE" + }, + { + "type": "vcs", + "url": "https://github.com/example/pkg/tree/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "comment": "exact source revision" + } + ], + "properties": [ + { + "name": "python:requires-dist", + "value": "alpha>=1" + }, + { + "name": "python:requires-dist", + "value": "beta; extra == \"cli\"" + }, + { + "name": "python:requires-dist", + "value": "gamma; python_version < \"3\"" + }, + { + "name": "python:requires-dist", + "value": "delta; extra in \"a,b\"" + }, + { + "name": "python:provides-extra", + "value": "cli" + }, + { + "name": "python:classifier", + "value": "License :: OSI Approved :: MIT License" + }, + { + "name": "python:classifier", + "value": "Programming Language :: Python :: 3" + }, + { + "name": "python:keywords", + "value": "sbom,pinned" + }, + { + "name": "pkg-name:vcs-commit", + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "supplier": { + "name": "Example Org", + "url": [ + "https://example.org" + ] + } + }, + "manufacturer": { + "name": "Example Org", + "url": [ + "https://example.org" + ] + }, + "supplier": { + "name": "Example Org", + "url": [ + "https://example.org" + ] + }, + "authors": [ + { + "name": "not-an-email" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0", + "acknowledgement": "declared" + } + ], + "properties": [ + { + "name": "pkg-name:sbom:scope", + "value": "Components are the wheels bundled as data files under src/pkg/wheels and the direct runtime dependencies declared in the wheel's core metadata, which carry no version because the installer resolves them." + }, + { + "name": "pkg-name:sbom:file", + "value": "pkg-name.cdx.json" + }, + { + "name": "pkg-name:sbom:repository", + "value": "https://github.com/example/pkg" + } + ] + }, + "components": [ + { + "type": "library", + "bom-ref": "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl", + "name": "pip", + "version": "1.0", + "purl": "pkg:pypi/pip@1.0", + "licenses": [ + { + "expression": "MIT", + "acknowledgement": "declared" + } + ], + "externalReferences": [ + { + "type": "distribution", + "url": "https://pypi.org/project/pip/1.0/" + } + ], + "properties": [ + { + "name": "pkg-name:bundled-wheel", + "value": "pkg_name/wheels/pip-1.0-py3-none-any.whl" + } + ], + "hashes": [ + { + "alg": "SHA-256", + "content": "a64ff1b762e23c4eb0a3b4a8fb85fe12d18de3dce86ea6471f3f546285976734" + } + ], + "evidence": { + "identity": [ + { + "field": "purl", + "confidence": 1, + "methods": [ + { + "technique": "manifest-analysis", + "confidence": 1, + "value": "pip-1.0.dist-info/METADATA" + } + ] + }, + { + "field": "hash", + "confidence": 1, + "methods": [ + { + "technique": "hash-comparison", + "confidence": 1, + "value": "a64ff1b762e23c4eb0a3b4a8fb85fe12d18de3dce86ea6471f3f546285976734" + } + ] + } + ] + }, + "components": [ + { + "type": "file", + "bom-ref": "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl#pip/__init__.py", + "name": "pip/__init__.py", + "hashes": [ + { + "alg": "SHA-256", + "content": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ], + "properties": [ + { + "name": "size", + "value": "0" + } + ] + }, + { + "type": "file", + "bom-ref": "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl#pip/_vendor/vendor.txt", + "name": "pip/_vendor/vendor.txt", + "hashes": [ + { + "alg": "SHA-256", + "content": "26c04392ab4b025968e4dbdaca5fcc4ec977c70607bb02d4ae7bc724d4ba92ea" + } + ], + "properties": [ + { + "name": "size", + "value": "26" + } + ] + }, + { + "type": "file", + "bom-ref": "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl#pip-1.0.dist-info/METADATA", + "name": "pip-1.0.dist-info/METADATA", + "hashes": [ + { + "alg": "SHA-256", + "content": "3ea62ee2e6d4513f0a06630188f10dbeace9ed76fd29897bf76edd496d948e37" + } + ], + "properties": [ + { + "name": "size", + "value": "69" + } + ] + }, + { + "type": "library", + "bom-ref": "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl#vendored/pkg:pypi/idna@3.7", + "name": "idna", + "version": "3.7", + "purl": "pkg:pypi/idna@3.7", + "externalReferences": [], + "properties": [ + { + "name": "pkg-name:vendored-manifest", + "value": "pip/_vendor/vendor.txt" + } + ], + "evidence": { + "identity": [ + { + "field": "purl", + "confidence": 1, + "methods": [ + { + "technique": "manifest-analysis", + "confidence": 1, + "value": "pip/_vendor/vendor.txt" + } + ] + } + ] + } + }, + { + "type": "library", + "bom-ref": "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl#vendored/pkg:pypi/urllib3@1.26.4", + "name": "urllib3", + "version": "1.26.4", + "purl": "pkg:pypi/urllib3@1.26.4", + "externalReferences": [], + "properties": [ + { + "name": "pkg-name:vendored-manifest", + "value": "pip/_vendor/vendor.txt" + } + ], + "evidence": { + "identity": [ + { + "field": "purl", + "confidence": 1, + "methods": [ + { + "technique": "manifest-analysis", + "confidence": 1, + "value": "pip/_vendor/vendor.txt" + } + ] + } + ] + } + } + ] + }, + { + "type": "library", + "bom-ref": "requires-dist:alpha>=1", + "name": "alpha", + "purl": "pkg:pypi/alpha", + "externalReferences": [ + { + "type": "distribution", + "url": "https://pypi.org/project/alpha/" + } + ], + "properties": [ + { + "name": "pkg-name:requires-dist", + "value": "alpha>=1" + } + ] + }, + { + "type": "library", + "bom-ref": "requires-dist:gamma; python_version < \"3\"", + "name": "gamma", + "purl": "pkg:pypi/gamma", + "externalReferences": [ + { + "type": "distribution", + "url": "https://pypi.org/project/gamma/" + } + ], + "properties": [ + { + "name": "pkg-name:requires-dist", + "value": "gamma; python_version < \"3\"" + }, + { + "name": "pkg-name:environment-marker", + "value": "python_version < \"3\"" + } + ] + }, + { + "type": "library", + "bom-ref": "requires-dist:delta; extra in \"a,b\"", + "name": "delta", + "purl": "pkg:pypi/delta", + "externalReferences": [ + { + "type": "distribution", + "url": "https://pypi.org/project/delta/" + } + ], + "properties": [ + { + "name": "pkg-name:requires-dist", + "value": "delta; extra in \"a,b\"" + }, + { + "name": "pkg-name:environment-marker", + "value": "extra in \"a,b\"" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/pkg-name@1.0", + "dependsOn": [ + "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl", + "requires-dist:alpha>=1", + "requires-dist:gamma; python_version < \"3\"", + "requires-dist:delta; extra in \"a,b\"" + ] + }, + { + "ref": "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl", + "dependsOn": [ + "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl#vendored/pkg:pypi/idna@3.7", + "bundled:pkg_name/wheels/pip-1.0-py3-none-any.whl#vendored/pkg:pypi/urllib3@1.26.4" + ] + }, + { + "ref": "tool:pkg:pypi/alpha@1.0", + "dependsOn": [ + "tool:pkg:pypi/beta@2.0" + ] + }, + { + "ref": "tool:pkg:pypi/beta@2.0", + "dependsOn": [] + }, + { + "ref": "tool:pkg:pypi/gamma@3.0", + "dependsOn": [] + } + ], + "compositions": [ + { + "aggregate": "incomplete", + "dependencies": [ + "pkg:pypi/pkg-name@1.0" + ] + } + ], + "formulation": [ + { + "bom-ref": "formula:wheel-build", + "workflows": [ + { + "bom-ref": "workflow:wheel-build", + "uid": "wheel-build", + "name": "build the wheel and this SBOM", + "taskTypes": [ + "build" + ], + "resourceReferences": [ + { + "ref": "tool:pkg:generic/cpython@3.14.0" + }, + { + "ref": "tool:pkg:pypi/alpha@1.0" + }, + { + "ref": "tool:pkg:pypi/beta@2.0" + }, + { + "ref": "tool:pkg:pypi/gamma@3.0" + } + ], + "outputs": [ + { + "type": "artifact", + "resource": { + "ref": "pkg:pypi/pkg-name@1.0" + } + } + ], + "inputs": [ + { + "environmentVars": [ + { + "name": "SOURCE_DATE_EPOCH", + "value": "1700000000" + } + ] + } + ] + } + ] + } + ] +} diff --git a/tests/_generate/test_archive.py b/tests/_generate/test_archive.py new file mode 100644 index 0000000..960528e --- /dev/null +++ b/tests/_generate/test_archive.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import dataclasses +import io +import json +import zipfile +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom import BundledWheelInfo, ContentProvider, Contributions, ProviderContext +from python_sbom._collect._facts import collect_facts +from python_sbom._collect._revision import revision_reader +from python_sbom._errors import SbomError +from python_sbom._generate._archive import describe_archive +from python_sbom._model._inputs import Revision +from python_sbom._validate._artifact import validate + +if TYPE_CHECKING: + from conftest import WriteWheel + + from python_sbom import Component + from python_sbom._model._types import Document + +_COMMIT: Final[str] = "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff" +_LIBRARY: Final[str] = "__bundled__/distlib-0.4-py3-none-any" +_WHEEL: Final[str] = "app/wheels/pip-1.0-py3-none-any.whl" + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + (root := tmp_path / "project").mkdir() + (root / "pyproject.toml").write_text( + "[tool.python-sbom]\nsupplier = { name = 'Example Org' }\nrepository = 'https://github.com/example/app'\n", + encoding="utf-8", + ) + (root / "build_app.py").write_text("print('build')\n", encoding="utf-8") + return root + + +@pytest.fixture +def archive(tmp_path: Path, write_wheel: WriteWheel) -> Path: + wheel = write_wheel( + tmp_path / "pip-1.0-py3-none-any.whl", + {"pip/__init__.py": b"", "pip-1.0.dist-info/METADATA": b"Name: pip\nVersion: 1.0\nLicense-Expression: MIT\n"}, + ) + with zipfile.ZipFile(target := tmp_path / "app.pyz", "w") as handle: + handle.writestr("__main__.py", "import app\n") + handle.writestr("loaded.json", json.dumps({_LIBRARY: ["3.14"]})) + handle.writestr("app-1.0.dist-info/METADATA", "Name: app\nVersion: 1.0\nLicense-Expression: MIT\n") + handle.writestr("app/__init__.py", "") + handle.writestr("app/", "") + handle.writestr(_WHEEL, wheel.read_bytes()) + handle.writestr( + f"{_LIBRARY}/distlib-0.4.dist-info/METADATA", "Name: distlib\nVersion: 0.4\nLicense-Expression: MIT\n" + ) + handle.writestr(f"{_LIBRARY}/distlib/__init__.py", "") + handle.writestr("app.pyz.cdx.json", "{}") + return target + + +@pytest.fixture +def document(project: Path, archive: Path) -> Document: + facts = collect_facts(project, revision_reader(_COMMIT)) + return describe_archive( + project, archive, facts=facts, provider=Bundles("loaded.json"), build_scripts=(PurePosixPath("build_app.py"),) + ) + + +class Bundles(ContentProvider): + def __init__(self, table: str) -> None: + self._table = table + + def bundled_wheel(self, context: ProviderContext, wheel: PurePosixPath) -> BundledWheelInfo: + # reads the archive, as a real provider reads its tables + loaded = json.loads(context.read(PurePosixPath(self._table))) + return BundledWheelInfo( + properties=[{"name": "app:loaded-for", "value": python} for python in loaded.get(str(wheel), [])], + supplier=context.settings.supplier, + ) + + def contributions(self, context: ProviderContext) -> Contributions: + zlib: Component = {"type": "library", "bom-ref": "pkg:generic/zlib@1.3", "name": "zlib", "version": "1.3"} + return Contributions( + components=[zlib], + dependencies=[{"ref": "pkg:generic/zlib@1.3", "dependsOn": []}], + root_properties=[ + {"name": "extra:project", "value": context.root.name}, + {"name": "extra:table", "value": self._table}, + ], + ) + + +def test_archive_document_validates(document: Document, tmp_path: Path) -> None: + (sbom := tmp_path / "app.pyz.cdx.json").write_text(json.dumps(document), encoding="utf-8") + assert validate(sbom, frozenset({"MIT"})) == [] + + +def test_archive_root(document: Document) -> None: + root = document["metadata"]["component"] + assert {key: value for key, value in root.items() if key != "components"} == { + "type": "application", + "bom-ref": "pkg:generic/app.pyz@1.0", + "name": "app.pyz", + "version": "1.0", + "purl": "pkg:generic/app.pyz@1.0", + "supplier": {"name": "Example Org", "url": []}, + "externalReferences": [ + {"type": "security-contact", "url": "https://github.com/example/app/security/policy"}, + {"type": "advisories", "url": "https://github.com/example/app/security/advisories"}, + { + "type": "vcs", + "url": f"https://github.com/example/app/tree/{_COMMIT}", + "comment": "exact source revision", + }, + ], + "properties": [ + {"name": "extra:project", "value": "project"}, + {"name": "extra:table", "value": "loaded.json"}, + {"name": "app:vcs-commit", "value": _COMMIT}, + ], + } + + +def test_archive_of_dirty_checkout(project: Path, archive: Path) -> None: + # the same provenance a wheel built from a dirty checkout carries, so the commit is not taken for the exact source + facts = dataclasses.replace( + collect_facts(project, revision_reader(_COMMIT)), revision=Revision(commit=_COMMIT, dirty=True) + ) + root = describe_archive(project, archive, facts=facts, provider=None, build_scripts=())["metadata"]["component"] + assert (root.get("properties", [])[-1], root.get("externalReferences", [])[-1]["comment"]) == ( + {"name": "app:vcs-dirty", "value": "true"}, + "source revision, with uncommitted changes", + ) + + +def test_archive_loose_files_belong_to_the_archive(document: Document) -> None: + files = document["metadata"]["component"].get("components", []) + assert [file["name"] for file in files] == ["__main__.py", "loaded.json"] + + +def test_archive_project_distribution(document: Document) -> None: + app = document["components"][0] + assert [(child["type"], child["name"]) for child in app.get("components", [])] == [ + ("file", "app-1.0.dist-info/METADATA"), + ("file", "app/__init__.py"), + ("library", "pip"), + ] + + +def test_archive_bundled_wheel(document: Document) -> None: + pip = document["components"][0].get("components", [])[2] + assert (pip["bom-ref"], pip.get("supplier"), pip.get("properties", [])[0]) == ( + f"bundled:{_WHEEL}", + {"name": "Example Org", "url": []}, + {"name": "app:bundled-wheel", "value": _WHEEL}, + ) + + +def test_archive_unpacked_wheel(document: Document) -> None: + distlib = document["components"][1] + assert ( + distlib["bom-ref"], + "supplier" in distlib, + distlib.get("properties", []), + [child["name"] for child in distlib.get("components", [])], + ) == ( + f"bundled:{_LIBRARY}", + True, + [{"name": "app:bundled-wheel", "value": _LIBRARY}, {"name": "app:loaded-for", "value": "3.14"}], + [f"{_LIBRARY}/distlib-0.4.dist-info/METADATA", f"{_LIBRARY}/distlib/__init__.py"], + ) + + +def test_archive_tools_and_metadata(document: Document) -> None: + metadata = document["metadata"] + script = metadata["tools"]["components"][0] + assert (script["name"], script.get("externalReferences"), metadata.get("properties"), metadata.get("supplier")) == ( + "build_app.py", + [{"type": "vcs", "url": f"https://github.com/example/app/blob/{_COMMIT}/build_app.py"}], + [ + { + "name": "app:sbom:scope", + "value": "Components are the distributions the archive holds, as wheels or unpacked, each with its" + " files; the archive's own top-level files sit under the root component.", + }, + {"name": "app:sbom:file", "value": "app.pyz.cdx.json"}, + {"name": "app:sbom:repository", "value": "https://github.com/example/app"}, + ], + {"name": "Example Org", "url": []}, + ) + + +def test_archive_contributions(document: Document) -> None: + assert (document["components"][-1]["name"], document["dependencies"][-1]) == ( + "zlib", + {"ref": "pkg:generic/zlib@1.3", "dependsOn": []}, + ) + + +def test_archive_defaults(tmp_path: Path, archive: Path) -> None: + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'app'\n", encoding="utf-8") + document = describe_archive( + tmp_path, archive, facts=collect_facts(tmp_path, revision_reader(None)), provider=None, build_scripts=() + ) + assert ("supplier" in document["metadata"], document["metadata"]["component"].get("properties")) == (False, []) + + +def test_archive_content_provider_option(tmp_path: Path, archive: Path) -> None: + (tmp_path / "pyproject.toml").write_text( + f"[tool.python-sbom]\ncontent-provider = '{tmp_path.name}:Provider'\n", encoding="utf-8" + ) + (tmp_path / f"{tmp_path.name}.py").write_text( + "from python_sbom import ContentProvider\n\n\nclass Provider(ContentProvider):\n pass\n", encoding="utf-8" + ) + tools = describe_archive( + tmp_path, archive, facts=collect_facts(tmp_path, revision_reader(None)), provider=None, build_scripts=() + )["metadata"]["tools"]["components"] + assert tools[0]["name"] == f"{tmp_path.name}.py" + + +def test_archive_without_project_distribution(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("", encoding="utf-8") + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as handle: + handle.writestr("__main__.py", "") + (archive := tmp_path / "bare.pyz").write_bytes(buffer.getvalue()) + with pytest.raises(SbomError, match=r"bare\.pyz holds no top-level \.dist-info/METADATA naming the project"): + describe_archive( + tmp_path, archive, facts=collect_facts(tmp_path, revision_reader(None)), provider=None, build_scripts=() + ) + + +def test_archive_asks_provider_in_component_order(project: Path, archive: Path) -> None: + asked: Final[list[str]] = [] + + class Recording(ContentProvider): + def bundled_wheel(self, context: ProviderContext, wheel: PurePosixPath) -> BundledWheelInfo: + asked.append(str(wheel)) + return super().bundled_wheel(context, wheel) + + describe_archive( + project, archive, facts=collect_facts(project, revision_reader(None)), provider=Recording(), build_scripts=() + ) + assert asked == [_WHEEL, _LIBRARY] + + +def test_archive_top_level_wheel_belongs_to_the_archive(project: Path, tmp_path: Path, write_wheel: WriteWheel) -> None: + wheel = write_wheel( + tmp_path / "pip-1.0-py3-none-any.whl", {"pip-1.0.dist-info/METADATA": b"Name: pip\nVersion: 1.0\n"} + ) + with zipfile.ZipFile(archive := tmp_path / "app.pyz", "w") as handle: + handle.writestr("app-1.0.dist-info/METADATA", "Name: app\nVersion: 1.0\n") + handle.writestr(wheel.name, wheel.read_bytes()) + root = describe_archive( + project, archive, facts=collect_facts(project, revision_reader(None)), provider=None, build_scripts=() + )["metadata"]["component"] + assert [(child["bom-ref"], child["name"]) for child in root.get("components", [])] == [ + (f"bundled:{wheel.name}", "pip") + ] + + +def test_archive_refuses_distributions_sharing_a_directory(project: Path, tmp_path: Path) -> None: + with zipfile.ZipFile(archive := tmp_path / "app.pyz", "w") as handle: + handle.writestr("app-1.0.dist-info/METADATA", "Name: app\nVersion: 1.0\n") + for name in ("bar", "foo"): + handle.writestr(f"vendor/{name}-1.0.dist-info/METADATA", f"Name: {name}\nVersion: 1.0\n") + with pytest.raises(SbomError, match=r"app\.pyz holds more than one \.dist-info in vendor/, so their files cannot"): + describe_archive( + project, archive, facts=collect_facts(project, revision_reader(None)), provider=None, build_scripts=() + ) + + +@pytest.mark.parametrize("placement", [pytest.param("", id="top-level"), pytest.param("app/", id="nested")]) +def test_archive_bundled_wheel_judged_wherever_it_sits( + project: Path, tmp_path: Path, write_wheel: WriteWheel, placement: str +) -> None: + # the license policy and the vendored edges cover a wheel at the archive's top level as they do one nested deeper + wheel = write_wheel( + tmp_path / "pip-1.0-py3-none-any.whl", + { + "pip-1.0.dist-info/METADATA": b"Name: pip\nVersion: 1.0\nLicense-Expression: GPL-3.0-only\n", + "pip/_vendor/vendor.txt": b"urllib3==1.26.4\n", + }, + ) + with zipfile.ZipFile(archive := tmp_path / "app.pyz", "w") as handle: + handle.writestr("app-1.0.dist-info/METADATA", "Name: app\nVersion: 1.0\nLicense-Expression: MIT\n") + handle.writestr(f"{placement}{wheel.name}", wheel.read_bytes()) + document = describe_archive( + project, archive, facts=collect_facts(project, revision_reader(None)), provider=None, build_scripts=() + ) + (sbom := tmp_path / "app.pyz.cdx.json").write_text(json.dumps(document), encoding="utf-8") + ref = f"bundled:{placement}{wheel.name}" + vendoring = [entry["ref"] for entry in document["dependencies"] if entry["ref"].startswith("bundled:")] + assert (validate(sbom, frozenset({"MIT"})), vendoring) == ( + [f"{ref}: license GPL-3.0-only is outside the allowed licenses ['MIT']"], + [ref], + ) diff --git a/tests/_generate/test_embed.py b/tests/_generate/test_embed.py new file mode 100644 index 0000000..46b2d4b --- /dev/null +++ b/tests/_generate/test_embed.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import hashlib +import json +import zipfile +from pathlib import Path +from typing import TYPE_CHECKING, Final + +from python_sbom import BundledWheelInfo, ContentProvider, ProviderContext +from python_sbom._generate._embed import embed_sbom + +if TYPE_CHECKING: + from pathlib import PurePosixPath + + import pytest + from conftest import Install, WriteWheel + from pytest_mock import MockerFixture + + +_PINNED: Final[Path] = Path(__file__).parent / "pinned" + + +def test_bundled_wheel_named_by_its_path_inside_the_wheel(tmp_path: Path, write_wheel: WriteWheel) -> None: + (root := tmp_path / "project").mkdir() + (root / "pyproject.toml").write_text("[tool.python-sbom]\nbundled-wheels = ['src/pkg/wheels/*.whl']\n") + bundled = write_wheel( + root / "src" / "pkg" / "wheels" / "pip-1.0-py3-none-any.whl", + {"pip/__init__.py": b"", "pip-1.0.dist-info/METADATA": b"Name: pip\nVersion: 1.0\nLicense-Expression: MIT\n"}, + ) + wheel = write_wheel( + tmp_path / "dist" / "pkg-1.0-py3-none-any.whl", + { + "pkg/wheels/pip-1.0-py3-none-any.whl": bundled.read_bytes(), + "pkg/wheels/table.txt": b"3.15\n", + "pkg-1.0.dist-info/METADATA": b"Name: pkg\nVersion: 1.0\n", + }, + ) + written = embed_sbom(wheel, root, provider=Table("table.txt"), commit=None, build_env=[str(root)]) + (pip,) = json.loads(zipfile.ZipFile(wheel).read(written))["components"] + assert (pip["bom-ref"], pip["properties"][:2]) == ( + "bundled:pkg/wheels/pip-1.0-py3-none-any.whl", + [ + {"name": "pkg:bundled-wheel", "value": "pkg/wheels/pip-1.0-py3-none-any.whl"}, + {"name": "pkg:table", "value": "3.15"}, + ], + ) + + +class Table(ContentProvider): + def __init__(self, name: str) -> None: + self._name = name + + def bundled_wheel(self, context: ProviderContext, wheel: PurePosixPath) -> BundledWheelInfo: + # the table sits next to the wheel inside the built wheel + value = context.read(wheel.parent / self._name).decode().strip() + return BundledWheelInfo(properties=[{"name": f"{context.identity.property_prefix}:table", "value": value}]) + + +def test_identical_bundled_copies_keep_their_own_paths(tmp_path: Path, write_wheel: WriteWheel) -> None: + (root := tmp_path / "project").mkdir() + (root / "pyproject.toml").write_text("[tool.python-sbom]\nbundled-wheels = ['src/*/wheels/*.whl']\n") + pip = {"pip-1.0.dist-info/METADATA": b"Name: pip\nVersion: 1.0\nLicense-Expression: MIT\n"} + for package in ("a", "b"): + content = write_wheel(root / "src" / package / "wheels" / "pip-1.0-py3-none-any.whl", pip).read_bytes() + wheel = write_wheel( + tmp_path / "dist" / "pkg-1.0-py3-none-any.whl", + { + "a/wheels/pip-1.0-py3-none-any.whl": content, + "b/wheels/pip-1.0-py3-none-any.whl": content, + "pkg-1.0.dist-info/METADATA": b"Name: pkg\nVersion: 1.0\n", + }, + ) + written = embed_sbom(wheel, root, provider=None, commit=None, build_env=[str(root)]) + assert [component["bom-ref"] for component in json.loads(zipfile.ZipFile(wheel).read(written))["components"]] == [ + "bundled:a/wheels/pip-1.0-py3-none-any.whl", + "bundled:b/wheels/pip-1.0-py3-none-any.whl", + ] + + +def test_embed_output_is_pinned( + tmp_path: Path, write_wheel: WriteWheel, install: Install, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: + # every input fixed, down to the interpreter's version, so the SBOM and the wheel's members must not change by a + # byte; a change that means to alter them updates the pinned files with it + monkeypatch.setenv("SOURCE_DATE_EPOCH", "1700000000") + mocker.patch("platform.python_version", autospec=True, return_value="3.14.0") + metadata = b"""\ +Metadata-Version: 2.4 +Name: Pkg.Name +Version: 1.0 +Summary: a pinned example +Keywords: sbom,pinned +Author: Author Name +Author-email: Author , plain@example.com +Maintainer-email: not-an-email +License-Expression: MIT OR Apache-2.0 +License-File: LICENSE +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 3 +Project-URL: Source, https://github.com/example/pkg +Project-URL: Source, https://gitlab.com/example/pkg +Project-URL: Documentation, https://pkg.readthedocs.io +Project-URL: Bug Tracker, https://github.com/example/pkg/issues +Requires-Dist: alpha>=1 +Requires-Dist: beta; extra == "cli" +Requires-Dist: gamma; python_version < "3" +Requires-Dist: delta; extra in "a,b" +Provides-Extra: cli +""" + (root := tmp_path / "project").mkdir() + (root / "pyproject.toml").write_bytes( + b"[tool.python-sbom]\nsupplier = { name = 'Example Org', url = ['https://example.org'] }\n" + b"bundled-wheels = ['src/pkg/wheels/*.whl']\n" + ) + pip = { + "pip/__init__.py": b"", + "pip/_vendor/vendor.txt": b"urllib3==1.26.4\nidna==3.7\n", + "pip-1.0.dist-info/METADATA": b"Metadata-Version: 2.4\nName: pip\nVersion: 1.0\nLicense-Expression: MIT\n", + } + # stored, as deflate output differs between zlib builds and the SBOM records the bundled wheel's hash + bundled = write_wheel(root / "src" / "pkg" / "wheels" / "pip-1.0-py3-none-any.whl", pip, compression=0) + site = tmp_path / "site-packages" + install(site, "Name: alpha\nVersion: 1.0\nRequires-Dist: beta\n", {"alpha.py": b"", "../../bin/alpha": b""}) + install(site, "Name: beta\nVersion: 2.0\n", {"beta.so": b"\x7fELF"}, purelib=False) + install(site, "Name: gamma\nVersion: 3.0\n", {}, direct_url='{"url": "file:///c", "dir_info": {"editable": true}}') + wheel = write_wheel( + tmp_path / "dist" / "pkg_name-1.0-py3-none-any.whl", + { + "pkg_name/__init__.py": b"x = 1\n", + "pkg_name/wheels/pip-1.0-py3-none-any.whl": bundled.read_bytes(), + "pkg_name-1.0.dist-info/METADATA": metadata, + "pkg_name-1.0.dist-info/licenses/LICENSE": b"MIT terms\n", + "pkg_name-1.0.dist-info/WHEEL": b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + }, + compression=0, + ) + written = embed_sbom(wheel, root, provider=None, commit="a" * 40, build_env=[str(site)]) + with zipfile.ZipFile(wheel) as archive: + sbom = archive.read(written) + members = [ + [info.filename, list(info.date_time), info.compress_type, info.external_attr, _sha256(archive.read(info))] + for info in archive.infolist() + ] + assert sbom == (_PINNED / "embed.cdx.json").read_bytes() + assert members == json.loads((_PINNED / "embed-members.json").read_bytes()) + + +def _sha256(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() diff --git a/tests/_model/test_bom.py b/tests/_model/test_bom.py new file mode 100644 index 0000000..2b0b632 --- /dev/null +++ b/tests/_model/test_bom.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +import dataclasses +import json +import uuid +from pathlib import PurePosixPath +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom import BundledWheelInfo, ContentProvider, Contributions, ProviderContext +from python_sbom._model._inputs import BuildScript, BundledWheel, Revision +from python_sbom._model._metadata import parse_metadata + +if TYPE_CHECKING: + from conftest import Describe + + from python_sbom import Component + from python_sbom._model._inputs import BuildFacts, Settings + from python_sbom._model._types import OrganizationalEntity + +_INTERPRETER: Final[str] = "tool:pkg:generic/cpython@3.15.0" + + +def test_minimal_document(describe: Describe) -> None: + document = describe() + body = {key: value for key, value in document.items() if key not in {"$schema", "bomFormat", "specVersion"}} + serial = body.pop("serialNumber") + del body["version"] + expected_body = { + "metadata": { + "timestamp": "2023-11-14T22:13:20+00:00", + "lifecycles": [{"phase": "build"}], + "tools": { + "components": [ + { + "type": "platform", + "bom-ref": _INTERPRETER, + "name": "cpython", + "version": "3.15.0", + "purl": "pkg:generic/cpython@3.15.0", + "properties": [{"name": "python:implementation", "value": "CPython"}], + } + ] + }, + "component": { + "type": "application", + "bom-ref": "pkg:pypi/example@1.0", + "name": "Example", + "version": "1.0", + "purl": "pkg:pypi/example@1.0", + "externalReferences": [ + {"type": "distribution", "url": "https://pypi.org/project/Example/1.0/"}, + { + "type": "attestation", + "url": "https://pypi.org/integrity/Example/1.0/example-1.0-py3-none-any.whl/provenance", + }, + ], + "properties": [], + }, + "properties": [ + { + "name": "example:sbom:scope", + "value": "Components are the direct runtime dependencies declared in the wheel's core metadata," + " which carry no version because the installer resolves them.", + }, + {"name": "example:sbom:file", "value": "example.cdx.json"}, + ], + }, + "components": [], + "dependencies": [{"ref": "pkg:pypi/example@1.0", "dependsOn": []}], + "compositions": [{"aggregate": "incomplete", "dependencies": ["pkg:pypi/example@1.0"]}], + "formulation": [ + { + "bom-ref": "formula:wheel-build", + "workflows": [ + { + "bom-ref": "workflow:wheel-build", + "uid": "wheel-build", + "name": "build the wheel and this SBOM", + "taskTypes": ["build"], + "resourceReferences": [{"ref": _INTERPRETER}], + "outputs": [{"type": "artifact", "resource": {"ref": "pkg:pypi/example@1.0"}}], + } + ], + } + ], + } + assert body == expected_body + namespace = uuid.uuid5(uuid.NAMESPACE_URL, "https://pypi.org/project/example/sboms") + assert serial == f"urn:uuid:{uuid.uuid5(namespace, json.dumps(expected_body, sort_keys=True))}" + + +def test_header(describe: Describe) -> None: + document = describe() + header = {key: document[key] for key in ("$schema", "bomFormat", "specVersion", "version")} + assert header == { + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + } + + +def test_serial_is_stable(describe: Describe) -> None: + assert describe()["serialNumber"] == describe()["serialNumber"] + + +def test_serial_follows_content(describe: Describe) -> None: + assert describe()["serialNumber"] != describe("Summary: other\n")["serialNumber"] + + +@pytest.mark.parametrize( + ("dirty", "trail"), + [ + pytest.param(False, ("c0ffee", "exact source revision", []), id="clean"), + pytest.param( + True, + ("main", "source revision, with uncommitted changes", [{"name": "example:vcs-dirty", "value": "true"}]), + id="dirty", + ), + ], +) +def test_root_component( + describe: Describe, + settings: Settings, + facts: BuildFacts, + dirty: bool, + trail: tuple[str, str, list[dict[str, str]]], +) -> None: + # a dirty checkout's files may differ from its commit's, so its links fall back to the default branch + tree, comment, dirty_properties = trail + supplier: OrganizationalEntity = {"name": "Example Org", "url": ["https://example.org"]} + document = describe( + "License-File: LICENSE\nProject-URL: Source, https://github.com/example/example/\n", + settings=dataclasses.replace(settings, supplier=supplier), + facts=dataclasses.replace(facts, revision=Revision(commit="c0ffee", dirty=dirty)), + copyright_line="Copyright (c) 2026 Example", + ) + repository = "https://github.com/example/example" + assert document["metadata"]["component"] == { + "type": "application", + "bom-ref": "pkg:pypi/example@1.0", + "name": "Example", + "version": "1.0", + "purl": "pkg:pypi/example@1.0", + "supplier": supplier, + "copyright": "Copyright (c) 2026 Example", + "externalReferences": [ + {"type": "vcs", "url": f"{repository}/", "comment": "Project-URL: Source"}, + {"type": "distribution", "url": "https://pypi.org/project/Example/1.0/"}, + { + "type": "attestation", + "url": "https://pypi.org/integrity/Example/1.0/example-1.0-py3-none-any.whl/provenance", + }, + {"type": "security-contact", "url": f"{repository}/security/policy"}, + {"type": "advisories", "url": f"{repository}/security/advisories"}, + {"type": "license", "url": f"{repository}/blob/{tree}/LICENSE"}, + {"type": "vcs", "url": f"{repository}/tree/c0ffee", "comment": comment}, + ], + "properties": [{"name": "example:vcs-commit", "value": "c0ffee"}, *dirty_properties], + } + + +def test_supplier_makes_and_supplies_the_document(describe: Describe, settings: Settings) -> None: + supplier: OrganizationalEntity = {"name": "Example Org", "url": []} + metadata = describe(settings=dataclasses.replace(settings, supplier=supplier))["metadata"] + assert metadata.get("manufacturer") == supplier + assert metadata.get("supplier") == supplier + + +def test_metadata_repeats_root_authors_and_licenses(describe: Describe) -> None: + metadata = describe("Author: Author\nLicense-Expression: MIT\n")["metadata"] + assert metadata.get("authors") == [{"name": "Author"}] + assert metadata.get("licenses") == [{"expression": "MIT", "acknowledgement": "declared"}] + + +def test_root_licenses_from_classifiers(describe: Describe) -> None: + document = describe("Classifier: License :: OSI Approved :: MIT License\n") + assert document["metadata"]["component"].get("licenses") == [ + {"license": {"name": "MIT License", "acknowledgement": "declared"}}, + {"license": {"id": "MIT", "acknowledgement": "concluded"}}, + ] + + +def test_repository_setting_wins_over_project_urls(describe: Describe, settings: Settings) -> None: + document = describe( + "Project-URL: Source, https://github.com/example/example\n", + settings=dataclasses.replace(settings, repository="https://git.example.org/example"), + ) + assert {"name": "example:sbom:repository", "value": "https://git.example.org/example"} in document["metadata"][ + "properties" + ] + + +def test_no_security_links_outside_github(describe: Describe, settings: Settings) -> None: + document = describe(settings=dataclasses.replace(settings, repository="https://git.example.org/example")) + types = {reference["type"] for reference in document["metadata"]["component"]["externalReferences"]} + assert types.isdisjoint({"security-contact", "advisories"}) + + +def test_no_license_link_without_repository(describe: Describe) -> None: + document = describe("License-File: LICENSE\n") + types = {reference["type"] for reference in document["metadata"]["component"]["externalReferences"]} + assert "license" not in types + + +def test_revision_without_repository_records_commit_only(describe: Describe, facts: BuildFacts) -> None: + document = describe(facts=dataclasses.replace(facts, revision=Revision(commit="c0ffee", dirty=False))) + root = document["metadata"]["component"] + assert root["properties"] == [{"name": "example:vcs-commit", "value": "c0ffee"}] + assert [reference["type"] for reference in root["externalReferences"]] == ["distribution", "attestation"] + + +def test_declared_dependency(describe: Describe) -> None: + requirement = 'tomli>=2; python_version < "3.11"' + document = describe(f"Requires-Dist: {requirement}\n") + assert document["components"] == [ + { + "type": "library", + "bom-ref": f"requires-dist:{requirement}", + "name": "tomli", + "purl": "pkg:pypi/tomli", + "externalReferences": [{"type": "distribution", "url": "https://pypi.org/project/tomli/"}], + "properties": [ + {"name": "example:requires-dist", "value": requirement}, + {"name": "example:environment-marker", "value": 'python_version < "3.11"'}, + ], + } + ] + + +def test_repeated_requirement_is_one_component(describe: Describe) -> None: + document = describe("Requires-Dist: packaging>=24\nRequires-Dist: packaging>=24\n") + assert [component["bom-ref"] for component in document["components"]] == ["requires-dist:packaging>=24"] + + +def test_scope_names_bundled_directories(describe: Describe, settings: Settings) -> None: + document = describe(settings=dataclasses.replace(settings, bundled_wheels=("src/b/*.whl", "src/a/x/*.whl"))) + assert document["metadata"]["properties"][0]["value"] == ( + "Components are the wheels bundled as data files under src/a/x and src/b and the direct runtime dependencies" + " declared in the wheel's core metadata, which carry no version because the installer resolves them." + ) + + +def test_bundled_wheel_and_its_vendored_packages(describe: Describe) -> None: + wheel = BundledWheel( + path="src/pip-1.0-py3-none-any.whl", + sha256="ab" * 32, + metadata_name="pip-1.0.dist-info/METADATA", + metadata=parse_metadata("Name: pip\nVersion: 1.0\n"), + files=[], + vendored=[("pip/_vendor/vendor.txt", parse_metadata("Name: urllib3\nVersion: 1.26.4\n"))], + ) + + class Seeded(ContentProvider): + def __init__(self, name: str) -> None: + self._name = name + + def bundled_wheel(self, context: ProviderContext, wheel: PurePosixPath) -> BundledWheelInfo: + return BundledWheelInfo( + properties=[{"name": f"{context.identity.property_prefix}:{self._name}", "value": str(wheel)}] + ) + + document = describe(bundled=[wheel], content=Seeded("seeded")) + assert document["components"][0]["properties"][1] == { + "name": "example:seeded", + "value": "src/pip-1.0-py3-none-any.whl", + } + bundled_ref = "bundled:src/pip-1.0-py3-none-any.whl" + assert document["dependencies"][:2] == [ + {"ref": "pkg:pypi/example@1.0", "dependsOn": [bundled_ref]}, + {"ref": bundled_ref, "dependsOn": [f"{bundled_ref}#vendored/pkg:pypi/urllib3@1.26.4"]}, + ] + + +@pytest.mark.parametrize( + ("revision", "tree"), + [ + pytest.param(None, "main", id="unknown"), + pytest.param(Revision(commit="c0ffee", dirty=False), "c0ffee", id="clean"), + pytest.param(Revision(commit="c0ffee", dirty=True), "main", id="dirty"), + ], +) +def test_build_script_is_a_linked_tool( + describe: Describe, settings: Settings, facts: BuildFacts, revision: Revision | None, tree: str +) -> None: + document = describe( + settings=dataclasses.replace(settings, repository="https://github.com/example/example"), + facts=dataclasses.replace(facts, revision=revision), + build_scripts=[BuildScript(path=PurePosixPath("hatch_build.py"), sha256="ef" * 32)], + ) + assert document["metadata"]["tools"]["components"][0] == { + "type": "application", + "bom-ref": "tool:hatch_build.py", + "name": "hatch_build.py", + "version": "1.0", + "hashes": [{"alg": "SHA-256", "content": "ef" * 32}], + "externalReferences": [ + {"type": "vcs", "url": f"https://github.com/example/example/blob/{tree}/hatch_build.py"} + ], + } + + +@pytest.mark.parametrize( + ("from_environment", "inputs"), + [ + pytest.param(False, None, id="unset"), + pytest.param( + True, [{"environmentVars": [{"name": "SOURCE_DATE_EPOCH", "value": "1700000000"}]}], id="from-environment" + ), + ], +) +def test_formulation_names_source_date_epoch_only_when_set( + describe: Describe, facts: BuildFacts, from_environment: bool, inputs: list[dict[str, list[dict[str, str]]]] | None +) -> None: + document = describe(facts=dataclasses.replace(facts, epoch_from_environment=from_environment)) + assert document.get("formulation", [])[0]["workflows"][0].get("inputs") == inputs + + +def test_contributions_join_before_references_and_serial(describe: Describe) -> None: + library: Component = {"type": "library", "bom-ref": "pkg:generic/zlib@1.3", "name": "zlib", "version": "1.3"} + tool: Component = {"type": "application", "bom-ref": "tool:pkg:generic/cmake@4", "name": "cmake", "version": "4"} + + class Native(ContentProvider): + def __init__(self, components: list[Component]) -> None: + self._components = components + + def contributions(self, context: ProviderContext) -> Contributions: + return Contributions( + components=self._components, + dependencies=[{"ref": component["bom-ref"], "dependsOn": []} for component in self._components], + root_properties=[{"name": "native:prefix", "value": context.identity.property_prefix}], + tools=[tool], + ) + + native = describe(content=Native([library])) + assert ( + native["components"], + native["dependencies"], + native["metadata"]["component"]["properties"], + native["metadata"]["tools"]["components"][-1], + native.get("formulation", [])[0]["workflows"][0]["resourceReferences"], + ) == ( + [library], + [ + {"ref": "pkg:pypi/example@1.0", "dependsOn": ["pkg:generic/zlib@1.3"]}, + {"ref": "pkg:generic/zlib@1.3", "dependsOn": []}, + ], + [{"name": "native:prefix", "value": "example"}], + tool, + [{"ref": _INTERPRETER}, {"ref": "tool:pkg:generic/cmake@4"}], + ) + assert native["serialNumber"] != describe()["serialNumber"] + + +_ORG: Final[OrganizationalEntity] = {"name": "Example Org", "url": []} +_PIP: Final[str] = "src/pip-1.0-py3-none-any.whl" + + +class _ProjectSupplies(ContentProvider): + # the wheels the project builds itself, which its own supplier supplies + def __init__(self, *own: str) -> None: + self._own = frozenset(own) + + def bundled_wheel(self, context: ProviderContext, wheel: PurePosixPath) -> BundledWheelInfo: + if str(wheel) in self._own: + return BundledWheelInfo(supplier=context.settings.supplier) + return BundledWheelInfo() + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + pytest.param(_ProjectSupplies(_PIP), _ORG, id="by-project"), + pytest.param(_ProjectSupplies(), None, id="unnamed"), + ], +) +def test_bundled_wheel_supplier_is_the_projects_choice( + describe: Describe, settings: Settings, content: ContentProvider, expected: OrganizationalEntity | None +) -> None: + wheel = BundledWheel( + path=_PIP, + sha256="ab" * 32, + metadata_name="pip-1.0.dist-info/METADATA", + metadata=parse_metadata("Name: pip\nVersion: 1.0\nProject-URL: Source, https://github.com/example/pip\n"), + files=[], + vendored=[], + ) + document = describe( + settings=dataclasses.replace(settings, supplier=_ORG), + bundled=[wheel], + content=content, + ) + assert document["components"][0].get("supplier") == expected diff --git a/tests/_model/test_components.py b/tests/_model/test_components.py new file mode 100644 index 0000000..c09ed62 --- /dev/null +++ b/tests/_model/test_components.py @@ -0,0 +1,436 @@ +from __future__ import annotations + +from pathlib import PurePosixPath +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom import BundledWheelInfo +from python_sbom._model._components import ( + bundled_wheel_component, + component_from_metadata, + script_tool, + vendored_dependencies, +) +from python_sbom._model._inputs import BundledWheel, RecordRow +from python_sbom._model._metadata import parse_metadata +from python_sbom._model._names import purl + +if TYPE_CHECKING: + from python_sbom import Component, Property + from python_sbom._model._types import License, OrganizationalEntity + +_WHEEL: Final[str] = "src/example/wheels/pip-1.0-py3-none-any.whl" + + +def test_component_from_metadata() -> None: + metadata = parse_metadata( + "Name: Example\nVersion: 1.0\nSummary: an example\nMaintainer-email: Maintainer \n" + "License-Expression: MIT\nRequires-Python: >=3.11\nRequires-Dist: packaging>=24\nProvides-Extra: cli\n" + "Classifier: Programming Language :: Python :: 3\nKeywords: sample,demo\n" + "Project-URL: Source, https://github.com/example/example\n" + ) + assert component_from_metadata(metadata) == { + "type": "library", + "bom-ref": "pkg:pypi/example@1.0", + "name": "Example", + "version": "1.0", + "purl": "pkg:pypi/example@1.0", + "description": "an example", + "authors": [{"name": "Maintainer", "email": "maintainer@example.com"}], + "licenses": [{"expression": "MIT", "acknowledgement": "declared"}], + "externalReferences": [ + {"type": "vcs", "url": "https://github.com/example/example", "comment": "Project-URL: Source"} + ], + "properties": [ + {"name": "python:requires-python", "value": ">=3.11"}, + {"name": "python:requires-dist", "value": "packaging>=24"}, + {"name": "python:provides-extra", "value": "cli"}, + {"name": "python:classifier", "value": "Programming Language :: Python :: 3"}, + {"name": "python:keywords", "value": "sample,demo"}, + ], + } + + +@pytest.mark.parametrize( + ("fields", "expected"), + [ + pytest.param("Author: Author\nMaintainer: Maintainer\n", [{"name": "Maintainer"}], id="maintainer-wins"), + pytest.param( + "Author-email: Author , other@example.com\n", + [{"name": "Author", "email": "author@example.com"}, {"email": "other@example.com"}], + id="authors-without-maintainer", + ), + pytest.param( + "Author-email: Jane Doe \n", [{"name": "Jane Doe jane at example.com"}], id="bad-email" + ), + ], +) +def test_component_authors(fields: str, expected: list[dict[str, str]]) -> None: + assert component_from_metadata(parse_metadata(f"Name: a\nVersion: 1\n{fields}"))["authors"] == expected + + +def test_component_without_authors_has_no_key() -> None: + assert "authors" not in component_from_metadata(parse_metadata("Name: a\nVersion: 1\n")) + + +@pytest.mark.parametrize( + ("fields", "expected"), + [ + pytest.param( + "Classifier: License :: OSI Approved :: MIT License\n", + [ + {"license": {"name": "MIT License", "acknowledgement": "declared"}}, + {"license": {"id": "MIT", "acknowledgement": "concluded"}}, + ], + id="classifier", + ), + pytest.param( + "License: apache-2.0\n", + [ + {"license": {"name": "apache-2.0", "acknowledgement": "declared"}}, + {"license": {"id": "Apache-2.0", "acknowledgement": "concluded"}}, + ], + id="license-field", + ), + pytest.param( + "Classifier: License :: OSI Approved :: MIT License\nLicense: MIT\n", + [ + {"license": {"name": "MIT License", "acknowledgement": "declared"}}, + {"license": {"name": "MIT", "acknowledgement": "declared"}}, + {"license": {"id": "MIT", "acknowledgement": "concluded"}}, + ], + id="same-id-once", + ), + pytest.param( + "Classifier: License :: OSI Approved :: BSD License\nLicense: MIT\n", + [ + {"license": {"name": "BSD License", "acknowledgement": "declared"}}, + {"license": {"name": "MIT", "acknowledgement": "declared"}}, + ], + id="one-unresolved-name-concludes-nothing", + ), + pytest.param( + "License: LicenseRef-Proprietary\n", + [{"license": {"name": "LicenseRef-Proprietary", "acknowledgement": "declared"}}], + id="license-ref", + ), + pytest.param( + "License: MIT OR Apache-2.0\n", + [{"license": {"name": "MIT OR Apache-2.0", "acknowledgement": "declared"}}], + id="expression-in-license-field", + ), + pytest.param( + "License: not a license\n", + [{"license": {"name": "not a license", "acknowledgement": "declared"}}], + id="invalid-expression", + ), + ], +) +def test_component_licenses(fields: str, expected: list[License]) -> None: + assert component_from_metadata(parse_metadata(f"Name: a\nVersion: 1\n{fields}"))["licenses"] == expected + + +def test_component_ignores_license_text() -> None: + metadata = parse_metadata("Name: a\nVersion: 1\nLicense: Copyright\n the full text\n") + assert "licenses" not in component_from_metadata(metadata) + + +@pytest.mark.parametrize( + ("fields", "expected"), + [ + pytest.param("Home-page: UNKNOWN\n", [], id="unknown-home-page"), + pytest.param( + "Home-page: https://example.com\n", [{"type": "website", "url": "https://example.com"}], id="home-page" + ), + pytest.param("Project-URL: no comma\n", [], id="url-without-label"), + pytest.param( + "Project-URL: Bug Tracker, https://example.com/issues\n", + [{"type": "issue-tracker", "url": "https://example.com/issues", "comment": "Project-URL: Bug Tracker"}], + id="normalized-label", + ), + pytest.param( + "Project-URL: Source Code, https://example.com/x\n", + [{"type": "vcs", "url": "https://example.com/x", "comment": "Project-URL: Source Code"}], + id="pep-753-source-code", + ), + pytest.param( + "Project-URL: Bugs, https://example.com/x\n", + [{"type": "issue-tracker", "url": "https://example.com/x", "comment": "Project-URL: Bugs"}], + id="pep-753-bugs", + ), + pytest.param( + "Project-URL: Issue, https://example.com/x\n", + [{"type": "issue-tracker", "url": "https://example.com/x", "comment": "Project-URL: Issue"}], + id="pep-753-issue", + ), + pytest.param( + "Project-URL: Release Notes, https://example.com/x\n", + [{"type": "release-notes", "url": "https://example.com/x", "comment": "Project-URL: Release Notes"}], + id="pep-753-release-notes", + ), + pytest.param( + "Project-URL: What's New, https://example.com/x\n", + [{"type": "release-notes", "url": "https://example.com/x", "comment": "Project-URL: What's New"}], + id="pep-753-whats-new", + ), + pytest.param( + "Project-URL: History, https://example.com/x\n", + [{"type": "release-notes", "url": "https://example.com/x", "comment": "Project-URL: History"}], + id="pep-753-history", + ), + pytest.param( + "Project-URL: Funding, https://example.com/fund\n", + [{"type": "other", "url": "https://example.com/fund", "comment": "Project-URL: Funding"}], + id="unknown-label", + ), + ], +) +def test_component_references(fields: str, expected: list[dict[str, str]]) -> None: + component = component_from_metadata(parse_metadata(f"Name: a\nVersion: 1\n{fields}")) + assert component["externalReferences"] == expected + + +def test_bundled_wheel_component() -> None: + wheel = BundledWheel( + path=_WHEEL, + sha256="ab" * 32, + metadata_name="pip-1.0.dist-info/METADATA", + metadata=parse_metadata("Name: pip\nVersion: 1.0\n"), + files=[RecordRow(path="pip/__init__.py", algorithm="SHA-256", digest="cd" * 32, size="0")], + vendored=[], + ) + supplier: OrganizationalEntity = {"name": "PyPA", "url": ["https://www.pypa.io"]} + properties: list[Property] = [{"name": "example:seeded-for-python", "value": "3.15"}] + assert bundled_wheel_component(wheel, "example", BundledWheelInfo(properties=properties, supplier=supplier)) == { + "type": "library", + "bom-ref": f"bundled:{_WHEEL}", + "name": "pip", + "version": "1.0", + "purl": "pkg:pypi/pip@1.0", + "supplier": supplier, + "hashes": [{"alg": "SHA-256", "content": "ab" * 32}], + "externalReferences": [{"type": "distribution", "url": "https://pypi.org/project/pip/1.0/"}], + "properties": [ + {"name": "example:bundled-wheel", "value": _WHEEL}, + {"name": "example:seeded-for-python", "value": "3.15"}, + ], + "evidence": { + "identity": [ + { + "field": "purl", + "confidence": 1, + "methods": [ + {"technique": "manifest-analysis", "confidence": 1, "value": "pip-1.0.dist-info/METADATA"} + ], + }, + { + "field": "hash", + "confidence": 1, + "methods": [{"technique": "hash-comparison", "confidence": 1, "value": "ab" * 32}], + }, + ] + }, + "components": [ + { + "type": "file", + "bom-ref": f"bundled:{_WHEEL}#pip/__init__.py", + "name": "pip/__init__.py", + "hashes": [{"alg": "SHA-256", "content": "cd" * 32}], + "properties": [{"name": "size", "value": "0"}], + } + ], + } + + +def test_bundled_wheel_vendored_metadata_wins_over_manifest() -> None: + manifest = "pip/_vendor/vendor.txt" + dist_info = "pip/_vendor/urllib3-1.26.4.dist-info/METADATA" + wheel = BundledWheel( + path=_WHEEL, + sha256="ab" * 32, + metadata_name="pip-1.0.dist-info/METADATA", + metadata=parse_metadata("Name: pip\nVersion: 1.0\n"), + files=[], + vendored=[ + (manifest, parse_metadata("Name: urllib3\nVersion: 1.26.4\n")), + (dist_info, parse_metadata("Name: urllib3\nVersion: 1.26.4\nLicense-Expression: MIT\n")), + ], + ) + component = bundled_wheel_component(wheel, "example", BundledWheelInfo()) + assert component["components"] == [ + { + "type": "library", + "bom-ref": f"bundled:{_WHEEL}#vendored/pkg:pypi/urllib3@1.26.4", + "name": "urllib3", + "version": "1.26.4", + "purl": "pkg:pypi/urllib3@1.26.4", + "licenses": [{"expression": "MIT", "acknowledgement": "declared"}], + "externalReferences": [], + "properties": [{"name": "example:vendored-manifest", "value": dist_info}], + "evidence": { + "identity": [ + { + "field": "purl", + "confidence": 1, + "methods": [{"technique": "manifest-analysis", "confidence": 1, "value": dist_info}], + } + ] + }, + } + ] + + +def test_bundled_wheel_component_ref_tells_copies_apart() -> None: + def component(path: str) -> Component: + wheel = BundledWheel( + path=path, + sha256="ab" * 32, + metadata_name="pip-1.0.dist-info/METADATA", + metadata=parse_metadata("Name: pip\nVersion: 1.0\n"), + files=[], + vendored=[], + ) + return bundled_wheel_component(wheel, "example", BundledWheelInfo()) + + refs = {component(f"src/{directory}/pip-1.0-py3-none-any.whl")["bom-ref"] for directory in ("a", "b")} + assert refs == {"bundled:src/a/pip-1.0-py3-none-any.whl", "bundled:src/b/pip-1.0-py3-none-any.whl"} + + +def test_bundled_wheel_supplier_never_comes_from_the_wheel() -> None: + wheel = BundledWheel( + path=_WHEEL, + sha256="ab" * 32, + metadata_name="pip-1.0.dist-info/METADATA", + metadata=parse_metadata("Name: pip\nVersion: 1.0\nProject-URL: Source, https://github.com/example/pip\n"), + files=[], + vendored=[], + ) + component = bundled_wheel_component(wheel, "example", BundledWheelInfo()) + assert "supplier" not in component + + +def test_script_tool_with_link() -> None: + tool = script_tool(PurePosixPath("build/hook.py"), "ef" * 32, "1.0", "https://example.com/hook.py") + assert tool == { + "type": "application", + "bom-ref": "tool:build/hook.py", + "name": "hook.py", + "version": "1.0", + "hashes": [{"alg": "SHA-256", "content": "ef" * 32}], + "externalReferences": [{"type": "vcs", "url": "https://example.com/hook.py"}], + } + + +def test_script_tool_without_link() -> None: + assert "externalReferences" not in script_tool(PurePosixPath("hook.py"), "ef" * 32, "1.0", None) + + +def test_vendored_spellings_of_one_package_merge() -> None: + wheel = BundledWheel( + path=_WHEEL, + sha256="ab" * 32, + metadata_name="pip-1.0.dist-info/METADATA", + metadata=parse_metadata("Name: pip\nVersion: 1.0\n"), + files=[], + vendored=[ + ("pip/_vendor/vendor.txt", parse_metadata("Name: jaraco.text\nVersion: 4.0\n")), + ( + "pip/_vendor/jaraco_text-4.0.dist-info/METADATA", + parse_metadata("Name: jaraco_text\nVersion: 4.0\n"), + ), + ], + ) + component = bundled_wheel_component(wheel, "example", BundledWheelInfo()) + assert [child["name"] for child in component.get("components", [])] == ["jaraco_text"] + + +@pytest.mark.parametrize( + "name", + [ + pytest.param("jaraco.context", id="dots"), + pytest.param("jaraco_context", id="underscore"), + pytest.param("Jaraco-Context", id="case"), + pytest.param("jaraco..context", id="run-of-separators"), + ], +) +def test_spellings_of_one_project_share_a_purl(name: str) -> None: + component = component_from_metadata(parse_metadata(f"Name: {name}\nVersion: 6.1.0\n")) + assert (component["purl"], component["bom-ref"]) == ( + "pkg:pypi/jaraco-context@6.1.0", + "pkg:pypi/jaraco-context@6.1.0", + ) + + +def test_purl_escapes_a_local_version() -> None: + # the purl spec percent-encodes every version character outside its unreserved set, + among them + assert purl("pkg", "1.0+local.1") == "pkg:pypi/pkg@1.0%2Blocal.1" + + +def test_vendored_dependencies_at_any_depth_in_document_order() -> None: + def bundled(ref: str, *children: Component) -> Component: + return {"type": "library", "bom-ref": ref, "name": ref, "components": list(children)} + + vendored: Component = {"type": "library", "bom-ref": "bundled:b.whl#vendored/pkg:pypi/six@1", "name": "six"} + # a file whose path holds the marker is no vendored package + file: Component = {"type": "file", "bom-ref": "bundled:a.whl#x#vendored/y", "name": "x#vendored/y"} + components = [ + bundled("pkg:pypi/app@1", bundled("bundled:a.whl", file), bundled("bundled:b.whl", vendored)), + bundled("bundled:c.whl", {**vendored, "bom-ref": "bundled:c.whl#vendored/pkg:pypi/six@1"}), + ] + assert vendored_dependencies(components) == [ + {"ref": "bundled:b.whl", "dependsOn": ["bundled:b.whl#vendored/pkg:pypi/six@1"]}, + {"ref": "bundled:c.whl", "dependsOn": ["bundled:c.whl#vendored/pkg:pypi/six@1"]}, + ] + + +@pytest.mark.parametrize( + ("fields", "references", "kept"), + [ + pytest.param( + "Project-URL: Source, git@github.com:example/pkg.git\n", + [], + [{"name": "python:project-url", "value": "Source, git@github.com:example/pkg.git"}], + id="scp-style-git", + ), + pytest.param( + "Project-URL: Docs, https://example.org/my docs\n", + [{"type": "documentation", "url": "https://example.org/my%20docs", "comment": "Project-URL: Docs"}], + [{"name": "python:project-url", "value": "Docs, https://example.org/my docs"}], + id="space", + ), + pytest.param( + "Project-URL: Template, https://example.org/{name}/x\n", + [{"type": "other", "url": "https://example.org/%7Bname%7D/x", "comment": "Project-URL: Template"}], + [{"name": "python:project-url", "value": "Template, https://example.org/{name}/x"}], + id="braces", + ), + pytest.param( + "Home-page: https://example.org/a b\n", + [{"type": "website", "url": "https://example.org/a%20b"}], + [{"name": "python:home-page", "value": "https://example.org/a b"}], + id="home-page-space", + ), + pytest.param( + "Home-page: https://example.org:port/\n", + [], + [{"name": "python:home-page", "value": "https://example.org:port/"}], + id="home-page-bad-port", + ), + pytest.param( + "Home-page: https://example.org/\nProject-URL: Source, https://github.com/example/pkg\n", + [ + {"type": "website", "url": "https://example.org/"}, + {"type": "vcs", "url": "https://github.com/example/pkg", "comment": "Project-URL: Source"}, + ], + [], + id="valid-kept-as-is", + ), + ], +) +def test_url_no_iri_reference_is_encoded_or_kept_as_property( + fields: str, references: list[dict[str, str]], kept: list[dict[str, str]] +) -> None: + # validation requires an IRI reference, so a URL copied as written would build an SBOM validate rejects + component = component_from_metadata(parse_metadata(f"Name: a\nVersion: 1\n{fields}")) + assert (component["externalReferences"], component["properties"]) == (references, kept) diff --git a/tests/_model/test_content.py b/tests/_model/test_content.py new file mode 100644 index 0000000..e1f2eba --- /dev/null +++ b/tests/_model/test_content.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import zipfile +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING + +from python_sbom import BundledWheelInfo, ContentProvider, Contributions + +if TYPE_CHECKING: + from conftest import ContextFor + + +def test_default_provider_adds_nothing_to_bundled_wheels(context_for: ContextFor) -> None: + info = ContentProvider().bundled_wheel(context_for(Path(), Path()), PurePosixPath("a.whl")) + assert info == BundledWheelInfo() + + +def test_default_provider_contributes_nothing(context_for: ContextFor) -> None: + assert ContentProvider().contributions(context_for(Path(), Path())) == Contributions() + + +def test_context_carries_project(context_for: ContextFor, tmp_path: Path) -> None: + context = context_for(tmp_path, tmp_path) + assert (context.root, context.identity.property_prefix, context.settings.default_branch) == ( + tmp_path, + "example", + "main", + ) + + +def test_context_reads_through_its_reader(context_for: ContextFor, tmp_path: Path) -> None: + with zipfile.ZipFile(wheel := tmp_path / "a.whl", "w") as archive: + archive.writestr("embed/table.py", "x = 1\n") + assert context_for(tmp_path, wheel).read(PurePosixPath("embed/table.py")) == b"x = 1\n" diff --git a/tests/_model/test_document.py b/tests/_model/test_document.py new file mode 100644 index 0000000..260a087 --- /dev/null +++ b/tests/_model/test_document.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import json +import uuid +from typing import TYPE_CHECKING, cast + +import pytest + +from python_sbom import ContentProvider, Contributions +from python_sbom._errors import SbomError +from python_sbom._model._document import ( + check_refs, + checked_contributions, + contributed_properties, + formulation, + with_header, +) + +if TYPE_CHECKING: + from python_sbom import Component, Dependency, Property + from python_sbom._model._inputs import BuildFacts + from python_sbom._model._types import Body, Formula + + +def test_document_serial_derives_from_body() -> None: + body: Body = { + "metadata": { + "timestamp": "2023-11-14T22:13:20+00:00", + "lifecycles": [{"phase": "build"}], + "tools": {"components": []}, + "component": {"type": "application", "bom-ref": "app", "name": "app"}, + }, + "components": [], + "dependencies": [], + } + namespace = uuid.uuid5(uuid.NAMESPACE_URL, "https://example.org/sboms") + assert with_header(body, namespace) == { + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": f"urn:uuid:{uuid.uuid5(namespace, json.dumps(body, sort_keys=True))}", + "version": 1, + **body, + } + + +def test_check_refs_refuses_repeated_refs() -> None: + body: Body = { + "metadata": { + "timestamp": "2023-11-14T22:13:20+00:00", + "lifecycles": [{"phase": "build"}], + "tools": {"components": [{"type": "application", "bom-ref": "app", "name": "tool"}]}, + "component": {"type": "application", "bom-ref": "app", "name": "app"}, + }, + "components": [], + "dependencies": [], + } + with pytest.raises(SbomError, match="bom-ref values repeat: app"): + check_refs(body) + + +def test_check_refs_refuses_unknown_dependency() -> None: + body: Body = { + "metadata": { + "timestamp": "2023-11-14T22:13:20+00:00", + "lifecycles": [{"phase": "build"}], + "tools": {"components": []}, + "component": {"type": "application", "bom-ref": "app", "name": "app"}, + }, + "components": [], + "dependencies": [{"ref": "app", "dependsOn": ["ghost"]}], + } + with pytest.raises(SbomError, match="dependencies name bom-refs no component has: ghost"): + check_refs(body) + + +def test_contributed_properties_keep_their_own_prefix() -> None: + properties: list[Property] = [{"name": "extra:key", "value": "1"}] + assert contributed_properties(properties, "example") == properties + + +def test_contributed_properties_refuse_the_project_prefix() -> None: + with pytest.raises(SbomError, match="example:vcs-commit use the example: prefix python-sbom writes"): + contributed_properties([{"name": "example:vcs-commit", "value": "0"}], "example") + + +def _partial(**fields: str | list[dict[str, str]]) -> Component: + # a component a provider got wrong, which the types alone would not let through + return cast("Component", fields) + + +def test_check_refs_refuses_a_second_dependency_entry_for_a_ref() -> None: + # as when a provider adds an entry for the root, whose entry python-sbom writes + body = _body([], [{"ref": "app", "dependsOn": []}, {"ref": "app", "dependsOn": []}], []) + with pytest.raises(SbomError, match="dependencies list more than one entry for: app"): + check_refs(body) + + +def _body(components: list[Component], dependencies: list[Dependency], formulation: list[Formula]) -> Body: + return { + "metadata": { + "timestamp": "2023-11-14T22:13:20+00:00", + "lifecycles": [{"phase": "build"}], + "tools": {"components": []}, + "component": {"type": "application", "bom-ref": "app", "name": "app"}, + }, + "components": components, + "dependencies": dependencies, + "formulation": formulation, + } + + +@pytest.mark.parametrize( + "ref", [pytest.param("formula:build", id="formula"), pytest.param("workflow:build", id="workflow")] +) +def test_check_refs_refuses_a_component_taking_a_formulation_ref(facts: BuildFacts, ref: str) -> None: + formula = formulation("build", "build the app", [], "app", facts) + body = _body([{"type": "library", "bom-ref": ref, "name": "clash"}], [], [formula]) + with pytest.raises(SbomError, match=f"bom-ref values repeat: {ref}"): + check_refs(body) + + +@pytest.mark.parametrize( + ("contributions", "missing"), + [ + pytest.param(Contributions(components=[_partial(type="library", name="zlib")]), "bom-ref", id="component"), + pytest.param( + Contributions( + components=[ + _partial(type="library", name="zlib", **{"bom-ref": "zlib", "components": [{"type": "file"}]}) + ] + ), + "bom-ref, name", + id="nested", + ), + pytest.param(Contributions(tools=[_partial(name="cmake", **{"bom-ref": "tool:cmake"})]), "type", id="tool"), + ], +) +def test_contributions_need_the_fields_the_build_reads(contributions: Contributions, missing: str) -> None: + with pytest.raises(SbomError, match=f"content provider ContentProvider added a component without {missing}: "): + checked_contributions(contributions, ContentProvider()) + + +def test_complete_contributions_pass() -> None: + contributions = Contributions(components=[{"type": "library", "bom-ref": "zlib", "name": "zlib"}]) + assert checked_contributions(contributions, ContentProvider()) is contributions diff --git a/tests/_model/test_identity.py b/tests/_model/test_identity.py new file mode 100644 index 0000000..3abc30d --- /dev/null +++ b/tests/_model/test_identity.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import dataclasses +import uuid +from typing import TYPE_CHECKING + +import pytest + +from python_sbom._model._identity import ProjectIdentity, project_identity +from python_sbom._model._metadata import parse_metadata + +if TYPE_CHECKING: + from python_sbom._model._inputs import Settings + + +@pytest.mark.parametrize( + ("repository", "urls", "expected"), + [ + pytest.param(None, "", None, id="none"), + pytest.param( + None, + "Project-URL: Docs, https://example.org\nProject-URL: Source, https://github.com/example/example/\n", + "https://github.com/example/example", + id="first-vcs-project-url", + ), + pytest.param( + "https://git.example.org/example", + "Project-URL: Source, https://github.com/example/example\n", + "https://git.example.org/example", + id="setting-wins", + ), + pytest.param( + None, + "Project-URL: Source, git@github.com:example/example.git\n" + "Project-URL: Repository, https://github.com/example/example\n", + "https://github.com/example/example", + id="scp-style-passed-over", + ), + pytest.param( + None, + "Project-URL: Source, https://git.example.org/{team}/example\n", + "https://git.example.org/%7Bteam%7D/example", + id="encoded", + ), + ], +) +def test_project_identity_repository( + settings: Settings, repository: str | None, urls: str, expected: str | None +) -> None: + metadata = parse_metadata(f"Name: My_Project\nVersion: 1.0\n{urls}") + identity = project_identity(metadata, dataclasses.replace(settings, repository=repository)) + assert identity == ProjectIdentity( + property_prefix="my-project", + repository=expected, + serial_namespace=uuid.uuid5(uuid.NAMESPACE_URL, f"{expected or 'https://pypi.org/project/my-project'}/sboms"), + ) diff --git a/tests/_model/test_iri.py b/tests/_model/test_iri.py new file mode 100644 index 0000000..ef2b6fa --- /dev/null +++ b/tests/_model/test_iri.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import Final + +import pytest +from jsonschema_rs import Draft7Validator + +from python_sbom._model._iri import iri_reference + +_IRI_REFERENCE: Final[Draft7Validator] = Draft7Validator( + {"type": "string", "format": "iri-reference"}, validate_formats=True +) + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + pytest.param("https://example.org/a?b=1#c", "https://example.org/a?b=1#c", id="valid"), + pytest.param("https://example.org/รผ๐Ÿ˜€", "https://example.org/รผ๐Ÿ˜€", id="unicode"), + pytest.param("https://example.org/%e2%82", "https://example.org/%e2%82", id="percent-encoded"), + pytest.param("UNKNOWN", "UNKNOWN", id="relative"), + pytest.param("//example.org/a", "//example.org/a", id="network-path"), + pytest.param("ssh://git@github.com/org/repo", "ssh://git@github.com/org/repo", id="userinfo"), + pytest.param("http://[::1]:80/", "http://[::1]:80/", id="ipv6"), + pytest.param("http://[v1.x]/", "http://[v1.x]/", id="ip-future"), + pytest.param("https://example.org:/", "https://example.org:/", id="empty-port"), + pytest.param("https://example.org/a b", "https://example.org/a%20b", id="space"), + pytest.param("https://example.org/{a}", "https://example.org/%7Ba%7D", id="braces"), + pytest.param('https://example.org/|^`\\"', "https://example.org/%3Ca%3E%7C%5E%60%5C%22", id="delimiters"), + pytest.param("https://example.org/a[1]?b[2]", "https://example.org/a%5B1%5D?b%5B2%5D", id="brackets"), + pytest.param("https://example.org/a#b#c", "https://example.org/a#b%23c", id="second-hash"), + pytest.param("https://example.org/%zz", "https://example.org/%25zz", id="bare-percent"), + pytest.param("https://example.org/a\tb", "https://example.org/a%09b", id="control"), + pytest.param("https://example.org/\x85\ue000", "https://example.org/%C2%85%EE%80%80", id="no-ucschar"), + pytest.param("https://ex ample.org/", "https://ex%20ample.org/", id="host-space"), + pytest.param("https://a@b@example.org/", "https://a%40b@example.org/", id="userinfo-at"), + pytest.param("git@github.com:org/repo.git", None, id="scp-style"), + pytest.param("https://example.org:port/", None, id="port"), + pytest.param("https://example.org:\uff18\uff10/", None, id="non-ascii-port"), + pytest.param("http://[bad/", None, id="unclosed-literal"), + pytest.param("http://[fe80::1%eth0]/", None, id="zone-index"), + pytest.param("http://[::1]x/", None, id="after-literal"), + ], +) +def test_iri_reference(text: str, expected: str | None) -> None: + assert iri_reference(text) == expected + assert expected is None or _IRI_REFERENCE.is_valid(expected) diff --git a/tests/_model/test_licenses.py b/tests/_model/test_licenses.py new file mode 100644 index 0000000..fdc2c17 --- /dev/null +++ b/tests/_model/test_licenses.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from python_sbom._model._licenses import licenses +from python_sbom._model._metadata import parse_metadata + +if TYPE_CHECKING: + from python_sbom import License + + +@pytest.mark.parametrize( + ("expression", "expected"), + [ + pytest.param( + "mit or apache-2.0", [{"expression": "MIT OR Apache-2.0", "acknowledgement": "declared"}], id="canonical" + ), + pytest.param( + "MIT WITH GPL-3.0-only", + [{"license": {"name": "MIT WITH GPL-3.0-only", "acknowledgement": "declared"}}], + id="invalid-kept-as-name", + ), + ], +) +def test_license_expression(expression: str, expected: list[License]) -> None: + assert licenses(parse_metadata(f"License-Expression: {expression}\n")) == expected diff --git a/tests/_model/test_markers.py b/tests/_model/test_markers.py new file mode 100644 index 0000000..8664285 --- /dev/null +++ b/tests/_model/test_markers.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import subprocess +import sys + +import pytest +from packaging.requirements import Requirement + +from python_sbom._model._markers import applies_without_extras + + +@pytest.mark.parametrize( + ("requirement", "applies"), + [ + pytest.param('click; extra == "cli"', False, id="extra"), + pytest.param('click; "cli" == extra', False, id="extra-on-the-right"), + pytest.param('click; extra != "cli"', True, id="not-extra"), + pytest.param('click; python_version < "3.11"', True, id="environment"), + pytest.param('click; extra == "cli" or python_version < "3.11"', True, id="extra-or-environment"), + pytest.param('click; extra == "cli" and python_version < "3.11"', False, id="extra-and-environment"), + pytest.param('click; (extra == "a" or extra == "b") and os_name == "nt"', False, id="grouped-extras"), + pytest.param( + 'click; python_version < "3.11" and (extra == "a" or os_name == "nt")', True, id="grouped-unknown" + ), + pytest.param('click; python_version >= "3" or extra == "a"', True, id="environment-or-extra"), + pytest.param("click", True, id="no-marker"), + pytest.param('click; extra == "A_b" and extra != "a-b"', False, id="extra-names-normalized"), + # only the conditions on extra decide, so a marker no environment satisfies still leaves the requirement listed + pytest.param('click; python_version < "3" and python_version >= "3"', True, id="environment-contradiction"), + pytest.param('click; (os_name == "nt" and os_name != "nt") or extra == "a"', True, id="contradiction-or-extra"), + pytest.param('click; extra in "a,b"', True, id="extra-in"), + pytest.param('click; extra not in "a,b"', False, id="extra-not-in"), + # extra compared as a version has no answer, so it stays unknown + pytest.param('click; extra ~= "1.0" and os_name == "nt"', True, id="extra-compatible-release"), + ], +) +def test_applies_without_extras(requirement: str, applies: bool) -> None: + assert applies_without_extras(Requirement(requirement)) is applies + + +def test_verdicts_hold_under_python_optimize() -> None: + # python -O strips assert statements, through which dep-logic turned these comparisons on extra away and so lost + # requirements that apply; the verdicts here must not depend on the mode + markers = { + 'extra in "a,b" and os_name == "nt"': True, + 'extra not in "a,b" or python_version < "3.11"': True, + 'extra < "b" and sys_platform == "linux"': False, + '"a" in extra or os_name == "nt"': True, + 'extra not in "" and python_version >= "3"': False, + '(extra >= "a" or extra == "cli") and os_name != "nt"': False, + } + script = ( + "import sys\n" + "from packaging.requirements import Requirement\n" + "from python_sbom._model._markers import applies_without_extras\n" + "print(*(applies_without_extras(Requirement(f'click; {marker}')) for marker in sys.argv[1:]))\n" + ) + result = subprocess.run([sys.executable, "-O", "-c", script, *markers], capture_output=True, text=True, check=True) + assert result.stdout.split() == [str(verdict) for verdict in markers.values()] + + +def test_requirement_kept_without_packaging_parser(monkeypatch: pytest.MonkeyPatch) -> None: + # None in sys.modules makes the import fail, as when a packaging release moves its private marker tree + monkeypatch.setitem(sys.modules, "packaging._parser", None) + assert applies_without_extras(Requirement('click; extra == "cli"')) is True + + +def test_requirement_kept_without_marker_tree(monkeypatch: pytest.MonkeyPatch) -> None: + # a packaging release that renames the private attribute holding the parsed tree + requirement = Requirement('click; extra == "cli"') + monkeypatch.delattr(requirement.marker, "_markers") + assert applies_without_extras(requirement) is True diff --git a/tests/_model/test_metadata.py b/tests/_model/test_metadata.py new file mode 100644 index 0000000..588f459 --- /dev/null +++ b/tests/_model/test_metadata.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import pytest +from packaging.metadata import Metadata + +from python_sbom._model._components import component_from_metadata +from python_sbom._model._metadata import parse_metadata + + +def test_repeated_single_use_field_keeps_every_value() -> None: + # packaging moves an Author given twice out of raw, and unparsed keeps both as written + metadata = parse_metadata("Name: a\nVersion: 1\nAuthor: Ann\nAuthor: Bob\n") + assert (metadata.values("author"), metadata.first("author")) == (["Ann", "Bob"], "Ann") + + +def test_absent_field() -> None: + metadata = parse_metadata("Name: a\nVersion: 1\n") + assert (metadata.values("classifiers"), metadata.first("summary")) == ([], None) + + +def test_without_name_and_version() -> None: + metadata = parse_metadata("Summary: nameless\n") + assert (metadata.name, metadata.version) == ("", "") + + +@pytest.mark.parametrize( + ("fields", "keywords"), + [ + pytest.param("Keywords: a,b\n", ["a,b"], id="comma-separated"), + pytest.param("Keywords: a b\n", ["a b"], id="space-separated"), + pytest.param("Keywords: a, b\n", ["a, b"], id="comma-and-space"), + pytest.param("Keywords: a , b,c\n", ["a , b,c"], id="mixed-spacing"), + pytest.param("Keywords: a\nKeywords: b\n", ["a", "b"], id="repeated"), + pytest.param("", [], id="absent"), + ], +) +def test_keywords(fields: str, keywords: list[str]) -> None: + assert parse_metadata(f"Name: a\nVersion: 1\n{fields}").keywords() == keywords + + +@pytest.mark.parametrize( + ("fields", "urls"), + [ + pytest.param( + "Project-URL: Source, https://a\nProject-URL: Docs, https://d\n", + [("Source", "https://a"), ("Docs", "https://d")], + id="distinct-labels", + ), + # packaging moves every Project-URL to unparsed when a label repeats, and none of them is lost + pytest.param( + "Project-URL: Source, https://a\nProject-URL: Source , https://b\nProject-URL: Docs, https://d\n", + [("Source", "https://a"), ("Source", "https://b"), ("Docs", "https://d")], + id="repeated-label", + ), + pytest.param("Project-URL: Source\nProject-URL: Docs, https://d\n", [("Docs", "https://d")], id="without-url"), + pytest.param( + "Project-URL: Source, https://a\nProject-URL: Source, https://b\nProject-URL: Bare\n", + [("Source", "https://a"), ("Source", "https://b")], + id="repeated-label-and-without-url", + ), + ], +) +def test_project_urls(fields: str, urls: list[tuple[str, str]]) -> None: + assert parse_metadata(f"Name: a\nVersion: 1\n{fields}").project_urls() == urls + + +# the headers of installed distributions that packaging's validating parser rejects, which the SBOM still describes +@pytest.mark.parametrize( + ("text", "described"), + [ + pytest.param( + "Metadata-Version: 2.1\nName: six\nVersion: 1.17.0\nLicense: MIT\n" + "Classifier: License :: OSI Approved :: MIT License\nLicense-File: LICENSE\n", + ("six", "1.17.0", ["MIT License", "MIT"]), + id="six-license-file-before-2.4", + ), + pytest.param( + "Metadata-Version: 2.1\nName: covdefaults\nVersion: 2.3.0\nLicense: MIT\n" + "Description-Content-Type: text/markdown\nLicense-File: LICENSE\nRequires-Dist: coverage (>=6.0.2)\n", + ("covdefaults", "2.3.0", ["MIT"]), + id="covdefaults-license-file-before-2.4", + ), + pytest.param( + "Metadata-Version: 2.0\nName: ply\nVersion: 3.11\nLicense: BSD\nDescription-Content-Type: UNKNOWN\n" + "Platform: UNKNOWN\n", + ("ply", "3.11", ["BSD"]), + id="ply-unknown-version-and-content-type", + ), + pytest.param( + "Metadata-Version: 2.1\nName: uri-template\nVersion: 1.3.0\nLicense: MIT License\n" + "Project-URL: homepage, https://gitlab.linss.com/open-source/python/uri-template\nKeywords: config\n" + "License-File: LICENSE\nProvides-Extra: dev\nRequires-Dist: mypy ; extra == 'dev'\n", + ("uri-template", "1.3.0", ["MIT License"]), + id="uri-template-license-file-before-2.4", + ), + ], +) +def test_metadata_the_validating_parser_rejects(text: str, described: tuple[str, str, list[str]]) -> None: + with pytest.raises(ExceptionGroup): + Metadata.from_email(text, validate=True) + component = component_from_metadata(parse_metadata(text)) + names = [entry["license"]["name"] for entry in component["licenses"] if "name" in entry["license"]] + assert (component["name"], component["version"], names) == described + + +@pytest.mark.parametrize("newline", [pytest.param("\n", id="lf"), pytest.param("\r\n", id="crlf")]) +def test_body_is_not_read(newline: str) -> None: + # the headers end at the first empty line; a line of whitespace still continues the one before + text = newline.join(["Name: a", "Version: 1", "Summary: one", " more", "", "Author: in the body", ""]) + metadata = parse_metadata(text) + assert (metadata.first("summary"), metadata.first("author"), "description" in metadata.raw) == ( + f"one{newline} more", + None, + False, + ) diff --git a/tests/_model/test_tools.py b/tests/_model/test_tools.py new file mode 100644 index 0000000..cc3e859 --- /dev/null +++ b/tests/_model/test_tools.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import dataclasses +from typing import TYPE_CHECKING + +from python_sbom._model._inputs import InstalledDistribution, RecordRow +from python_sbom._model._metadata import parse_metadata +from python_sbom._model._tools import tool_components + +if TYPE_CHECKING: + from python_sbom._model._inputs import BuildFacts + + +def test_tools_list_interpreter_then_distributions_by_name(facts: BuildFacts) -> None: + row = RecordRow(path="zeta/__init__.py", algorithm="SHA-256", digest="ab" * 32, size="0") + distributions = [ + InstalledDistribution( + name="zeta", metadata=parse_metadata("Name: Zeta\nVersion: 2\n"), files=[row], depends_on=[] + ), + InstalledDistribution( + name="alpha", metadata=parse_metadata("Name: alpha\nVersion: 1\n"), files=[], depends_on=["zeta"] + ), + ] + tools, dependencies = tool_components(dataclasses.replace(facts, distributions=distributions)) + assert tools[1:] == [ + { + "type": "library", + "bom-ref": "tool:pkg:pypi/alpha@1", + "name": "alpha", + "version": "1", + "purl": "pkg:pypi/alpha@1", + "externalReferences": [], + "properties": [], + }, + { + "type": "library", + "bom-ref": "tool:pkg:pypi/zeta@2", + "name": "Zeta", + "version": "2", + "purl": "pkg:pypi/zeta@2", + "externalReferences": [], + "properties": [], + "components": [ + { + "type": "file", + "bom-ref": "tool:pkg:pypi/zeta@2#zeta/__init__.py", + "name": "zeta/__init__.py", + "hashes": [{"alg": "SHA-256", "content": "ab" * 32}], + "properties": [{"name": "size", "value": "0"}], + } + ], + }, + ] + assert dependencies == [ + {"ref": "tool:pkg:pypi/alpha@1", "dependsOn": ["tool:pkg:pypi/zeta@2"]}, + {"ref": "tool:pkg:pypi/zeta@2", "dependsOn": []}, + ] + + +def test_tool_bom_ref_and_purl_agree(facts: BuildFacts) -> None: + # the distribution is keyed by its normalized name, while its metadata spells it with a dot + tool = InstalledDistribution( + name="boolean-py", metadata=parse_metadata("Name: boolean.py\nVersion: 5.0\n"), files=[], depends_on=[] + ) + (_, component), _ = tool_components(dataclasses.replace(facts, distributions=[tool])) + assert (component["bom-ref"], component.get("purl")) == ("tool:pkg:pypi/boolean-py@5.0", "pkg:pypi/boolean-py@5.0") diff --git a/tests/_seeder/LICENSE b/tests/_seeder/LICENSE new file mode 100644 index 0000000..b69c8d9 --- /dev/null +++ b/tests/_seeder/LICENSE @@ -0,0 +1,3 @@ +Copyright (c) 2026 Example + +MIT terms diff --git a/tests/_seeder/hatch_build.py b/tests/_seeder/hatch_build.py new file mode 100644 index 0000000..4475a61 --- /dev/null +++ b/tests/_seeder/hatch_build.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import ast +from typing import TYPE_CHECKING + +from python_sbom import BundledWheelInfo, ContentProvider +from python_sbom.hatch import SbomBuildHook + +if TYPE_CHECKING: + from pathlib import PurePosixPath + + from python_sbom import ProviderContext + + +def get_build_hook() -> type[SeedSbomHook]: + # the module also sees the base class, so hatchling needs to be told which subclass to load + return SeedSbomHook + + +class SeedSbomHook(SbomBuildHook): + def content_provider(self) -> ContentProvider: # ruff: ignore[no-self-use] # overrides the hook's factory + return BundleSupport("BUNDLE_SUPPORT") + + +class BundleSupport(ContentProvider): + def __init__(self, table: str) -> None: + self._table = table + + def bundled_wheel(self, context: ProviderContext, wheel: PurePosixPath) -> BundledWheelInfo: + # the table next to the wheels decides which Python versions receive each one; it is read by name, without + # importing the package + module = ast.parse(context.read(wheel.parent / "__init__.py").decode("utf-8")) + support: dict[str, dict[str, str]] = ast.literal_eval( + next( + node.value + for node in module.body + if isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == self._table for target in node.targets) + ) + ) + return BundledWheelInfo( + properties=[ + {"name": f"{context.identity.property_prefix}:seeded-for-python", "value": python} + for python, wheels in support.items() + if wheel.name in wheels.values() + ], + # the seeder bundles only its own supplier's wheels + supplier=context.settings.supplier, + ) diff --git a/tests/_seeder/pyproject.toml b/tests/_seeder/pyproject.toml new file mode 100644 index 0000000..7289986 --- /dev/null +++ b/tests/_seeder/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +build-backend = "hatchling.build" +requires = [ "hatchling>=1.28", "python-sbom" ] + +[project] +name = "seeder" +version = "1.0" +description = "Seeds new environments with the wheels it bundles" +license = "MIT" +license-files = [ "LICENSE" ] +maintainers = [ { name = "Maintainer", email = "maintainer@example.com" } ] +requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", +] +urls.Source = "https://github.com/example/seeder" + +[tool.hatch] +build.hooks.custom = {} +build.targets.wheel.packages = [ "src/seeder" ] + +[tool.python-sbom] +supplier = { name = "Example Org", url = [ "https://example.org" ] } +bundled-wheels = [ "src/seeder/embed/*.whl" ] diff --git a/tests/_seeder/src/seeder/__init__.py b/tests/_seeder/src/seeder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_seeder/src/seeder/embed/__init__.py b/tests/_seeder/src/seeder/embed/__init__.py new file mode 100644 index 0000000..1f40729 --- /dev/null +++ b/tests/_seeder/src/seeder/embed/__init__.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from pathlib import Path + +# the layout of virtualenv's seed/wheels/embed/__init__.py: another assignment comes before the table +BUNDLE_FOLDER = Path(__file__).absolute().parent +BUNDLE_SUPPORT = { + "3.14": {"pip": "pip-1.0-py3-none-any.whl"}, + "3.15": {"pip": "pip-1.0-py3-none-any.whl"}, +} diff --git a/tests/_validate/test_artifact.py b/tests/_validate/test_artifact.py new file mode 100644 index 0000000..55c7948 --- /dev/null +++ b/tests/_validate/test_artifact.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import json +import tracemalloc +import zipfile +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom._errors import SbomError +from python_sbom._formats._json import dumps +from python_sbom._validate._artifact import validate + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + from conftest import Describe, WriteWheel + +_METADATA: Final[dict[str, bytes]] = {"example-1.0.dist-info/METADATA": b"Name: Example\nVersion: 1.0\n"} + + +def test_archive_without_sbom(tmp_path: Path, write_wheel: WriteWheel) -> None: + wheel = write_wheel(tmp_path / "example-1.0-py3-none-any.whl", _METADATA) + assert validate(wheel, None) == ["no SBOM found in example-1.0-py3-none-any.whl"] + + +def test_every_sbom_is_checked(tmp_path: Path, write_wheel: WriteWheel, describe: Describe) -> None: + # the schema makes the serial optional, so the loaded JSON, not the typed document, loses it + broken = json.loads(dumps(describe())) + del broken["serialNumber"] + foreign = json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1}).encode() + wheel = write_wheel( + tmp_path / "example-1.0-py3-none-any.whl", + { + **_METADATA, + "example-1.0.dist-info/sboms/example.cdx.json": json.dumps(broken).encode(), + "example-1.0.dist-info/sboms/other.cdx.json": foreign, + "example-1.0.dist-info/sboms/notes.txt": b"not an SBOM", + "app.pyz.cdx.json": foreign, + "example/nested.cdx.json": json.dumps(broken).encode(), + "example/sboms/nested.cdx.json": json.dumps(broken).encode(), + }, + ) + assert validate(wheel, None) == [ + "example-1.0.dist-info/sboms/example.cdx.json: serialNumber must be a urn:uuid: RFC 4122 UUID, got ''" + ] + + +def test_sbom_of_every_dist_info_is_checked(tmp_path: Path, write_wheel: WriteWheel, describe: Describe) -> None: + # a decoy .dist-info sorting first must not hide the SBOM of the wheel's own one + broken = json.loads(dumps(describe())) + del broken["serialNumber"] + wheel = write_wheel( + tmp_path / "example-1.0-py3-none-any.whl", + { + "aaa-0.dist-info/METADATA": b"Name: aaa\nVersion: 0\n", + "aaa-0.dist-info/sboms/aaa.cdx.json": dumps(describe()), + **_METADATA, + "example-1.0.dist-info/sboms/example.cdx.json": json.dumps(broken).encode(), + }, + record=False, + ) + assert validate(wheel, None) == [ + "example-1.0.dist-info/sboms/example.cdx.json: serialNumber must be a urn:uuid: RFC 4122 UUID, got ''" + ] + + +def test_oversized_sbom_is_reported_and_the_rest_checked(tmp_path: Path, write_wheel: WriteWheel) -> None: + # 64 MiB of whitespace compresses to a few KiB; reading stops past the 1,058,816 bytes ten components allow + sboms = { + "example-1.0.dist-info/sboms/big.cdx.json": b" " * (64 * 1024 * 1024), + "example-1.0.dist-info/sboms/small.cdx.json": b"not JSON", + } + wheel = write_wheel(tmp_path / "example-1.0-py3-none-any.whl", {**_METADATA, **sboms}) + problems, peak = traced(lambda: validate(wheel, None, max_components=10)) + assert problems == [ + ( + "example-1.0.dist-info/sboms/big.cdx.json: is over 1058816 bytes, the size limit for 10 components;" + " pass a higher --max-components to check it" + ), + "example-1.0.dist-info/sboms/small.cdx.json: is not JSON: Expecting value: line 1 column 1 (char 0)", + ] + assert peak < 8 * 1024 * 1024 + + +def traced(run: Callable[[], list[str]]) -> tuple[list[str], int]: + tracemalloc.start() + try: + result = run() + _, peak = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + return result, peak + + +def test_sboms_are_held_one_at_a_time(tmp_path: Path, write_wheel: WriteWheel) -> None: + # each compresses to a few KiB, so a small wheel packs many SBOMs that together decompress to 64 MiB + sboms = {f"example-1.0.dist-info/sboms/x{index:02}.cdx.json": b" " * (2 * 1024 * 1024) for index in range(32)} + wheel = write_wheel(tmp_path / "example-1.0-py3-none-any.whl", {**_METADATA, **sboms}, record=False) + problems, peak = traced(lambda: validate(wheel, None)) + assert problems == [ + f"{name}: is not JSON: Expecting value: line 1 column 2097153 (char 2097152)" for name in sorted(sboms) + ] + assert peak < 16 * 1024 * 1024 + + +def test_oversized_json_file(tmp_path: Path) -> None: + (sbom := tmp_path / "example.cdx.json").write_bytes(b" " * 1_058_817) + assert validate(sbom, None, max_components=10) == [ + "is over 1058816 bytes, the size limit for 10 components; pass a higher --max-components to check it" + ] + + +def test_sbom_not_utf8_is_reported_and_the_rest_checked(tmp_path: Path, write_wheel: WriteWheel) -> None: + sboms = { + "example-1.0.dist-info/sboms/utf16.cdx.json": b"\xff\xfe{}", + "example-1.0.dist-info/sboms/other.cdx.json": b"not JSON", + } + wheel = write_wheel(tmp_path / "example-1.0-py3-none-any.whl", {**_METADATA, **sboms}) + assert validate(wheel, None) == [ + "example-1.0.dist-info/sboms/other.cdx.json: is not JSON: Expecting value: line 1 column 1 (char 0)", + "example-1.0.dist-info/sboms/utf16.cdx.json: is not UTF-8: invalid start byte at byte 0", + ] + + +def test_archive_with_duplicate_member(tmp_path: Path) -> None: + with zipfile.ZipFile(wheel := tmp_path / "example-1.0-py3-none-any.whl", "w") as archive: + archive.writestr("example.cdx.json", b"{}") + with pytest.warns(UserWarning, match="Duplicate name"): + archive.writestr("example.cdx.json", b"{}") + with pytest.raises(SbomError, match=r"holds more than one member named example\.cdx\.json"): + validate(wheel, None) + + +def test_corrupt_archive(tmp_path: Path) -> None: + with zipfile.ZipFile(wheel := tmp_path / "example-1.0-py3-none-any.whl", "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("example.cdx.json", b"{}" * 1000) + data = bytearray(wheel.read_bytes()) + data[40:50] = b"\xff" * 10 + wheel.write_bytes(bytes(data)) + with pytest.raises(SbomError, match=r"example-1\.0-py3-none-any\.whl is not a valid zip archive"): + validate(wheel, None) + + +def test_json_file(tmp_path: Path, describe: Describe) -> None: + (sbom := tmp_path / "example.cdx.json").write_bytes(dumps(describe())) + assert validate(sbom, frozenset({"MIT"})) == [] + + +def test_json_file_component_cap(tmp_path: Path) -> None: + components = [{"type": "library", "name": name} for name in ("one", "two", "three")] + (sbom := tmp_path / "example.cdx.json").write_text(json.dumps({"components": components}), encoding="utf-8") + assert validate(sbom, None, max_components=2) == [ + "lists more than 2 components, nested ones included; pass a higher --max-components to check it" + ] diff --git a/tests/_validate/test_document.py b/tests/_validate/test_document.py new file mode 100644 index 0000000..55e1354 --- /dev/null +++ b/tests/_validate/test_document.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +import dataclasses +import json +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom._formats._json import dumps +from python_sbom._model._inputs import InstalledDistribution, RecordRow +from python_sbom._model._metadata import parse_metadata +from python_sbom._validate._document import is_document, validate_document + +if TYPE_CHECKING: + from collections.abc import Callable + + from conftest import Describe + from pytest_mock import MockerFixture + + from python_sbom import Component, License + from python_sbom._model._inputs import BuildFacts + from python_sbom._model._types import Document + from python_sbom._validate._limits import Json + +# what auditwheel writes: CycloneDX 1.4 conventions, a legacy tools array, no timestamp +_FOREIGN: Final[bytes] = json.dumps({ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "component": {"type": "library", "bom-ref": "pkg:pypi/plain@1.0", "name": "plain", "version": "1.0"}, + "tools": [{"name": "auditwheel", "version": "6.4.0"}], + }, + "components": [{"type": "library", "bom-ref": "pkg:rpm/almalinux/libfoo@1", "name": "libfoo", "version": "1"}], + "dependencies": [{"ref": "pkg:pypi/plain@1.0", "dependsOn": ["pkg:rpm/almalinux/libfoo@1"]}], +}).encode() + + +def test_valid(describe: Describe) -> None: + assert validate_document(dumps(describe("Requires-Dist: click\nLicense-Expression: MIT\n")), None) == [] + + +def test_not_json() -> None: + assert validate_document(b"this is not json", None) == ["is not JSON: Expecting value: line 1 column 1 (char 0)"] + + +def test_not_utf8() -> None: + assert validate_document(b'{"name": "\xe9t\xe9"}', None) == ["is not UTF-8: invalid continuation byte at byte 10"] + + +def test_schema_violation() -> None: + (problem,) = validate_document( + json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.6", "version": "one"}).encode(), None + ) + assert problem == 'does not conform to the CycloneDX 1.6 schema: "one" is not of type "integer" at $.version' + + +def test_other_tools_documents_get_the_schema_check_alone() -> None: + assert validate_document(_FOREIGN, None) == [] + + +@pytest.mark.parametrize( + ("max_components", "limit"), + [pytest.param(100_000, 103_448_576, id="default"), pytest.param(10, 1_058_816, id="lowered")], +) +def test_oversized_document_is_not_parsed(max_components: int, limit: int) -> None: + assert validate_document(b" " * (limit + 1), None, max_components) == [ + ( + f"is over {limit} bytes, the size limit for {max_components} components; pass a higher --max-components to" + " check it" + ) + ] + + +def test_document_at_the_size_limit_is_parsed() -> None: + assert validate_document(b" " * 1_058_816, None, 10) == [ + "is not JSON: Expecting value: line 1 column 1058817 (char 1058816)" + ] + + +def test_component_cap_counts_nested_components() -> None: + assert validate_document(many_components(1_001), None) == [ + "lists more than 100000 components, nested ones included; pass a higher --max-components to check it" + ] + + +def many_components(groups: int) -> bytes: + # groups of 100 parts each + components = [ + { + "type": "library", + "name": f"group-{group}", + "components": [{"type": "library", "name": f"part-{part}"} for part in range(99)], + } + for group in range(groups) + ] + return json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, "components": components}).encode() + + +def test_component_cap_can_be_raised() -> None: + assert validate_document(many_components(1_001), None, max_components=100_100) == [] + + +def test_component_cap_can_be_lowered() -> None: + assert validate_document(many_components(2), None, max_components=199) == [ + "lists more than 199 components, nested ones included; pass a higher --max-components to check it" + ] + + +@pytest.mark.parametrize( + ("files", "expected"), + [ + # virtualenv's wheel lists 5,207 components and its zipapp 5,452, nearly all of them files of its build tools + pytest.param(5_500, [], id="virtualenv"), + # the interpreter and the tool add two, so these reach the default cap, at about 54 MB, and one more file + # passes it; the size limit must leave both to the component count + pytest.param(99_998, [], id="at-the-cap"), + pytest.param( + 99_999, + ["lists more than 100000 components, nested ones included; pass a higher --max-components to check it"], + id="over-the-cap", + ), + ], +) +def test_sbom_the_default_cap_admits(describe: Describe, facts: BuildFacts, files: int, expected: list[str]) -> None: + rows = [ + RecordRow(path=f"tool/module_{index}.py", algorithm="SHA-256", digest="0" * 64, size="1") + for index in range(files) + ] + tool = InstalledDistribution( + name="tool", metadata=parse_metadata("Name: tool\nVersion: 1.0\n"), files=rows, depends_on=[] + ) + document = describe(facts=dataclasses.replace(facts, distributions=[tool])) + assert validate_document(dumps(document), None) == expected + + +def test_missing_fields(describe: Describe) -> None: + # the schema makes the timestamp optional, so the loaded JSON, not the typed document, loses it + loaded = json.loads(dumps(describe())) + del loaded["metadata"]["timestamp"] + assert validate_document(json.dumps(loaded).encode(), None) == [ + "lacks metadata.timestamp, which every document python-sbom writes carries" + ] + + +def test_serial_is_required(describe: Describe) -> None: + # the schema makes it optional, so the loaded JSON, not the typed document, loses it + loaded = json.loads(dumps(describe())) + del loaded["serialNumber"] + assert validate_document(json.dumps(loaded).encode(), None) == [ + "serialNumber must be a urn:uuid: RFC 4122 UUID, got ''" + ] + + +def test_duplicate_refs(describe: Describe) -> None: + library: Component = {"type": "library", "bom-ref": "pkg:pypi/example@1.0", "name": "copy"} + assert validate_document(with_components(describe(), library), None) == [ + "bom-ref values repeat: pkg:pypi/example@1.0" + ] + + +def with_components(document: Document, *components: Component) -> bytes: + document["components"].extend(components) + document["dependencies"][0].setdefault("dependsOn", []).extend(component["bom-ref"] for component in components) + return dumps(document) + + +def test_duplicate_refs_in_other_tools_documents() -> None: + foreign = json.loads(_FOREIGN) + foreign["components"].append({"type": "library", "bom-ref": "pkg:pypi/plain@1.0", "name": "copy"}) + assert validate_document(json.dumps(foreign).encode(), None) == ["bom-ref values repeat: pkg:pypi/plain@1.0"] + + +def test_component_without_bom_ref(describe: Describe) -> None: + # the schema makes the bom-ref optional, so the loaded JSON, not the typed document, loses it + loaded = json.loads(dumps(describe())) + loaded["components"].append({"type": "library", "name": "nameless"}) + assert validate_document(json.dumps(loaded).encode(), None) == ["components lack a bom-ref: ['nameless']"] + + +@pytest.mark.parametrize( + "mutate", + [ + pytest.param( + lambda loaded: loaded["formulation"][0]["workflows"][0].pop("resourceReferences"), + id="workflow-without-resources", + ), + pytest.param( + lambda loaded: loaded["formulation"][0]["workflows"][0]["resourceReferences"].append({ + "externalReference": {"type": "other", "url": "https://example.org"} + }), + id="workflow-external-resource", + ), + pytest.param( + lambda loaded: loaded["metadata"]["properties"].append({"name": "example:extra"}), + id="property-without-value", + ), + pytest.param( + lambda loaded: loaded["metadata"]["component"]["properties"].append({"name": "example:extra"}), + id="root-property-without-value", + ), + pytest.param( + lambda loaded: loaded["metadata"].update(supplier={"url": ["https://example.org"]}), + id="supplier-without-name", + ), + ], +) +def test_schema_valid_variations_pass(describe: Describe, mutate: Callable[[dict[str, Json]], None]) -> None: + loaded = json.loads(dumps(describe())) + mutate(loaded) + assert validate_document(json.dumps(loaded).encode(), None) == [] + + +def test_unknown_dependency_refs(describe: Describe) -> None: + document = describe() + document["dependencies"].append({"ref": "ghost", "dependsOn": ["phantom"]}) + assert validate_document(dumps(document), None) == ["dependencies name bom-refs no component has: ghost, phantom"] + + +def test_root_must_depend_on_components(describe: Describe) -> None: + document = describe("Requires-Dist: click\n") + document["dependencies"][0]["dependsOn"] = [] + assert validate_document(dumps(document), None) == ["root dependsOn [] != components ['requires-dist:click']"] + + +def test_workflow_must_reference_tools(describe: Describe) -> None: + document = describe() + document.get("formulation", [])[0]["workflows"][0]["resourceReferences"].append({"ref": "pkg:pypi/example@1.0"}) + assert validate_document(dumps(document), None) == [ + "workflow wheel-build references unknown tools: ['pkg:pypi/example@1.0']" + ] + + +def test_formula_without_workflows(describe: Describe) -> None: + loaded = json.loads(dumps(describe())) + loaded["formulation"] = [{"bom-ref": "formula:other"}] + assert validate_document(json.dumps(loaded).encode(), None) == [] + + +def test_spdx_rendering_must_validate(describe: Describe) -> None: + document = describe() + properties = document["metadata"].get("properties", []) + properties.append({"name": "example:sbom:repository", "value": "https://example.org/a#fragment"}) + (problem,) = validate_document(dumps(document), None) + assert problem.startswith("SPDX rendering invalid: ") + + +def test_spdx_rendering_must_parse(describe: Describe) -> None: + raw = with_components(describe(), shipped([{"expression": "MIT AND", "acknowledgement": "declared"}])) + assert any(problem.startswith("SPDX rendering does not parse: ") for problem in validate_document(raw, None)) + + +def shipped(licenses: list[License]) -> Component: + return { + "type": "library", + "bom-ref": "bundled:lib.whl", + "name": "lib", + "hashes": [{"alg": "SHA-256", "content": "ab" * 32}], + "licenses": licenses, + } + + +def test_deep_nesting_is_reported(describe: Describe) -> None: + # two levels per component, deep enough to refuse and shallow enough for any Python's JSON parser + nested = "".join( + f'{{"type": "library", "bom-ref": "deep-{level}", "name": "deep", "components": [' for level in range(100) + ) + nested += "]}" * 100 + raw = json.dumps(describe()).replace('"components": []', f'"components": [{nested}]', 1).encode() + assert validate_document(raw, None) == ["nests more than 128 levels deep, too deep to check"] + + +def test_nesting_too_deep_to_parse_is_reported() -> None: + assert validate_document(b"[" * 1_000_000 + b"]" * 1_000_000, None) == [ + "nests too deep for the JSON parser to read" + ] + + +@pytest.mark.parametrize( + "constant", + [pytest.param("NaN", id="nan"), pytest.param("Infinity", id="infinity"), pytest.param("-Infinity", id="minus")], +) +def test_non_finite_numbers_are_rejected(constant: str) -> None: + raw = f'{{"bomFormat": "CycloneDX", "specVersion": "1.6", "version": {constant}}}'.encode() + assert validate_document(raw, None) == [f"holds {float(constant)}, which is not a JSON number"] + + +def test_non_finite_document_is_rejected() -> None: + assert validate_document(b"NaN", None) == ["holds nan, which is not a JSON number"] + + +def test_license_policy(describe: Describe) -> None: + raw = with_components(describe(), shipped([{"expression": "MIT AND GPL-3.0-only"}])) + assert validate_document(raw, frozenset({"MIT"})) == [ + "bundled:lib.whl: license MIT AND GPL-3.0-only is outside the allowed licenses ['MIT']" + ] + + +def test_unexpected_error_is_reported(describe: Describe, mocker: MockerFixture) -> None: + mocker.patch("python_sbom._validate._document.render_spdx", autospec=True, side_effect=RuntimeError("x" * 300)) + assert validate_document(dumps(describe()), None) == [f"could not be checked, RuntimeError: {'x' * 200}"] + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + pytest.param(json.loads(_FOREIGN), True, id="foreign"), + pytest.param({"metadata": {"component": {"name": "plain"}}}, False, id="root-without-bom-ref"), + pytest.param({"metadata": {"component": {"bom-ref": "plain"}}}, False, id="root-without-name"), + pytest.param({"metadata": {}}, False, id="no-root"), + pytest.param([], False, id="not-an-object"), + ], +) +def test_is_document(value: Json, expected: bool) -> None: + assert is_document(value) is expected + + +def test_repeated_dependency_entry_cannot_hide_an_invalid_one(describe: Describe) -> None: + # read as a mapping, the valid second entry for the root would replace the invalid first one, which the SPDX + # rendering still turns into an edge + document = describe("Requires-Dist: click\n") + document["dependencies"].insert(0, {"ref": document["metadata"]["component"]["bom-ref"], "dependsOn": []}) + assert validate_document(dumps(document), None) == [ + f"dependencies list more than one entry for: {document['metadata']['component']['bom-ref']}" + ] diff --git a/tests/_validate/test_licenses.py b/tests/_validate/test_licenses.py new file mode 100644 index 0000000..237dc6c --- /dev/null +++ b/tests/_validate/test_licenses.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom._validate._licenses import license_problems + +if TYPE_CHECKING: + from python_sbom import Component, License + +_ALLOWED: Final[frozenset[str]] = frozenset({"MIT", "BSD-3-Clause", "GPL-2.0-only"}) + + +@pytest.mark.parametrize( + "licenses", + [ + pytest.param([{"expression": "MIT"}], id="expression"), + pytest.param([{"license": {"id": "mit", "acknowledgement": "concluded"}}], id="id-case-insensitive"), + pytest.param([{"expression": "MIT OR GPL-3.0-only"}], id="any-of"), + pytest.param([{"expression": "(GPL-3.0-only OR MIT) AND BSD-3-Clause"}], id="grouped"), + pytest.param([{"expression": "GPL-2.0-only WITH Classpath-exception-2.0"}], id="exception"), + ], +) +def test_allows(licenses: list[License]) -> None: + assert license_problems([shipped(licenses)], _ALLOWED) == [] + + +def shipped(licenses: list[License]) -> Component: + return { + "type": "library", + "bom-ref": "bundled:lib.whl", + "name": "lib", + "hashes": [{"alg": "SHA-256", "content": "ab" * 32}], + "licenses": licenses, + } + + +@pytest.mark.parametrize( + ("licenses", "problem"), + [ + pytest.param( + [{"expression": "MIT AND GPL-3.0-only"}], + "license MIT AND GPL-3.0-only is outside the allowed licenses ['BSD-3-Clause', 'GPL-2.0-only', 'MIT']", + id="denied", + ), + pytest.param( + [{"license": {"name": "BSD License", "acknowledgement": "declared"}}], + "declares license names ['BSD License'] with no SPDX id", + id="names", + ), + pytest.param([], "declares no license", id="none"), + pytest.param( + [{"expression": "MIT AND"}], + "license expression 'MIT AND' is malformed: AND requires two or more licenses as in: MIT AND BSD", + id="end", + ), + pytest.param([{"expression": "(MIT"}], "license expression '(MIT' is malformed: Invalid expression", id="open"), + pytest.param( + [{"expression": "(MIT ISC)"}], + "license expression '(MIT ISC)' is malformed: Invalid symbols sequence such as (A B) for token: \"ISC\" at" + " position: 5", + id="pair", + ), + pytest.param( + [{"expression": "MIT)"}], + "license expression 'MIT)' is malformed: Unbalanced parenthesis for token: \")\" at position: 3", + id="close", + ), + pytest.param( + [{"expression": "AND MIT"}], + "license expression 'AND MIT' is malformed: Invalid operator sequence without symbols such as AND OR or OR" + ' OR for token: "AND"', + id="op", + ), + pytest.param( + [{"expression": "MIT WITH OR"}], + "license expression 'MIT WITH OR' is malformed: Invalid expression for token: \"WITH\" at position: 4", + id="with", + ), + pytest.param([{"expression": " "}], "license expression ' ' is malformed: it is empty", id="empty"), + pytest.param( + [{"expression": "( OR MIT"}], + "license expression '( OR MIT' is malformed: it does not parse", + id="assertion", + ), + pytest.param([{"expression": "()"}], "license expression '()' is malformed: it does not parse", id="no-group"), + ], +) +def test_reports(licenses: list[License], problem: str) -> None: + assert license_problems([shipped(licenses)], _ALLOWED) == [f"bundled:lib.whl: {problem}"] + + +@pytest.mark.parametrize( + "expression", + [ + pytest.param(f"{'(' * 5000}MIT{')' * 5000}", id="parentheses"), + pytest.param( + f"{''.join(f'MIT {operator} (' for operator in ('AND', 'OR') * 2500)}MIT{')' * 5000}", id="operators" + ), + ], +) +def test_deep_nesting_is_allowed(expression: str) -> None: + # a recursive walk would exhaust the stack this deep + assert license_problems([shipped([{"expression": expression}])], _ALLOWED) == [] + + +def test_deep_nesting_is_denied() -> None: + expression = f"{'MIT AND (' * 5000}GPL-3.0-only{')' * 5000}" + assert license_problems([shipped([{"expression": expression}])], _ALLOWED) == [ + f"bundled:lib.whl: license {expression} is outside the allowed licenses ['BSD-3-Clause', 'GPL-2.0-only', 'MIT']" + ] + + +@pytest.mark.parametrize( + ("expression", "allowed", "problems"), + [ + pytest.param( + "LicenseRef-Apache", + frozenset({"Apache-2.0"}), + ["bundled:lib.whl: license LicenseRef-Apache is outside the allowed licenses ['Apache-2.0']"], + id="license-ref-is-not-apache", + ), + pytest.param("Net-SNMP", frozenset({"Net-SNMP"}), [], id="spdx-id-is-kept"), + ], +) +def test_judges_the_declared_id_not_an_alias(expression: str, allowed: frozenset[str], problems: list[str]) -> None: + assert license_problems([shipped([{"expression": expression}])], allowed) == problems + + +def test_judges_only_shipped_components() -> None: + declared: Component = {"type": "library", "bom-ref": "requires-dist:click", "name": "click"} + vendored: Component = {"type": "library", "bom-ref": "bundled:outer.whl#vendored", "name": "vendored"} + outer = shipped([{"expression": "MIT"}]) + outer["components"] = [vendored, {"type": "file", "bom-ref": "outer#a.py", "name": "a.py"}] + assert license_problems([declared, outer], frozenset({"MIT"})) == [] + + +def test_component_without_bom_ref_is_named() -> None: + # the schema makes the bom-ref optional, so the loaded JSON, not the typed document, loses it + component = json.loads(json.dumps(shipped([]))) + del component["bom-ref"] + assert license_problems([component], _ALLOWED) == ["lib: declares no license"] diff --git a/tests/_validate/test_schemas.py b/tests/_validate/test_schemas.py new file mode 100644 index 0000000..02820e7 --- /dev/null +++ b/tests/_validate/test_schemas.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import hashlib +import sys +from importlib.resources import files +from typing import TYPE_CHECKING + +import pytest +from spdx_tools.spdx.parser.jsonlikedict.json_like_dict_parser import JsonLikeDictParser +from spdx_tools.spdx.validation.document_validator import validate_full_spdx_document + +from python_sbom._errors import MissingExtraError +from python_sbom._formats._json import dumps +from python_sbom._formats._spdx import render_spdx +from python_sbom._validate._document import validate_document +from python_sbom._validate._schemas import schema_error, spdx_problems + +if TYPE_CHECKING: + from collections.abc import Callable + + from conftest import Describe + + from python_sbom import Component + from python_sbom._formats._spdx import Spdx + from python_sbom._validate._limits import Json + + +@pytest.mark.parametrize( + ("path", "release", "digest"), + [ + pytest.param( + "schema/bom-1.6.schema.json", + "1.6.2", + "18f57f7482593bad9f21b4feed09084640cbeff419d62ad5090c5ceccca5b37d", + id="bom-1.6", + ), + pytest.param( + "schema/jsf-0.82.schema.json", + "1.6.2", + "8bae002c25e723db7ee1f26afde680ae1a2b1a8f6b4b4b0fd65dc3becb090aae", + id="jsf-0.82", + ), + pytest.param( + "schema/spdx.schema.json", + "1.7.2", + "4b345e2329f209f34e960ae2a8e7cb46a166907e6a45e94978565925dc47b359", + id="spdx", + ), + pytest.param( + "LICENSE", "1.7.2", "6c29f22a4a7385285c6f579ec9f33c5e989f00739d6b257243a0b082ec9447ae", id="license" + ), + ], +) +def test_schema_files_are_the_upstream_release(path: str, release: str, digest: str) -> None: + # the SHA-256 of https://raw.githubusercontent.com/CycloneDX/specification//, so the shipped copy + # stays the released file byte for byte; a newer release replaces the file and its digest together + shipped = files("python_sbom._validate") / "schemas" / path.rpartition("/")[2] + assert hashlib.sha256(shipped.read_bytes()).hexdigest() == digest, f"not the file of the {release} release" + + +@pytest.mark.parametrize( + "module", + [ + pytest.param("jsonschema_rs", id="jsonschema-rs"), + pytest.param("license_expression", id="license-expression"), + pytest.param("spdx_tools", id="spdx-tools"), + ], +) +def test_validate_without_extra(monkeypatch: pytest.MonkeyPatch, module: str) -> None: + # None in sys.modules makes the import fail, as when the extra is not installed; even a document the cheap checks + # would reject reports the missing extra + monkeypatch.setitem(sys.modules, module, None) + with pytest.raises(MissingExtraError, match=r"pip install 'python-sbom\[validate\]'"): + validate_document(b"not JSON", None) + + +def test_validate_with_broken_extra(monkeypatch: pytest.MonkeyPatch) -> None: + # find_spec finds the extra's modules, yet one of their dependencies, such as spdx-tools' beartype, fails to import + monkeypatch.setitem(sys.modules, "python_sbom._validate._schemas", None) + with pytest.raises(MissingExtraError, match=r"pip install 'python-sbom\[validate\]'") as raised: + validate_document(b'{"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1}', None) + assert isinstance(raised.value.__cause__, ImportError) + + +def test_license_policy_with_broken_extra(describe: Describe, monkeypatch: pytest.MonkeyPatch) -> None: + # the license parser loads only for a policy, and a broken one reports the extra as missing there too + document = dumps(describe()) + monkeypatch.setitem(sys.modules, "python_sbom._validate._licenses", None) + assert validate_document(document, None) == [] + with pytest.raises(MissingExtraError, match=r"pip install 'python-sbom\[validate\]'"): + validate_document(document, frozenset({"MIT"})) + + +def test_schema_error_names_the_path() -> None: + document: Json = {"bomFormat": "CycloneDX", "specVersion": "1.6", "version": "one"} + assert schema_error(document) == '"one" is not of type "integer" at $.version' + + +def test_schema_error_quotes_keys_that_are_not_names() -> None: + document: Json = {"bomFormat": "CycloneDX", "specVersion": "1.6", "metadata": {"component": {"bom-ref": ""}}} + assert schema_error(document) == '"type" is a required property at $.metadata.component' + document = { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "metadata": {"component": {"type": "library", "name": "a", "bom-ref": ""}}, + } + assert schema_error(document) == "\"\" is shorter than 1 character at $.metadata.component['bom-ref']" + + +def test_schema_error_clips_the_offending_value() -> None: + document: Json = {"bomFormat": "CycloneDX", "specVersion": "1.6", "version": "x" * 1000} + assert schema_error(document) == f'"{"x" * 199} at $.version' + + +def _url(value: str) -> dict[str, Json]: + return {"externalReferences": [{"type": "website", "url": value}]} + + +@pytest.mark.parametrize( + ("fields", "valid"), + [ + pytest.param(_url("https://example.org/a?b=c#d"), True, id="url"), + pytest.param(_url("https://exรคmple.org/รผ"), True, id="url-non-ascii"), + # RFC 3987 section 2.2: ucschar runs to U+1FFFD, past the Basic Multilingual Plane + pytest.param(_url("https://example.org/๐Ÿ˜€"), True, id="url-astral"), + # RFC 3986 section 3.2.2: IP-literal = "[" ( IPv6address / IPvFuture ) "]" + pytest.param(_url("http://[::1]/"), True, id="url-ipv6-literal"), + pytest.param(_url("https://example.org/\n"), False, id="url-trailing-newline"), + pytest.param(_url("https://exa mple.org"), False, id="url-space"), + pytest.param(_url("https://example.org/%zz"), False, id="url-bad-percent-escape"), + # RFC 3339 section 5.6: time-second runs to 60 for a leap second + pytest.param({"metadata": {"timestamp": "2016-12-31T23:59:60Z"}}, True, id="timestamp-leap-second"), + pytest.param({"metadata": {"timestamp": "2016-12-31T23:59:59Z\n"}}, False, id="timestamp-trailing-newline"), + # RFC 6531 Mailbox: a local part and a domain, the domain's labels starting with a letter or digit + pytest.param({"metadata": {"authors": [{"email": "a@b.org"}]}}, True, id="email"), + pytest.param({"metadata": {"authors": [{"email": "@b.org"}]}}, False, id="email-without-local-part"), + pytest.param({"metadata": {"authors": [{"email": "a@-b.org"}]}}, False, id="email-label-starts-with-hyphen"), + # draft 7 section 6.3.3: patterns are ECMA-262, whose $ does not match before a final newline + pytest.param({"serialNumber": "urn:uuid:bde8d34d-2da7-5045-bd69-644208002436\n"}, False, id="pattern-newline"), + ], +) +def test_formats_follow_their_rfcs(fields: dict[str, Json], valid: bool) -> None: + assert (schema_error({"bomFormat": "CycloneDX", "specVersion": "1.6", **fields}) is None) is valid + + +@pytest.mark.parametrize( + ("first", "second", "unique"), + [ + pytest.param({"version": 1}, {"version": 1.0}, False, id="int-equals-float"), + pytest.param({"version": "1"}, {"version": "1"}, False, id="same"), + pytest.param({"version": "1", "group": "g"}, {"group": "g", "version": "1"}, False, id="key-order"), + pytest.param({"version": "1"}, {"version": "2"}, True, id="different"), + ], +) +def test_unique_items(first: dict[str, Json], second: dict[str, Json], unique: bool) -> None: + components: list[Json] = [{"type": "library", "name": "a", **first}, {"type": "library", "name": "a", **second}] + assert (schema_error({"bomFormat": "CycloneDX", "specVersion": "1.6", "components": components}) is None) is unique + + +def test_many_unique_components_pass() -> None: + components: list[Json] = [ + {"type": "library", "bom-ref": f"r{index}", "name": f"n{index}"} for index in range(20_000) + ] + assert schema_error({"bomFormat": "CycloneDX", "specVersion": "1.6", "components": components}) is None + + +def _declare(spdx: Spdx, expression: str, text: str | None = None) -> None: + spdx["packages"][0]["licenseDeclared"] = expression + if text is not None: + spdx["hasExtractedLicensingInfos"] = [ + {"licenseId": "LicenseRef-Custom", "extractedText": text, "name": "custom"} + ] + + +@pytest.mark.parametrize( + ("mutate", "valid"), + [ + pytest.param(lambda _: None, True, id="as-rendered"), + pytest.param( + lambda spdx: spdx.update(documentNamespace="https://example.org/a#fragment"), False, id="namespace" + ), + pytest.param( + lambda spdx: spdx["relationships"].append({ + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": "SPDXRef-ghost", + }), + False, + id="unknown-relationship-end", + ), + pytest.param(lambda spdx: spdx["packages"].append(spdx["packages"][0]), False, id="duplicate-id"), + pytest.param( + lambda spdx: spdx["packages"][0].update(checksums=[{"algorithm": "SHA256", "checksumValue": "ab"}]), + False, + id="short-checksum", + ), + pytest.param(lambda spdx: _declare(spdx, "MIT AND Foo-Bar"), False, id="unknown-license"), + pytest.param(lambda spdx: _declare(spdx, "GPL-2.0-only WITH MIT"), False, id="license-as-exception"), + pytest.param(lambda spdx: _declare(spdx, "MIT OR LicenseRef-Custom", "custom"), True, id="license-ref"), + pytest.param(lambda spdx: _declare(spdx, "MIT OR LicenseRef-Custom", ""), False, id="license-ref-without-text"), + ], +) +def test_spdx_problems_match_spdx_tools(describe: Describe, mutate: Callable[[Spdx], None], valid: bool) -> None: + # the full validation of spdx-tools, whose time grows with the square of the elements, is the reference + spdx = render_spdx(describe("Requires-Dist: click\n")) + mutate(spdx) + reference = validate_full_spdx_document(JsonLikeDictParser().parse(dict(spdx))) + assert spdx_problems(spdx) == [f"SPDX rendering invalid: {message.validation_message}" for message in reference] + assert (not reference) is valid + + +def test_many_packages_pass(describe: Describe) -> None: + # spdx-tools' full validation took a minute and a half for 16,000 packages, so this guards the linear checks + document = describe() + libraries: list[Component] = [ + {"type": "library", "bom-ref": f"r{index}", "name": f"n{index}"} for index in range(20_000) + ] + document["components"].extend(libraries) + document["dependencies"][0].setdefault("dependsOn", []).extend(f"r{index}" for index in range(20_000)) + assert spdx_problems(render_spdx(document)) == [] diff --git a/tests/_wheel/test_bundled.py b/tests/_wheel/test_bundled.py new file mode 100644 index 0000000..d05a42f --- /dev/null +++ b/tests/_wheel/test_bundled.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import base64 +import hashlib +import re +import zipfile +from io import BytesIO +from itertools import starmap +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom._errors import SbomError +from python_sbom._model._inputs import BundledWheel, RecordRow +from python_sbom._wheel._bundled import read_bundled_wheels + +if TYPE_CHECKING: + from collections.abc import Mapping + from pathlib import Path + + from conftest import WriteWheel + +_INIT: Final[str] = "pip/__init__.py" +_METADATA: Final[str] = "pip-1.0.dist-info/METADATA" +_PIP: Final[dict[str, bytes]] = {_INIT: b"", _METADATA: b"Name: pip\nVersion: 1.0\n"} + + +# defined before the parametrized cases below, which build their RECORD rows with it at import time +def row(path: str, content: bytes, algorithm: str = "sha256", *, encoded: str = "", size: int | None = None) -> str: + encoded = encoded or base64.urlsafe_b64encode(hashlib.new(algorithm, content).digest()).rstrip(b"=").decode() + return f"{path},{algorithm}={encoded},{len(content) if size is None else size}\n" + + +_METADATA_ROW: Final[str] = row(_METADATA, _PIP[_METADATA]) +_RECORD_ROW: Final[str] = "pip-1.0.dist-info/RECORD,,\n" + + +def test_bundled_wheel(tmp_path: Path, write_wheel: WriteWheel) -> None: + wheel = write_wheel( + tmp_path / "src" / "pip-1.0-py3-none-any.whl", + { + **_PIP, + "pip/_vendor/vendor.txt": b"# pinned\n\nurllib3==1.26.4 # http\n", + "pip/_vendor/rich-13.0.dist-info/METADATA": b"Name: rich\nVersion: 13.0\n", + }, + ) + bundled = _bundled(wheel.read_bytes(), "src/pip-1.0-py3-none-any.whl") + assert (bundled.path, bundled.sha256, bundled.metadata_name, bundled.metadata.name, bundled.files[0]) == ( + "src/pip-1.0-py3-none-any.whl", + hashlib.sha256(wheel.read_bytes()).hexdigest(), + "pip-1.0.dist-info/METADATA", + "pip", + RecordRow(path="pip/__init__.py", algorithm="SHA-256", digest=hashlib.sha256(b"").hexdigest(), size="0"), + ) + assert [(source, metadata.raw) for source, metadata in bundled.vendored] == [ + ("pip/_vendor/vendor.txt", {"name": "urllib3", "version": "1.26.4"}), + ("pip/_vendor/rich-13.0.dist-info/METADATA", {"name": "rich", "version": "13.0"}), + ] + + +@pytest.mark.parametrize( + ("files", "message"), + [ + pytest.param( + {"pip/__init__.py": b""}, + r"is not a wheel: Wheel doesn't contain exactly one \.dist-info directory", + id="no-dist-info", + ), + pytest.param({"pip-1.0.dist-info/WHEEL": b""}, "has no pip-1.0.dist-info/METADATA", id="no-metadata"), + pytest.param( + {"aaa-0.dist-info/METADATA": b"Name: aaa\nVersion: 0\n", **_PIP}, + r"is not a wheel: Wheel doesn't contain exactly one \.dist-info directory", + id="two-dist-info", + ), + pytest.param({"pip-1.0.dist-info/METADATA": b"Name: pip\n"}, "METADATA lacks Name or Version", id="no-version"), + pytest.param( + {"pip-1.0.dist-info/METADATA": _PIP["pip-1.0.dist-info/METADATA"]}, + "has no pip-1.0.dist-info/RECORD", + id="no-record", + ), + pytest.param( + {"pip-1.0.dist-info/METADATA": b"Name: pip\nVersion: 1.0\n" + b" " * 16 * 1024 * 1024}, + "METADATA is over the 16777216 byte limit", + id="oversized-metadata", + ), + pytest.param( + {**_PIP, "pip-1.0.dist-info/RECORD": b"\n" * (16 * 1024 * 1024 + 1)}, + "RECORD is over the 16777216 byte limit", + id="oversized-record", + ), + ], +) +def test_malformed_bundled_wheel( + tmp_path: Path, write_wheel: WriteWheel, files: Mapping[str, bytes], message: str +) -> None: + wheel = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", files, record=False) + with pytest.raises(SbomError, match=message): + _bundled(wheel.read_bytes(), "pip-1.0-py3-none-any.whl") + + +@pytest.mark.parametrize( + ("name", "message"), + [ + pytest.param("pip.whl", "Not a valid wheel filename: pip.whl", id="unparsable"), + pytest.param( + "other-1.0-py3-none-any.whl", r"Wheel \.dist-info directory doesn't match wheel filename", id="other" + ), + ], +) +def test_bundled_wheel_file_name_names_the_project( + tmp_path: Path, write_wheel: WriteWheel, name: str, message: str +) -> None: + wheel = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", _PIP) + with pytest.raises(SbomError, match=f"bundled wheel {name} is not a wheel: {message}"): + _bundled(wheel.read_bytes(), name) + + +def test_corrupt_bundled_wheel() -> None: + with pytest.raises(SbomError, match=r"bundled wheel pip\.whl is not a valid zip archive"): + _bundled(b"not a zip", "pip.whl") + + +def test_bundled_wheel_with_duplicate_member(tmp_path: Path) -> None: + # installers extract the first copy, so a RECORD that hashes the second one would hide the installed code + with zipfile.ZipFile(wheel := tmp_path / "pip-1.0-py3-none-any.whl", "w") as archive: + archive.writestr(_INIT, b"import os\n") + with pytest.warns(UserWarning, match="Duplicate name"): + archive.writestr(_INIT, b"") + archive.writestr(_METADATA, _PIP[_METADATA]) + archive.writestr("pip-1.0.dist-info/RECORD", f"{row(_INIT, b'')}{_METADATA_ROW}") + with pytest.raises(SbomError, match=f"holds more than one member named {_INIT}, ignoring case"): + _bundled(wheel.read_bytes(), "pip-1.0-py3-none-any.whl") + + +@pytest.mark.parametrize( + ("algorithm", "expected"), + [ + pytest.param("sha256", "SHA-256", id="sha256"), + pytest.param("sha384", "SHA-384", id="sha384"), + pytest.param("sha512", "SHA-512", id="sha512"), + ], +) +def test_bundled_wheel_record_algorithm(tmp_path: Path, write_wheel: WriteWheel, algorithm: str, expected: str) -> None: + record = "".join(row(path, content, algorithm) for path, content in _PIP.items()) + _RECORD_ROW + files = {**_PIP, "pip-1.0.dist-info/RECORD": record.encode()} + wheel = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", files, record=False) + assert [ + (file.path, file.algorithm, file.digest) + for file in _bundled(wheel.read_bytes(), "pip-1.0-py3-none-any.whl").files + ] == [(path, expected, hashlib.new(algorithm, content).hexdigest()) for path, content in _PIP.items()] + + +@pytest.mark.parametrize( + ("record", "message"), + [ + pytest.param( + f"{_INIT},sha256=x\n", "RECORD does not parse: Row Index 0: expected 3 elements, got 2", id="columns" + ), + pytest.param( + f"{_METADATA_ROW}\n{_RECORD_ROW}", + "RECORD does not parse: Row Index 1: expected 3 elements, got 0", + id="blank-line", + ), + pytest.param( + f"{_INIT},,0\n{_METADATA_ROW}{_RECORD_ROW}", + f"RECORD disagrees with the wheel: hash / size of {_INIT} is not included in RECORD", + id="no-digest", + ), + pytest.param( + f"{row(_INIT, b'')}{_METADATA_ROW}", + "RECORD disagrees with the wheel: pip-1.0.dist-info/RECORD is not mentioned in RECORD", + id="record-not-recorded", + ), + pytest.param( + f"{row(_INIT, b'', 'sha224')}{_METADATA_ROW}{_RECORD_ROW}", + f"RECORD row for {_INIT}: unknown hash algorithm 'sha224'", + id="algorithm-cyclonedx-lacks", + ), + # installer would call digest() without the length shake_128 needs and raise a TypeError + pytest.param( + f"{_INIT},shake_128=AAAA,0\n{_METADATA_ROW}{_RECORD_ROW}", + f"RECORD row for {_INIT}: unknown hash algorithm 'shake_128'", + id="algorithm-needing-a-length", + ), + pytest.param(_METADATA_ROW, f"RECORD lacks a digest for {_INIT}", id="member-not-recorded"), + pytest.param( + f"{row(_INIT, b'', encoded='A' * 43)}{row(_INIT, b'')}{_METADATA_ROW}", + f"RECORD lists {_INIT} more than once", + id="listed-twice", + ), + pytest.param( + f"{row(_INIT, b'')}{row(_INIT, b'', 'sha512', encoded='A' * 86)}{_METADATA_ROW}", + f"RECORD lists {_INIT} more than once", + id="listed-twice-by-other-algorithm", + ), + pytest.param( + f"{row(_INIT, b'')}{row('pip/ghost.py', b'', 'md5', size=99)}{_METADATA_ROW}", + "RECORD lists pip/ghost.py, which the wheel does not hold", + id="recorded-not-member", + ), + pytest.param( + f"{row(_INIT, b'', size=999999)}{_METADATA_ROW}{_RECORD_ROW}", + f"RECORD disagrees with the wheel: hash / size of {_INIT} didn't match RECORD", + id="size-mismatch", + ), + pytest.param( + f"{row(_INIT, b'', 'sha512', encoded='A' * 86)}{_METADATA_ROW}{_RECORD_ROW}", + f"RECORD disagrees with the wheel: hash / size of {_INIT} didn't match RECORD", + id="digest-mismatch", + ), + ], +) +def test_record_disagrees_with_wheel(tmp_path: Path, write_wheel: WriteWheel, record: str, message: str) -> None: + files = {**_PIP, "pip-1.0.dist-info/RECORD": record.encode()} + wheel = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", files, record=False) + with pytest.raises(SbomError, match=message): + _bundled(wheel.read_bytes(), "pip-1.0-py3-none-any.whl") + + +def test_record_signatures_need_no_row(tmp_path: Path, write_wheel: WriteWheel) -> None: + record = "".join(starmap(row, _PIP.items())) + _RECORD_ROW + files = {**_PIP, "pip-1.0.dist-info/RECORD": record.encode(), "pip-1.0.dist-info/RECORD.jws": b"{}"} + wheel = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", files, record=False) + assert [file.path for file in _bundled(wheel.read_bytes(), "pip-1.0-py3-none-any.whl").files] == list(_PIP) + + +@pytest.mark.parametrize( + "signature", + [ + pytest.param("pip-1.0.dist-info/sub/RECORD.jws", id="nested"), + pytest.param("pip-1.0.dist-info/../pip/RECORD.p7s", id="parent"), + ], +) +def test_signature_away_from_record_needs_a_row(tmp_path: Path, write_wheel: WriteWheel, signature: str) -> None: + # installer lets these go unlisted, though a signature only sits beside RECORD + record = "".join(starmap(row, _PIP.items())) + _RECORD_ROW + files = {**_PIP, "pip-1.0.dist-info/RECORD": record.encode(), signature: b"import os\n"} + wheel = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", files, record=False) + with pytest.raises(SbomError, match=f"RECORD lacks a digest for {re.escape(signature)}"): + _bundled(wheel.read_bytes(), "pip-1.0-py3-none-any.whl") + + +def test_vendored_metadata_needs_name_and_version(tmp_path: Path, write_wheel: WriteWheel) -> None: + files = {**_PIP, "pip/_vendor/rich-13.0.dist-info/METADATA": b"Metadata-Version: 2.1\n"} + wheel = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", files) + with pytest.raises(SbomError, match=r"pip/_vendor/rich-13\.0\.dist-info/METADATA lacks Name or Version"): + _bundled(wheel.read_bytes(), "pip-1.0-py3-none-any.whl") + + +@pytest.mark.parametrize( + ("manifest", "message"), + [ + pytest.param(b"urllib3 ==\n", "does not parse: urllib3 ==", id="unparsable"), + pytest.param(b"urllib3>=1\n", "pins no one exact version: urllib3>=1", id="range"), + pytest.param(b"urllib3==1.*\n", "pins no one exact version: urllib3==1.*", id="wildcard"), + pytest.param(b"urllib3==1,!=1.1\n", "pins no one exact version: urllib3==1,!=1.1", id="two-specifiers"), + ], +) +def test_vendor_manifest_needs_exact_pins( + tmp_path: Path, write_wheel: WriteWheel, manifest: bytes, message: str +) -> None: + wheel = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", {**_PIP, "pip/_vendor/vendor.txt": manifest}) + with pytest.raises(SbomError, match=message): + _bundled(wheel.read_bytes(), "pip-1.0-py3-none-any.whl") + + +def _bundled(data: bytes, path: str) -> BundledWheel: + return read_bundled_wheels([path], lambda _: BytesIO(data))[0] + + +def test_bundled_wheels_come_in_the_order_given(tmp_path: Path, write_wheel: WriteWheel) -> None: + # read side by side, yet reported in order, the first broken wheel's error included + good = write_wheel(tmp_path / "pip-1.0-py3-none-any.whl", _PIP).read_bytes() + wheels = {"pip-1.0-py3-none-any.whl": good, "b.whl": b"not a zip", "c.whl": b"neither"} + paths = ["pip-1.0-py3-none-any.whl", "b.whl", "c.whl"] + assert [bundled.path for bundled in read_bundled_wheels(paths[:1] * 2, lambda name: BytesIO(wheels[name]))] == [ + paths[0], + paths[0], + ] + with pytest.raises(SbomError, match=r"^bundled wheel b\.whl is not a valid zip archive"): + read_bundled_wheels(paths, lambda name: BytesIO(wheels[name])) diff --git a/tests/_wheel/test_reader.py b/tests/_wheel/test_reader.py new file mode 100644 index 0000000..c8d5bf4 --- /dev/null +++ b/tests/_wheel/test_reader.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import zipfile +from contextlib import ExitStack +from pathlib import Path, PurePosixPath + +import pytest + +from python_sbom._errors import SbomError +from python_sbom._wheel._reader import open_artifact + + +def test_provider_reads_a_member(tmp_path: Path) -> None: + with zipfile.ZipFile(wheel := tmp_path / "a.whl", "w") as archive: + archive.writestr("embed/table.py", "x = 1\n") + with open_artifact(wheel) as opened: + assert opened.provider_read(PurePosixPath("embed/table.py")) == b"x = 1\n" + + +def test_provider_read_misses(tmp_path: Path) -> None: + with zipfile.ZipFile(wheel := tmp_path / "a.whl", "w") as archive: + archive.writestr("other.py", "") + with open_artifact(wheel) as opened, pytest.raises(KeyError, match=r"missing\.py"): + opened.provider_read(PurePosixPath("missing.py")) + + +def test_artifact_checked_on_open(tmp_path: Path) -> None: + # the same checks as every other read, so names equal ignoring case are refused before any member is read + with zipfile.ZipFile(wheel := tmp_path / "a.whl", "w") as archive: + archive.writestr("table.py", "") + archive.writestr("TABLE.py", "") + with ( + pytest.raises(SbomError, match=r"holds more than one member named table\.py, ignoring case"), + ExitStack() as stack, + ): + stack.enter_context(open_artifact(wheel)) diff --git a/tests/_wheel/test_record.py b/tests/_wheel/test_record.py new file mode 100644 index 0000000..9fb6fd5 --- /dev/null +++ b/tests/_wheel/test_record.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import hashlib + +import pytest + +from python_sbom._errors import SbomError +from python_sbom._model._inputs import RecordRow +from python_sbom._wheel._record import record_rows + + +@pytest.mark.parametrize( + ("digest", "algorithm", "expected"), + [ + pytest.param( + "sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU", + "SHA-256", + hashlib.sha256(b"").hexdigest(), + id="sha256", + ), + pytest.param( + "md5=1B2M2Y8AsgTpgAmY7PhCfg", "MD5", hashlib.md5(b"", usedforsecurity=False).hexdigest(), id="md5" + ), + ], +) +def test_record_rows_decode_digests(digest: str, algorithm: str, expected: str) -> None: + assert record_rows([("a.py", digest, "0")], "w") == [ + RecordRow(path="a.py", algorithm=algorithm, digest=expected, size="0") + ] + + +def test_record_rows_skip_unhashed() -> None: + assert record_rows([("w.dist-info/RECORD", "", "")], "w") == [] + + +@pytest.mark.parametrize( + ("digest", "message"), + [ + pytest.param("blake2b=AAAA", "w: RECORD row for a.py: unknown hash algorithm 'blake2b'", id="algorithm"), + pytest.param("sha256=abcde", "w: RECORD row for a.py: digest 'abcde' is not urlsafe base64", id="base64"), + ], +) +def test_record_rows_reject_digests(digest: str, message: str) -> None: + with pytest.raises(SbomError, match=message): + record_rows([("a.py", digest, "0")], "w") diff --git a/tests/_wheel/test_write.py b/tests/_wheel/test_write.py new file mode 100644 index 0000000..1f555f4 --- /dev/null +++ b/tests/_wheel/test_write.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +import stat +import struct +import sys +import zipfile +import zlib +from pathlib import Path +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom._errors import SbomError +from python_sbom._wheel._write import append_member, write_sbom + +if TYPE_CHECKING: + from conftest import WriteWheel + from pytest_mock import MockerFixture + +_SBOM: Final[str] = "native-1.0.dist-info/sboms/native.cdx.json" +# the SHA-256 of b"{}\n", urlsafe base64 without padding +_SBOM_ROW: Final[str] = f"{_SBOM},sha256=yj0WO6sFU4GCciYUBWjzvvfqrBh869doeOC2Pp5EI1Y,3" + + +@pytest.fixture +def wheel(tmp_path: Path, write_wheel: WriteWheel) -> Path: + return write_wheel( + tmp_path / "dist" / "native-1.0-py3-none-any.whl", + {"native/__init__.py": b"", "native-1.0.dist-info/METADATA": b"Name: native\nVersion: 1.0\n"}, + ) + + +def test_embed_returns_member_path(wheel: Path) -> None: + assert write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) == _SBOM + + +def test_embed_puts_sbom_before_record(wheel: Path) -> None: + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + assert zipfile.ZipFile(wheel).namelist() == [ + "native/__init__.py", + "native-1.0.dist-info/METADATA", + _SBOM, + "native-1.0.dist-info/RECORD", + ] + + +def test_embed_records_sbom_before_record_row(wheel: Path) -> None: + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + record = zipfile.ZipFile(wheel).read("native-1.0.dist-info/RECORD").decode().splitlines() + assert record[-2:] == [_SBOM_ROW, "native-1.0.dist-info/RECORD,,"] + + +def test_embed_replaces_existing_sbom(wheel: Path) -> None: + write_sbom(wheel, "native.cdx.json", b"[]\n", 1_700_000_000) + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + with zipfile.ZipFile(wheel) as archive: + assert archive.read(_SBOM) == b"{}\n" + assert [ + line for line in archive.read("native-1.0.dist-info/RECORD").decode().splitlines() if "sboms" in line + ] == [_SBOM_ROW] + + +def test_embed_keeps_other_entries(wheel: Path) -> None: + before = {info.filename: (info.date_time, info.external_attr) for info in zipfile.ZipFile(wheel).infolist()} + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + after = {info.filename: (info.date_time, info.external_attr) for info in zipfile.ZipFile(wheel).infolist()} + assert {name: after[name] for name in before} == before + + +def test_embed_keeps_other_members_compressed_as_they_were(tmp_path: Path) -> None: + # level 9 over repetitive text gives other bytes than zipfile's default level, so a recompressed member would differ + with zipfile.ZipFile(wheel := tmp_path / "native-1.0-py3-none-any.whl", "w", compresslevel=9) as handle: + handle.writestr( + "native/data.txt", b"".join(b"line %d\n" % number for number in range(5000)), zipfile.ZIP_DEFLATED + ) + handle.writestr("native-1.0.dist-info/METADATA", b"Name: native\nVersion: 1.0\n") + handle.writestr("native-1.0.dist-info/RECORD", b"native-1.0.dist-info/RECORD,,\n") + before = _stored(wheel, "native/data.txt") + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + assert _stored(wheel, "native/data.txt") == before + + +def _stored(wheel: Path, name: str) -> bytes: + with zipfile.ZipFile(wheel) as archive: + info = archive.getinfo(name) + data = wheel.read_bytes() + name_length, extra_length = struct.unpack(" None: + write_sbom(wheel, "native.cdx.json", b"{}\n", epoch) + info = zipfile.ZipFile(wheel).getinfo(_SBOM) + assert (info.date_time, info.create_system, info.external_attr) == ( + date_time, + 3, + (stat.S_IFREG | 0o644) << 16, + ) + + +def test_embed_is_deterministic(tmp_path: Path, write_wheel: WriteWheel) -> None: + files = {"native/__init__.py": b"", "native-1.0.dist-info/METADATA": b"Name: native\nVersion: 1.0\n"} + first, second = (write_wheel(tmp_path / name / "native-1.0-py3-none-any.whl", files) for name in ("a", "b")) + for wheel in (first, second): + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + assert first.read_bytes() == second.read_bytes() + + +def test_embed_keeps_record_line_endings(tmp_path: Path, write_wheel: WriteWheel) -> None: + record = b"native-1.0.dist-info/METADATA,,\r\nnative-1.0.dist-info/RECORD,,\r\n" + wheel = write_wheel( + tmp_path / "native-1.0-py3-none-any.whl", + {"native-1.0.dist-info/METADATA": b"Name: native\n", "native-1.0.dist-info/RECORD": record}, + record=False, + ) + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + assert zipfile.ZipFile(wheel).read("native-1.0.dist-info/RECORD") == ( + f"native-1.0.dist-info/METADATA,,\r\n{_SBOM_ROW}\r\nnative-1.0.dist-info/RECORD,,\r\n".encode() + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows has no POSIX permission bits") +def test_embed_keeps_wheel_permissions(wheel: Path) -> None: # pragma: win32 no cover + wheel.chmod(0o640) + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + assert stat.S_IMODE(wheel.stat().st_mode) == 0o640 + + +@pytest.mark.parametrize( + ("files", "message"), + [ + pytest.param( + {"native/__init__.py": b""}, + r"is not a wheel: Wheel doesn't contain exactly one \.dist-info directory", + id="no-dist-info", + ), + pytest.param( + {"native-1.0.dist-info/RECORD": b""}, + "has no native-1.0.dist-info/METADATA", + id="no-metadata", + ), + pytest.param( + {"native-1.0.dist-info/METADATA": b"Name: native\n"}, + "has no native-1.0.dist-info/RECORD", + id="no-record", + ), + ], +) +def test_embed_refuses_non_wheel(tmp_path: Path, files: dict[str, bytes], message: str) -> None: + with zipfile.ZipFile(archive := tmp_path / "native-1.0-py3-none-any.whl", "w") as handle: + for name, content in files.items(): + handle.writestr(name, content) + with pytest.raises(SbomError, match=message): + write_sbom(archive, "native.cdx.json", b"{}\n", 1_700_000_000) + + +def test_failed_embed_leaves_wheel_and_no_temporary_file(wheel: Path, mocker: MockerFixture) -> None: + original = wheel.read_bytes() + mocker.patch.object(Path, "replace", autospec=True, side_effect=OSError("disk full")) + with pytest.raises(OSError, match="disk full"): + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + assert [path.name for path in wheel.parent.iterdir()] == [wheel.name] + assert wheel.read_bytes() == original + + +def test_embed_refuses_duplicate_member(tmp_path: Path) -> None: + with zipfile.ZipFile(wheel := tmp_path / "native-1.0-py3-none-any.whl", "w") as handle: + handle.writestr("native-1.0.dist-info/METADATA", b"Name: native\n") + handle.writestr("native-1.0.dist-info/RECORD", b"") + with pytest.warns(UserWarning, match="Duplicate name"): + handle.writestr("native-1.0.dist-info/RECORD", b"") + with pytest.raises(SbomError, match=r"holds more than one member named native-1\.0\.dist-info/record"): + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + + +def test_embed_refuses_corrupt_archive(tmp_path: Path) -> None: + (wheel := tmp_path / "native-1.0-py3-none-any.whl").write_bytes(b"not a zip") + with pytest.raises(SbomError, match="is not a valid zip archive: File is not a zip file"): + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + + +def test_wheel_with_two_dist_info_directories(tmp_path: Path, write_wheel: WriteWheel) -> None: + wheel = write_wheel( + tmp_path / "native-1.0-py3-none-any.whl", + { + "aaa-0.dist-info/METADATA": b"Name: aaa\nVersion: 0\n", + "native-1.0.dist-info/METADATA": b"Name: native\nVersion: 1.0\n", + }, + record=False, + ) + message = r"Wheel doesn't contain exactly one \.dist-info directory .*'aaa-0\.dist-info', 'native-1\.0\.dist-info'" + with pytest.raises(SbomError, match=message): + write_sbom(wheel, "native.cdx.json", b"{}\n", 0) + + +def test_embed_ends_an_unterminated_last_row(tmp_path: Path, write_wheel: WriteWheel) -> None: + # no row for RECORD itself, and no line end after the last row, so the SBOM's row goes after it on a line of its own + record = b"native-1.0.dist-info/METADATA,," + wheel = write_wheel( + tmp_path / "native-1.0-py3-none-any.whl", + {"native-1.0.dist-info/METADATA": b"Name: native\n", "native-1.0.dist-info/RECORD": record}, + record=False, + ) + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + assert zipfile.ZipFile(wheel).read("native-1.0.dist-info/RECORD").decode().splitlines() == [ + "native-1.0.dist-info/METADATA,,", + _SBOM_ROW, + ] + + +@pytest.mark.parametrize( + ("record", "expected"), + [ + pytest.param( + b'native-1.0.dist-info/METADATA,,\n"native-1.0.dist-info/sboms/native.cdx.json",sha256=stale,1\n' + b"native-1.0.dist-info/RECORD,,\n", + f"native-1.0.dist-info/METADATA,,\n{_SBOM_ROW}\nnative-1.0.dist-info/RECORD,,\n".encode(), + id="quoted-sbom-row", + ), + pytest.param( + b'native-1.0.dist-info/METADATA,,\n"native-1.0.dist-info/RECORD",,\n', + f'native-1.0.dist-info/METADATA,,\n{_SBOM_ROW}\n"native-1.0.dist-info/RECORD",,\n'.encode(), + id="quoted-record-row", + ), + pytest.param( + b"native-1.0.dist-info/METADATA,,\r\n" + b'"native-1.0.dist-info/sboms/native.cdx.json",sha256=stale,1\r\n' + b"native-1.0.dist-info/RECORD,,\r\n", + f"native-1.0.dist-info/METADATA,,\r\n{_SBOM_ROW}\r\nnative-1.0.dist-info/RECORD,,\r\n".encode(), + id="crlf", + ), + pytest.param( + b"native-1.0.dist-info/METADATA,,\nnative-1.0.dist-info/RECORD,,", + f"native-1.0.dist-info/METADATA,,\n{_SBOM_ROW}\nnative-1.0.dist-info/RECORD,,\n".encode(), + id="no-final-newline", + ), + ], +) +def test_embed_rewrites_record_by_parsed_path( + tmp_path: Path, write_wheel: WriteWheel, record: bytes, expected: bytes +) -> None: + wheel = write_wheel( + tmp_path / "native-1.0-py3-none-any.whl", + {"native-1.0.dist-info/METADATA": b"Name: native\n", "native-1.0.dist-info/RECORD": record}, + record=False, + ) + write_sbom(wheel, "native.cdx.json", b"{}\n", 1_700_000_000) + assert zipfile.ZipFile(wheel).read("native-1.0.dist-info/RECORD") == expected + + +def test_append_member_deflates_at_level_nine(tmp_path: Path) -> None: + # the archive stores its members, as virtualenv's zipapp does, yet the SBOM goes in deflated, and the members keep + # their bytes + with zipfile.ZipFile(archive := tmp_path / "app.pyz", "w", zipfile.ZIP_STORED) as handle: + handle.writestr("__main__.py", b"import app\n" * 100) + before = _stored(archive, "__main__.py") + content = b'{"components": []}\n' * 100 + append_member(archive, "app.pyz.cdx.json", content, 1_700_000_000) + entry = zipfile.ZipFile(archive).getinfo("app.pyz.cdx.json") + compressor = zlib.compressobj(9, zlib.DEFLATED, -zlib.MAX_WBITS) + assert ( + entry.compress_type, + entry.create_system, + entry.date_time, + entry.external_attr, + _stored(archive, "app.pyz.cdx.json"), + _stored(archive, "__main__.py"), + ) == ( + zipfile.ZIP_DEFLATED, + 3, + (2023, 11, 14, 22, 13, 20), + (stat.S_IFREG | 0o644) << 16, + compressor.compress(content) + compressor.flush(), + before, + ) + + +def test_append_member_refuses_a_second_copy(tmp_path: Path) -> None: + with zipfile.ZipFile(archive := tmp_path / "app.pyz", "w") as handle: + handle.writestr("app.pyz.cdx.json", b"{}") + with pytest.raises(SbomError, match=r"app\.pyz already holds app\.pyz\.cdx\.json"): + append_member(archive, "app.pyz.cdx.json", b"{}\n", 1_700_000_000) diff --git a/tests/_wheel/test_zip.py b/tests/_wheel/test_zip.py new file mode 100644 index 0000000..297dcc2 --- /dev/null +++ b/tests/_wheel/test_zip.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import io +import struct +import tracemalloc +import zipfile +from contextlib import ExitStack +from io import BytesIO +from typing import TYPE_CHECKING + +import pytest + +from python_sbom._errors import SbomError +from python_sbom._wheel._zip import open_archive, read_member, sha256_hex + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@pytest.mark.parametrize( + "names", + [ + pytest.param(("a.py", "A.py"), id="case"), + pytest.param(("pkg/a.py", "PKG/a.py"), id="directory-case"), + ], +) +def test_open_archive_refuses_names_equal_ignoring_case(names: tuple[str, str]) -> None: + message = f"w holds more than one member named {names[0].lower()}, ignoring case" + with pytest.raises(SbomError, match=message), ExitStack() as stack: + stack.enter_context(open_archive(zip_of(*((name, b"") for name in names)), "w")) + + +def zip_of(*members: tuple[str, bytes]) -> BytesIO: + buffer = BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for name, content in members: + archive.writestr(name, content) + return buffer + + +def test_open_archive_refuses_corrupt_member() -> None: + data = bytearray(zip_of(("a", b"hello" * 1000)).getvalue()) + data[40:50] = b"\xff" * 10 + with pytest.raises(SbomError, match="w is not a valid zip"), open_archive(BytesIO(bytes(data)), "w") as archive: + archive.read("a") + + +def test_read_member() -> None: + with open_archive(zip_of(("a", b"12345")), "w") as archive: + assert read_member(archive, "a", "w", limit=5) == b"12345" + + +def test_read_member_over_limit() -> None: + with open_archive(zip_of(("a", b"12345")), "w") as archive, pytest.raises(SbomError, match="w: a is over the 4"): + read_member(archive, "a", "w", limit=4) + + +def test_read_member_missing() -> None: + with open_archive(zip_of(("a", b"")), "w") as archive, pytest.raises(SbomError, match="w has no b"): + read_member(archive, "b", "w") + + +@pytest.mark.usefixtures("traced") +def test_read_member_refuses_understated_size() -> None: + # the archive claims 10 bytes for 32 MiB; reading the member whole would decompress all of it before the CRC check + data = bytearray(zip_of(("a", b"A" * 32 * 1024 * 1024)).getvalue()) + data[22:26] = data[(central := data.rfind(b"PK\x01\x02")) + 24 : central + 28] = struct.pack(" Iterator[None]: + tracemalloc.start() + yield + tracemalloc.stop() + + +def test_sha256_hex() -> None: + assert sha256_hex(io.BytesIO(b"abc")) == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..47c74b6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import base64 +import hashlib +import shutil +import subprocess +import zipfile +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Protocol + +import pytest + +from python_sbom import ContentProvider, ProviderContext +from python_sbom._model._bom import assemble +from python_sbom._model._document import checked_contributions +from python_sbom._model._identity import project_identity +from python_sbom._model._inputs import BuildFacts, BuildScript, BundledWheel, Interpreter, Settings, WheelTree +from python_sbom._model._metadata import parse_metadata +from python_sbom._model._wheel import wheel_artifact +from python_sbom._wheel._reader import open_artifact + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + from python_sbom._model._types import Document + + +class WriteWheel(Protocol): + def __call__( + self, wheel: Path, files: Mapping[str, bytes], *, record: bool = True, compression: int = ... + ) -> Path: ... + + +class Install(Protocol): + def __call__( + self, site: Path, metadata: str, files: Mapping[str, bytes], *, purelib: bool = True, direct_url: str = "" + ) -> None: ... + + +class Git(Protocol): + def __call__(self, *arguments: str) -> str: ... + + +class ContextFor(Protocol): + def __call__(self, root: Path, artifact: Path) -> ProviderContext: ... + + +class Describe(Protocol): + def __call__( # ruff: ignore[too-many-arguments] # each a part of the wheel a test varies + self, + metadata: str = "", + *, + settings: Settings = ..., + facts: BuildFacts = ..., + copyright_line: str | None = None, + bundled: list[BundledWheel] | None = None, + build_scripts: list[BuildScript] | None = None, + content: ContentProvider | None = None, + ) -> Document: ... + + +@pytest.fixture +def write_wheel() -> WriteWheel: + def write( + wheel: Path, files: Mapping[str, bytes], *, record: bool = True, compression: int = zipfile.ZIP_DEFLATED + ) -> Path: + members = dict(files) + if record: + dist_info = next(name.split("/")[0] for name in files if name.endswith(".dist-info/METADATA")) + rows = "".join(f"{name},{record_digest(content)},{len(content)}\n" for name, content in files.items()) + members[f"{dist_info}/RECORD"] = f"{rows}{dist_info}/RECORD,,\n".encode() + wheel.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(wheel, "w", zipfile.ZIP_DEFLATED) as archive: + for name, content in members.items(): + # Unix, the default everywhere but Windows, so the bytes match across operating systems + (info := zipfile.ZipInfo(name, (2024, 1, 1, 0, 0, 0))).create_system = 3 + archive.writestr(info, content, compression) + return wheel + + return write + + +def record_digest(content: bytes) -> str: + return f"sha256={base64.urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b'=').decode()}" + + +@pytest.fixture +def install() -> Install: + def install( + site: Path, metadata: str, files: Mapping[str, bytes], *, purelib: bool = True, direct_url: str = "" + ) -> None: + name, version = (line.split(": ", 1)[1] for line in metadata.splitlines()[:2]) + dist_info = f"{name}-{version}.dist-info" + members = { + **files, + f"{dist_info}/METADATA": metadata.encode(), + f"{dist_info}/WHEEL": f"Wheel-Version: 1.0\nRoot-Is-Purelib: {str(purelib).lower()}\n".encode(), + } + if direct_url: + members[f"{dist_info}/direct_url.json"] = direct_url.encode() + rows = "".join(f"{path},{record_digest(content)},{len(content)}\n" for path, content in members.items()) + members[f"{dist_info}/RECORD"] = f"{rows}{dist_info}/RECORD,,\n".encode() + for path, content in members.items(): + (target := site / path).parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + + return install + + +@pytest.fixture +def git(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Git: + # the user's git configuration, such as commit signing, stays out of the test repository + (config := tmp_path / "gitconfig").write_text("[user]\nname = Test\nemail = test@example.com\n", encoding="utf-8") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config)) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + (checkout := tmp_path / "checkout").mkdir() + executable = shutil.which("git") + assert executable is not None + + def run(*arguments: str) -> str: + return subprocess.run( + [executable, "-C", str(checkout), *arguments], check=True, capture_output=True, text=True + ).stdout.strip() + + run("init", "--quiet") + return run + + +@pytest.fixture +def facts() -> BuildFacts: + return BuildFacts( + epoch=1_700_000_000, + epoch_from_environment=False, + revision=None, + interpreter=Interpreter(name="cpython", version="3.15.0", implementation="CPython"), + distributions=[], + ) + + +@pytest.fixture +def settings() -> Settings: + return Settings(supplier=None, repository=None, default_branch="main", bundled_wheels=(), content_provider=None) + + +@pytest.fixture +def describe(settings: Settings, facts: BuildFacts) -> Describe: + # the wheel pipeline from the read wheel on, as embed_into runs it, without a wheel on disk + def describe( # ruff: ignore[too-many-arguments] # each a part of the wheel a test varies + metadata: str = "", + *, + settings: Settings = settings, + facts: BuildFacts = facts, + copyright_line: str | None = None, + bundled: list[BundledWheel] | None = None, + build_scripts: list[BuildScript] | None = None, + content: ContentProvider | None = None, + ) -> Document: + tree = WheelTree( + name="example-1.0-py3-none-any.whl", + metadata=parse_metadata(f"Name: Example\nVersion: 1.0\n{metadata}"), + copyright=copyright_line, + bundled=bundled or [], + build_scripts=build_scripts or [], + ) + project = project_identity(tree.metadata, settings) + content = content or ContentProvider() + context = ProviderContext(root=Path(), settings=settings, identity=project, read=artifact_read(Path(tree.name))) + built = wheel_artifact( + tree, + project=project, + settings=settings, + bundled_info={ + wheel.path: content.bundled_wheel(context, PurePosixPath(wheel.path)) for wheel in tree.bundled + }, + ) + return assemble(built, project, settings, facts, checked_contributions(content.contributions(context), content)) + + return describe + + +@pytest.fixture +def context_for(settings: Settings) -> ContextFor: + def context(root: Path, artifact: Path) -> ProviderContext: + identity = project_identity(parse_metadata("Name: Example\nVersion: 1.0\n"), settings) + return ProviderContext(root=root, settings=settings, identity=identity, read=artifact_read(artifact)) + + return context + + +def artifact_read(artifact: Path) -> Callable[[PurePosixPath], bytes]: + # opens the artifact for each read, as a test context outlives no build that would hold it open + def read(path: PurePosixPath) -> bytes: + with open_artifact(artifact) as opened: + return opened.provider_read(path) + + return read diff --git a/tests/hatch/conftest.py b/tests/hatch/conftest.py new file mode 100644 index 0000000..cdba574 --- /dev/null +++ b/tests/hatch/conftest.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import TYPE_CHECKING, Final, Protocol + +import pytest +from hatchling.builders.sdist import SdistBuilder +from hatchling.builders.wheel import WheelBuilder + +if TYPE_CHECKING: + from tests.conftest import WriteWheel + + +class BuildWheel(Protocol): + def __call__(self, root: Path, *, version: str = "standard", target: str = "wheel") -> Path: ... + + +_SEEDER: Final[Path] = Path(__file__).parent.parent / "_seeder" + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + project = """\ +[project] +name = "Example" +version = "1.0" +description = "an example" +license = "MIT" +license-files = ["LICENSE"] +dependencies = ["packaging>=24"] +urls.Source = "https://github.com/example/example" + +[tool.hatch.build.targets.wheel] +packages = ["src/example"] +""" + (package := tmp_path / "project" / "src" / "example").mkdir(parents=True) + (package / "__init__.py").write_text("", encoding="utf-8") + (tmp_path / "project" / "LICENSE").write_text("Copyright (c) 2026 Example\n\nMIT terms\n", encoding="utf-8") + (tmp_path / "project" / "pyproject.toml").write_text( + f"{project}\n[tool.hatch.build.hooks.python-sbom]\n", encoding="utf-8" + ) + return tmp_path / "project" + + +@pytest.fixture +def build_wheel(tmp_path: Path) -> BuildWheel: + def build(root: Path, *, version: str = "standard", target: str = "wheel") -> Path: + builder = (WheelBuilder if target == "wheel" else SdistBuilder)(str(root)) + (artifact,) = builder.build(directory=str(tmp_path / "dist"), versions=[version]) + return Path(artifact) + + return build + + +@pytest.fixture +def seeder(tmp_path: Path, write_wheel: WriteWheel) -> Path: + # a copy, so the build leaves nothing in the source tree; the hook script stays the checked-in one, so coverage + # measures it + shutil.copytree(_SEEDER, target := tmp_path / "seeder", ignore=shutil.ignore_patterns("hatch_build.py")) + pyproject = target / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + "build.hooks.custom = {}", + f'build.hooks.custom.path = "{(_SEEDER / "hatch_build.py").as_posix()}"', + ), + encoding="utf-8", + ) + # the pip wheel a seeder bundles, built here so the repository holds no binaries + write_wheel( + target / "src" / "seeder" / "embed" / "pip-1.0-py3-none-any.whl", + { + "pip/__init__.py": b"", + "pip/_vendor/vendor.txt": b"urllib3==1.26.4\n", + "pip-1.0.dist-info/METADATA": b"Metadata-Version: 2.4\nName: pip\nVersion: 1.0\nLicense-Expression: MIT\n", + "pip-1.0.dist-info/WHEEL": b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + }, + ) + return target diff --git a/tests/hatch/test_hook.py b/tests/hatch/test_hook.py new file mode 100644 index 0000000..d14ef87 --- /dev/null +++ b/tests/hatch/test_hook.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import gc +import json +import shutil +import tarfile +import tempfile +import time +import zipfile +from typing import TYPE_CHECKING, Final + +import pytest + +from python_sbom._errors import SbomError +from python_sbom._validate._artifact import validate + +if TYPE_CHECKING: + from pathlib import Path + + from conftest import BuildWheel + + from python_sbom._model._types import Document + from tests.conftest import Git + +_SBOM: Final[str] = "example-1.0.dist-info/sboms/example.cdx.json" +# a later hook that changes what the wheel declares and how it is tagged, after the SBOM hook's turn +_RETAG: Final[str] = """\ +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class Retag(BuildHookInterface): + def initialize(self, version, build_data): + build_data["dependencies"].append("late-dependency>=1") + build_data["tag"] = "py3-none-manylinux_2_17_x86_64" +""" +# a hook after the SBOM hook that fails the build, before hatchling writes anything +_BREAK: Final[str] = """\ +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class Break(BuildHookInterface): + def initialize(self, version, build_data): + raise RuntimeError("broken") +""" + + +def sbom_of(wheel: Path, name: str = _SBOM) -> Document: + return json.loads(zipfile.ZipFile(wheel).read(name)) + + +def test_wheel_carries_valid_sbom(project: Path, build_wheel: BuildWheel) -> None: + assert validate(build_wheel(project), frozenset({"MIT"})) == [] + + +@pytest.fixture +def retagged_wheel(project: Path, build_wheel: BuildWheel) -> Path: + (project / "hatch_build.py").write_text(_RETAG, encoding="utf-8") + configure(project, "[tool.hatch.build.hooks.custom]\n") + return build_wheel(project) + + +def configure(root: Path, tables: str) -> None: + pyproject = root / "pyproject.toml" + pyproject.write_text(f"{pyproject.read_text(encoding='utf-8')}{tables}", encoding="utf-8") + + +def test_sbom_lists_dependencies_a_later_hook_adds(retagged_wheel: Path) -> None: + assert [component["name"] for component in sbom_of(retagged_wheel)["components"]] == [ + "packaging", + "late-dependency", + ] + + +def test_sbom_names_the_retagged_wheel(retagged_wheel: Path) -> None: + references = sbom_of(retagged_wheel)["metadata"]["component"]["externalReferences"] + assert {reference["type"]: reference["url"] for reference in references}["attestation"] == ( + f"https://pypi.org/integrity/Example/1.0/{retagged_wheel.name}/provenance" + ) + + +def test_later_hook_retags_the_wheel(retagged_wheel: Path) -> None: + assert retagged_wheel.name == "example-1.0-py3-none-manylinux_2_17_x86_64.whl" + + +def test_project_table_options(project: Path, build_wheel: BuildWheel) -> None: + configure( + project, + "enable-by-default = true\nrequire-runtime-dependencies = false\n" + "[tool.python-sbom]\ndefault-branch = 'dev'\nsupplier = { name = 'Example Org' }\n", + ) + root = sbom_of(build_wheel(project))["metadata"]["component"] + assert (root.get("supplier"), root["externalReferences"][-1]) == ( + {"name": "Example Org", "url": []}, + {"type": "license", "url": "https://github.com/example/example/blob/dev/LICENSE"}, + ) + + +def test_hook_table_refuses_python_sbom_options(project: Path, build_wheel: BuildWheel) -> None: + configure(project, "default-branch = 'trunk'\n") + with pytest.raises(SbomError, match=r"hook's table holds default-branch; move python-sbom's options"): + build_wheel(project) + + +def test_reproducible_build_uses_hatchling_time(project: Path, build_wheel: BuildWheel) -> None: + assert sbom_of(build_wheel(project))["metadata"]["timestamp"] == "2020-02-02T00:00:00+00:00" + + +def test_non_reproducible_build_uses_current_time(project: Path, build_wheel: BuildWheel) -> None: + (project / "pyproject.toml").write_text( + (project / "pyproject.toml") + .read_text(encoding="utf-8") + .replace("[tool.hatch.build.targets.wheel]\n", "[tool.hatch.build.targets.wheel]\nreproducible = false\n"), + encoding="utf-8", + ) + before = time.time() + timestamp = sbom_of(build_wheel(project))["metadata"]["timestamp"] + assert timestamp >= time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(int(before))) + + +def test_source_date_epoch_wins(project: Path, build_wheel: BuildWheel, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SOURCE_DATE_EPOCH", "1700000000") + assert sbom_of(build_wheel(project))["metadata"]["timestamp"] == "2023-11-14T22:13:20+00:00" + + +def test_editable_wheel_has_no_sbom(project: Path, build_wheel: BuildWheel) -> None: + # a .pth editable, which needs no editables package + pyproject = project / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + "[tool.hatch.build.targets.wheel]\n", "[tool.hatch.build.targets.wheel]\ndev-mode-dirs = ['src']\n" + ), + encoding="utf-8", + ) + names = zipfile.ZipFile(build_wheel(project, version="editable")).namelist() + assert not [name for name in names if "/sboms/" in name] + + +def test_sdist_has_no_sbom(project: Path, build_wheel: BuildWheel) -> None: + configure(project, "[tool.hatch.build.targets.sdist.hooks.python-sbom]\n") + sdist = build_wheel(project, version="standard", target="sdist") + assert sdist.name.endswith(".tar.gz") + assert "sboms" not in sdist.read_bytes().decode("latin-1") + + +def test_content_provider_option(project: Path, build_wheel: BuildWheel, tmp_path: Path) -> None: + (project / f"{tmp_path.name}.py").write_text( + "from python_sbom import ContentProvider, Contributions\n\n\n" + "class Content(ContentProvider):\n" + " def contributions(self, context):\n" + " return Contributions(" + " root_properties=[{'name': 'extra:by', 'value': 'hook'}])\n", + encoding="utf-8", + ) + configure(project, f"[tool.python-sbom]\ncontent-provider = '{tmp_path.name}:Content'\n") + document = sbom_of(build_wheel(project)) + assert {"name": "extra:by", "value": "hook"} in document["metadata"]["component"]["properties"] + assert document["metadata"]["tools"]["components"][0]["name"] == f"{tmp_path.name}.py" + + +def test_seeder_bundles_described(seeder: Path, build_wheel: BuildWheel) -> None: + (pip,) = sbom_of(build_wheel(seeder), "seeder-1.0.dist-info/sboms/seeder.cdx.json")["components"] + assert pip.get("properties", [])[:3] == [ + {"name": "seeder:bundled-wheel", "value": "seeder/embed/pip-1.0-py3-none-any.whl"}, + {"name": "seeder:seeded-for-python", "value": "3.14"}, + {"name": "seeder:seeded-for-python", "value": "3.15"}, + ] + + +def test_seeder_hook_script_is_a_tool(seeder: Path, build_wheel: BuildWheel) -> None: + document = sbom_of(build_wheel(seeder), "seeder-1.0.dist-info/sboms/seeder.cdx.json") + assert document["metadata"]["tools"]["components"][0]["name"] == "hatch_build.py" + + +def test_seeder_supplier(seeder: Path, build_wheel: BuildWheel) -> None: + document = sbom_of(build_wheel(seeder), "seeder-1.0.dist-info/sboms/seeder.cdx.json") + assert document["metadata"].get("supplier") == {"name": "Example Org", "url": ["https://example.org"]} + + +def test_seeder_bundled_wheel_supplier(seeder: Path, build_wheel: BuildWheel) -> None: + document = sbom_of(build_wheel(seeder), "seeder-1.0.dist-info/sboms/seeder.cdx.json") + assert document["components"][0].get("supplier") == {"name": "Example Org", "url": ["https://example.org"]} + + +@pytest.fixture +def checkout(project: Path, tmp_path: Path, git: Git) -> Path: + shutil.copytree(project, target := tmp_path / "checkout", dirs_exist_ok=True) + git("add", ".") + git("commit", "--quiet", "--message", "first") + return target + + +def test_sdist_records_clean_revision(checkout: Path, git: Git, build_wheel: BuildWheel) -> None: + files = sdist_files(build_wheel(checkout, target="sdist")) + assert files.get(".python-sbom-revision") == f"node: {git('rev-parse', 'HEAD')}\n".encode() + + +def sdist_files(sdist: Path) -> dict[str, bytes]: + with tarfile.open(sdist) as archive: + return { + member.name.split("/", 1)[1]: handle.read() + for member in archive.getmembers() + if (handle := archive.extractfile(member)) is not None + } + + +def test_sdist_of_dirty_checkout_records_no_revision(checkout: Path, build_wheel: BuildWheel) -> None: + (checkout / "LICENSE").write_text("changed\n", encoding="utf-8") + assert ".python-sbom-revision" not in sdist_files(build_wheel(checkout, target="sdist")) + + +def test_each_build_reads_the_revision(checkout: Path, git: Git, build_wheel: BuildWheel) -> None: + build_wheel(checkout, target="sdist") + (checkout / "LICENSE").write_text("changed\n", encoding="utf-8") + git("commit", "--quiet", "--all", "--message", "second") + files = sdist_files(build_wheel(checkout, target="sdist")) + assert files.get(".python-sbom-revision") == f"node: {git('rev-parse', 'HEAD')}\n".encode() + + +@pytest.fixture +def temp_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + (directory := tmp_path / "temp").mkdir() + monkeypatch.setattr(tempfile, "tempdir", str(directory)) + monkeypatch.setenv("TMPDIR", str(directory)) + return directory + + +@pytest.mark.parametrize("target", [pytest.param("sdist", id="sdist"), pytest.param("wheel", id="wheel")]) +def test_build_leaves_no_temporary_files(checkout: Path, temp_dir: Path, build_wheel: BuildWheel, target: str) -> None: + build_wheel(checkout, target=target) + assert list(temp_dir.iterdir()) == [] + + +def test_failed_build_leaves_no_temporary_files( + checkout: Path, temp_dir: Path, git: Git, build_wheel: BuildWheel +) -> None: + (checkout / "hatch_build.py").write_text(_BREAK, encoding="utf-8") + # hatchling compiles the hook script, which must not make the checkout dirty, so the sdist records the revision + (checkout / ".gitignore").write_text("__pycache__/\n", encoding="utf-8") + configure(checkout, "[tool.hatch.build.hooks.custom]\n") + git("add", ".") + git("commit", "--quiet", "--message", "break the build") + with pytest.raises(RuntimeError, match="broken"): + build_wheel(checkout, target="sdist") + # the failed build's frames hold the hook in a reference cycle + gc.collect() + assert list(temp_dir.iterdir()) == [] diff --git a/tests/test_no_hatchling.py b/tests/test_no_hatchling.py new file mode 100644 index 0000000..9a84f05 --- /dev/null +++ b/tests/test_no_hatchling.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import subprocess +import sys + + +def test_core_imports_without_hatchling() -> None: + # None in sys.modules makes the import fail, as when hatchling is not installed + code = ( + "import sys; sys.modules['hatchling'] = None\n" + "import python_sbom\n" + "try:\n import python_sbom.hatch\nexcept ImportError:\n print('core only')\n" + ) + assert subprocess.check_output([sys.executable, "-c", code], text=True) == "core only\n" diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..8286e1c --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import collections.abc +import functools +import importlib +import inspect +import pathlib +import pkgutil +import typing +from typing import TYPE_CHECKING, Final + +import pytest + +import python_sbom +import python_sbom.hatch +from python_sbom import Settings +from python_sbom._model._inputs import RecordRow +from python_sbom._model._metadata import CoreMetadata + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + +_Hint = object # simplint: ignore[SMP602] # hints are arbitrary typing objects + +_PUBLIC: Final[set[str]] = { + "BundledWheelInfo", + "Component", + "Contact", + "ContentProvider", + "Contributions", + "Dependency", + "Evidence", + "ExternalReference", + "Hash", + "Identity", + "IdentityMethod", + "License", + "LicenseInfo", + "OrganizationalEntity", + "ProjectIdentity", + "Property", + "ProviderContext", + "SbomError", + "Settings", + "__version__", +} + + +def test_public_api() -> None: + assert set(python_sbom.__all__) == _PUBLIC + + +def test_hatch_api() -> None: + assert set(python_sbom.hatch.__all__) == {"SbomBuildHook"} + + +def reached(start: _Hint) -> set[_Hint]: + # every python_sbom type a hint of a public name leads to, through fields, members and nested hints; a private type + # ends the walk there, so the leak is what gets reported even when its own hints do not resolve + found: set[_Hint] = set() + pending: list[_Hint] = [start] + public = exported() + while pending: + if (item := pending.pop()) in found: + continue + found.add(item) + if _ours(item) and item not in public: + continue + if inspect.isclass(item): + pending.extend(typing.get_type_hints(item, globalns=_names(), include_extras=True).values()) + for member in _members(item): + pending.extend(typing.get_type_hints(member, globalns=_names(), include_extras=True).values()) + # a Callable's parameters come as one list among its arguments + for argument in typing.get_args(item): + pending.extend(argument if isinstance(argument, list) else [argument]) + if (origin := typing.get_origin(item)) is not None: + pending.append(origin) + return {item for item in found if _ours(item)} + + +def _members(cls: type) -> Iterator[_Hint]: + # read the class dictionaries, since attribute access hides a property's getter and unwraps class and static methods + for owner in inspect.getmro(cls): + if not _ours(owner): + continue + for value in owner.__dict__.values(): + match value: + case property(): + yield value.fget + case classmethod() | staticmethod(): + yield value.__func__ + case functools.cached_property(): + yield value.func + case _ if inspect.isfunction(value): + yield value + + +def _ours(item: _Hint) -> bool: + return getattr(item, "__module__", "").startswith("python_sbom") + + +@pytest.mark.parametrize("name", sorted(_PUBLIC - {"__version__"}), ids=str) +def test_public_types_only_reach_public_types(name: str) -> None: + assert reached(getattr(python_sbom, name)) - exported() == set() + + +def test_hook_only_reaches_public_types() -> None: + assert reached(python_sbom.hatch.SbomBuildHook) - exported() == set() + + +@pytest.mark.parametrize( + "wrap", + [ + pytest.param(property, id="property"), + pytest.param(functools.cached_property, id="cached_property"), + pytest.param(classmethod, id="classmethod"), + pytest.param(staticmethod, id="staticmethod"), + ], +) +def test_closure_reports_private_member_types( + monkeypatch: pytest.MonkeyPatch, wrap: Callable[[Callable[[Settings], RecordRow]], _Hint] +) -> None: + def leak(_: Settings) -> RecordRow: + raise NotImplementedError + + monkeypatch.setattr(Settings, "leak", wrap(leak), raising=False) + assert reached(Settings) - exported() == {RecordRow} + + +def test_closure_reports_private_type_with_unresolved_hints(monkeypatch: pytest.MonkeyPatch) -> None: + # CoreMetadata names packaging's RawMetadata, which no python_sbom module imports at runtime + def leak(_: Settings) -> CoreMetadata: + raise NotImplementedError + + monkeypatch.setattr(Settings, "leak", property(leak), raising=False) + assert reached(Settings) - exported() == {CoreMetadata} + + +def exported() -> set[_Hint]: + return {getattr(python_sbom, name) for name in _PUBLIC} | {python_sbom.hatch.SbomBuildHook} + + +def _names() -> dict[str, _Hint]: + # the annotations name types the modules import only for type checking, so every module's names resolve them + modules = [ + pathlib, + collections.abc, + typing, + *(importlib.import_module(found.name) for found in pkgutil.walk_packages(python_sbom.__path__, "python_sbom.")), + ] + return dict(member for module in modules for member in inspect.getmembers(module)) diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..4730451 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from packaging.version import Version + +from python_sbom import __version__ + + +def test_version() -> None: + assert Version(__version__).release diff --git a/tox.toml b/tox.toml new file mode 100644 index 0000000..d558966 --- /dev/null +++ b/tox.toml @@ -0,0 +1,163 @@ +requires = [ "tox>=4.64.7", "tox-uv>=1.36.1" ] +env_list = [ "3.15", "3.14", "3.13", "3.12", "3.11", "3.14t", "3.15t", "docs", "fix", "pkg_meta", "type" ] +skip_missing_interpreters = true + +[env_run_base] +description = "run the tests with pytest under {env_name}" +default_base_python = [ "3.15" ] +package = "wheel" +wheel_build_env = ".pkg" +dependency_groups = [ "test" ] +pass_env = [ "PYTEST_*" ] +set_env.COVERAGE_FILE = { replace = "env", name = "COVERAGE_FILE", default = "{env_tmp_dir}{/}.coverage" } +commands = [ + [ + "pytest", + { replace = "posargs", default = [ + "--no-cov-on-fail", + "--cov", + "python_sbom", + "--cov", + "{tox_root}{/}tests", + "--cov-config", + "{tox_root}{/}pyproject.toml", + "--cov-context", + "test", + "--cov-report", + "term-missing:skip-covered", + "--cov-report", + "html:{env_tmp_dir}{/}htmlcov", + "tests", + ], extend = true }, + ], +] + +[env_pkg_base] +default_base_python = [ "3.15" ] + +[env.docs] +description = "Build documentation" +dependency_groups = [ "docs" ] +commands = [ + [ + "python", + "-c", + # RE2 lacks Python 3.15 wheels: https://github.com/google/re2/issues/647 + """\ + import glob; import pathlib; import subprocess; subprocess.check_call(['uv', 'tool', 'run', '--python', '3.14', \ + '--from', 'proselint>=0.16', 'proselint', 'check', *(path for path in glob.glob('docs/*.rst') if \ + pathlib.Path(path).name != 'changelog.rst')])\ + """, + ], + [ + "sphinx-build", + "-d", + "{env_tmp_dir}/doctree", + "docs", + "{env:READTHEDOCS_OUTPUT:{env_tmp_dir}{/}docs_out}/html", + "--color", + "-b", + "html", + "-W", + "--keep-going", + { replace = "posargs", extend = true }, + ], + [ + "python", + "-c", + """\ + import pathlib; print('documentation available under ' + \ + (pathlib.Path(r'{env:READTHEDOCS_OUTPUT:{env_tmp_dir}{/}docs_out}/html') / 'index.html').as_uri())\ + """, + ], +] + +[env.fix] +description = "format the code base to adhere to our styles, and complain about what we cannot do automatically" +skip_install = true +dependency_groups = [ "lint" ] +pass_env = [ + { replace = "ref", of = [ + "env_run_base", + "pass_env", + ], extend = true }, + "DISABLE_PRE_COMMIT_UV_PATCH", + "PROGRAMDATA", +] +commands = [ [ "pre-commit", "run", "--all-files", "--show-diff-on-failure", { replace = "posargs", extend = true } ] ] + +[env.pkg_meta] +description = "check that the long description is valid" +skip_install = true +dependency_groups = [ "pkg_meta" ] +commands = [ + [ + "uv", + "build", + "--sdist", + "--wheel", + "--out-dir", + "{env_tmp_dir}", + ".", + ], + [ + "twine", + "check", + "--strict", + "{env_tmp_dir}{/}*", + ], + [ + "check-wheel-contents", + "--no-config", + # the CycloneDX schemas' Apache-2.0 LICENSE ships beside them and, as a license file, again under .dist-info/licenses + "--ignore", + "W002", + "{env_tmp_dir}", + ], + [ + "python", + "-c", + # the project's own wheel carries the SBOM its hook writes, which lists that hook script among the build tools + """\ + import glob, json, zipfile; (wheel,) = glob.glob(r'{env_tmp_dir}{/}*.whl'); archive = zipfile.ZipFile(wheel); \ + (sbom,) = (name for name in archive.namelist() if '/sboms/' in name); tools = \ + json.loads(archive.read(sbom))['metadata']['tools']['components']; assert any(tool['name'] == 'hatch_build.py' for \ + tool in tools), f'hatch_build.py missing from {sbom}'\ + """, + ], + [ + "python", + "-c", + # the built wheel validates its own SBOM, so a schema file or validator dependency left out of it fails here + """\ + import glob, pathlib, subprocess, sys; (wheel,) = glob.glob(r'{env_tmp_dir}{/}*.whl'); subprocess.check_call(['uv', \ + 'pip', 'install', '--python', sys.executable, f'python-sbom[validate] @ {pathlib.Path(wheel).as_uri()}'])\ + """, + ], + [ + "python-sbom", + "validate", + "{env_tmp_dir}", + ], +] + +[env.type] +description = "run type check on code base" +dependency_groups = [ "type" ] +commands = [ [ "ty", "check", "--output-format", "concise", "--error-on-warning", "--python-platform", "all", "." ] ] + +[env.bench] +description = "time python-sbom's hot paths with pyperf (not run in CI); `prepare` the fixtures first" +dependency_groups = [ "bench" ] +extras = [ "hatch", "validate" ] +set_env.PYTHONPATH = "{tox_root}" +commands = [ [ "python", "-m", "bench", { replace = "posargs", extend = true } ] ] + +[env.dev] +description = "dev environment with all deps at {envdir}" +package = "editable" +dependency_groups = [ "dev" ] +commands = [ + [ "uv", "pip", "tree" ], + [ "python", "-c", "import sys; print(sys.executable)" ], +]