Files
meshcore-analyzer/tools/validate-protos.py
Kpa-clawbot 9d7a3eb2d1 feat: capture one fixture per packet type (fixes #177)
Add per-payload-type packet detail fixtures captured from production:
- packet-type-advert.json (payload_type=4, ADVERT)
- packet-type-grptxt-decrypted.json (payload_type=5, decrypted GRP_TXT)
- packet-type-grptxt-undecrypted.json (payload_type=5, decryption_failed GRP_TXT)
- packet-type-txtmsg.json (payload_type=1, TXT_MSG)
- packet-type-req.json (payload_type=0, REQ)

Update validate-protos.py to validate all 5 new fixtures against
PacketDetailResponse proto message.

Update CI deploy workflow to automatically capture per-type fixtures
on each deploy, including both decrypted and undecrypted GRP_TXT.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 18:19:55 -07:00

658 lines
25 KiB
Python

#!/usr/bin/env python3
"""
Validate proto definitions against captured Node fixtures.
Parses each .proto file, extracts message field names/types/json_names,
then compares against the actual JSON fixtures to find mismatches.
Usage:
python tools/validate-protos.py
"""
import json
import os
import re
import sys
from collections import defaultdict
PROTO_DIR = os.path.join(os.path.dirname(__file__), '..', 'proto')
FIXTURE_DIR = os.path.join(PROTO_DIR, 'testdata', 'node-fixtures')
# ─── Proto Parser ───────────────────────────────────────────────────────────────
def parse_proto_file(filepath):
"""Parse a .proto file and return dict of message_name -> { fields, oneofs }."""
with open(filepath, encoding='utf-8') as f:
content = f.read()
messages = {}
# Remove comments
content_clean = re.sub(r'//[^\n]*', '', content)
_parse_messages(content_clean, messages)
return messages
def _parse_messages(content, messages, prefix=''):
"""Recursively parse message definitions."""
msg_pattern = re.compile(
r'message\s+(\w+)\s*\{', re.DOTALL
)
pos = 0
while pos < len(content):
m = msg_pattern.search(content, pos)
if not m:
break
msg_name = m.group(1)
full_name = f'{prefix}{msg_name}' if prefix else msg_name
brace_start = m.end() - 1
brace_end = _find_matching_brace(content, brace_start)
if brace_end == -1:
break
body = content[brace_start + 1:brace_end]
fields = _parse_fields(body)
messages[full_name] = fields
# Parse nested messages
_parse_messages(body, messages, prefix=f'{full_name}.')
pos = brace_end + 1
def _find_matching_brace(content, start):
"""Find the closing brace matching the opening brace at start."""
depth = 0
for i in range(start, len(content)):
if content[i] == '{':
depth += 1
elif content[i] == '}':
depth -= 1
if depth == 0:
return i
return -1
def _parse_fields(body):
"""Parse fields from a message body."""
fields = {}
# Handle oneof blocks - extract their fields
oneof_pattern = re.compile(r'oneof\s+\w+\s*\{([^}]*)\}', re.DOTALL)
oneof_fields = []
for om in oneof_pattern.finditer(body):
oneof_body = om.group(1)
for fm in _field_pattern().finditer(oneof_body):
oneof_fields.append(fm)
# Remove oneof blocks and nested message blocks from body for regular field parsing
body_no_oneof = oneof_pattern.sub('', body)
body_no_nested = _remove_nested_messages(body_no_oneof)
for fm in _field_pattern().finditer(body_no_nested):
_add_field(fm, fields)
# Add oneof fields
for fm in oneof_fields:
_add_field(fm, fields, is_oneof=True)
# Handle map fields
map_pattern = re.compile(
r'map\s*<\s*(\w+)\s*,\s*(\w+)\s*>\s+(\w+)\s*=\s*\d+'
r'(?:\s*\[json_name\s*=\s*"([^"]+)"\])?\s*;'
)
for mm in map_pattern.finditer(body_no_nested):
key_type = mm.group(1)
val_type = mm.group(2)
field_name = mm.group(3)
json_name = mm.group(4) or field_name
fields[json_name] = {
'proto_name': field_name,
'proto_type': f'map<{key_type},{val_type}>',
'repeated': False,
'optional': False,
'is_map': True,
}
return fields
def _field_pattern():
return re.compile(
r'(repeated\s+|optional\s+)?'
r'(\w+)\s+'
r'(\w+)\s*=\s*\d+'
r'(?:\s*\[([^\]]*)\])?\s*;'
)
def _add_field(match, fields, is_oneof=False):
modifier = (match.group(1) or '').strip()
proto_type = match.group(2)
field_name = match.group(3)
options = match.group(4) or ''
json_name = field_name
jn_match = re.search(r'json_name\s*=\s*"([^"]+)"', options)
if jn_match:
json_name = jn_match.group(1)
fields[json_name] = {
'proto_name': field_name,
'proto_type': proto_type,
'repeated': modifier == 'repeated',
'optional': modifier == 'optional' or is_oneof,
'is_map': False,
}
def _remove_nested_messages(body):
"""Remove nested message/enum blocks from body."""
result = []
depth = 0
in_nested = False
i = 0
# Find 'message X {' or 'enum X {' patterns
nested_start = re.compile(r'(?:message|enum)\s+\w+\s*\{')
while i < len(body):
if not in_nested:
m = nested_start.search(body, i)
if m and m.start() == i:
in_nested = True
depth = 1
i = m.end()
continue
elif m:
result.append(body[i:m.start()])
in_nested = True
depth = 1
i = m.end()
continue
else:
result.append(body[i:])
break
else:
if body[i] == '{':
depth += 1
elif body[i] == '}':
depth -= 1
if depth == 0:
in_nested = False
i += 1
return ''.join(result)
# ─── Type Checking ──────────────────────────────────────────────────────────────
PROTO_SCALAR_TYPES = {
'int32': (int, float),
'int64': (int, float),
'uint32': (int, float),
'uint64': (int, float),
'sint32': (int, float),
'sint64': (int, float),
'fixed32': (int, float),
'fixed64': (int, float),
'sfixed32': (int, float),
'sfixed64': (int, float),
'float': (int, float),
'double': (int, float),
'bool': (bool,),
'string': (str,),
'bytes': (str,), # base64-encoded in JSON
}
def check_type_match(proto_type, json_value):
"""Check if a JSON value matches the expected proto type.
Returns (matches, detail_string)."""
if json_value is None:
return True, 'null (optional)'
if proto_type in PROTO_SCALAR_TYPES:
expected = PROTO_SCALAR_TYPES[proto_type]
actual_type = type(json_value).__name__
if isinstance(json_value, expected):
return True, f'{actual_type} matches {proto_type}'
# Special: int is valid for float/double
if proto_type in ('float', 'double') and isinstance(json_value, int):
return True, f'int is valid for {proto_type}'
return False, f'expected {proto_type} but got {actual_type}'
# Message type - should be a dict
if isinstance(json_value, dict):
return True, f'object (message {proto_type})'
if isinstance(json_value, list):
return True, f'array (repeated {proto_type})'
return False, f'expected message {proto_type} but got {type(json_value).__name__}'
# ─── Fixture→Message Mapping ───────────────────────────────────────────────────
FIXTURE_TO_MESSAGE = {
'stats.json': ('StatsResponse', 'object'),
'health.json': ('HealthResponse', 'object'),
'perf.json': ('PerfResponse', 'object'),
'nodes.json': ('NodeListResponse', 'object'),
'node-detail.json': ('NodeDetailResponse', 'object'),
'node-health.json': ('NodeHealthResponse', 'object'),
'node-search.json': ('NodeSearchResponse', 'object'),
'node-paths.json': ('NodePathsResponse', 'object'),
'node-analytics.json': ('NodeAnalyticsResponse', 'object'),
'bulk-health.json': ('BulkHealthEntry', 'array'),
'observers.json': ('ObserverListResponse', 'object'),
'observer-detail.json': ('ObserverDetailResponse', 'object'),
'observer-analytics.json': ('ObserverAnalyticsResponse', 'object'),
'packets.json': ('PacketListResponse', 'object'),
'packets-grouped.json': ('GroupedPacketListResponse', 'object'),
'packets-since.json': ('GroupedPacketListResponse', 'object'),
'packet-detail.json': ('PacketDetailResponse', 'object'),
'packet-type-advert.json': ('PacketDetailResponse', 'object'),
'packet-type-grptxt-decrypted.json': ('PacketDetailResponse', 'object'),
'packet-type-grptxt-undecrypted.json': ('PacketDetailResponse', 'object'),
'packet-type-txtmsg.json': ('PacketDetailResponse', 'object'),
'packet-type-req.json': ('PacketDetailResponse', 'object'),
'packet-timestamps.json': ('PacketTimestampsResponse', 'bare-array'),
'channels.json': ('ChannelListResponse', 'object'),
'channel-messages.json': ('ChannelMessagesResponse', 'object'),
'analytics-rf.json': ('RFAnalyticsResponse', 'object'),
'analytics-topology.json': ('TopologyResponse', 'object'),
'analytics-channels.json': ('ChannelAnalyticsResponse', 'object'),
'analytics-hash-sizes.json': ('HashSizeAnalyticsResponse', 'object'),
'analytics-distance.json': ('DistanceAnalyticsResponse', 'object'),
'analytics-subpaths.json': ('SubpathsResponse', 'object'),
'config-theme.json': ('ThemeResponse', 'object'),
'config-regions.json': ('RegionsResponse', 'bare-map'),
'config-client.json': ('ClientConfigResponse', 'object'),
'config-cache.json': ('CacheConfigResponse', 'object'),
'config-map.json': ('MapConfigResponse', 'object'),
'iata-coords.json': ('IataCoordsResponse', 'object'),
'websocket-message.json': ('WSMessage', 'object'),
}
# Sub-message field mappings for recursive validation
FIELD_TYPE_TO_MESSAGE = {
# stats.proto
'MemoryStats': 'MemoryStats',
'EventLoopStats': 'EventLoopStats',
'CacheStats': 'CacheStats',
'WebSocketStats': 'WebSocketStats',
'HealthPacketStoreStats': 'HealthPacketStoreStats',
'HealthPerfStats': 'HealthPerfStats',
'SlowQuery': 'SlowQuery',
'EndpointStats': 'EndpointStats',
'PerfCacheStats': 'PerfCacheStats',
'PerfPacketStoreStats': 'PerfPacketStoreStats',
'PacketStoreIndexes': 'PacketStoreIndexes',
'SqliteStats': 'SqliteStats',
'SqliteRowCounts': 'SqliteRowCounts',
'WalPages': 'WalPages',
# common.proto
'RoleCounts': 'RoleCounts',
'SignalStats': 'SignalStats',
'Histogram': 'Histogram',
'HistogramBin': 'HistogramBin',
'TimeBucket': 'TimeBucket',
# node.proto
'Node': 'Node',
'NodeObserverStats': 'NodeObserverStats',
'NodeStats': 'NodeStats',
'PathHop': 'PathHop',
'PathEntry': 'PathEntry',
'TimeRange': 'TimeRange',
'SnrTrendEntry': 'SnrTrendEntry',
'PayloadTypeCount': 'PayloadTypeCount',
'HopDistEntry': 'HopDistEntry',
'PeerInteraction': 'PeerInteraction',
'HeatmapCell': 'HeatmapCell',
'ComputedNodeStats': 'ComputedNodeStats',
# observer.proto
'Observer': 'Observer',
'SnrDistributionEntry': 'SnrDistributionEntry',
# packet.proto
'Transmission': 'Transmission',
'Observation': 'Observation',
'GroupedPacket': 'GroupedPacket',
'ByteRange': 'ByteRange',
'PacketBreakdown': 'PacketBreakdown',
# decoded.proto
'DecodedResult': 'DecodedResult',
'DecodedHeader': 'DecodedHeader',
'DecodedPath': 'DecodedPath',
'DecodedPayload': 'DecodedPayload',
'DecodedFlatPayload': 'DecodedFlatPayload',
'DecodedTransportCodes': 'DecodedTransportCodes',
'AdvertFlags': 'AdvertFlags',
'AdvertPayload': 'AdvertPayload',
# channel.proto
'Channel': 'Channel',
'ChannelMessage': 'ChannelMessage',
# analytics.proto
'PayloadTypeSignal': 'PayloadTypeSignal',
'SignalOverTimeEntry': 'SignalOverTimeEntry',
'ScatterPoint': 'ScatterPoint',
'PayloadTypeEntry': 'PayloadTypeEntry',
'HourlyCount': 'HourlyCount',
'TopologyHopDist': 'TopologyHopDist',
'TopRepeater': 'TopRepeater',
'TopPair': 'TopPair',
'HopsVsSnr': 'HopsVsSnr',
'ObserverRef': 'ObserverRef',
'ObserverReach': 'ObserverReach',
'ReachRing': 'ReachRing',
'ReachNode': 'ReachNode',
'MultiObsObserver': 'MultiObsObserver',
'MultiObsNode': 'MultiObsNode',
'BestPathEntry': 'BestPathEntry',
'ChannelAnalyticsSummary': 'ChannelAnalyticsSummary',
'TopSender': 'TopSender',
'ChannelTimelineEntry': 'ChannelTimelineEntry',
'DistanceSummary': 'DistanceSummary',
'DistanceHop': 'DistanceHop',
'DistancePath': 'DistancePath',
'DistancePathHop': 'DistancePathHop',
'CategoryDistStats': 'CategoryDistStats',
'DistOverTimeEntry': 'DistOverTimeEntry',
'HashSizeHourly': 'HashSizeHourly',
'HashSizeHop': 'HashSizeHop',
'MultiByteNode': 'MultiByteNode',
'Subpath': 'Subpath',
# websocket.proto
'WSPacketData': 'WSPacketData',
}
# ─── Validator ──────────────────────────────────────────────────────────────────
class Mismatch:
def __init__(self, fixture, path, severity, message):
self.fixture = fixture
self.path = path
self.severity = severity # 'ERROR' or 'WARNING'
self.message = message
def __str__(self):
return f' [{self.severity}] {self.path}: {self.message}'
def validate_object(fixture_name, data, message_name, all_messages, path='',
mismatches=None):
"""Validate a JSON object against a proto message definition."""
if mismatches is None:
mismatches = []
if not isinstance(data, dict):
mismatches.append(Mismatch(
fixture_name, path or message_name, 'ERROR',
f'Expected object for {message_name}, got {type(data).__name__}'
))
return mismatches
if message_name not in all_messages:
mismatches.append(Mismatch(
fixture_name, path or message_name, 'WARNING',
f'Message {message_name} not found in parsed protos'
))
return mismatches
proto_fields = all_messages[message_name]
current_path = path or message_name
# Check for fixture fields not in proto
for json_key in data.keys():
if json_key.startswith('_'):
# Underscore-prefixed fields are internal/computed, skip
continue
if json_key not in proto_fields:
mismatches.append(Mismatch(
fixture_name, f'{current_path}.{json_key}', 'ERROR',
f'Field "{json_key}" exists in fixture but NOT in proto {message_name}'
))
# Check proto fields against fixture
for json_key, field_info in proto_fields.items():
proto_type = field_info['proto_type']
is_optional = field_info['optional']
is_repeated = field_info['repeated']
is_map = field_info['is_map']
if json_key not in data:
if is_optional:
continue # Optional field absent — OK
if is_repeated or is_map:
continue # Repeated/map fields default to empty — OK
# Proto3 scalars default to zero-value, so absence is valid
if proto_type in PROTO_SCALAR_TYPES:
continue
# Message fields default to null/absent
if proto_type not in PROTO_SCALAR_TYPES:
mismatches.append(Mismatch(
fixture_name, f'{current_path}.{json_key}', 'WARNING',
f'Proto field "{json_key}" ({proto_type}) absent from fixture '
f'(may be zero-value default)'
))
continue
value = data[json_key]
# Null value
if value is None:
if not is_optional:
mismatches.append(Mismatch(
fixture_name, f'{current_path}.{json_key}', 'ERROR',
f'Field "{json_key}" is null in fixture but NOT optional in proto'
))
continue
# Map type
if is_map:
if not isinstance(value, dict):
mismatches.append(Mismatch(
fixture_name, f'{current_path}.{json_key}', 'ERROR',
f'Expected map/object for "{json_key}", got {type(value).__name__}'
))
else:
# Validate map values if they're message types
val_type = proto_type.split(',')[1].rstrip('>')
if val_type in FIELD_TYPE_TO_MESSAGE:
msg_name = FIELD_TYPE_TO_MESSAGE[val_type]
for mk, mv in list(value.items())[:3]:
if isinstance(mv, dict):
validate_object(
fixture_name, mv, msg_name, all_messages,
f'{current_path}.{json_key}["{mk[:20]}"]',
mismatches
)
continue
# Repeated type
if is_repeated:
if not isinstance(value, list):
mismatches.append(Mismatch(
fixture_name, f'{current_path}.{json_key}', 'ERROR',
f'Expected array for repeated "{json_key}", got {type(value).__name__}'
))
elif len(value) > 0:
sample = value[0]
if proto_type in PROTO_SCALAR_TYPES:
ok, detail = check_type_match(proto_type, sample)
if not ok:
mismatches.append(Mismatch(
fixture_name,
f'{current_path}.{json_key}[0]', 'ERROR',
f'Array element type mismatch: {detail}'
))
elif proto_type in FIELD_TYPE_TO_MESSAGE:
msg_name = FIELD_TYPE_TO_MESSAGE[proto_type]
if isinstance(sample, dict):
validate_object(
fixture_name, sample, msg_name, all_messages,
f'{current_path}.{json_key}[0]',
mismatches
)
continue
# Scalar type
if proto_type in PROTO_SCALAR_TYPES:
ok, detail = check_type_match(proto_type, value)
if not ok:
mismatches.append(Mismatch(
fixture_name, f'{current_path}.{json_key}', 'ERROR',
f'Type mismatch: {detail}'
))
continue
# Message type
if proto_type in FIELD_TYPE_TO_MESSAGE:
msg_name = FIELD_TYPE_TO_MESSAGE[proto_type]
if isinstance(value, dict):
validate_object(
fixture_name, value, msg_name, all_messages,
f'{current_path}.{json_key}',
mismatches
)
else:
mismatches.append(Mismatch(
fixture_name, f'{current_path}.{json_key}', 'ERROR',
f'Expected object for message field {proto_type}, '
f'got {type(value).__name__}'
))
continue
return mismatches
# ─── Main ───────────────────────────────────────────────────────────────────────
def main():
# Parse all proto files
all_messages = {}
proto_files = sorted(f for f in os.listdir(PROTO_DIR) if f.endswith('.proto'))
for pf in proto_files:
filepath = os.path.join(PROTO_DIR, pf)
msgs = parse_proto_file(filepath)
all_messages.update(msgs)
print(f'Parsed {len(all_messages)} messages from {len(proto_files)} proto files')
print(f'Messages: {", ".join(sorted(all_messages.keys()))}')
print()
# Load and validate fixtures
fixture_files = sorted(f for f in os.listdir(FIXTURE_DIR) if f.endswith('.json'))
all_mismatches = []
fixtures_checked = 0
for fixture_file in fixture_files:
if fixture_file not in FIXTURE_TO_MESSAGE:
print(f'⚠ No mapping for fixture: {fixture_file} — skipping')
continue
message_name, shape = FIXTURE_TO_MESSAGE[fixture_file]
filepath = os.path.join(FIXTURE_DIR, fixture_file)
with open(filepath, encoding='utf-8') as f:
data = json.load(f)
fixtures_checked += 1
mismatches = []
if shape == 'object':
validate_object(fixture_file, data, message_name, all_messages,
mismatches=mismatches)
elif shape == 'array':
# Bare array — validate first element against the message
if isinstance(data, list):
if len(data) > 0 and isinstance(data[0], dict):
validate_object(fixture_file, data[0], message_name,
all_messages, path=f'{message_name}[0]',
mismatches=mismatches)
# Flag structural note (serialization concern, not a field mismatch)
mismatches.append(Mismatch(
fixture_file, message_name, 'WARNING',
f'API returns a bare JSON array, but proto wraps it in a '
f'response message. Serialization layer must handle unwrapping.'
))
else:
mismatches.append(Mismatch(
fixture_file, message_name, 'ERROR',
f'Expected array for bare-array fixture, got {type(data).__name__}'
))
elif shape == 'bare-array':
# Bare array of scalars (e.g. packet-timestamps)
if isinstance(data, list):
mismatches.append(Mismatch(
fixture_file, message_name, 'WARNING',
f'API returns a bare JSON array of {len(data)} elements. '
f'Proto wraps it in {message_name}. '
f'Serialization layer must handle unwrapping.'
))
else:
mismatches.append(Mismatch(
fixture_file, message_name, 'ERROR',
f'Expected array, got {type(data).__name__}'
))
elif shape == 'bare-map':
# Bare JSON object used as a map (e.g. config-regions)
if isinstance(data, dict):
mismatches.append(Mismatch(
fixture_file, message_name, 'WARNING',
f'API returns a bare JSON map with {len(data)} entries. '
f'Proto wraps it in {message_name}.regions. '
f'Serialization layer must handle unwrapping.'
))
else:
mismatches.append(Mismatch(
fixture_file, message_name, 'ERROR',
f'Expected map, got {type(data).__name__}'
))
if mismatches:
all_mismatches.extend(mismatches)
# ─── Report ─────────────────────────────────────────────────────────────────
print('=' * 78)
print(f'VALIDATION REPORT — {fixtures_checked} fixtures checked')
print('=' * 78)
print()
# Group by fixture
by_fixture = defaultdict(list)
for m in all_mismatches:
by_fixture[m.fixture].append(m)
error_count = sum(1 for m in all_mismatches if m.severity == 'ERROR')
warn_count = sum(1 for m in all_mismatches if m.severity == 'WARNING')
for fixture_file in sorted(by_fixture.keys()):
fixture_mismatches = by_fixture[fixture_file]
msg_name = FIXTURE_TO_MESSAGE[fixture_file][0]
errors = [m for m in fixture_mismatches if m.severity == 'ERROR']
warnings = [m for m in fixture_mismatches if m.severity == 'WARNING']
status = '' if errors else '' if warnings else ''
print(f'{status} {fixture_file}{msg_name}')
for m in fixture_mismatches:
print(str(m))
print()
# List clean fixtures
clean = [f for f in fixture_files
if f in FIXTURE_TO_MESSAGE and f not in by_fixture]
if clean:
for f in clean:
msg_name = FIXTURE_TO_MESSAGE[f][0]
print(f'{f}{msg_name}')
print()
print('' * 78)
print(f'Total: {error_count} errors, {warn_count} warnings '
f'across {len(by_fixture)} fixtures with issues')
print(f'Clean: {len(clean)} fixtures with no issues')
print('' * 78)
return 1 if error_count > 0 else 0
if __name__ == '__main__':
sys.exit(main())