mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 18:13:23 +09:00
[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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"1": "7fc859f7fa756818401f4162e7d49a25d430b91e44d89c297321e3cb36c69638"
|
||||
}
|
||||
Reference in New Issue
Block a user