mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-05 20:13:47 +08:00
345 lines
16 KiB
Python
345 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Enforce cereal's reserved-event contract against the pinned upstream schemas.
|
|
|
|
See openpilot/cereal/README.md, Custom forks. When syncing upstream, review and
|
|
update schema-policy.json alongside upstream-sync.json. Custom fields belong
|
|
inside reserved custom.capnp structs or the exact reviewed payload children
|
|
already shipped by this fork; those children grant no Event slots and their
|
|
existing bodies and reserved-carrier references are immutable. log.capnp may rename only their event
|
|
aliases and the reserved raw-data aliases, preserving ordinals and type IDs.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
POLICY = Path(__file__).with_name('schema-policy.json')
|
|
DECLARATION = re.compile(r'^struct (\w+) @(0x[0-9a-fA-F]+)\s*\{', re.MULTILINE)
|
|
SCHEMA_ROOTS = ('openpilot/cereal', 'opendbc_repo/opendbc/car')
|
|
|
|
|
|
def sha256(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def custom_structs(source):
|
|
# Mask comments and strings before counting braces; quoted defaults may
|
|
# contain braces without ending a message definition.
|
|
masked = re.sub(r'"(?:\\.|[^"\\])*"|\#[^\n]*', lambda m: ' ' * len(m[0]), source)
|
|
definitions, bodies, spans = {}, {}, []
|
|
cursor = 0
|
|
for match in DECLARATION.finditer(masked):
|
|
if match.start() < cursor:
|
|
continue
|
|
depth, end = 1, match.end()
|
|
while depth and end < len(masked):
|
|
depth += (masked[end] == '{') - (masked[end] == '}')
|
|
end += 1
|
|
if depth:
|
|
raise ValueError('Unclosed custom struct')
|
|
name, type_id = match.groups()
|
|
if type_id.lower() in definitions or name in definitions.values():
|
|
raise ValueError('Duplicate custom type ID or name')
|
|
definitions[type_id.lower()] = name
|
|
bodies[type_id.lower()] = source[match.end():end - 1]
|
|
spans.append((match.start(), end))
|
|
cursor = end
|
|
remainder = source
|
|
for start, end in reversed(spans):
|
|
remainder = remainder[:start] + remainder[end:]
|
|
return definitions, bodies, remainder
|
|
|
|
|
|
def source_tokens(source):
|
|
"""Tokenize the reviewed schema subset, preserving quoted defaults exactly."""
|
|
return re.findall(r'"(?:\\.|[^"\\])*"|[A-Za-z_][A-Za-z_0-9]*|0x[0-9a-fA-F]+|[0-9]+|[^\s]',
|
|
re.sub(r'"(?:\\.|[^"\\])*"|#[^\n]*',
|
|
lambda match: match[0] if match[0].startswith('"') else '', source))
|
|
|
|
|
|
def source_scope(source, kind='struct'):
|
|
"""Parse fields, enum values and nested scopes; unsupported syntax fails shut.
|
|
|
|
This is a source identity contract, not a replacement for compiled wire-layout
|
|
checks. In particular no annotations, aliases, groups or implicit unions are
|
|
accepted by this subset without a separate grammar/contract review.
|
|
"""
|
|
tokens = source_tokens(source)
|
|
position = 0
|
|
|
|
def parse(scope_kind, nested=False):
|
|
nonlocal position
|
|
fields, children, unions = [], {}, []
|
|
while position < len(tokens) and tokens[position] != '}':
|
|
word = tokens[position]
|
|
if word in ('struct', 'enum', 'union'):
|
|
position += 1
|
|
if word == 'union':
|
|
name, type_id = '', None
|
|
else:
|
|
if position >= len(tokens) or not re.fullmatch(r'[A-Za-z_]\w*', tokens[position]):
|
|
raise ValueError('Invalid nested type name')
|
|
name, type_id = tokens[position], None
|
|
position += 1
|
|
if position < len(tokens) and tokens[position] == '@':
|
|
position += 1
|
|
if position >= len(tokens) or not re.fullmatch(r'0x[0-9a-fA-F]+', tokens[position]):
|
|
raise ValueError('Invalid nested type ID')
|
|
type_id = tokens[position].lower()
|
|
position += 1
|
|
if position >= len(tokens) or tokens[position] != '{' or scope_kind == 'enum':
|
|
raise ValueError('Unsupported nested declaration')
|
|
position += 1
|
|
child = {'kind': word, 'type_id': type_id, 'body': parse(word, True)}
|
|
if word == 'union':
|
|
unions.append(child)
|
|
elif name in children:
|
|
raise ValueError('Duplicate nested type name')
|
|
else:
|
|
children[name] = child
|
|
continue
|
|
start = position
|
|
while position < len(tokens) and tokens[position] not in (';', '{', '}'):
|
|
position += 1
|
|
if position >= len(tokens) or tokens[position] != ';':
|
|
raise ValueError('Unsupported or unterminated member declaration')
|
|
member = tokens[start:position]
|
|
position += 1
|
|
if (len(member) < 3 or not re.fullmatch(r'[A-Za-z_]\w*', member[0]) or
|
|
member[1] != '@' or not member[2].isdigit()):
|
|
raise ValueError('Unsupported member declaration')
|
|
if scope_kind == 'enum':
|
|
if len(member) != 3:
|
|
raise ValueError('Unsupported enumerant declaration')
|
|
else:
|
|
if len(member) < 5 or member[3] != ':':
|
|
raise ValueError('Unsupported field declaration')
|
|
type_end = member.index('=') if '=' in member else len(member)
|
|
descriptor = ''.join(member[4:type_end])
|
|
# No aliases/annotations/inline defaults can silently affect resolution.
|
|
type_pattern = r'[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*'
|
|
while descriptor.startswith('List(') and descriptor.endswith(')'):
|
|
descriptor = descriptor[5:-1]
|
|
if not re.fullmatch(type_pattern, descriptor):
|
|
raise ValueError('Unsupported field type')
|
|
if type_end < len(member):
|
|
default = ''.join(member[type_end + 1:])
|
|
if not re.fullmatch(r'"(?:\\.|[^"\\])*"|true|false|[-+]?[0-9]+(?:\.[0-9]+)?', default):
|
|
raise ValueError('Unsupported field default')
|
|
fields.append(member)
|
|
if nested:
|
|
if position >= len(tokens):
|
|
raise ValueError('Unclosed nested declaration')
|
|
position += 1
|
|
elif position != len(tokens):
|
|
raise ValueError('Unexpected closing brace')
|
|
names = [field[0] for field in fields]
|
|
ordinals = [int(field[2]) for field in fields]
|
|
# Anonymous-union fields share their containing struct's ordinal space.
|
|
for union in unions:
|
|
names.extend(field[0] for field in union['body']['fields'])
|
|
ordinals.extend(int(field[2]) for field in union['body']['fields'])
|
|
if len(names) != len(set(names)) or len(ordinals) != len(set(ordinals)):
|
|
raise ValueError('Duplicate member name or ordinal')
|
|
if set(names) & set(children):
|
|
raise ValueError('Field shadows nested type')
|
|
return {'fields': fields, 'children': children, 'unions': unions}
|
|
|
|
return parse(kind)
|
|
|
|
|
|
def preserves_scope(current, historical):
|
|
"""Allow recursive appends while preserving every historical declaration."""
|
|
old_fields, new_fields = historical['fields'], current['fields']
|
|
if new_fields[:len(old_fields)] != old_fields or current['unions'] != historical['unions']:
|
|
return False
|
|
if old_fields and any(int(field[2]) <= max(int(old[2]) for old in old_fields)
|
|
for field in new_fields[len(old_fields):]):
|
|
return False
|
|
for name, old in historical['children'].items():
|
|
new = current['children'].get(name)
|
|
if (new is None or new['kind'] != old['kind'] or new['type_id'] != old['type_id'] or
|
|
not preserves_scope(new['body'], old['body'])):
|
|
return False
|
|
return True
|
|
|
|
|
|
def payload_child_contract(definitions, bodies, reviewed):
|
|
"""The nine shipped payload helpers are not additional Event reservations."""
|
|
errors, trees, ids = [], {}, set()
|
|
for type_id, name in definitions.items():
|
|
trees[name] = source_scope(bodies[type_id])
|
|
ids.add(type_id)
|
|
for name, helper in reviewed.items():
|
|
type_id = helper['type_id']
|
|
if (definitions.get(type_id) != name or
|
|
trees.get(name) != source_scope(helper['body'])):
|
|
errors.append(f'custom.capnp: reviewed payload child {name} changed')
|
|
|
|
scopes = {}
|
|
def visit(name, tree):
|
|
scopes[name] = tree
|
|
for child_name, child in tree['children'].items():
|
|
child_id = child['type_id']
|
|
if child_id:
|
|
if child_id in ids:
|
|
raise ValueError('Duplicate explicit nested type ID')
|
|
ids.add(child_id)
|
|
visit(name + '.' + child_name, child['body'])
|
|
for name, tree in trees.items():
|
|
visit(name, tree)
|
|
|
|
def resolved(scope, descriptor):
|
|
while descriptor.startswith('List(') and descriptor.endswith(')'):
|
|
descriptor = descriptor[5:-1]
|
|
parts = scope.split('.')
|
|
while parts:
|
|
candidate = '.'.join(parts) + '.' + descriptor
|
|
if candidate in scopes:
|
|
return candidate
|
|
parts.pop()
|
|
return descriptor if descriptor in scopes else None
|
|
|
|
incoming = {name: [] for name in reviewed}
|
|
# Lexical nesting alone never makes a type reachable. Only an
|
|
# actual field reference from an already-reachable scope does.
|
|
graph = {scope: set() for scope in scopes}
|
|
for scope, tree in scopes.items():
|
|
fields = tree['fields'] + [field for union in tree['unions'] for field in union['body']['fields']]
|
|
for field in fields:
|
|
end = field.index('=') if '=' in field else len(field)
|
|
target = resolved(scope, ''.join(field[4:end]))
|
|
if target is None:
|
|
continue
|
|
source_root, target_root = scope.split('.')[0], target.split('.')[0]
|
|
graph[scope].add(target)
|
|
if target_root in reviewed and source_root not in reviewed:
|
|
incoming[target_root].append({'scope': scope, 'field': field[0], 'ordinal': int(field[2]),
|
|
'target': target, 'declaration': ''.join(field)})
|
|
def ordered(entries):
|
|
return sorted(entries, key=lambda entry: (entry['scope'], entry['ordinal']))
|
|
|
|
for name, helper in reviewed.items():
|
|
if ordered(incoming[name]) != ordered(helper['owners']):
|
|
errors.append(f'custom.capnp: reviewed payload child {name} carrier references changed')
|
|
pending, reachable = list(set(trees) - set(reviewed)), set()
|
|
while pending:
|
|
name = pending.pop()
|
|
if name in reachable:
|
|
continue
|
|
reachable.add(name)
|
|
pending.extend(graph[name] - reachable)
|
|
if set(reviewed) - reachable:
|
|
errors.append('custom.capnp: reviewed payload children must remain reachable from reserved carriers')
|
|
return errors
|
|
|
|
|
|
def validate(schemas, policy, sync):
|
|
errors = []
|
|
expected_paths = {*policy['unchanged_schemas'], 'openpilot/cereal/custom.capnp', 'openpilot/cereal/log.capnp'}
|
|
for path in sorted(set(schemas) - expected_paths):
|
|
errors.append(f'{path}: schema has no explicit policy coverage')
|
|
for path in sorted(expected_paths - set(schemas)):
|
|
errors.append(f'{path}: policy-covered schema is missing')
|
|
if expected_paths - set(schemas):
|
|
return errors
|
|
dependencies = {entry['path']: entry['commit'] for entry in sync['dependencies']}
|
|
if sync['upstream']['commit'] != policy['upstream_commit'] or dependencies.get('opendbc_repo') != policy['opendbc_commit']:
|
|
errors.append('Schema policy must be reviewed against the current upstream sync pins')
|
|
for path, expected in policy['unchanged_schemas'].items():
|
|
reviewed = policy.get('reviewed_enum_additions', {}).get(path)
|
|
candidate = schemas[path]
|
|
if reviewed:
|
|
source = candidate.decode()
|
|
pattern = rf'(enum {re.escape(reviewed["enum"])}\s*\{{)([^}}]*)(\}})'
|
|
match = re.search(pattern, source)
|
|
field = reviewed['field']
|
|
if match is None or len(re.findall(rf'(?m)^\s*{re.escape(field)}\s*$', match[2])) != 1:
|
|
errors.append(f'{path}: reviewed enum addition is missing or changed')
|
|
else:
|
|
body = re.sub(rf'(?m)^[ \t]*{re.escape(field)}[ \t]*\n', '', match[2])
|
|
source = source[:match.start(2)] + body + source[match.end(2):]
|
|
candidate = source.encode()
|
|
if sha256(candidate) != expected:
|
|
errors.append(f'{path}: upstream-owned schema changed; use custom.capnp reserved structs')
|
|
try:
|
|
definitions, bodies, remainder = custom_structs(schemas['openpilot/cereal/custom.capnp'].decode())
|
|
except (UnicodeDecodeError, ValueError) as error:
|
|
return [*errors, f'custom.capnp: {error}']
|
|
reviewed_children = policy.get('reviewed_payload_children', {})
|
|
expected_ids = ({slot['type_id'] for slot in policy['reserved']} |
|
|
{helper['type_id'] for helper in reviewed_children.values()})
|
|
if set(definitions) != expected_ids:
|
|
errors.append('custom.capnp: reserved type IDs were removed/changed or unreserved top-level types were added')
|
|
reserved_by_name = {slot['struct']: slot for slot in policy['reserved']}
|
|
for index in policy['historical_empty_slots']:
|
|
original_name = f'CustomReserved{index}'
|
|
type_id = reserved_by_name[original_name]['type_id']
|
|
body = re.sub(r'#[^\n]*', '', bodies.get(type_id, ''))
|
|
if definitions.get(type_id) != original_name or body.strip():
|
|
errors.append(f'custom.capnp: historical slot {index} must retain its original name and empty body')
|
|
for index, restored in policy.get('historical_restored', {}).items():
|
|
type_id = restored['type_id']
|
|
body = re.sub(r'#[^\n]*', '', bodies.get(type_id, ''))
|
|
field = restored['field']
|
|
if not re.search(rf'(?m)^\s*{re.escape(field)}\s*$', body):
|
|
errors.append(f'custom.capnp: historical slot {index} must preserve {field}')
|
|
slot = next((entry for entry in policy['reserved'] if entry['type_id'] == type_id), None)
|
|
if slot is None or slot['ordinal'] != restored['ordinal']:
|
|
errors.append(f'custom.capnp: historical slot {index} type ID or event ordinal changed')
|
|
for restored in policy.get('historical_prefixes', {}).values():
|
|
try:
|
|
preserved = (definitions.get(restored['type_id']) == restored['name'] and
|
|
preserves_scope(source_scope(bodies.get(restored['type_id'], '')), source_scope(restored['body'])))
|
|
except ValueError as error:
|
|
# Invalid syntax/duplicate ordinals inside an occupied historical scope
|
|
# also violate that scope's contract; retain this diagnostic boundary.
|
|
errors.append(f'custom.capnp: historical occupied prefix changed: {error}')
|
|
else:
|
|
if not preserved:
|
|
errors.append('custom.capnp: historical occupied prefix changed')
|
|
try:
|
|
errors.extend(payload_child_contract(definitions, bodies, reviewed_children))
|
|
except ValueError as error:
|
|
errors.append(f'custom.capnp: source contract rejected: {error}')
|
|
if remainder.strip() != policy['custom_header'].strip():
|
|
errors.append('custom.capnp: edits outside the reserved structs')
|
|
log = schemas['openpilot/cereal/log.capnp'].decode()
|
|
for slot in policy['reserved']:
|
|
name = definitions.get(slot['type_id'])
|
|
if name is None:
|
|
continue
|
|
# Changing the name is permitted; changing its type or ordinal is not.
|
|
pattern = rf'(?m)^(\s*)\w+ @{slot["ordinal"]} :Custom\.{re.escape(name)};'
|
|
log, count = re.subn(pattern, rf'\g<1>{slot["field"]} @{slot["ordinal"]} :Custom.{slot["struct"]};', log)
|
|
if count != 1:
|
|
errors.append(f'log.capnp: reserved event @{slot["ordinal"]} no longer points to its original custom type ID')
|
|
for ordinal, name in policy['raw_reserved'].items():
|
|
log, count = re.subn(rf'(?m)^(\s*)\w+ @{ordinal} :Data;', rf'\g<1>{name} @{ordinal} :Data;', log)
|
|
if count != 1:
|
|
errors.append(f'log.capnp: reserved raw event @{ordinal} changed type or ordinal')
|
|
if policy.get('reviewed_log_additions') or policy.get('reviewed_log_ordinal_remaps'):
|
|
errors.append('log.capnp: core schema exceptions are not permitted; use custom.capnp reserved structs')
|
|
if sha256(log.encode()) != policy['log_sha256']:
|
|
errors.append('log.capnp: changes outside the permitted reserved-event aliases')
|
|
return errors
|
|
|
|
|
|
def read_inputs(root=ROOT):
|
|
policy = json.loads(POLICY.read_text())
|
|
paths = {path.relative_to(root).as_posix() for schema_root in SCHEMA_ROOTS for path in (root / schema_root).rglob('*.capnp')}
|
|
return {path: (root / path).read_bytes() for path in paths}, policy, json.loads((root / 'upstream-sync.json').read_text())
|
|
|
|
|
|
def main():
|
|
errors = validate(*read_inputs())
|
|
print(json.dumps({'passed': not errors, 'errors': errors}, indent=2))
|
|
return int(bool(errors))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|