ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
soup_manifest.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3r"""The on-disk format of a vendored-SOUP upstream provenance manifest.
4
5One manifest per vendored component lives under ``docs/sbom/upstream/`` and
6records, for every file we vendor, the **git blob SHA-1 that the upstream
7project publishes for it**. A git blob id is a content hash
8(``sha1("blob <len>\0" + bytes)``), so recording it pins the bytes exactly --
9and because the same digest can be derived directly from every worktree file,
10verifying the claim offline is a comparison of two hashes computed by two
11different projects, never a value compared against itself (#548). Worktree
12enumeration also makes the gate accurate before a vendor move is staged.
13
14Why the hashes come from upstream and not from us
15-------------------------------------------------
16``gen_sbom.py`` re-derives an integrity digest over each vendored tree on every
17run, which proves the tree has not changed since the SBOM was regenerated. It
18cannot prove the tree was RIGHT when it was vendored: a bad copy, a partial
19subset or a moved tag would be hashed faithfully and reported clean forever.
20The manifests close that by carrying evidence that did not originate here --
21``check_soup_upstream.py --refresh`` fetches the pinned upstream revision and
22writes down what upstream says, and ``--check`` compares our tree to it.
23
24Record kinds
25------------
26Four, and the last two are the point: SOUP is sometimes patched on purpose, so
27"modified" and "corrupted" have to be distinguishable by a machine.
28
29``ok``
30 Vendored at the same relative path upstream uses, byte-identical.
31``moved``
32 Byte-identical, but relocated in our tree (xz-embedded flattens
33 ``linux/lib/xz/``; the RSIP blob mirrors upstream's root ``LICENSE.md`` as
34 ``UPSTREAM_LICENSE.md``). The upstream path is recorded so the mapping is
35 reviewable rather than inferred at check time.
36``patch``
37 A deliberate local modification. Both hashes are recorded: upstream's (so
38 a moved pin is still caught) and ours (so an *additional* edit on top of
39 the reviewed patch fails). The registry must declare the file in
40 ``patched_files`` or the gate rejects it.
41``local``
42 A file with no upstream counterpart at all -- a build-generated artifact we
43 vendor because the firmware build does not run upstream's generator, or a
44 first-party shim. The registry must declare it in ``local_files``.
45
46Line format, whitespace-separated (no vendored path in this tree contains a
47space, and ``parse_manifest`` rejects one that does)::
48
49 ok <mode> <upstream-blob> <rel-path>
50 moved <mode> <upstream-blob> <rel-path> <upstream-path>
51 patch <mode> <upstream-blob> <local-blob> <rel-path>
52 local <mode> <local-blob> <rel-path>
53"""
54
55from __future__ import annotations
56
57import hashlib
58import re
59import stat
60import subprocess
61from dataclasses import dataclass
62from pathlib import Path
63
64REPO_ROOT = Path(__file__).resolve().parents[2]
65MANIFEST_DIR = Path("docs/sbom/upstream")
66MANIFEST_SUFFIX = ".manifest"
67
68KIND_OK = "ok"
69KIND_MOVED = "moved"
70KIND_PATCH = "patch"
71KIND_LOCAL = "local"
72
73# Kinds whose bytes were proven equal to a hash published by the upstream
74# project. The vacuity floor counts these and nothing else: a manifest made
75# entirely of `patch` and `local` rows records only our own opinion of our own
76# tree, which is the defect this whole gate exists to remove.
77UPSTREAM_VERIFIED_KINDS = (KIND_OK, KIND_MOVED)
78
79BLOB_RE = re.compile(r"\A[0-9a-f]{40}\Z")
80MODE_RE = re.compile(r"\A[0-7]{6}\Z")
81
82HEADER_PREFIX = "# "
83MANIFEST_BANNER = "ra8-firmware SOUP upstream provenance manifest -- generated, do not hand-edit"
84
85
86class ManifestError(Exception):
87 """A manifest is unreadable, malformed, or claims something impossible."""
88
89
90@dataclass(frozen=True)
91class Entry:
92 """One vendored file's provenance record.
93
94 Attributes:
95 kind: One of `KINDS`.
96 mode: Six-digit git file mode (``100644`` / ``100755`` / ``120000``).
97 rel_path: Path relative to the component root, as vendored.
98 upstream_blob: Upstream's blob SHA-1, or None for `KIND_LOCAL`.
99 local_blob: Our blob SHA-1, set only for `KIND_PATCH` / `KIND_LOCAL`.
100 upstream_path: Upstream's path, set only for `KIND_MOVED`.
101 """
102
103 kind: str
104 mode: str
105 rel_path: str
106 upstream_blob: str | None = None
107 local_blob: str | None = None
108 upstream_path: str | None = None
109
110 def format(self) -> str:
111 """Render this entry as one manifest line (no trailing newline)."""
112 if self.kind == KIND_OK:
113 return f"{KIND_OK} {self.mode} {self.upstream_blob} {self.rel_path}"
114 if self.kind == KIND_MOVED:
115 return (
116 f"{KIND_MOVED} {self.mode} {self.upstream_blob} "
117 f"{self.rel_path} {self.upstream_path}"
118 )
119 if self.kind == KIND_PATCH:
120 return (
121 f"{KIND_PATCH} {self.mode} {self.upstream_blob} {self.local_blob} {self.rel_path}"
122 )
123 return f"{KIND_LOCAL} {self.mode} {self.local_blob} {self.rel_path}"
124
125
126@dataclass(frozen=True)
127class Manifest:
128 """A parsed manifest: its header fields plus one `Entry` per vendored file."""
129
130 key: str
131 header: dict[str, str]
132 entries: tuple[Entry, ...]
133
134 def verified_count(self) -> int:
135 """Return how many entries were proven against an upstream-published hash."""
136 return sum(1 for e in self.entries if e.kind in UPSTREAM_VERIFIED_KINDS)
137
138 def by_path(self) -> dict[str, Entry]:
139 """Return the entries keyed by component-relative path."""
140 return {e.rel_path: e for e in self.entries}
141
142
143def manifest_path(key: str) -> Path:
144 """Return the repo-relative manifest path for a registry key.
145
146 Nested keys keep their shape (``esp-hosted/protobuf-c`` ->
147 ``esp-hosted/protobuf-c.manifest``) so the directory mirrors the registry.
148
149 Args:
150 key: The registry component key.
151
152 Returns:
153 Repo-relative path of that component's manifest.
154 """
155 return MANIFEST_DIR / (key + MANIFEST_SUFFIX)
156
157
158def _parse_entry(line: str, lineno: int, path: Path) -> Entry:
159 """Parse one manifest body line into an `Entry`.
160
161 Args:
162 line: The raw line, without its newline.
163 lineno: 1-based line number, for error messages.
164 path: Manifest path, for error messages.
165
166 Returns:
167 The parsed entry.
168
169 Raises:
170 ManifestError: On any malformed field.
171 """
172 fields = line.split(" ")
173 kind = fields[0]
174 # kind -> (field count, indices that must be 40-hex blob ids)
175 shape = {
176 KIND_OK: (4, (2,)),
177 KIND_MOVED: (5, (2,)),
178 KIND_PATCH: (5, (2, 3)),
179 KIND_LOCAL: (4, (2,)),
180 }
181 if kind not in shape:
182 message = f"{path}:{lineno}: unknown record kind '{kind}'"
183 raise ManifestError(message)
184 width, blob_fields = shape[kind]
185 if len(fields) != width:
186 message = f"{path}:{lineno}: '{kind}' record needs {width} fields, got {len(fields)}"
187 raise ManifestError(message)
188 if not MODE_RE.match(fields[1]):
189 message = f"{path}:{lineno}: '{fields[1]}' is not a git file mode"
190 raise ManifestError(message)
191 for index in blob_fields:
192 if not BLOB_RE.match(fields[index]):
193 message = f"{path}:{lineno}: '{fields[index]}' is not a 40-hex blob id"
194 raise ManifestError(message)
195 if kind == KIND_OK:
196 return Entry(kind, fields[1], fields[3], upstream_blob=fields[2])
197 if kind == KIND_MOVED:
198 return Entry(kind, fields[1], fields[3], upstream_blob=fields[2], upstream_path=fields[4])
199 if kind == KIND_PATCH:
200 return Entry(kind, fields[1], fields[4], upstream_blob=fields[2], local_blob=fields[3])
201 return Entry(kind, fields[1], fields[3], local_blob=fields[2])
202
203
204def parse_manifest(key: str, text: str, path: Path) -> Manifest:
205 """Parse a manifest document.
206
207 Args:
208 key: Registry key the manifest is expected to describe.
209 text: Full manifest text.
210 path: Manifest path, for error messages.
211
212 Returns:
213 The parsed manifest.
214
215 Raises:
216 ManifestError: On a malformed header, a malformed record, a duplicate
217 path, or a header ``component:`` that names a different component.
218 """
219 header: dict[str, str] = {}
220 entries: list[Entry] = []
221 for lineno, raw in enumerate(text.splitlines(), start=1):
222 if not raw:
223 continue
224 if raw.startswith(HEADER_PREFIX):
225 field, _, value = raw[len(HEADER_PREFIX) :].partition(": ")
226 if value:
227 header[field] = value
228 continue
229 if raw.startswith("#"):
230 continue
231 if "\t" in raw or " " in raw:
232 message = f"{path}:{lineno}: fields are single-space separated"
233 raise ManifestError(message)
234 entries.append(_parse_entry(raw, lineno, path))
235 seen: set[str] = set()
236 for entry in entries:
237 if entry.rel_path in seen:
238 message = f"{path}: duplicate record for '{entry.rel_path}'"
239 raise ManifestError(message)
240 seen.add(entry.rel_path)
241 if header.get("component") != key:
242 message = f"{path}: header says component '{header.get('component')}', expected '{key}'"
243 raise ManifestError(message)
244 return Manifest(key=key, header=header, entries=tuple(entries))
245
246
247def format_manifest(key: str, header: dict[str, str], entries: list[Entry]) -> str:
248 """Render a manifest document deterministically.
249
250 Args:
251 key: Registry key; written as the ``component`` header field.
252 header: Ordered header fields (``component`` is inserted first).
253 entries: The records; sorted by path here so the file is reproducible.
254
255 Returns:
256 The manifest text, newline-terminated.
257 """
258 lines = [f"# {MANIFEST_BANNER}.", f"# component: {key}"]
259 lines.extend(f"# {field}: {value}" for field, value in header.items())
260 lines.extend(entry.format() for entry in sorted(entries, key=lambda e: e.rel_path))
261 return "\n".join(lines) + "\n"
262
263
264def git_ls_files(
265 rel_path: str, exclude: tuple[str, ...] = (), root: Path | None = None
266) -> dict[str, tuple[str, str]]:
267 """Return ``{component-relative path: (mode, blob)}`` for a vendored tree.
268
269 Git supplies the tracked plus untracked/non-ignored worktree census. Blob
270 ids and modes are derived from the current bytes so an unstaged vendor move,
271 mutation, addition, or deletion is audited exactly as it stands. Ignored
272 build artefacts cannot enter the census.
273
274 Args:
275 rel_path: Repo-relative path of the component (a directory or one file).
276 exclude: Repo-relative prefixes to drop -- nested components that carry
277 their own registry entry and their own manifest.
278 root: Repository to enumerate; defaults to this checkout. The selftest
279 passes a scratch repository so it drives this exact function.
280
281 Returns:
282 The tracked files, keyed by path relative to `rel_path`.
283
284 Raises:
285 ManifestError: When ``git ls-files`` fails or a path contains a space.
286 """
287 repo = root or REPO_ROOT
288 proc = subprocess.run( # noqa: S603 # trusted: fixed git argv, no shell
289 [ # noqa: S607 -- trusted: fixed git argv
290 "git",
291 "ls-files",
292 "--cached",
293 "--others",
294 "--exclude-standard",
295 "-z",
296 "--",
297 rel_path,
298 ],
299 cwd=repo,
300 capture_output=True,
301 text=True,
302 check=False,
303 )
304 if proc.returncode != 0:
305 message = f"`git ls-files -- {rel_path}` failed: {proc.stderr.strip()}"
306 raise ManifestError(message)
307 out: dict[str, tuple[str, str]] = {}
308 prefix = rel_path.rstrip("/") + "/"
309 for record in proc.stdout.split("\0"):
310 if not record:
311 continue
312 path = record
313 if " " in path:
314 message = f"vendored path '{path}' contains a space; format cannot encode it"
315 raise ManifestError(message)
316 if any(path == drop or path.startswith(drop.rstrip("/") + "/") for drop in exclude):
317 continue
318 source = repo / path
319 if not source.exists() and not source.is_symlink():
320 continue
321 if source.is_symlink():
322 mode = "120000"
323 data = str(source.readlink()).encode()
324 else:
325 mode = "100755" if source.stat().st_mode & stat.S_IXUSR else "100644"
326 data = source.read_bytes()
327 blob = hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() # noqa: S324 -- Git object IDs require SHA-1
328 out[path[len(prefix) :] if path.startswith(prefix) else Path(path).name] = (mode, blob)
329 return out