1#!/usr/bin/env python3
2"""Generate the build manifest required by scripts/collect_first_presentation.sh.
3
4The collector binds the measured binary to its source and dependency
5revisions via `go version -m` plus this manifest, and fails closed when
6attribution is missing or mismatched. Generate the manifest at build time
7from the same tree that produced the binary:
8
9 python3 scripts/generate_build_manifest.py --binary dist/dumber
10 DUMBER_CEF_DIR=/path/to/cef-runtime scripts/collect_first_presentation.sh
11
12Manifest schema (JSON, sorted keys):
13 {
14 "binary_sha256": "<hex sha256 of the measured binary>",
15 "source_revision": "<40-hex git revision of the built source>",
16 "modules": {
17 "<module path>": {"version": "<exact version>", "revision": "<40-hex>"},
18 ...
19 }
20 }
21"""
22
23import argparse
24import hashlib
25import json
26import os
27import re
28import subprocess
29import sys
30
31UPSTREAM_MODULE = "github.com/bnema/purego-cef2gtk"
32VERSION_RE = r"(v\d+\.\d+\.\d+)-0\.\d{14}-([0-9a-f]{12})"
33TAG_RE = r"v\d+\.\d+\.\d+"
34
35
36def run(args, **kwargs):
37 return subprocess.check_output(args, text=True, **kwargs).strip()
38
39
40def fail(message):
41 print("generate-build-manifest: {}".format(message), file=sys.stderr)
42 raise SystemExit(2)
43
44
45def main():
46 parser = argparse.ArgumentParser(description="Generate a collector build manifest.")
47 parser.add_argument("--binary", required=True, help="Measured binary to attribute.")
48 parser.add_argument("--output", default=None, help="Manifest path (default: <binary>.manifest.json).")
49 parser.add_argument("--source-revision", default=None,
50 help="40-hex source revision (default: git rev-parse HEAD).")
51 parser.add_argument("--module", default=UPSTREAM_MODULE, help="Dependency module to bind.")
52 args = parser.parse_args()
53
54 if not os.path.isfile(args.binary):
55 fail("binary not found")
56 output = args.output or (args.binary + ".manifest.json")
57
58 with open(args.binary, "rb") as candidate:
59 # Chunked streaming hash: hashlib.file_digest needs 3.11+, this
60 # stays compatible with older 3.x interpreters.
61 digest = hashlib.sha256()
62 for chunk in iter(lambda: candidate.read(65536), b""):
63 digest.update(chunk)
64 binary_sha256 = digest.hexdigest()
65
66 try:
67 buildinfo = run(["go", "version", "-m", args.binary], stderr=subprocess.DEVNULL)
68 except (OSError, subprocess.CalledProcessError):
69 fail("could not read embedded build info")
70 if "=>" in buildinfo:
71 fail("binary was built with a replacement; refusing to attribute it")
72 dep_version = None
73 vcs_revision = None
74 vcs_modified = None
75 for line in buildinfo.splitlines():
76 parts = line.split()
77 if len(parts) >= 3 and parts[0] == "dep" and parts[1] == args.module:
78 dep_version = parts[2]
79 # `go version -m` reports stamping as "build vcs.revision=<hex>"
80 # (two tab-separated fields, key=value form).
81 elif len(parts) >= 2 and parts[0] == "build" and parts[1].startswith("vcs."):
82 key, _, value = parts[1].partition("=")
83 if key == "vcs.revision":
84 vcs_revision = value
85 elif key == "vcs.modified":
86 vcs_modified = value
87 # The measured binary, not the ambient checkout, is the provenance
88 # source: build-manifest builds with VCS stamping enabled, so the
89 # embedded revision describes the exact built tree. Binaries without
90 # stamping (for example -buildvcs=false quick builds) or built from a
91 # dirty tree are rejected instead of inheriting the checkout HEAD.
92 if vcs_revision is None or not re.fullmatch(r"[0-9a-f]{40}", vcs_revision):
93 fail("binary lacks embedded VCS revision; rebuild with VCS stamping enabled")
94 if vcs_modified != "false":
95 fail("binary was built from a dirty checkout; refusing to attribute it")
96 if dep_version is None:
97 fail("dependency {} not found in build info".format(args.module))
98 pseudo = re.fullmatch(VERSION_RE, dep_version)
99 tag = re.fullmatch(TAG_RE, dep_version)
100 if not pseudo and not tag:
101 fail("dependency version is not an immutable tag or pseudo-version")
102
103 try:
104 downloaded = json.loads(run(["go", "mod", "download", "-json", "{}@{}".format(args.module, dep_version)],
105 stderr=subprocess.DEVNULL))
106 except (OSError, subprocess.CalledProcessError, ValueError):
107 fail("could not resolve dependency origin")
108 origin = downloaded.get("Origin") or {}
109 revision = origin.get("Hash", "")
110 if origin.get("VCS") != "git" or not re.fullmatch(r"[0-9a-f]{40}", revision):
111 fail("dependency origin is not an immutable git hash")
112 if pseudo and not revision.startswith(pseudo.group(2)):
113 fail("dependency revision does not match its pseudo-version")
114
115 source_revision = args.source_revision or vcs_revision
116 if not re.fullmatch(r"[0-9a-f]{40}", source_revision or ""):
117 fail("source revision must be a 40-hex git revision")
118 if source_revision != vcs_revision:
119 fail("source revision does not match the revision embedded in the binary")
120
121 manifest = {
122 "binary_sha256": binary_sha256,
123 "source_revision": source_revision,
124 "modules": {
125 args.module: {"version": dep_version, "revision": revision},
126 },
127 }
128 with open(output, "w", encoding="utf-8") as handle:
129 json.dump(manifest, handle, indent=2, sort_keys=True)
130 handle.write("\n")
131 print("manifest: {}".format(output))
132
133
134if __name__ == "__main__":
135 main()