Files
rUv aac4155584 feat(bfi): add validated capture and bounded research tooling (#2055)
* feat(bfi): add validated capture and bounded research tooling

* fix(deps): patch Wasmtime for RUSTSEC-2026-0316
2026-09-29 23:53:48 -04:00

191 lines
10 KiB
Python

#!/usr/bin/env python3
"""Compare full-capture BFI coverage with the sender's recorded traffic interval."""
import argparse
import collections
import hashlib
import io
import json
import math
import os
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'bfi'))
import bfi_decode as decoder
import quality_report as quality
NS = 1000000000
MAX_JSON = 1024 * 1024
CLOCK_TOLERANCE_NS = 1000000
require = decoder.require
def number(value, low, high, reason, integer=False):
require(type(value) in ((int,) if integer else (int,float)) and low <= value <= high
and math.isfinite(value), reason)
return value
def read_json(path):
path = Path(path)
require(path.is_file() and path.stat().st_size <= MAX_JSON, 'json_missing_or_too_large')
with path.open('rb') as stream:
raw = stream.read(MAX_JSON+1)
require(len(raw) <= MAX_JSON, 'json_grew_beyond_limit')
def pairs(items):
result = {}
for key,value in items:
require(key not in result, 'duplicate_json_key')
result[key] = value
return result
try:
value = json.loads(raw, object_pairs_hook=pairs,
parse_constant=lambda _: require(False,'nonfinite_json_constant'))
except (ValueError,UnicodeError,RecursionError) as error:
if isinstance(error,decoder.DecodeError):
raise
raise decoder.DecodeError('invalid_json') from error
require(isinstance(value,dict), 'json_object_required')
return value, hashlib.sha256(raw).hexdigest()
def traffic_window(traffic, manifest):
start,end,duration = quality.manifest_window(manifest)
require(type(traffic.get('schema_version')) is int and traffic['schema_version']==1, 'unsupported_traffic_schema')
require(traffic.get('capture_exit') == 0 and type(traffic.get('capture_exit')) is int
and traffic.get('restoration_verified') is True
and not any(traffic.get(k) for k in ('error','cleanup_verification_error','evidence_write_error')),
'traffic_run_not_successful')
for stage in ('preflight','before_traffic'):
check = traffic.get(stage)
require(isinstance(check,dict) and all(check.get(k) is True for k in
('source_verified_on_ethernet','direct_route_verified','expected_neighbor_verified')),
'traffic_link_evidence_missing')
capture_seconds = number(traffic.get('capture_seconds'),6,120,'invalid_capture_profile',True)
requested = number(traffic.get('requested_seconds'),1,capture_seconds-5,'invalid_traffic_duration',True)
pps = number(traffic.get('maximum_packets_per_second'),1,1000,'invalid_traffic_pps',True)
require(manifest.get('requested_seconds') == capture_seconds, 'capture_profile_mismatch')
planned = pps*requested
require(planned <= 60000 and planned*1200 <= 64*1024*1024, 'traffic_payload_budget')
require(traffic.get('planned_datagrams') == planned and type(traffic.get('planned_datagrams')) is int,
'planned_datagrams_mismatch')
require(type(traffic.get('payload_bytes')) is int and traffic['payload_bytes']==1200
and type(traffic.get('destination_port')) is int and traffic['destination_port']==9,
'unsupported_payload_or_port')
require(traffic.get('profile') == f'udp-downlink-{pps}pps-1200bytes-{requested}s', 'traffic_profile_string_mismatch')
sent = number(traffic.get('sent_packets'),1,planned,'invalid_sent_packets',True)
require(type(traffic.get('sent_payload_bytes')) is int and traffic['sent_payload_bytes']==sent*1200,
'sent_payload_mismatch')
armed = number(traffic.get('armed_at_unix_ns'),1,(1<<63)-1,'invalid_armed_timestamp',True)
traffic_start = number(traffic.get('traffic_started_unix_ns'),1,(1<<63)-1,'invalid_traffic_start',True)
traffic_end = number(traffic.get('traffic_ended_unix_ns'),1,(1<<63)-1,'invalid_traffic_end',True)
require(start <= armed <= traffic_start < traffic_end <= end, 'traffic_interval_outside_capture')
elapsed = number(traffic.get('traffic_elapsed_seconds'),requested-.001,requested+2,
'short_or_overlong_traffic_interval')
require(abs(traffic_end-traffic_start-round(elapsed*NS)) <= CLOCK_TOLERANCE_NS,
'traffic_wall_monotonic_clock_mismatch')
actual_pps = number(traffic.get('actual_packets_per_second'),0,1001,'invalid_actual_traffic_pps')
require(math.isclose(actual_pps,sent/elapsed,rel_tol=1e-9,abs_tol=1e-9), 'actual_traffic_rate_mismatch')
return traffic_start,traffic_end,{'requested_seconds':requested,'requested_pps':pps,'sent_packets':sent,
'payload_bytes':1200,'sent_payload_bytes':sent*1200,
'monotonic_elapsed_seconds':elapsed,'actual_sent_packets_per_second':actual_pps}
def report_timestamps(raw, ap, client):
"""Selection only after quality.analyze has validated every packet and timestamp."""
seen, timestamps = collections.OrderedDict(), []
for timestamp, packet, _ in decoder.pcap_packets(io.BytesIO(raw)):
frame = decoder.strip_radiotap(packet)
fc = int.from_bytes(frame[:2],'little')
if (fc>>2)&3 or (fc>>4)&15 not in (13,14) or fc&0xc400:
continue
if quality.direction(frame[4:10],frame[10:16],frame[16:22],ap,client) != 'expected_client_to_ap' or frame[22]&15:
continue
body = frame[24:]
if body[:2] not in (b'\x15\x00',b'\x1e\x00',b'\x24\x00'):
continue
try:
decoder.decode_report(body)
except decoder.DecodeError as error:
if error.reason.startswith('unsupported_'):
continue
raise
key = (frame[22:24],hashlib.sha256(body).digest())
prior = seen.get(key)
duplicate = bool(fc&0x0800 and prior is not None and timestamp-prior <= NS)
seen[key] = timestamp
seen.move_to_end(key)
if len(seen)>4096:
seen.popitem(last=False)
if not duplicate:
timestamps.append(timestamp)
return timestamps
def analyze(raw, manifest, traffic, ap, client):
traffic_start,traffic_end,profile = traffic_window(traffic,manifest)
full = quality.analyze(io.BytesIO(raw),manifest,ap,client)
timestamps = report_timestamps(raw,ap,client)
require(len(timestamps)==full['feedback']['unique_reports'], 'selection_disagrees_with_quality_report')
start,end,duration = quality.manifest_window(manifest)
inside = [t for t in timestamps if traffic_start <= t <= traffic_end]
before = [t for t in timestamps if t < traffic_start]
after = [t for t in timestamps if t > traffic_end]
def window(times,a,b):
coverage = quality.coverage(times,a,b,(b-a)/NS) if b>a else None
if coverage is not None:
coverage['bin_origin'] = 'interval_start'
coverage['bin_origin_unix_ns'] = a
return {'start_unix_ns':a,'end_unix_ns':b,'duration_seconds':(b-a)/NS,
'coverage':coverage}
return {'schema_version':1,'status':'ANALYZED','full_capture':full,
'traffic_interval':window(inside,traffic_start,traffic_end),
'before_traffic':window(before,start,traffic_start),'after_traffic':window(after,traffic_end,end),
'traffic_profile':profile,
'endpoint_rule':'Traffic includes both endpoints; before is strictly earlier, after strictly later',
'clock_tolerance_ns':CLOCK_TOLERANCE_NS,
'receipt_association':'Caller-supplied traffic receipt validated for interval/profile; original traffic receipt does not contain PCAP hash',
'limitations':['Traffic timestamps bound sender setup/send loop, not exact first/last over-air datagrams',
'UDP sends do not prove client delivery or trigger AP sounding',
'Coverage is captured report continuity, not RF packet loss or AP sounding rate',
'Hashing binds these input bytes to this analysis, not an authenticated common capture session',
'Traffic/full-capture results have different denominators; original full-capture claims are unchanged']}
def main(argv=None):
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('pcap',type=Path)
for name in ('manifest','traffic','ap','client','output'):
parser.add_argument('--'+name,required=True)
args=parser.parse_args(argv)
try:
manifest,manifest_hash=read_json(args.manifest)
traffic,traffic_hash=read_json(args.traffic)
require(args.pcap.is_file() and args.pcap.stat().st_size <= decoder.MAX_FILE,'capture_missing_or_too_large')
with args.pcap.open('rb') as stream:
raw=stream.read(decoder.MAX_FILE+1)
require(len(raw)<=decoder.MAX_FILE,'capture_grew_beyond_limit')
result=analyze(raw,manifest,traffic,decoder.mac(args.ap),decoder.mac(args.client))
result['input_sha256']={'pcap':hashlib.sha256(raw).hexdigest(),'manifest':manifest_hash,'traffic':traffic_hash,
'analyzer_source':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
'decoder_source':hashlib.sha256(Path(decoder.__file__).read_bytes()).hexdigest(),
'quality_source':hashlib.sha256(Path(quality.__file__).read_bytes()).hexdigest()}
output=Path(args.output).expanduser().resolve()
require(not any((p/'.git').exists() for p in (output,*output.parents)),'output_must_be_outside_git')
require(not output.exists(),'output_must_be_new')
value=json.dumps(result,indent=2,allow_nan=False)+'\n'
require(len(value.encode())<=8*1024*1024,'output_byte_limit')
os.umask(0o077)
with output.open('x',encoding='utf-8') as stream:
stream.write(value)
print(json.dumps({'status':'ANALYZED','full_reports':result['full_capture']['feedback']['unique_reports'],
'traffic_reports':result['traffic_interval']['coverage']['unique_reports']}))
return 0
except (decoder.DecodeError,OSError) as error:
print(json.dumps({'status':'INPUT_ERROR','reason':str(error) if isinstance(error,decoder.DecodeError) else 'file_io_error'}))
return 1
if __name__=='__main__':
raise SystemExit(main())