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 @@
1
+ """Zip artifacts: one checked handle per build, verifying the wheels they bundle, and writing an SBOM in."""
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from typing import TYPE_CHECKING, Final
6
+
7
+ from installer.records import InvalidRecordEntry, parse_record_file
8
+ from installer.sources import WheelFile
9
+ from packaging.requirements import InvalidRequirement, Requirement
10
+
11
+ from python_sbom._errors import SbomError
12
+ from python_sbom._model._inputs import BundledWheel, RecordRow
13
+ from python_sbom._model._metadata import CoreMetadata, parse_metadata
14
+
15
+ from ._record import record_rows
16
+ from ._zip import open_archive, read_member, read_wheel_metadata, sha256_hex
17
+
18
+ if TYPE_CHECKING:
19
+ import zipfile
20
+ from collections.abc import Callable
21
+ from typing import IO
22
+
23
+
24
+ # wheels verified at once, each perhaps in memory whole
25
+ _WORKERS: Final[int] = 4
26
+
27
+
28
+ def read_bundled_wheels(paths: list[str], open_wheel: Callable[[str], IO[bytes]]) -> list[BundledWheel]:
29
+ # inflating and hashing release the GIL, so the wheels verify side by side, a few at a time, as each may be held in
30
+ # memory whole; results and the first error come in the order given
31
+ def read(path: str) -> BundledWheel:
32
+ with open_wheel(path) as stream:
33
+ return _read_bundled_wheel(stream, path)
34
+
35
+ with ThreadPoolExecutor(max_workers=_WORKERS) as pool:
36
+ return list(pool.map(read, paths))
37
+
38
+
39
+ def _read_bundled_wheel(stream: IO[bytes], path: str) -> BundledWheel:
40
+ where: Final[str] = f"bundled wheel {path}"
41
+ sha256: Final[str] = sha256_hex(stream)
42
+ stream.seek(0)
43
+ with open_archive(stream, where) as archive:
44
+ # installer takes the project name from the archive's file name, which an archive read from memory lacks
45
+ archive.filename = path
46
+ members: Final[list[str]] = sorted(info.filename for info in archive.infolist() if not info.is_dir())
47
+ wheel, metadata = read_wheel_metadata(archive, where)
48
+ dist_info: Final[str] = wheel.dist_info_dir
49
+ _check_named(metadata, where, f"{dist_info}/METADATA")
50
+ files: Final[list[RecordRow]] = _verified_record(archive, wheel, where)
51
+ vendored: Final[list[tuple[str, CoreMetadata]]] = [
52
+ *_vendor_manifest(archive, where, members),
53
+ *(
54
+ (name, _vendored_metadata(archive, where, name))
55
+ for name in members
56
+ if "/_vendor/" in name and name.endswith(".dist-info/METADATA")
57
+ ),
58
+ ]
59
+ return BundledWheel(
60
+ path=path,
61
+ sha256=sha256,
62
+ metadata_name=f"{dist_info}/METADATA",
63
+ metadata=metadata,
64
+ files=files,
65
+ vendored=vendored,
66
+ )
67
+
68
+
69
+ def _check_named(metadata: CoreMetadata, where: str, name: str) -> None:
70
+ if not (metadata.name and metadata.version):
71
+ msg = f"{where}: {name} lacks Name or Version"
72
+ raise SbomError(msg)
73
+
74
+
75
+ def _verified_record(archive: zipfile.ZipFile, wheel: WheelFile, where: str) -> list[RecordRow]:
76
+ # the SBOM copies these rows, so a RECORD that misstates the wheel would make the SBOM misstate it too
77
+ dist_info: Final[str] = wheel.dist_info_dir
78
+ # installer decompresses RECORD whole, so a capped read comes first and bounds installer's read of the same bytes
79
+ record: Final[str] = read_member(archive, f"{dist_info}/RECORD", where).decode("utf-8")
80
+ try:
81
+ rows: Final[list[tuple[str, str, str]]] = list(parse_record_file(record.splitlines()))
82
+ # read before installer's check, which takes any hashlib name and fails on one needing a length, such as
83
+ # shake_128, with a TypeError
84
+ found: Final[list[RecordRow]] = record_rows(rows, where)
85
+ except InvalidRecordEntry as error:
86
+ msg = f"{where}: RECORD does not parse: {error}"
87
+ raise SbomError(msg) from error
88
+ # installer checks the last row of a path only, skips rows naming no member, and lets a RECORD.jws or RECORD.p7s
89
+ # anywhere under the .dist-info, "../" included, go unlisted, so each of these checks is ours
90
+ if repeated := sorted(path for path, count in Counter(path for path, _, _ in rows).items() if count > 1):
91
+ msg = f"{where}: RECORD lists {', '.join(repeated)} more than once"
92
+ raise SbomError(msg)
93
+ members: Final[set[str]] = {info.filename for info in archive.infolist() if not info.is_dir()}
94
+ recorded: Final[set[str]] = {path for path, _, _ in rows}
95
+ # installer checks the row of RECORD itself, the one row without a digest
96
+ if missing := sorted(
97
+ members - recorded - {f"{dist_info}/{name}" for name in ("RECORD", "RECORD.jws", "RECORD.p7s")}
98
+ ):
99
+ msg = f"{where}: RECORD lacks a digest for {', '.join(missing)}"
100
+ raise SbomError(msg)
101
+ if extra := sorted(recorded - members):
102
+ msg = f"{where}: RECORD lists {', '.join(extra)}, which the wheel does not hold"
103
+ raise SbomError(msg)
104
+ try:
105
+ wheel.validate_record()
106
+ except WheelFile.validation_error as error:
107
+ # installer documents the exception class only, not the list of issues it carries
108
+ issues: Final[list[str]] = getattr(error, "issues", [str(error)])
109
+ prefix: Final[str] = f"In {archive.filename}, "
110
+ msg = f"{where}: RECORD disagrees with the wheel: {'; '.join(issue.removeprefix(prefix) for issue in issues)}"
111
+ raise SbomError(msg) from error
112
+ return found
113
+
114
+
115
+ def _vendor_manifest(archive: zipfile.ZipFile, where: str, members: list[str]) -> list[tuple[str, CoreMetadata]]:
116
+ # pip lists what it vendors in _vendor/vendor.txt instead of shipping each vendored .dist-info
117
+ found: Final[list[tuple[str, CoreMetadata]]] = []
118
+ for name in (member for member in members if member.endswith("/_vendor/vendor.txt")):
119
+ for line in read_member(archive, name, where).decode("utf-8").splitlines():
120
+ if not (text := line.partition("#")[0].strip()):
121
+ continue
122
+ try:
123
+ pins = list((requirement := Requirement(text)).specifier)
124
+ except InvalidRequirement as error:
125
+ msg = f"{where}: vendored requirement in {name} does not parse: {text}"
126
+ raise SbomError(msg) from error
127
+ if len(pins) != 1 or pins[0].operator != "==" or "*" in pins[0].version:
128
+ msg = f"{where}: vendored requirement in {name} pins no one exact version: {text}"
129
+ raise SbomError(msg)
130
+ found.append((name, CoreMetadata(raw={"name": requirement.name, "version": pins[0].version}, unparsed={})))
131
+ return found
132
+
133
+
134
+ def _vendored_metadata(archive: zipfile.ZipFile, where: str, name: str) -> CoreMetadata:
135
+ metadata: Final[CoreMetadata] = parse_metadata(read_member(archive, name, where).decode("utf-8"))
136
+ _check_named(metadata, where, name)
137
+ return metadata
138
+
139
+
140
+ __all__ = [
141
+ "read_bundled_wheels",
142
+ ]
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from dataclasses import dataclass
5
+ from io import BytesIO
6
+ from typing import TYPE_CHECKING, Final
7
+
8
+ from ._bundled import read_bundled_wheels
9
+ from ._zip import open_archive, read_member, sha256_hex
10
+
11
+ if TYPE_CHECKING:
12
+ import zipfile
13
+ from collections.abc import Generator
14
+ from pathlib import Path, PurePosixPath
15
+
16
+ from python_sbom._model._inputs import BundledWheel
17
+
18
+ # a bundled wheel is read whole, to hash it and open it as a zip, so its size is capped as metadata's is
19
+ _WHEEL_LIMIT: Final[int] = 512 * 1024 * 1024
20
+
21
+
22
+ @contextmanager
23
+ def open_artifact(path: Path) -> Generator[ArtifactReader, None, None]:
24
+ # one open zip serves every read of a build, the provider's included, so the archive is checked and indexed once
25
+ with open_archive(path, str(path)) as archive:
26
+ yield ArtifactReader(path=path, archive=archive, members=frozenset(archive.namelist()))
27
+
28
+
29
+ @dataclass(frozen=True, slots=True, kw_only=True)
30
+ class ArtifactReader:
31
+ path: Path
32
+ archive: zipfile.ZipFile
33
+ members: frozenset[str]
34
+
35
+ def digest(self, name: str) -> str:
36
+ with self.archive.open(name) as member:
37
+ return sha256_hex(member)
38
+
39
+ def provider_read(self, path: PurePosixPath) -> bytes:
40
+ # a provider asking for a member the artifact lacks gets the KeyError a mapping raises, which it may expect
41
+ if (name := str(path)) not in self.members:
42
+ raise KeyError(name)
43
+ return self.read(name)
44
+
45
+ def read(self, name: str) -> bytes:
46
+ return read_member(self.archive, name, str(self.path))
47
+
48
+ def bundled_wheels(self, names: list[str]) -> list[BundledWheel]:
49
+ # zipfile serializes the seeks of concurrent member reads on its own lock, so the verifying threads share this
50
+ # handle and inflate side by side
51
+ return read_bundled_wheels(
52
+ names, lambda name: BytesIO(read_member(self.archive, name, str(self.path), _WHEEL_LIMIT))
53
+ )
54
+
55
+
56
+ __all__ = [
57
+ "ArtifactReader",
58
+ "open_artifact",
59
+ ]
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ from itertools import starmap
6
+ from typing import TYPE_CHECKING, Final
7
+
8
+ from installer.records import RecordEntry
9
+
10
+ from python_sbom._errors import SbomError
11
+ from python_sbom._model._inputs import RecordRow
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Iterable
15
+
16
+ # the algorithms a wheel RECORD may name, as CycloneDX spells them
17
+ _ALGORITHMS: Final[dict[str, str]] = {
18
+ "md5": "MD5",
19
+ "sha1": "SHA-1",
20
+ "sha256": "SHA-256",
21
+ "sha384": "SHA-384",
22
+ "sha512": "SHA-512",
23
+ }
24
+
25
+
26
+ def record_rows(rows: Iterable[tuple[str, str, str]], where: str) -> list[RecordRow]:
27
+ # the rows with a digest, as the SBOM lists them: RECORD's own row has none, nor has a file an installer left
28
+ # unhashed; installer's InvalidRecordEntry passes to the caller, which knows what a malformed row means there
29
+ found: Final[list[RecordRow]] = []
30
+ for entry in starmap(RecordEntry.from_elements, rows):
31
+ if entry.hash_ is None:
32
+ continue
33
+ algorithm, digest = _decode_digest(str(entry.hash_), f"{where}: RECORD row for {entry.path}")
34
+ found.append(RecordRow(path=entry.path, algorithm=algorithm, digest=digest, size=str(entry.size)))
35
+ return found
36
+
37
+
38
+ def _decode_digest(digest: str, where: str) -> tuple[str, str]:
39
+ # installer checks RECORD digests as urlsafe base64 under any hashlib name, while CycloneDX takes hex under its own
40
+ # names for a few algorithms
41
+ name, _, encoded = digest.partition("=")
42
+ if (algorithm := _ALGORITHMS.get(name)) is None:
43
+ msg = f"{where}: unknown hash algorithm {name!r}"
44
+ raise SbomError(msg)
45
+ try:
46
+ raw = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
47
+ except binascii.Error as error:
48
+ msg = f"{where}: digest {encoded!r} is not urlsafe base64"
49
+ raise SbomError(msg) from error
50
+ return algorithm, raw.hex()
51
+
52
+
53
+ __all__ = [
54
+ "record_rows",
55
+ ]
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import os
5
+ import shutil
6
+ import stat
7
+ import tempfile
8
+ import time
9
+ import zipfile
10
+ from io import BytesIO, StringIO
11
+ from pathlib import Path
12
+ from typing import Final
13
+
14
+ import zipremove
15
+ from installer.records import Hash, RecordEntry
16
+ from installer.utils import copyfileobj_with_hashing
17
+
18
+ from python_sbom._errors import SbomError
19
+
20
+ from ._zip import open_archive, wheel_source
21
+
22
+ # 1980-01-01, the earliest time a zip entry holds
23
+ _ZIP_EPOCH: Final[int] = 315532800
24
+
25
+
26
+ def write_sbom(wheel: Path, name: str, content: bytes, epoch: int) -> str:
27
+ with open_archive(wheel, str(wheel)) as archive:
28
+ _, dist_info = wheel_source(archive, str(wheel))
29
+ record: Final[str] = f"{dist_info}/RECORD"
30
+ if missing := sorted({f"{dist_info}/METADATA", record} - set(archive.namelist())):
31
+ msg = f"{wheel} has no {', '.join(missing)}"
32
+ raise SbomError(msg)
33
+ record_info: Final[zipfile.ZipInfo] = archive.getinfo(record)
34
+ record_data: Final[bytes] = archive.read(record_info)
35
+ sbom: Final[str] = f"{dist_info}/sboms/{name}"
36
+ entry: Final[zipfile.ZipInfo] = _entry(sbom, epoch)
37
+ entry.compress_type = zipfile.ZIP_DEFLATED
38
+ rewritten: Final[bytes] = _rewritten_record(record_data, record, sbom, content)
39
+ # a name no "dist/*.whl" glob matches, in the wheel's directory so the final rename stays on one file system
40
+ descriptor, temporary = tempfile.mkstemp(dir=wheel.parent, prefix=".python-sbom-", suffix=".tmp")
41
+ os.close(descriptor)
42
+ try: # ruff: ignore[too-many-statements-in-try-clause] # whichever step fails, the temporary file goes
43
+ # copied through the process: copy_file_range makes some file systems first flush the wheel just written
44
+ with wheel.open("rb") as source, Path(temporary).open("wb") as target:
45
+ shutil.copyfileobj(source, target)
46
+ # the other members keep their bytes as stored, still compressed, so only the SBOM and RECORD get compressed
47
+ # here; both go last, RECORD after the SBOM
48
+ with zipremove.ZipFile(temporary, "a") as archive:
49
+ stale = [info for info in archive.infolist() if info.filename in {record, sbom}]
50
+ archive.repack(list(map(archive.remove, stale))) # ty: ignore[unresolved-attribute] # zipremove adds both
51
+ archive.writestr(entry, content)
52
+ archive.writestr(record_info, rewritten)
53
+ Path(temporary).chmod(stat.S_IMODE(wheel.stat().st_mode))
54
+ Path(temporary).replace(wheel)
55
+ except BaseException:
56
+ Path(temporary).unlink(missing_ok=True)
57
+ raise
58
+ return sbom
59
+
60
+
61
+ def _entry(name: str, epoch: int) -> zipfile.ZipInfo:
62
+ # zip times start at 1980; Unix attributes whichever system writes the entry, so the bytes match across systems
63
+ entry: Final[zipfile.ZipInfo] = zipfile.ZipInfo(name, time.gmtime(max(epoch, _ZIP_EPOCH))[:6])
64
+ entry.create_system = 3
65
+ entry.external_attr = (stat.S_IFREG | 0o644) << 16
66
+ return entry
67
+
68
+
69
+ def _rewritten_record(record: bytes, name: str, sbom: str, content: bytes) -> bytes:
70
+ lines: Final[list[str]] = [line for line in record.decode("utf-8").splitlines(keepends=True) if _path(line) != sbom]
71
+ newline: Final[str] = "\r\n" if lines and lines[0].endswith("\r\n") else "\n"
72
+ # a last row without its line end would take the SBOM's row onto its own line
73
+ if lines and not lines[-1].endswith(("\n", "\r")):
74
+ lines[-1] += newline
75
+ # the row of RECORD itself stays last
76
+ at: Final[int] = next((index for index, line in enumerate(lines) if _path(line) == name), len(lines))
77
+ # installer's helper hashes as it copies; the copy is thrown away
78
+ digest, size = copyfileobj_with_hashing(BytesIO(content), BytesIO(), "sha256")
79
+ row: Final[StringIO] = StringIO()
80
+ csv.writer(row, lineterminator=newline).writerow(RecordEntry(sbom, Hash("sha256", digest), size).to_row())
81
+ lines.insert(at, row.getvalue())
82
+ return "".join(lines).encode()
83
+
84
+
85
+ def _path(line: str) -> str:
86
+ # RECORD quotes a path that holds a comma, a quote or a line end, so the raw start of a line misses its row
87
+ return next(csv.reader([line]), [""])[0]
88
+
89
+
90
+ def append_member(archive: Path, name: str, content: bytes, epoch: int) -> None:
91
+ with zipfile.ZipFile(archive, "a") as handle:
92
+ if name in handle.namelist():
93
+ msg = f"{archive.name} already holds {name}"
94
+ raise SbomError(msg)
95
+ # deflated at the highest level whatever the other members use, since a zipapp may store them all and the SBOM
96
+ # then adds megabytes; a fixed level, time and attributes keep the bytes the same for one input on every system
97
+ handle.writestr(_entry(name, epoch), content, compress_type=zipfile.ZIP_DEFLATED, compresslevel=9)
98
+
99
+
100
+ __all__ = [
101
+ "append_member",
102
+ "write_sbom",
103
+ ]
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import zipfile
5
+ import zlib
6
+ from collections import Counter
7
+ from contextlib import contextmanager
8
+ from typing import IO, TYPE_CHECKING, Final
9
+
10
+ from installer.sources import WheelFile
11
+
12
+ from python_sbom._errors import SbomError
13
+ from python_sbom._model._metadata import CoreMetadata, parse_metadata
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Generator
17
+ from pathlib import Path
18
+
19
+ # metadata files beyond this size are refused, so a hostile wheel cannot exhaust memory
20
+ _METADATA_LIMIT: Final[int] = 16 * 1024 * 1024
21
+ # members inflate a chunk at a time, so a size the archive states falsely is caught before it is all in memory
22
+ CHUNK: Final[int] = 1024 * 1024
23
+
24
+
25
+ @contextmanager
26
+ def open_archive(file: Path | IO[bytes], where: str) -> Generator[zipfile.ZipFile, None, None]:
27
+ try:
28
+ with zipfile.ZipFile(file) as archive:
29
+ # installers extract the first of two same-named members and zipfile reads the last, and a file system that
30
+ # ignores case merges names that differ only by case, so such names would hide what gets installed;
31
+ # installer's WheelFile accepts names that differ only by case
32
+ if clashes := sorted(
33
+ name for name, count in Counter(name.casefold() for name in archive.namelist()).items() if count > 1
34
+ ):
35
+ msg = f"{where} holds more than one member named {', '.join(clashes)}, ignoring case"
36
+ raise SbomError(msg)
37
+ yield archive
38
+ # zipfile raises these as late as a member's last read, inside installer's checks too, which let them through
39
+ except (zipfile.BadZipFile, zlib.error) as error:
40
+ msg = f"{where} is not a valid zip archive: {error}"
41
+ raise SbomError(msg) from error
42
+
43
+
44
+ def read_wheel_metadata(archive: zipfile.ZipFile, where: str) -> tuple[WheelFile, CoreMetadata]:
45
+ wheel, dist_info = wheel_source(archive, where)
46
+ # read capped, as installer's read_dist_info decompresses the member whole
47
+ return wheel, parse_metadata(read_member(archive, f"{dist_info}/METADATA", where).decode("utf-8"))
48
+
49
+
50
+ def wheel_source(archive: zipfile.ZipFile, where: str) -> tuple[WheelFile, str]:
51
+ # installer refuses a wheel whose file name does not parse, or that holds other than one top-level .dist-info, since
52
+ # a second one is a decoy that could decide whose metadata counts
53
+ try:
54
+ wheel = WheelFile(archive)
55
+ dist_info = wheel.dist_info_dir
56
+ except ValueError as error:
57
+ msg = f"{where} is not a wheel: {error}"
58
+ raise SbomError(msg) from error
59
+ return wheel, dist_info
60
+
61
+
62
+ def read_member(archive: zipfile.ZipFile, name: str, where: str, limit: int = _METADATA_LIMIT) -> bytes:
63
+ try:
64
+ info = archive.getinfo(name)
65
+ except KeyError as error:
66
+ msg = f"{where} has no {name}"
67
+ raise SbomError(msg) from error
68
+ # counted as it decompresses, since the size the archive states may lie and one read decompresses it all at once
69
+ content: Final[bytearray] = bytearray()
70
+ with archive.open(info) as member:
71
+ while chunk := member.read(CHUNK):
72
+ content.extend(chunk)
73
+ if len(content) > limit:
74
+ msg = f"{where}: {name} is over the {limit} byte limit"
75
+ raise SbomError(msg)
76
+ return bytes(content)
77
+
78
+
79
+ def sha256_hex(stream: IO[bytes]) -> str:
80
+ # hashlib.file_digest wants readinto, which a zip member stream typed as IO[bytes] does not promise
81
+ digest: Final = hashlib.sha256()
82
+ while chunk := stream.read(CHUNK):
83
+ digest.update(chunk)
84
+ return digest.hexdigest()
85
+
86
+
87
+ __all__ = [
88
+ "CHUNK",
89
+ "open_archive",
90
+ "read_member",
91
+ "read_wheel_metadata",
92
+ "sha256_hex",
93
+ "wheel_source",
94
+ ]
@@ -0,0 +1,9 @@
1
+ """The hatchling build hook, which writes the SBOM into every wheel hatchling builds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._hook import SbomBuildHook
6
+
7
+ __all__ = [
8
+ "SbomBuildHook",
9
+ ]
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import tempfile
5
+ import weakref
6
+ from functools import cached_property
7
+ from pathlib import Path, PurePosixPath
8
+ from typing import TYPE_CHECKING, Any, Final
9
+
10
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
11
+ from hatchling.builders.utils import get_reproducible_timestamp
12
+ from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT
13
+
14
+ from python_sbom._collect._content import resolve_provider
15
+ from python_sbom._collect._revision import REVISION_FILE, default_revision
16
+ from python_sbom._collect._settings import settings_from_table
17
+ from python_sbom._errors import SbomError
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+
22
+ from python_sbom._model._content import ContentProvider
23
+ from python_sbom._model._inputs import Settings
24
+
25
+ # options of every hatchling hook table, which hatchling itself reads
26
+ _HATCHLING_OPTIONS: Final[frozenset[str]] = frozenset({
27
+ "dependencies",
28
+ "enable-by-default",
29
+ "path",
30
+ "require-runtime-dependencies",
31
+ "require-runtime-features",
32
+ })
33
+
34
+
35
+ class SbomBuildHook(BuildHookInterface):
36
+ """
37
+ Hatchling build hook that writes a CycloneDX SBOM into the wheel's ``.dist-info/sboms/``, as PEP 770 describes.
38
+
39
+ It runs once hatchling has written the wheel, so the SBOM describes the wheel's final metadata and file name
40
+ whatever other hooks changed. Options come from ``[tool.python-sbom]``, the one place ``python-sbom embed`` and
41
+ ``python-sbom archive`` read them too. For an sdist it records the source revision, so a wheel built from that sdist
42
+ still names its commit.
43
+
44
+ :cvar PLUGIN_NAME: the hook's name in ``[tool.hatch.build.hooks]``
45
+
46
+ """
47
+
48
+ PLUGIN_NAME = "python-sbom"
49
+ _remove_revision_file: Callable[[], None] | None = None
50
+
51
+ def initialize(
52
+ self,
53
+ version: str,
54
+ build_data: dict[str, Any], # simplint: ignore[SMP602] # hatchling's signature
55
+ ) -> None:
56
+ """
57
+ Read the source revision, and record it in an sdist.
58
+
59
+ :param version: hatchling's build variant
60
+ :param build_data: hatchling's build data, which receives the revision file
61
+
62
+ """
63
+ if self.target_name not in {"sdist", "wheel"} or version == "editable":
64
+ return
65
+ # once per build, before it writes the artifact into a checkout that may not ignore it; a value kept for the
66
+ # process would name an older commit in the next build after a commit
67
+ self._revision = revision = default_revision(Path(self.root))
68
+ # a dirty checkout has no commit that reproduces the sdist, so a wheel built from it names none
69
+ if self.target_name != "sdist" or revision is None or revision.dirty:
70
+ return
71
+ # outside the project, so the build leaves nothing behind in it
72
+ directory = tempfile.mkdtemp()
73
+ # hatchling reads the file only while it writes the sdist, and skips finalize when the build fails, so the
74
+ # directory then goes with the hook, at the latest when the interpreter exits
75
+ self._remove_revision_file = weakref.finalize(self, shutil.rmtree, directory)
76
+ # bytes, so the sdist holds the same LF line on every OS
77
+ (written := Path(directory) / REVISION_FILE).write_bytes(f"node: {revision.commit}\n".encode())
78
+ build_data["force_include"][str(written)] = REVISION_FILE
79
+
80
+ def finalize(
81
+ self,
82
+ version: str,
83
+ build_data: ( # ruff: ignore[unused-method-argument] # hatchling's signature
84
+ dict[str, Any] # simplint: ignore[SMP602] # see above
85
+ ),
86
+ artifact_path: str,
87
+ ) -> None:
88
+ """
89
+ Write the SBOM into the wheel hatchling built, and remove the revision file an sdist build wrote.
90
+
91
+ :param version: hatchling's build variant; an editable wheel gets no SBOM, since it carries no code
92
+ :param build_data: hatchling's build data
93
+ :param artifact_path: the built wheel
94
+
95
+ """
96
+ if self._remove_revision_file is not None:
97
+ self._remove_revision_file()
98
+ if self.target_name != "wheel" or version == "editable":
99
+ return
100
+ # what a wheel needs loads only when one is built, so an sdist build stays light
101
+ from python_sbom._collect._facts import collect_facts # ruff: ignore[import-outside-top-level] # see above
102
+ from python_sbom._generate._embed import embed_into # ruff: ignore[import-outside-top-level] # see above
103
+
104
+ root = Path(self.root)
105
+ # content_provider() may be a subclass's override, so its result goes through resolve_provider again, which
106
+ # returns a given instance as is, for the provider's source files to be listed among the build tools
107
+ content, provider_scripts = resolve_provider(self.content_provider(), self._settings, root)
108
+ # a custom hook script shapes the document, as a provider defined in the project does, so the SBOM lists both
109
+ # among the build tools
110
+ own = [PurePosixPath(self.config.get("path", DEFAULT_BUILD_SCRIPT))] if self.PLUGIN_NAME == "custom" else []
111
+ scripts = tuple(dict.fromkeys([*own, *provider_scripts]))
112
+ # hatchling's own fixed time for a reproducible build when SOURCE_DATE_EPOCH is unset, so the SBOM agrees with
113
+ # the zip entries hatchling wrote
114
+ facts = collect_facts(
115
+ root,
116
+ lambda _root: self._revision,
117
+ default_epoch=get_reproducible_timestamp() if self.build_config.reproducible else None,
118
+ )
119
+ embed_into(
120
+ Path(artifact_path), root, settings=self._settings, content=content, build_scripts=scripts, facts=facts
121
+ )
122
+
123
+ def content_provider(self) -> ContentProvider:
124
+ """
125
+ Return what the project adds to its SBOM.
126
+
127
+ Override it in a hatchling custom build hook to build the provider with what the hook knows; the
128
+ ``content-provider`` option is then ignored.
129
+
130
+ :returns: the ``content-provider`` option's provider, or one that adds nothing
131
+
132
+ """
133
+ return resolve_provider(None, self._settings, Path(self.root))[0]
134
+
135
+ @cached_property
136
+ def _settings(self) -> Settings:
137
+ # hatchling has parsed pyproject.toml already; the hook's own table holds only hatchling's options, so every
138
+ # entry point reads the same [tool.python-sbom]
139
+ if unknown := sorted(self.config.keys() - _HATCHLING_OPTIONS):
140
+ msg = (
141
+ f"the {self.PLUGIN_NAME} hook's table holds {', '.join(unknown)}; move python-sbom's options to"
142
+ " [tool.python-sbom], where python-sbom embed and python-sbom archive read them too"
143
+ )
144
+ raise SbomError(msg)
145
+ return settings_from_table(self.metadata.config.get("tool", {}).get("python-sbom", {}), "[tool.python-sbom]")
146
+
147
+
148
+ __all__ = [
149
+ "SbomBuildHook",
150
+ ]
@@ -0,0 +1,18 @@
1
+ """Register the build hook with hatchling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from hatchling.plugin import hookimpl
6
+
7
+ from ._hook import SbomBuildHook
8
+
9
+
10
+ @hookimpl
11
+ def hatch_register_build_hook() -> type[SbomBuildHook]:
12
+ """
13
+ Return the build hook hatchling loads for ``[tool.hatch.build.hooks.python-sbom]``.
14
+
15
+ :returns: the build hook class
16
+
17
+ """
18
+ return SbomBuildHook
python_sbom/py.typed ADDED
File without changes
python_sbom/version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.1'
22
+ __version_tuple__ = version_tuple = (0, 1, 1)
23
+
24
+ __commit_id__ = commit_id = None