[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
This commit is contained in:
2026-09-22 21:54:46 -04:00
parent 0293aebb17
commit c69d8dfdc8
5 changed files with 172 additions and 0 deletions
+10
View File
@@ -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
@@ -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
+13
View File
@@ -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)
+139
View File
@@ -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 <n>` 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())
+3
View File
@@ -0,0 +1,3 @@
{
"1": "7fc859f7fa756818401f4162e7d49a25d430b91e44d89c297321e3cb36c69638"
}