python-sbom 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. python_sbom/__init__.py +46 -0
  2. python_sbom/__main__.py +10 -0
  3. python_sbom/_cli/__init__.py +1 -0
  4. python_sbom/_cli/_archive.py +38 -0
  5. python_sbom/_cli/_embed.py +28 -0
  6. python_sbom/_cli/_main.py +28 -0
  7. python_sbom/_cli/_parser.py +144 -0
  8. python_sbom/_cli/_spdx.py +61 -0
  9. python_sbom/_cli/_validate.py +40 -0
  10. python_sbom/_collect/__init__.py +1 -0
  11. python_sbom/_collect/_archive.py +94 -0
  12. python_sbom/_collect/_content.py +109 -0
  13. python_sbom/_collect/_facts.py +155 -0
  14. python_sbom/_collect/_revision.py +93 -0
  15. python_sbom/_collect/_settings.py +82 -0
  16. python_sbom/_collect/_source.py +54 -0
  17. python_sbom/_collect/_wheel.py +55 -0
  18. python_sbom/_errors.py +15 -0
  19. python_sbom/_formats/__init__.py +1 -0
  20. python_sbom/_formats/_json.py +18 -0
  21. python_sbom/_formats/_spdx.py +263 -0
  22. python_sbom/_generate/__init__.py +1 -0
  23. python_sbom/_generate/_archive.py +84 -0
  24. python_sbom/_generate/_embed.py +74 -0
  25. python_sbom/_model/__init__.py +1 -0
  26. python_sbom/_model/_archive.py +117 -0
  27. python_sbom/_model/_bom.py +137 -0
  28. python_sbom/_model/_components.py +182 -0
  29. python_sbom/_model/_content.py +128 -0
  30. python_sbom/_model/_document.py +100 -0
  31. python_sbom/_model/_graph.py +64 -0
  32. python_sbom/_model/_identity.py +53 -0
  33. python_sbom/_model/_inputs.py +141 -0
  34. python_sbom/_model/_iri.py +96 -0
  35. python_sbom/_model/_licenses.py +58 -0
  36. python_sbom/_model/_markers.py +61 -0
  37. python_sbom/_model/_metadata.py +100 -0
  38. python_sbom/_model/_names.py +90 -0
  39. python_sbom/_model/_tools.py +48 -0
  40. python_sbom/_model/_types.py +281 -0
  41. python_sbom/_model/_wheel.py +89 -0
  42. python_sbom/_validate/__init__.py +1 -0
  43. python_sbom/_validate/_artifact.py +55 -0
  44. python_sbom/_validate/_document.py +221 -0
  45. python_sbom/_validate/_licenses.py +104 -0
  46. python_sbom/_validate/_limits.py +27 -0
  47. python_sbom/_validate/_schemas.py +124 -0
  48. python_sbom/_validate/schemas/LICENSE +201 -0
  49. python_sbom/_validate/schemas/bom-1.6.schema.json +5699 -0
  50. python_sbom/_validate/schemas/jsf-0.82.schema.json +240 -0
  51. python_sbom/_validate/schemas/spdx.schema.json +1662 -0
  52. python_sbom/_wheel/__init__.py +1 -0
  53. python_sbom/_wheel/_bundled.py +142 -0
  54. python_sbom/_wheel/_reader.py +59 -0
  55. python_sbom/_wheel/_record.py +55 -0
  56. python_sbom/_wheel/_write.py +103 -0
  57. python_sbom/_wheel/_zip.py +94 -0
  58. python_sbom/hatch/__init__.py +9 -0
  59. python_sbom/hatch/_hook.py +150 -0
  60. python_sbom/hatch/hooks.py +18 -0
  61. python_sbom/py.typed +0 -0
  62. python_sbom/version.py +24 -0
  63. python_sbom-0.1.1.dist-info/METADATA +95 -0
  64. python_sbom-0.1.1.dist-info/RECORD +69 -0
  65. python_sbom-0.1.1.dist-info/WHEEL +4 -0
  66. python_sbom-0.1.1.dist-info/entry_points.txt +5 -0
  67. python_sbom-0.1.1.dist-info/licenses/LICENSE +18 -0
  68. python_sbom-0.1.1.dist-info/licenses/src/python_sbom/_validate/schemas/LICENSE +201 -0
  69. python_sbom-0.1.1.dist-info/sboms/python-sbom.cdx.json +13548 -0
@@ -0,0 +1,281 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import NotRequired, TypedDict
4
+
5
+ # the document model is our own: cyclonedx-python-lib models neither formulation nor compositions (its model/bom.py
6
+ # holds only commented-out stubs, tracked upstream in CycloneDX/cyclonedx-python-lib#578 and #581), and its SortedSet
7
+ # collections re-order lists whose order the document keeps on purpose
8
+
9
+
10
+ class OrganizationalEntity(TypedDict):
11
+ """
12
+ An organization that supplies or makes a component.
13
+
14
+ :ivar name: the organization's name
15
+ :ivar url: the organization's web sites
16
+
17
+ """
18
+
19
+ name: str
20
+ url: list[str]
21
+
22
+
23
+ class Contact(TypedDict):
24
+ """
25
+ A person who wrote or maintains a component.
26
+
27
+ :ivar name: the person's name
28
+ :ivar email: the person's email address
29
+
30
+ """
31
+
32
+ name: NotRequired[str]
33
+ email: NotRequired[str]
34
+
35
+
36
+ class Hash(TypedDict):
37
+ """
38
+ A digest of a component's bytes.
39
+
40
+ :ivar alg: the algorithm as CycloneDX names it, such as ``SHA-256``
41
+ :ivar content: the digest as lowercase hex
42
+
43
+ """
44
+
45
+ alg: str
46
+ content: str
47
+
48
+
49
+ class Property(TypedDict):
50
+ """
51
+ A name and value pair attached to a component or the document.
52
+
53
+ :ivar name: the property's name; names this package writes start with the project's prefix or ``python:``
54
+ :ivar value: the property's value
55
+
56
+ """
57
+
58
+ name: str
59
+ value: str
60
+
61
+
62
+ class ExternalReference(TypedDict):
63
+ """
64
+ A link from a component to something about it.
65
+
66
+ :ivar type: the CycloneDX reference type, such as ``vcs`` or ``website``
67
+ :ivar url: the link
68
+ :ivar comment: what the link is, such as the Project-URL label it came from
69
+
70
+ """
71
+
72
+ type: str
73
+ url: str
74
+ comment: NotRequired[str]
75
+
76
+
77
+ class LicenseInfo(TypedDict):
78
+ """
79
+ One license, by SPDX id or by name.
80
+
81
+ :ivar id: the SPDX license id
82
+ :ivar name: the license's name, when it has no SPDX id
83
+ :ivar acknowledgement: ``declared`` when the metadata names it, ``concluded`` when python-sbom resolved it
84
+
85
+ """
86
+
87
+ id: NotRequired[str]
88
+ name: NotRequired[str]
89
+ acknowledgement: str
90
+
91
+
92
+ class License(TypedDict):
93
+ """
94
+ One license entry of a component: a license or an SPDX expression.
95
+
96
+ :ivar license: the license, by SPDX id or by name
97
+ :ivar expression: an SPDX license expression
98
+ :ivar acknowledgement: ``declared`` or ``concluded``, for an expression
99
+
100
+ """
101
+
102
+ license: NotRequired[LicenseInfo]
103
+ expression: NotRequired[str]
104
+ acknowledgement: NotRequired[str]
105
+
106
+
107
+ class IdentityMethod(TypedDict):
108
+ """
109
+ How a component's identity was established.
110
+
111
+ :ivar technique: the CycloneDX technique, such as ``manifest-analysis`` or ``hash-comparison``
112
+ :ivar confidence: from 0 to 1
113
+ :ivar value: what the technique read, such as the path of a ``METADATA`` file or a digest
114
+
115
+ """
116
+
117
+ technique: str
118
+ confidence: int
119
+ value: str
120
+
121
+
122
+ class Identity(TypedDict):
123
+ """
124
+ The evidence for one field of a component's identity.
125
+
126
+ :ivar field: the field it supports, such as ``purl`` or ``hash``
127
+ :ivar confidence: from 0 to 1
128
+ :ivar methods: how it was established
129
+
130
+ """
131
+
132
+ field: str
133
+ confidence: int
134
+ methods: list[IdentityMethod]
135
+
136
+
137
+ class Evidence(TypedDict):
138
+ """
139
+ Why a component is what it says it is.
140
+
141
+ :ivar identity: the evidence per field
142
+
143
+ """
144
+
145
+ identity: list[Identity]
146
+
147
+
148
+ #: A CycloneDX 1.6 component: a distribution, an application, the interpreter or a file, as a dictionary with the keys
149
+ #: the CycloneDX component object defines, of which python-sbom uses ``type``, ``bom-ref``, ``name``, ``version``,
150
+ #: ``purl``, ``supplier``, ``authors``, ``description``, ``licenses``, ``copyright``, ``hashes``,
151
+ #: ``externalReferences``, ``properties``, ``evidence`` and the nested ``components``.
152
+ Component = TypedDict(
153
+ "Component",
154
+ {
155
+ "type": str,
156
+ "bom-ref": str,
157
+ "name": str,
158
+ "version": NotRequired[str],
159
+ "purl": NotRequired[str],
160
+ "supplier": NotRequired[OrganizationalEntity],
161
+ "authors": NotRequired[list[Contact]],
162
+ "description": NotRequired[str],
163
+ "licenses": NotRequired[list[License]],
164
+ "copyright": NotRequired[str],
165
+ "hashes": NotRequired[list[Hash]],
166
+ "externalReferences": NotRequired[list[ExternalReference]],
167
+ "properties": NotRequired[list[Property]],
168
+ "evidence": NotRequired[Evidence],
169
+ "components": NotRequired[list["Component"]],
170
+ },
171
+ )
172
+
173
+
174
+ class Dependency(TypedDict):
175
+ """
176
+ The components one component depends on.
177
+
178
+ :ivar ref: the ``bom-ref`` of the dependent component
179
+ :ivar dependsOn: the ``bom-ref`` values of its dependencies
180
+
181
+ """
182
+
183
+ ref: str
184
+ dependsOn: NotRequired[list[str]]
185
+
186
+
187
+ class _Reference(TypedDict):
188
+ ref: str
189
+
190
+
191
+ class _Output(TypedDict):
192
+ type: str
193
+ resource: _Reference
194
+
195
+
196
+ class _Input(TypedDict):
197
+ environmentVars: list[Property]
198
+
199
+
200
+ _Workflow = TypedDict(
201
+ "_Workflow",
202
+ {
203
+ "bom-ref": str,
204
+ "uid": str,
205
+ "name": str,
206
+ "taskTypes": list[str],
207
+ "resourceReferences": list[_Reference],
208
+ "outputs": list[_Output],
209
+ "inputs": NotRequired[list[_Input]],
210
+ },
211
+ )
212
+
213
+
214
+ #: How an artifact was built: one or more workflows, each with its tools, inputs and outputs.
215
+ Formula = TypedDict("Formula", {"bom-ref": str, "workflows": list[_Workflow]})
216
+
217
+
218
+ class Lifecycle(TypedDict):
219
+ phase: str
220
+
221
+
222
+ class Tools(TypedDict):
223
+ components: list[Component]
224
+
225
+
226
+ class Metadata(TypedDict):
227
+ timestamp: str
228
+ lifecycles: list[Lifecycle]
229
+ tools: Tools
230
+ manufacturer: NotRequired[OrganizationalEntity]
231
+ authors: NotRequired[list[Contact]]
232
+ supplier: NotRequired[OrganizationalEntity]
233
+ component: Component
234
+ licenses: NotRequired[list[License]]
235
+ properties: NotRequired[list[Property]]
236
+
237
+
238
+ class Composition(TypedDict):
239
+ aggregate: str
240
+ dependencies: list[str]
241
+
242
+
243
+ class Body(TypedDict):
244
+ metadata: Metadata
245
+ components: list[Component]
246
+ dependencies: list[Dependency]
247
+ compositions: NotRequired[list[Composition]]
248
+ formulation: NotRequired[list[Formula]]
249
+
250
+
251
+ _Header = TypedDict(
252
+ "_Header",
253
+ {"$schema": str, "bomFormat": str, "specVersion": str, "serialNumber": str, "version": int},
254
+ )
255
+
256
+
257
+ class Document(Body, _Header):
258
+ pass
259
+
260
+
261
+ __all__ = [
262
+ "Body",
263
+ "Component",
264
+ "Composition",
265
+ "Contact",
266
+ "Dependency",
267
+ "Document",
268
+ "Evidence",
269
+ "ExternalReference",
270
+ "Formula",
271
+ "Hash",
272
+ "Identity",
273
+ "IdentityMethod",
274
+ "License",
275
+ "LicenseInfo",
276
+ "Lifecycle",
277
+ "Metadata",
278
+ "OrganizationalEntity",
279
+ "Property",
280
+ "Tools",
281
+ ]
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import PurePosixPath
4
+ from typing import TYPE_CHECKING, Final
5
+
6
+ from packaging.requirements import Requirement
7
+
8
+ from ._components import bundled_wheel_component, component_from_metadata, vendored_dependencies
9
+ from ._inputs import Artifact
10
+ from ._markers import applies_without_extras
11
+ from ._names import purl, pypi_url, sbom_file_name, sbom_property
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Mapping
15
+
16
+ from ._content import BundledWheelInfo
17
+ from ._identity import ProjectIdentity
18
+ from ._inputs import Settings, WheelTree
19
+ from ._types import Component
20
+
21
+
22
+ def wheel_artifact(
23
+ tree: WheelTree, *, project: ProjectIdentity, settings: Settings, bundled_info: Mapping[str, BundledWheelInfo]
24
+ ) -> Artifact:
25
+ metadata = tree.metadata
26
+ bundled = [
27
+ bundled_wheel_component(wheel, project.property_prefix, bundled_info[wheel.path]) for wheel in tree.bundled
28
+ ]
29
+ root = component_from_metadata(metadata)
30
+ root["type"] = "application"
31
+ name, version = metadata.name, metadata.version
32
+ root["externalReferences"] = [
33
+ *root["externalReferences"],
34
+ {"type": "distribution", "url": pypi_url(name, version)},
35
+ {"type": "attestation", "url": f"https://pypi.org/integrity/{name}/{version}/{tree.name}/provenance"},
36
+ ]
37
+ return Artifact(
38
+ root=root,
39
+ components=[
40
+ *bundled,
41
+ # hatchling repeats a Requires-Dist when a hook re-adds a dependency, and each bom-ref must be unique
42
+ *(
43
+ _declared_dependency(text, requirement, project.property_prefix)
44
+ for text in dict.fromkeys(metadata.values("requires_dist"))
45
+ if applies_without_extras(requirement := Requirement(text))
46
+ ),
47
+ ],
48
+ dependencies=vendored_dependencies(bundled),
49
+ build_scripts=tree.build_scripts,
50
+ version=version,
51
+ sbom_file=sbom_file_name(name),
52
+ scope=_scope(settings),
53
+ workflow=("wheel-build", "build the wheel and this SBOM"),
54
+ license_file=next(iter(metadata.values("license_files")), None),
55
+ copyright=tree.copyright,
56
+ )
57
+
58
+
59
+ def _declared_dependency(text: str, requirement: Requirement, property_prefix: str) -> Component:
60
+ component: Final[Component] = {
61
+ "type": "library",
62
+ # the same distribution can be declared more than once with different markers, so the purl is not unique
63
+ "bom-ref": f"requires-dist:{text}",
64
+ "name": requirement.name,
65
+ "purl": purl(requirement.name),
66
+ "externalReferences": [{"type": "distribution", "url": pypi_url(requirement.name)}],
67
+ "properties": [{"name": sbom_property(property_prefix, "requires-dist"), "value": text}],
68
+ }
69
+ if requirement.marker is not None:
70
+ component["properties"].append({
71
+ "name": sbom_property(property_prefix, "environment-marker"),
72
+ "value": str(requirement.marker),
73
+ })
74
+ return component
75
+
76
+
77
+ def _scope(settings: Settings) -> str:
78
+ declared = (
79
+ "the direct runtime dependencies declared in the wheel's core metadata, which carry no version because the"
80
+ " installer resolves them"
81
+ )
82
+ if directories := " and ".join(sorted({str(PurePosixPath(pattern).parent) for pattern in settings.bundled_wheels})):
83
+ return f"Components are the wheels bundled as data files under {directories} and {declared}."
84
+ return f"Components are {declared}."
85
+
86
+
87
+ __all__ = [
88
+ "wheel_artifact",
89
+ ]
@@ -0,0 +1 @@
1
+ """Checks of a document against the schema, its own references, its SPDX rendering and a license policy."""
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import zipfile
4
+ from typing import IO, TYPE_CHECKING, Final
5
+
6
+ from python_sbom._wheel._zip import CHUNK, open_archive
7
+
8
+ from ._document import validate_document
9
+ from ._limits import MAX_COMPONENTS, byte_limit
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+
15
+ def validate(path: Path, allowed_licenses: frozenset[str] | None, max_components: int = MAX_COMPONENTS) -> list[str]:
16
+ limit: Final[int] = byte_limit(max_components)
17
+ if not zipfile.is_zipfile(path):
18
+ with path.open("rb") as file:
19
+ return validate_document(_read(file, limit), allowed_licenses, max_components)
20
+ problems: Final[list[str]] = []
21
+ with open_archive(path, path.name) as archive:
22
+ if not (names := sorted(name for name in archive.namelist() if _is_sbom(name))):
23
+ return [f"no SBOM found in {path.name}"]
24
+ # one at a time, so memory holds a single SBOM however many the archive packs
25
+ for name in names:
26
+ with archive.open(name) as member:
27
+ raw = _read(member, limit)
28
+ problems.extend(
29
+ f"{name}: {problem}" for problem in validate_document(raw, allowed_licenses, max_components)
30
+ )
31
+ return problems
32
+
33
+
34
+ def _read(source: IO[bytes], limit: int) -> bytes:
35
+ # stops within a chunk past the limit: enough for validate_document to refuse the SBOM without reading all of it
36
+ content: Final[bytearray] = bytearray()
37
+ while len(content) <= limit and (chunk := source.read(CHUNK)):
38
+ content.extend(chunk)
39
+ return bytes(content)
40
+
41
+
42
+ def _is_sbom(name: str) -> bool:
43
+ # every top-level .dist-info counts, so a decoy one sorting first cannot hide the SBOM of another
44
+ match name.split("/"):
45
+ case [file]:
46
+ return file.endswith(".cdx.json")
47
+ case [dist_info, "sboms", file]:
48
+ return dist_info.endswith(".dist-info") and file.endswith(".cdx.json")
49
+ case _:
50
+ return False
51
+
52
+
53
+ __all__ = [
54
+ "validate",
55
+ ]
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import re
6
+ from importlib.util import find_spec
7
+ from typing import TYPE_CHECKING, Any, Final
8
+
9
+ from python_sbom._errors import MissingExtraError, SbomError
10
+ from python_sbom._formats._spdx import render_spdx
11
+ from python_sbom._model._graph import artifact_contents, reference_problems, repeated, walk_components
12
+ from python_sbom._model._names import SBOM_FILE
13
+
14
+ from ._limits import MAX_COMPONENTS, MAX_TEXT, Json, byte_limit
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Iterable
18
+ from typing import TypeIs # new in 3.13; annotations here are never evaluated
19
+
20
+ from python_sbom._model._types import Component, Document
21
+
22
+ # the schema leaves these optional, yet every document python-sbom writes carries them and the checks below read them
23
+ _REQUIRED: Final[tuple[tuple[str, ...], ...]] = (
24
+ ("metadata", "timestamp"),
25
+ ("metadata", "tools", "components"),
26
+ ("metadata", "component", "bom-ref"),
27
+ ("metadata", "component", "name"),
28
+ ("components",),
29
+ ("dependencies",),
30
+ )
31
+ _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}")
32
+ # the modules of the validate extra
33
+ _EXTRA: Final[tuple[str, ...]] = ("jsonschema_rs", "license_expression", "spdx_tools")
34
+ # the schema validator recurses once per level and crashes the process past a few thousand levels on a small thread
35
+ # stack; the SBOMs python-sbom and other tools write nest about a dozen levels deep
36
+ _MAX_DEPTH: Final[int] = 128
37
+
38
+
39
+ def validate_document(
40
+ raw: bytes, allowed_licenses: frozenset[str] | None, max_components: int = MAX_COMPONENTS
41
+ ) -> list[str]:
42
+ if len(raw) > (limit := byte_limit(max_components)):
43
+ problem = (
44
+ f"is over {limit} bytes, the size limit for {max_components} components;"
45
+ " pass a higher --max-components to check it"
46
+ )
47
+ return [problem]
48
+ # one document's problem never stops the others from being checked, so whatever the checks raise is reported
49
+ try:
50
+ return _problems(raw, allowed_licenses, max_components)
51
+ except MissingExtraError:
52
+ raise
53
+ except SbomError as error:
54
+ return [str(error)]
55
+ except Exception as error: # ruff: ignore[blind-except] # a hostile document may trip any check, see above
56
+ return [f"could not be checked, {type(error).__name__}: {str(error)[:MAX_TEXT]}"]
57
+
58
+
59
+ def _problems(raw: bytes, allowed_licenses: frozenset[str] | None, max_components: int) -> list[str]:
60
+ # every document must conform to the CycloneDX 1.6 schema; one python-sbom wrote must also carry the urn:uuid serial
61
+ # actions/attest needs, resolvable and unique bom-refs and a valid SPDX rendering, and meet the license policy. A
62
+ # missing extra fails every document alike, while importing it, half a second, waits until the cheap census passes
63
+ missing_extra: Final[str] = "validation needs the validate extra: pip install 'python-sbom[validate]'"
64
+ if any(find_spec(name) is None for name in _EXTRA):
65
+ raise MissingExtraError(missing_extra)
66
+ loaded: Final = load_json(raw)
67
+ if census := _census_problems(loaded, max_components):
68
+ return census
69
+ # find_spec sees only the top-level modules, not a dependency of theirs that is missing or broken
70
+ try:
71
+ from ._schemas import schema_error, spdx_problems # ruff: ignore[import-outside-top-level] # the optional extra, see above
72
+ except ImportError as error:
73
+ raise MissingExtraError(missing_extra) from error
74
+
75
+ if error := schema_error(loaded):
76
+ return [f"does not conform to the CycloneDX 1.6 schema: {error}"]
77
+ # python-sbom names its file in a metadata property, so documents other tools wrote get the schema check alone; the
78
+ # schema already guarantees the shape of what this reads
79
+ if not any(
80
+ prop.get("name", "").endswith(f":{SBOM_FILE}") for prop in loaded.get("metadata", {}).get("properties", [])
81
+ ):
82
+ return []
83
+ missing: Final[list[str]] = [".".join(path) for path in _REQUIRED if not _has(loaded, path)]
84
+ # is_document holds once nothing is missing; asking it narrows the loaded JSON to a document
85
+ if missing or not is_document(loaded):
86
+ return [f"lacks {', '.join(missing)}, which every document python-sbom writes carries"]
87
+ # optional in the CycloneDX spec itself, but actions/attest's format sniffer requires it to recognize the document
88
+ # as CycloneDX at all, and silently rejects anything missing it as an unknown format
89
+ if not _SERIAL.fullmatch(serial := loaded.get("serialNumber", "")):
90
+ return [f"serialNumber must be a urn:uuid: RFC 4122 UUID, got {serial!r}"]
91
+ # the SPDX rendering of a document whose references are broken only repeats those problems
92
+ problems: Final[list[str]] = _reference_problems(loaded) or spdx_problems(render_spdx(loaded))
93
+ if allowed_licenses is not None:
94
+ # the license parser loads only for a policy to judge against, as most checks have none
95
+ try:
96
+ from ._licenses import license_problems # ruff: ignore[import-outside-top-level] # see above
97
+ except ImportError as error:
98
+ raise MissingExtraError(missing_extra) from error
99
+ problems.extend(
100
+ license_problems(artifact_contents(loaded["metadata"]["component"], loaded["components"]), allowed_licenses)
101
+ )
102
+ return problems
103
+
104
+
105
+ def load_json(raw: bytes) -> Any: # ruff: ignore[any-type] # json.loads' own type: any shape until checked
106
+ # why bytes are no JSON value, worded once for validate and for spdx, which renders a document without validating it
107
+ try:
108
+ return json.loads(raw.decode("utf-8"))
109
+ except UnicodeDecodeError as error:
110
+ msg = f"is not UTF-8: {error.reason} at byte {error.start}"
111
+ raise SbomError(msg) from error
112
+ except json.JSONDecodeError as error:
113
+ msg = f"is not JSON: {error}"
114
+ raise SbomError(msg) from error
115
+ except RecursionError as error:
116
+ # the JSON parser recurses once per level and gives up near the recursion limit, long before the depth check
117
+ # sees the document
118
+ msg = "nests too deep for the JSON parser to read"
119
+ raise SbomError(msg) from error
120
+
121
+
122
+ def _census_problems(document: Json, max_components: int) -> list[str]:
123
+ # counts the components and bom-refs and measures the depth in one linear pass before the schema vouches for any
124
+ # shape; iterative, so deep nesting cannot exhaust the stack. Nested components count too: the schema checks every
125
+ # components array, and one array may hold them all, so only the total bounds its work
126
+ components = 0
127
+ refs: Final[list[str]] = []
128
+ # only lists and objects go on the stack, every other value is judged where its container is read, so a scalar
129
+ # nests too deep when its container already sits at the limit; the document starts out as the one item of a list
130
+ pending: Final[list[tuple[list[Json] | dict[str, Json], int]]] = [([document], 0)]
131
+ while pending:
132
+ node, depth = pending.pop()
133
+ if isinstance(node, dict):
134
+ if isinstance(ref := node.get("bom-ref"), str):
135
+ refs.append(ref)
136
+ components += len(listed) if isinstance(listed := node.get("components"), list) else 0
137
+ if components > max_components:
138
+ return [
139
+ (
140
+ f"lists more than {max_components} components, nested ones included;"
141
+ " pass a higher --max-components to check it"
142
+ )
143
+ ]
144
+ children = node.values() if isinstance(node, dict) else node
145
+ if children and depth >= _MAX_DEPTH:
146
+ return [f"nests more than {_MAX_DEPTH} levels deep, too deep to check"]
147
+ if problem := _push_containers(children, depth + 1, pending):
148
+ return problem
149
+ # every bom-ref in the document, wherever it sits, as CycloneDX requires them unique and documents other tools wrote
150
+ # get no reference check beyond this
151
+ if twice := repeated(refs):
152
+ return [f"bom-ref values repeat: {', '.join(twice)}"]
153
+ return []
154
+
155
+
156
+ def _push_containers(
157
+ children: Iterable[Json], depth: int, pending: list[tuple[list[Json] | dict[str, Json], int]]
158
+ ) -> list[str]:
159
+ for child in children:
160
+ if isinstance(child, (list, dict)):
161
+ pending.append((child, depth))
162
+ elif isinstance(child, float) and not math.isfinite(child):
163
+ # json.loads takes NaN and Infinity, which RFC 8259 leaves out of JSON and the schema reads as null
164
+ return [f"holds {child}, which is not a JSON number"]
165
+ return []
166
+
167
+
168
+ def _has(value: Json, path: tuple[str, ...]) -> bool:
169
+ for key in path:
170
+ if not isinstance(value, dict) or key not in value:
171
+ return False
172
+ value = value[key]
173
+ return True
174
+
175
+
176
+ def is_document(value: object) -> TypeIs[Document]:
177
+ # what the SPDX rendering reads without a fallback; the schema leaves even these optional
178
+ return isinstance(value, dict) and all(_has(value, ("metadata", "component", key)) for key in ("bom-ref", "name"))
179
+
180
+
181
+ def _reference_problems(document: Document) -> list[str]:
182
+ root: Final[Component] = document["metadata"]["component"]
183
+ tools: Final[list[Component]] = document["metadata"]["tools"]["components"]
184
+ components: Final[list[Component]] = list(walk_components([root, *document["components"], *tools]))
185
+ if nameless := sorted(component["name"] for component in components if "bom-ref" not in component):
186
+ return [f"components lack a bom-ref: {nameless}"]
187
+ formulas: Final[list[str]] = [
188
+ ref
189
+ for formula in document.get("formulation", [])
190
+ for ref in (formula["bom-ref"], *(workflow["bom-ref"] for workflow in formula.get("workflows", [])))
191
+ ]
192
+ if problems := reference_problems(
193
+ [*(component["bom-ref"] for component in components), *formulas], document["dependencies"]
194
+ ):
195
+ return problems
196
+ problems: Final[list[str]] = []
197
+ dependencies: Final[dict[str, set[str]]] = {
198
+ entry["ref"]: set(entry.get("dependsOn", [])) for entry in document["dependencies"]
199
+ }
200
+ direct: Final[set[str]] = {component["bom-ref"] for component in document["components"]}
201
+ if dependencies.get(root["bom-ref"]) != direct:
202
+ problems.append(
203
+ f"root dependsOn {sorted(dependencies.get(root['bom-ref'], []))} != components {sorted(direct)}"
204
+ )
205
+ tool_refs: Final[set[str]] = {tool["bom-ref"] for tool in tools}
206
+ for formula in document.get("formulation", []):
207
+ for workflow in formula.get("workflows", []):
208
+ # a workflow may also name an external resource, which no tool can be
209
+ referenced = {
210
+ reference["ref"] for reference in workflow.get("resourceReferences", []) if "ref" in reference
211
+ }
212
+ if unknown := referenced - tool_refs:
213
+ problems.append(f"workflow {workflow['uid']} references unknown tools: {sorted(unknown)}")
214
+ return problems
215
+
216
+
217
+ __all__ = [
218
+ "is_document",
219
+ "load_json",
220
+ "validate_document",
221
+ ]