3r"""The on-disk format of a vendored-SOUP upstream provenance manifest.
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.
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.
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.
30 Vendored at the same relative path upstream uses, byte-identical.
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.
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.
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``.
46Line format, whitespace-separated (no vendored path in this tree contains a
47space, and ``parse_manifest`` rejects one that does)::
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>
55from __future__
import annotations
61from dataclasses
import dataclass
62from pathlib
import Path
64REPO_ROOT = Path(__file__).resolve().parents[2]
65MANIFEST_DIR = Path(
"docs/sbom/upstream")
66MANIFEST_SUFFIX =
".manifest"
77UPSTREAM_VERIFIED_KINDS = (KIND_OK, KIND_MOVED)
79BLOB_RE = re.compile(
r"\A[0-9a-f]{40}\Z")
80MODE_RE = re.compile(
r"\A[0-7]{6}\Z")
83MANIFEST_BANNER =
"ra8-firmware SOUP upstream provenance manifest -- generated, do not hand-edit"
86class ManifestError(Exception):
87 """A manifest is unreadable, malformed, or claims something impossible."""
90@dataclass(frozen=
True)
92 """One vendored file's provenance record.
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`.
106 upstream_blob: str |
None =
None
107 local_blob: str |
None =
None
108 upstream_path: str |
None =
None
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:
116 f
"{KIND_MOVED} {self.mode} {self.upstream_blob} "
117 f
"{self.rel_path} {self.upstream_path}"
119 if self.kind == KIND_PATCH:
121 f
"{KIND_PATCH} {self.mode} {self.upstream_blob} {self.local_blob} {self.rel_path}"
123 return f
"{KIND_LOCAL} {self.mode} {self.local_blob} {self.rel_path}"
126@dataclass(frozen=True)
128 """A parsed manifest: its header fields plus one `Entry` per vendored file."""
131 header: dict[str, str]
132 entries: tuple[Entry, ...]
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)
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}
143def manifest_path(key: str) -> Path:
144 """Return the repo-relative manifest path for a registry key.
146 Nested keys keep their shape (``esp-hosted/protobuf-c`` ->
147 ``esp-hosted/protobuf-c.manifest``) so the directory mirrors the registry.
150 key: The registry component key.
153 Repo-relative path of that component's manifest.
155 return MANIFEST_DIR / (key + MANIFEST_SUFFIX)
158def _parse_entry(line: str, lineno: int, path: Path) -> Entry:
159 """Parse one manifest body line into an `Entry`.
162 line: The raw line, without its newline.
163 lineno: 1-based line number, for error messages.
164 path: Manifest path, for error messages.
170 ManifestError: On any malformed field.
172 fields = line.split(
" ")
177 KIND_MOVED: (5, (2,)),
178 KIND_PATCH: (5, (2, 3)),
179 KIND_LOCAL: (4, (2,)),
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)
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])
204def parse_manifest(key: str, text: str, path: Path) -> Manifest:
205 """Parse a manifest document.
208 key: Registry key the manifest is expected to describe.
209 text: Full manifest text.
210 path: Manifest path, for error messages.
216 ManifestError: On a malformed header, a malformed record, a duplicate
217 path, or a header ``component:`` that names a different component.
219 header: dict[str, str] = {}
220 entries: list[Entry] = []
221 for lineno, raw
in enumerate(text.splitlines(), start=1):
224 if raw.startswith(HEADER_PREFIX):
225 field, _, value = raw[len(HEADER_PREFIX) :].partition(
": ")
227 header[field] = value
229 if raw.startswith(
"#"):
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))
247def format_manifest(key: str, header: dict[str, str], entries: list[Entry]) -> str:
248 """Render a manifest document deterministically.
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.
256 The manifest text, newline-terminated.
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"
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.
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.
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.
282 The tracked files, keyed by path relative to `rel_path`.
285 ManifestError: When ``git ls-files`` fails or a path contains a space.
287 repo = root
or REPO_ROOT
288 proc = subprocess.run(
294 "--exclude-standard",
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"):
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):
319 if not source.exists()
and not source.is_symlink():
321 if source.is_symlink():
323 data = str(source.readlink()).encode()
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()
328 out[path[len(prefix) :]
if path.startswith(prefix)
else Path(path).name] = (mode, blob)