260 lines
12 KiB
Python
260 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
# Copyright (C) 2026 David (Corey) Herrera
|
|
"""
|
|
tensix_image_provenance.py
|
|
The CONTENT record of how one Tensix ELF was built, and the check that it still
|
|
describes the tree on disk.
|
|
|
|
------------------------------------------------------------------------------
|
|
WHY THIS EXISTS -- the exact failure it is written against
|
|
------------------------------------------------------------------------------
|
|
`tools/check_tensix_program_sources.sh` decides a program directory is current by
|
|
comparing MTIMES: the `.src` sidecar names a kernel source, and the directory is stale
|
|
if that source is NEWER than the ELF beside it. That check is defeated by
|
|
`explode_tensix_program_blob`, which writes every image and every sidecar out fresh --
|
|
so all 21 program directories report `current` the instant they are exploded,
|
|
whatever the blob inside them was built from.
|
|
|
|
That is not a hypothetical. The four `rtriad_*` readers' `brisc.elf` predated the
|
|
cooperative multicast (`2037181`, +388 lines in `kernels/resident_matmul_reader.c`) by
|
|
1588 B and rebuilt byte-identical at that commit's PARENT -- so the shipped matmul ran
|
|
a reader with no cooperative code, and a recorded closure ("cooperative multicast
|
|
cannot arm on Ornith") was measured on an image that could not cooperate. `programs/gdn`
|
|
went stale the same way behind five commits of GDN kernel work.
|
|
|
|
------------------------------------------------------------------------------
|
|
WHAT REPLACES THE MTIME, AND WHY IT CANNOT BE RESET BY TOUCHING FILES
|
|
------------------------------------------------------------------------------
|
|
Every image records, BESIDE ITSELF, a `<image>.prov` naming:
|
|
|
|
* the kernel source it was built from;
|
|
* the BUILD LINE -- every environment value that reaches code generation
|
|
(compile-time args, NoC index, watcher, assert flag, text base, CB table). A
|
|
kernel's own header is the authority on its recipe, and a short `CTARGS` list
|
|
folds to a literal 0 under SFPI GCC 15.1.0 with no diagnostic, so the recipe is
|
|
part of the identity of the image and not a comment about it;
|
|
* a CONTENT HASH of every in-tree file the compiler actually opened, taken from that
|
|
compile's own `-MMD` depfile rather than from a guess about what a kernel includes.
|
|
|
|
The check re-hashes those files where they sit now. A hash does not care about mtimes,
|
|
so an `explode` cannot launder it, `touch` cannot clear it, and a rebuild inside the
|
|
same second cannot hide behind GNU make 3.81's one-second granularity. A file that
|
|
moved, vanished or changed by one byte is named individually.
|
|
|
|
------------------------------------------------------------------------------
|
|
WHAT IT DELIBERATELY DOES NOT COVER, so the guarantee is not overread
|
|
------------------------------------------------------------------------------
|
|
* The SFPI toolchain is NOT hashed. It is pinned, it is not in this tree, and a check
|
|
that needs it could only run on the card host -- this one runs anywhere, which is the
|
|
point. `toolchain` in the recipe records the version string and nothing verifies it.
|
|
* FIRMWARE images have no provenance records; they are built from `fw/` by the same
|
|
builders, and rebuild-and-`cmp` is what pins their bytes.
|
|
* It is a check on INPUTS, not on the output: it says the recorded inputs still match
|
|
the tree, not that re-running the compiler would reproduce the ELF byte for byte.
|
|
That stronger claim is what a rebuild-and-`cmp` gives, and the audit that installed
|
|
this format did exactly that for all 21 programs.
|
|
|
|
Status: added 2026-08-19 for the tt-ornith-optimized program-blob regeneration, after
|
|
the mtime check passed 21 of 21 directories two of which were provably stale.
|
|
Issues: None
|
|
Todo: None
|
|
"""
|
|
|
|
import hashlib
|
|
import os
|
|
|
|
FORMAT_NAME = "ft-tt-prov"
|
|
FORMAT_VERSION = 1
|
|
|
|
# 128 bits of SHA-256, hex. Long enough that a collision is not a thing that happens, short
|
|
# enough that a provenance block stays readable in a diff of the generated header -- which
|
|
# is where a human actually reads it.
|
|
DIGEST_HEX_LENGTH = 32
|
|
|
|
# Recipe keys, in the order they are written. Fixed order so two records of the same build
|
|
# are byte-identical and a diff of the generated header shows only what really moved.
|
|
RECIPE_KEYS = (
|
|
"toolchain",
|
|
"ctargs",
|
|
"noc",
|
|
"watcher",
|
|
"lightweight_asserts",
|
|
"relocate_kernel_text",
|
|
"kernel_text_base",
|
|
"cb_table",
|
|
# `-D`s the build handed the compiler that no other field implies -- the DstSync mode
|
|
# the matmul triad is rendered at, for one. Added 2026-08-26 after a variant built at
|
|
# DstSync::SyncFull recorded a recipe IDENTICAL to the shipped SyncHalf one and rebuilt
|
|
# itself into the wrong kernel: the pair WAS passed, and `format_recipe` dropped it
|
|
# because it was not on this tuple. Records written before this exist without the field
|
|
# and still verify -- it is only WRITING that this list governs.
|
|
"defines",
|
|
)
|
|
|
|
|
|
class ProvenanceError(Exception):
|
|
"""A provenance record that cannot be parsed, or a check that cannot be run."""
|
|
|
|
|
|
def hash_bytes(payload):
|
|
"""The one digest function. Everything here is SHA-256 truncated to DIGEST_HEX_LENGTH."""
|
|
return hashlib.sha256(payload).hexdigest()[:DIGEST_HEX_LENGTH]
|
|
|
|
|
|
def hash_file(path):
|
|
with open(path, "rb") as handle:
|
|
return hash_bytes(handle.read())
|
|
|
|
|
|
def format_recipe(recipe):
|
|
"""`key=value` pairs in RECIPE_KEYS order. An absent key is written EMPTY rather than
|
|
omitted, so a build that stopped passing a variable reads as a change instead of
|
|
silently matching a record that never mentioned it."""
|
|
return " ".join("%s=%s" % (key, recipe.get(key, "")) for key in RECIPE_KEYS)
|
|
|
|
|
|
def build_record(image_name, source_name, recipe, inputs):
|
|
"""
|
|
Assemble a provenance record.
|
|
|
|
@param image_name the ELF's file name, e.g. "brisc.elf"
|
|
@param source_name the kernel source's basename, matching the `.src` sidecar
|
|
@param recipe dict over RECIPE_KEYS; missing keys are recorded empty
|
|
@param inputs {tt-relative path: content hash} -- every in-tree file the compile
|
|
opened, plus any input the compiler never sees (the CB table)
|
|
@return the record text, ending in a newline
|
|
"""
|
|
lines = [
|
|
"%s %u" % (FORMAT_NAME, FORMAT_VERSION),
|
|
"image %s" % image_name,
|
|
"source %s" % source_name,
|
|
"recipe %s" % format_recipe(recipe),
|
|
]
|
|
for path in sorted(inputs):
|
|
lines.append("input %s %s" % (path, inputs[path]))
|
|
lines.append("build_id %s" % hash_bytes(("\n".join(lines) + "\n").encode("utf-8")))
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def parse_record(text, origin):
|
|
"""
|
|
Read a record back.
|
|
|
|
REFUSES a record whose `build_id` does not cover its own body: the id is what a reader
|
|
quotes, so an edited record that still quotes its old id would be a record that lies
|
|
about itself.
|
|
|
|
@param text the record's full text
|
|
@param origin where it came from, for error messages
|
|
@return {"image", "source", "recipe" (raw string), "inputs" {path: hash},
|
|
"build_id"}
|
|
"""
|
|
lines = [line for line in text.splitlines() if line.strip()]
|
|
if not lines:
|
|
raise ProvenanceError("%s: empty provenance record" % origin)
|
|
|
|
header = lines[0].split()
|
|
if len(header) != 2 or header[0] != FORMAT_NAME:
|
|
raise ProvenanceError("%s: not a %s record (first line %r)"
|
|
% (origin, FORMAT_NAME, lines[0]))
|
|
if header[1] != str(FORMAT_VERSION):
|
|
raise ProvenanceError("%s: provenance format version %s, this tool speaks %u"
|
|
% (origin, header[1], FORMAT_VERSION))
|
|
|
|
record = {"image": None, "source": None, "recipe": None, "inputs": {}, "build_id": None}
|
|
body = []
|
|
for line in lines:
|
|
key, _, rest = line.partition(" ")
|
|
if key == "build_id":
|
|
record["build_id"] = rest.strip()
|
|
continue
|
|
body.append(line)
|
|
if key in ("image", "source", "recipe"):
|
|
record[key] = rest.strip()
|
|
elif key == "input":
|
|
parts = rest.split()
|
|
if len(parts) != 2:
|
|
raise ProvenanceError("%s: malformed input line %r" % (origin, line))
|
|
record["inputs"][parts[0]] = parts[1]
|
|
elif key != FORMAT_NAME:
|
|
raise ProvenanceError("%s: unknown provenance key %r" % (origin, key))
|
|
|
|
for key in ("image", "source", "recipe", "build_id"):
|
|
if not record[key]:
|
|
raise ProvenanceError("%s: provenance record has no %s" % (origin, key))
|
|
if not record["inputs"]:
|
|
raise ProvenanceError("%s: provenance record lists no inputs -- a record that hashes "
|
|
"nothing cannot go stale, which makes it worse than none"
|
|
% origin)
|
|
|
|
recomputed = hash_bytes(("\n".join(body) + "\n").encode("utf-8"))
|
|
if recomputed != record["build_id"]:
|
|
raise ProvenanceError("%s: build_id %s does not cover this record (recomputed %s) -- "
|
|
"the record has been edited since it was written"
|
|
% (origin, record["build_id"], recomputed))
|
|
return record
|
|
|
|
|
|
def is_build_intermediate(path):
|
|
"""
|
|
Is this recorded input a file the BUILD generated into its own output directory?
|
|
|
|
`build_tensix_program.sh` writes `chlkc_*.cpp`, `defines_generated.h` and
|
|
`gen_<proc>/kernel_includes.hpp` into `programs/<name>/` and the compiler opens them, so
|
|
they land in the depfile and in the record. `tt/programs/` is gitignored: on a CLEAN
|
|
CHECKOUT none of them exist, and reporting each one as STALE is what made this check
|
|
unusable as a gate -- foundry main's blob reports 94 findings of which 66 are only this,
|
|
and the 28 that matter (a gdnc kernel and the matmul reader/writer/compute, both moved
|
|
since the blob was last regenerated) are buried in the noise. Nobody gates on a check
|
|
that is red on a fresh clone for a reason that is not a defect.
|
|
|
|
The exemption is narrow ON PURPOSE: it applies only when the file is ABSENT. A build
|
|
intermediate that IS present is hashed like anything else, so a program directory on
|
|
disk is checked exactly as strictly as before.
|
|
"""
|
|
return path.startswith("programs/")
|
|
|
|
|
|
def check_record(record, tree_root, origin, unverifiable=None):
|
|
"""
|
|
Re-hash every recorded input where it sits now.
|
|
|
|
@param record as returned by parse_record()
|
|
@param tree_root the directory the recorded paths are relative to (foundry/tt)
|
|
@param origin what to name in the findings
|
|
@param unverifiable optional list; absent BUILD INTERMEDIATES are appended to it as
|
|
`(origin, path)` instead of being reported as stale. Pass None (the
|
|
default) to keep the old, stricter behaviour -- a caller that
|
|
cannot report the count must not get the exemption, or "unknown"
|
|
would silently become "green".
|
|
@return a list of human-readable problems; EMPTY means current
|
|
"""
|
|
problems = []
|
|
for path in sorted(record["inputs"]):
|
|
recorded = record["inputs"][path]
|
|
full = os.path.join(tree_root, path)
|
|
if not os.path.isfile(full):
|
|
if unverifiable is not None and is_build_intermediate(path):
|
|
unverifiable.append((origin, path))
|
|
continue
|
|
problems.append("%s: STALE -- %s was built from '%s', which is GONE from %s"
|
|
% (origin, record["image"], path, tree_root))
|
|
continue
|
|
current = hash_file(full)
|
|
if current != recorded:
|
|
problems.append("%s: STALE -- %s was built from '%s' at content %s, and that "
|
|
"file is %s now" % (origin, record["image"], path, recorded,
|
|
current))
|
|
return problems
|
|
|
|
|
|
def read_sidecar(image_path):
|
|
"""The `<image>.prov` beside an ELF, parsed. None when there is no sidecar -- which is
|
|
NOT the same as a clean check and every caller must treat it as unverifiable."""
|
|
sidecar = image_path + ".prov"
|
|
if not os.path.isfile(sidecar):
|
|
return None
|
|
with open(sidecar) as handle:
|
|
return parse_record(handle.read(), sidecar)
|