From c69d8dfdc8b30f6ed20048ee9a84167380a84c5c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 22 Sep 2026 21:43:44 -0400 Subject: [PATCH] [MG_Remote, MG_Test, ci] (P7): sha256(protocol.fbs) is pinned to MOBILEGL_PROTOCOL_CONTROL_REVISION, so a control-schema edit without a bump is red in the unit lane and in flatc-check instead of a silently absent field - the revision is a hand-bumped integer mixed into wireFingerprint and nothing tied it to protocol.fbs's content; scripts/ci/protocol_revision_pin.py holds {revision -> sha256(protocol.fbs)} in protocol_revision_pins.json and refuses a schema whose digest is not the current revision's, or a revision with no row - registered as ProtocolSchema.RevisionPinsDigest (label unit, MG_Test/Wire/CMakeLists.txt, --self-test proves both perturbations red before it says green) and as a step of CI's flatc-check job, the one that regenerates the header on every schema edit - --write records a bump's row and refuses to re-pin an existing revision to a different digest, which is what a schema edit without a bump looks like - mg_protocol_base.h says where the pin lives beside the revision it pins - red-once: protocol.fbs touched with one trailing comment, revision left at 1 -> "protocol.fbs has sha256 a7b5c7fb... but MOBILEGL_PROTOCOL_CONTROL_REVISION is still 1, which is pinned to 7fc859f7..." rc=1; restored, green --- .github/workflows/test.yml | 10 ++ .../MG_Remote/Protocol/mg_protocol_base.h | 7 + MobileGL/MG_Test/Wire/CMakeLists.txt | 13 ++ scripts/ci/protocol_revision_pin.py | 139 ++++++++++++++++++ scripts/ci/protocol_revision_pins.json | 3 + 5 files changed, 172 insertions(+) create mode 100644 scripts/ci/protocol_revision_pin.py create mode 100644 scripts/ci/protocol_revision_pins.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 68bc9aac..9cbaa52c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2217,6 +2217,16 @@ jobs: - name: Fail if the committed header is stale run: git diff --exit-code -- MobileGL/MG_Remote/Protocol/generated/protocol_generated.h + # P7 wave 2-F, F fix round (PH-7 (4) review). MOBILEGL_PROTOCOL_CONTROL_REVISION is mixed + # into wireFingerprint so a control-schema change refuses an old peer by name - but it is a + # hand-bumped integer, and nothing tied it to protocol.fbs's content. This step pins + # sha256(protocol.fbs) to the revision (scripts/ci/protocol_revision_pins.json): a schema + # edit that forgot the bump is red HERE, in the job that regenerates the header on every + # such edit, and not a silently absent field between an old server and a new client. Its + # self-test proves the check goes red on both perturbations before it says green. + - name: Fail if protocol.fbs changed without a MOBILEGL_PROTOCOL_CONTROL_REVISION bump + run: python3 scripts/ci/protocol_revision_pin.py --self-test + # P0.5 interface-purity gate A (ARCHITECTURE.md:501): the two extracted headers' include closure, # asserted on `-H` output because `nm --undefined-only` is blind to "included but not called" - # a header whose types are never named leaves no symbol behind, and "included at all" is exactly diff --git a/MobileGL/MG_Remote/Protocol/mg_protocol_base.h b/MobileGL/MG_Remote/Protocol/mg_protocol_base.h index e790d040..579d59d6 100644 --- a/MobileGL/MG_Remote/Protocol/mg_protocol_base.h +++ b/MobileGL/MG_Remote/Protocol/mg_protocol_base.h @@ -56,6 +56,13 @@ extern "C" { // control message changed shape, so a peer built before a control-schema change agreed with one // built after it and the difference surfaced as whatever the first misread field did. Bump this // with every protocol.fbs change a peer must agree on. +// +// THE PIN. This is a hand-bumped integer, and the bump is the only thing that moves the +// fingerprint - so a protocol.fbs edit that forgot it would put old and new peers back in +// silent agreement. scripts/ci/protocol_revision_pin.py holds {revision -> sha256(protocol.fbs)} +// (protocol_revision_pins.json) and refuses a schema whose digest is not the current revision's; +// it runs as ProtocolSchema.RevisionPinsDigest (label unit) and in CI's flatc-check job. After a +// bump: `python3 scripts/ci/protocol_revision_pin.py --write` records the new row. // 1 P7 wave 2-F, PH-7 (4): Welcome.dataNonce and the DataBind message. #define MOBILEGL_PROTOCOL_CONTROL_REVISION 1 diff --git a/MobileGL/MG_Test/Wire/CMakeLists.txt b/MobileGL/MG_Test/Wire/CMakeLists.txt index fc37f416..810ff863 100644 --- a/MobileGL/MG_Test/Wire/CMakeLists.txt +++ b/MobileGL/MG_Test/Wire/CMakeLists.txt @@ -146,6 +146,19 @@ foreach (wiretest IN ITEMS RemoteClientTest ServerLoopTest SessionHandshakeTest gtest_discover_tests(${wiretest} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) endforeach () +# P7 wave 2-F, F fix round (PH-7 (4) review). MOBILEGL_PROTOCOL_CONTROL_REVISION is a hand-bumped +# integer mixed into wireFingerprint, and nothing tied it to protocol.fbs's content: a schema edit +# that forgot the bump would leave old and new peers agreeing on the fingerprint and surface as a +# silently absent field. This entry runs the pin ({revision -> sha256(protocol.fbs)}, +# scripts/ci/protocol_revision_pin.py) with its self-test, so that edit is red in the unit lane. +# No compilation, no GL; label unit, the way MobileGLPurity.IncludeClosure is registered. +find_package(Python3 COMPONENTS Interpreter) +if (Python3_Interpreter_FOUND) + add_test(NAME ProtocolSchema.RevisionPinsDigest + COMMAND ${Python3_EXECUTABLE} ${MGL_ROOT}/scripts/ci/protocol_revision_pin.py --self-test) + set_tests_properties(ProtocolSchema.RevisionPinsDigest PROPERTIES LABELS unit) +endif () + # P6.5 manual fault-injection probe: requires an explicit live peer and is never # registered with ctest, so a device-less unit run cannot silently skip this gate. if (NOT WIN32) diff --git a/scripts/ci/protocol_revision_pin.py b/scripts/ci/protocol_revision_pin.py new file mode 100644 index 00000000..bc6cc0c9 --- /dev/null +++ b/scripts/ci/protocol_revision_pin.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""The control-schema revision pin (P7 wave 2-F, F fix round; PH-7 (4) review). + +MOBILEGL_PROTOCOL_CONTROL_REVISION (MobileGL/MG_Remote/Protocol/mg_protocol_base.h) is mixed into +wireFingerprint so that a control-schema change moves the fingerprint and two peers built on +either side of it refuse each other by name (Refuse{WireFingerprint}). The revision is a +hand-bumped integer, and until this gate nothing tied it to protocol.fbs's CONTENT: a later +protocol.fbs change without a bump would leave old and new peers agreeing on wireFingerprint and +surface as a silently absent field - the exact failure the revision was added to prevent. + +This script pins the two together. protocol_revision_pins.json beside it is a table +{revision: sha256(protocol.fbs)} of the schema each revision was minted for. The check reads the +header's current revision, hashes protocol.fbs (line endings normalised, so a CRLF checkout hashes +the same bytes as an LF one) and refuses when the current revision has no row or its row is not +the file's digest. Both directions are covered: a schema edit that forgot the bump is red (stale +row), and a bump that forgot the table is red (no row), so the table cannot fall behind the header. + +Registered as `ProtocolSchema.RevisionPinsDigest` (label unit, MG_Test/Wire/CMakeLists.txt) and as +a step of CI's flatc-check job - the job that regenerates the header on any protocol.fbs change, +which is where a forgotten bump would otherwise sail through. + + python3 scripts/ci/protocol_revision_pin.py the check + python3 scripts/ci/protocol_revision_pin.py --self-test the check, plus proof it goes red + python3 scripts/ci/protocol_revision_pin.py --write after a bump: pin the new revision + +`--write` refuses to overwrite an existing row with a different digest: that is what a schema edit +without a bump looks like, and the answer to it is a bump, not a re-pin (`--force` for the rare +fix-up of the bump commit itself). +""" +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +HEADER = ROOT / "MobileGL" / "MG_Remote" / "Protocol" / "mg_protocol_base.h" +SCHEMA = ROOT / "MobileGL" / "MG_Remote" / "Protocol" / "protocol.fbs" +PINS = Path(__file__).with_name("protocol_revision_pins.json") + +REVISION = re.compile(r"^\s*#define\s+MOBILEGL_PROTOCOL_CONTROL_REVISION\s+(\d+)\s*$", re.MULTILINE) + + +def read_revision(header_text): + """The header's current revision, or None when the define is missing.""" + found = REVISION.search(header_text) + return int(found.group(1)) if found else None + + +def schema_digest(schema_bytes): + """sha256 of the schema with CRLF folded to LF, hex.""" + return hashlib.sha256(schema_bytes.replace(b"\r\n", b"\n")).hexdigest() + + +def check(revision, digest, pins): + """The failures, as messages; empty when the pin holds.""" + if revision is None: + return [f"{HEADER.relative_to(ROOT).as_posix()} has no `#define " + f"MOBILEGL_PROTOCOL_CONTROL_REVISION ` line to pin"] + row = pins.get(str(revision)) + if row is None: + return [f"MOBILEGL_PROTOCOL_CONTROL_REVISION is {revision} and " + f"{PINS.relative_to(ROOT).as_posix()} has no row for it. A bump records the " + f"schema it was minted for in the same commit: " + f"`python3 scripts/ci/protocol_revision_pin.py --write`."] + if row != digest: + return [f"protocol.fbs has sha256 {digest} but MOBILEGL_PROTOCOL_CONTROL_REVISION is still " + f"{revision}, which is pinned to {row}. A control-schema change a peer must agree " + f"on needs a revision bump (mg_protocol_base.h) so wireFingerprint moves and an " + f"old peer is refused by name instead of misreading the new frame; then " + f"`python3 scripts/ci/protocol_revision_pin.py --write` pins the new revision."] + return [] + + +def load_pins(): + if not PINS.is_file(): + return {} + return json.loads(PINS.read_text(encoding="utf-8")) + + +def self_test(revision, digest, pins): + """The check must go red on the two perturbations it exists for, and green on the truth.""" + failures = [] + touched = schema_digest(SCHEMA.read_bytes() + b"\n// touched without a bump\n") + if not check(revision, touched, pins): + failures.append("self-test: a protocol.fbs edit with no revision bump was NOT refused") + if not check(revision + 1, digest, pins): + failures.append("self-test: a revision bump with no table row was NOT refused") + if check(revision, digest, pins): + failures.append("self-test: the recorded pin itself does not hold (see the check's own line)") + return failures + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--write", action="store_true", + help="record the current revision's digest in protocol_revision_pins.json") + parser.add_argument("--force", action="store_true", + help="with --write: overwrite an existing row that disagrees") + parser.add_argument("--self-test", action="store_true", + help="also prove the check goes red on a touched schema and on a rowless bump") + args = parser.parse_args() + + revision = read_revision(HEADER.read_text(encoding="utf-8")) + digest = schema_digest(SCHEMA.read_bytes()) + pins = load_pins() + + if args.write: + if revision is None: + print("::error::no MOBILEGL_PROTOCOL_CONTROL_REVISION define to pin", file=sys.stderr) + return 1 + existing = pins.get(str(revision)) + if existing is not None and existing != digest and not args.force: + print(f"::error::revision {revision} is already pinned to {existing} and protocol.fbs " + f"now hashes to {digest}: that is a schema change without a bump. Bump " + f"MOBILEGL_PROTOCOL_CONTROL_REVISION first, then --write (or --force to re-pin " + f"this revision on purpose).", file=sys.stderr) + return 1 + pins[str(revision)] = digest + ordered = {key: pins[key] for key in sorted(pins, key=int)} + PINS.write_text(json.dumps(ordered, indent=2) + "\n", encoding="utf-8") + print(f"protocol revision pin: revision {revision} -> sha256 {digest}") + return 0 + + failures = check(revision, digest, pins) + if args.self_test and not failures: + failures = self_test(revision, digest, pins) + if failures: + for message in failures: + print(f"::error::{message}", file=sys.stderr) + return 1 + print(f"protocol revision pin: revision {revision} holds (sha256 {digest[:16]}..., " + f"{len(pins)} revision(s) pinned{', self-test red on both perturbations' if args.self_test else ''})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/protocol_revision_pins.json b/scripts/ci/protocol_revision_pins.json new file mode 100644 index 00000000..a0a1006d --- /dev/null +++ b/scripts/ci/protocol_revision_pins.json @@ -0,0 +1,3 @@ +{ + "1": "7fc859f7fa756818401f4162e7d49a25d430b91e44d89c297321e3cb36c69638" +}