[PATCH 7/8] tests: Add a Connect-Info ABNF validator
Iegor Sergieienkov
iegor at nova-labs.com
Thu Aug 27 07:16:24 PDT 2026
Add a strict validator for the RADIUS Connect-Info syntax defined by
draft-grayson-connectinfo-10, for use by the wifi_stats tests.
The grammar carries an extensibility production:
keyValueAttribute =/ 1*NO-DELIM-COLON ":" *SP 1*NO-DELIM-COLON
NO-DELIM-COLON = %x21-2e / %x30-39 / %x3b-7e
NO-DELIM-COLON excludes only space, slash and colon, so it admits every
letter, and "RSSI:garbage", "Channel:0" and "FrameLoss:255" all parse
under the full grammar. The validator therefore rejects unknown keys by
default and always holds a recognised key to that key's own production.
It self-tests against every example string in Figures 5 and 6 of the
draft, and has a __main__ so it can be pointed at the output of other
implementations.
Also enable CONFIG_WIFI_STATS for the test builds and add Connect-Info
and Acct-Status-Type to the test RADIUS dictionary.
Signed-off-by: Iegor Sergieienkov <iegor at nova-labs.com>
---
tests/hwsim/connectinfo_abnf.py | 443 +++++++++++++++++++++++++++++
tests/hwsim/dictionary.radius | 6 +
tests/hwsim/example-hostapd.config | 2 +
3 files changed, 451 insertions(+)
create mode 100644 tests/hwsim/connectinfo_abnf.py
diff --git a/tests/hwsim/connectinfo_abnf.py b/tests/hwsim/connectinfo_abnf.py
new file mode 100644
index 000000000..c6240953e
--- /dev/null
+++ b/tests/hwsim/connectinfo_abnf.py
@@ -0,0 +1,443 @@
+#!/usr/bin/env python3
+#
+# Strict validator for the RADIUS Connect-Info attribute syntax defined by
+# draft-grayson-connectinfo-10 (Figure 4).
+# Copyright (c) 2026, Iegor Sergieienkov <iegor at nova-labs.com>
+#
+# This software may be distributed under the terms of the BSD license.
+# See README for more details.
+#
+# The extensibility production (keyValueAttribute =/ 1*NO-DELIM-COLON ":"
+# *SP 1*NO-DELIM-COLON) excludes only space, slash and colon, so "RSSI:garbage"
+# and "Channel:0" both parse under the full grammar and conformance becomes
+# nearly unfalsifiable. This validator therefore defaults to
+# allow_extensibility=False and always holds a recognised key to its own
+# production.
+#
+# ABNF literals are case-insensitive without an RFC 7405 "%s" prefix (RFC 5234
+# section 2.3), which draft-10 never uses, so "connect 11.00 mbps" conforms and
+# is accepted. Parsed tokens are normalised to the draft's canonical spelling.
+
+import re
+import sys
+
+# Alternations are ordered longest-first: ABNF alternation is unordered but
+# Python's is leftmost-first, so "[1-9]" first would match the "1" of "149".
+_MAXSPEED = r'(?:[1-9][0-9]{4}|[1-9][0-9]{3}|[1-9][0-9]{2}|[1-9][0-9]|[0-9])\.[0-9]{2}'
+_RATE = r'(?:[1-9][0-9]{3}|[1-9][0-9]{2}|[1-9][0-9]|[0-9])(?:\.[0-9])?'
+_SS = r'-?(?:1[0-9]{2}|[1-9][0-9]|[0-9])'
+_CHANNUM = r'(?:2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]|[1-9])'
+_GOC = r'(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]|[1-9])'
+_PCT = r'(?:100|[1-9][0-9]|[0-9])'
+_WINDOW = r'(?:[1-9][0-9]{2}|[1-9][0-9]|[0-9])[SM]'
+_WEIGHT = r'[1-9]'
+_SAMPLE = r'(?:[1-9][0-9]{2}|[1-9][0-9]|[0-9])'
+_ALGO = r'(?:MIN|MAX|AVG-LIN|AVG-EXP|ACC)'
+_AMENDMENT = r'(?:ac|ax|be|b|g|a|n)'
+
+# AGGR = ALGO SP ( WINDOW / (WEIGHT ["-" SAMPLE]) ); SP is %x20, singular.
+_AGGR = r'(?P<algo>' + _ALGO + r') (?P<span>' + _WINDOW + \
+ r'|' + _WEIGHT + r'(?:-' + _SAMPLE + r')?)'
+
+# DELIMITER = SLASH / 1*SP ; SLASH = *SP %x2F *SP
+_DELIMITER = r'(?: *\/ *| +)'
+
+# Values whose production permits a trailing ["(" AGGR ")"].
+_AGGR_KEYS = {
+ 'RSSI': _SS,
+ 'TxBitRate': _RATE,
+ 'RxBitRate': _RATE,
+ 'FrameLoss': _PCT,
+ 'FrameRetry': _PCT,
+}
+# Global-OC has no AGGR option in its production.
+_PLAIN_KEYS = {
+ 'Global-OC': _GOC,
+}
+
+_NO_DELIM_COLON = r'[\x21-\x2e\x30-\x39\x3b-\x7e]'
+_EXT = r'(?P<key>' + _NO_DELIM_COLON + r'+):(?P<val> *' + \
+ _NO_DELIM_COLON + r'+)'
+
+
+class ConnectInfoError(Exception):
+ def __init__(self, msg, text, offset):
+ super().__init__("%s at offset %d: %r<<HERE>>%r" %
+ (msg, offset, text[:offset], text[offset:]))
+
+
+class Attribute:
+ """kind is 'legacy', 'channel' or 'kv'."""
+
+ def __init__(self, kind, key, value, algo=None, span=None, via_ext=False):
+ self.kind = kind
+ self.key = key
+ self.value = value
+ self.algo = algo
+ self.span = span
+ self.via_ext = via_ext
+
+ def key_tuple(self):
+ return (self.kind, self.key, self.value, self.algo, self.span)
+
+ def __repr__(self):
+ agg = "(%s %s)" % (self.algo, self.span) if self.algo else ""
+ return "<%s %s=%s%s%s>" % (self.kind, self.key, self.value, agg,
+ " ext" if self.via_ext else "")
+
+
+class ConnectInfo:
+ def __init__(self, text):
+ self.text = text
+ self.attrs = []
+ self.maxspeed = None
+ self.amendment = None
+ self.channel = None
+
+ def get(self, key):
+ for a in self.attrs:
+ if a.key == key:
+ return a
+ return None
+
+ def count(self, key):
+ return len([a for a in self.attrs if a.key == key])
+
+ def used_extensibility(self):
+ return [a for a in self.attrs if a.via_ext]
+
+ def decomposition(self):
+ return [a.key_tuple() for a in self.attrs]
+
+
+class _Parser:
+ def __init__(self, text, allow_extensibility):
+ self.t = text
+ self.i = 0
+ self.ext_ok = allow_extensibility
+ self.out = ConnectInfo(text)
+
+ def _fail(self, msg):
+ raise ConnectInfoError(msg, self.t, self.i)
+
+ def _match(self, pattern, fold=True):
+ # fold=False is for patterns built only from the NO-DELIM-COLON byte
+ # range, which is a character class rather than an ABNF literal.
+ m = re.compile(pattern, re.IGNORECASE if fold else 0).match(self.t,
+ self.i)
+ if m:
+ self.i = m.end()
+ return m
+
+ def _startswith_fold(self, literal):
+ return self.t[self.i:self.i + len(literal)].lower() == literal.lower()
+
+ def _at_boundary(self):
+ if self.i >= len(self.t):
+ return True
+ return self.t[self.i] in ' /'
+
+ def parse(self):
+ if not self._match(r'CONNECT'):
+ self._fail("does not start with CONNECT")
+
+ self._parse_legacy_attributes()
+
+ seen_channel = False
+ while self.i < len(self.t):
+ if not self._match(_DELIMITER):
+ self._fail("expected a delimiter between attributes")
+ if self.i >= len(self.t):
+ self._fail("trailing delimiter with no attribute")
+ if self._startswith_fold('Channel:') and \
+ not (seen_channel and self.ext_ok):
+ if seen_channel:
+ # legacyChannelNum has one slot, but extensibility
+ # absorbs a second "Channel:", so at most one is a
+ # strict-mode policy rather than a grammar rule.
+ self._fail("Channel appears more than once (strict-mode "
+ "policy; the full grammar accepts the second "
+ "via the extensibility production at draft "
+ "line 317)")
+ seen_channel = True
+ self._parse_channel()
+ else:
+ self._parse_key_value()
+ return self.out
+
+ def _parse_legacy_attributes(self):
+ # legacyAttributes = 1*SP MAXSPEED " Mbps" DELIMITER WIFIAMENDMENT
+ # Optional as a whole, but atomic: neither half may appear alone.
+ save = self.i
+ if not self._match(r' +'):
+ return
+ m = self._match(r'(?P<v>' + _MAXSPEED + r') Mbps')
+ if not m:
+ self.i = save
+ return
+ maxspeed = m.group('v')
+ if not self._match(_DELIMITER):
+ self._fail("MAXSPEED not followed by a delimiter and amendment")
+ am = self._match(r'802\.11(?P<a>' + _AMENDMENT + r')')
+ if not am:
+ self._fail("MAXSPEED present without a valid WIFIAMENDMENT")
+ if not self._at_boundary():
+ self._fail("trailing characters after the 802.11 amendment")
+ self.out.maxspeed = float(maxspeed)
+ # AMENDMENT folds, so normalise to the draft's own spelling.
+ self.out.amendment = am.group('a').lower()
+ self.out.attrs.append(Attribute('legacy', 'MAXSPEED', maxspeed))
+ self.out.attrs.append(Attribute('legacy', 'WIFIAMENDMENT',
+ self.out.amendment))
+
+ def _parse_channel(self):
+ self.i += len('Channel:')
+ self._match(r' *')
+ m = self._match(r'(?P<v>' + _CHANNUM + r')')
+ if not m or not self._at_boundary():
+ self._fail("Channel value is not a valid CHANNUM (1-249)")
+ self.out.channel = int(m.group('v'))
+ self.out.attrs.append(Attribute('channel', 'Channel', m.group('v')))
+
+ def _parse_key_value(self):
+ for key, value_re in list(_AGGR_KEYS.items()) + list(_PLAIN_KEYS.items()):
+ # Key names are ABNF literals and so fold; "rssi:garbage" must
+ # reach the RSSI production and fail there rather than escaping
+ # into the extensibility catch-all as a vendor attribute.
+ if not self._startswith_fold(key + ':'):
+ continue
+ self.i += len(key) + 1
+ self._match(r' *')
+ m = self._match(r'(?P<v>' + value_re + r')')
+ if not m:
+ self._fail("%s value does not match its production" % key)
+ value = m.group('v')
+ algo = span = None
+ if self.i < len(self.t) and self.t[self.i] == '(':
+ if key in _PLAIN_KEYS:
+ self._fail("%s must not carry an aggregation suffix" % key)
+ self.i += 1
+ a = self._match(_AGGR)
+ if not a:
+ self._fail("malformed AGGR for %s" % key)
+ # ALGO and TIMEUNIT fold; hand the caller canonical case.
+ algo = a.group('algo').upper()
+ span = a.group('span').upper()
+ if not self._match(r'\)'):
+ self._fail("unterminated aggregation suffix for %s" % key)
+ if not self._at_boundary():
+ self._fail("trailing characters after the %s value" % key)
+ self.out.attrs.append(Attribute('kv', key, value, algo, span))
+ return
+
+ # Not a recognised key: only the extensibility production remains.
+ # NO-DELIM-COLON is a byte range, not a literal, so it does not fold.
+ if not self.ext_ok:
+ m = re.compile(_NO_DELIM_COLON + r'+').match(self.t, self.i)
+ name = m.group(0) if m else self.t[self.i:]
+ self._fail("unrecognised attribute %r and extensibility is "
+ "disabled" % name)
+ m = self._match(_EXT, fold=False)
+ if not m or not self._at_boundary():
+ self._fail("not a valid keyValueAttribute")
+ self.out.attrs.append(Attribute('kv', m.group('key'),
+ m.group('val').lstrip(),
+ via_ext=True))
+
+
+def parse(text, allow_extensibility=False):
+ """Raises ConnectInfoError on any deviation from Figure 4. Recognised keys
+ are always held to their own production; allow_extensibility governs only
+ unrecognised keys. Literals are matched case-insensitively per RFC 5234
+ section 2.3, and the decomposition is returned in canonical case."""
+ if not isinstance(text, str):
+ text = text.decode('utf-8')
+ if len(text.encode('utf-8')) > 253:
+ raise ConnectInfoError("attribute exceeds the 253-octet RADIUS limit",
+ text, 253)
+ for ch in text:
+ if ord(ch) < 0x20 or ord(ch) == 0x7f:
+ raise ConnectInfoError("control character in attribute", text,
+ text.index(ch))
+ if text != text.strip():
+ raise ConnectInfoError("leading or trailing whitespace", text, 0)
+ return _Parser(text, allow_extensibility).parse()
+
+
+def is_valid(text, allow_extensibility=False):
+ try:
+ parse(text, allow_extensibility)
+ return True
+ except ConnectInfoError:
+ return False
+
+
+# Figures 5 and 6 of the draft, unwrapped. These pin the validator itself: a
+# rejection here means the validator is wrong, not the implementation.
+DRAFT_EXAMPLES = [
+ "CONNECT 11.00 Mbps 802.11b",
+ "CONNECT 54.00 Mbps / 802.11n / RSSI: 53 / Channel: 1",
+ "CONNECT 54.00 Mbps / 802.11n / Channel: 1 / RSSI: 53",
+ "CONNECT 400.00 Mbps 802.11ac Channel:44 RSSI:50",
+ "CONNECT RSSI:56 TxBitRate:150.0 RxBitRate:150.0 Global-OC:116",
+ "CONNECT 400.00 Mbps 802.11ax RSSI:56 TxBitRate:150.0 "
+ "RxBitRate:150.0 Global-OC:133",
+ "CONNECT RSSI:56(AVG-LIN 10M) TxBitRate:150.0(MAX 10M) "
+ "RxBitRate:150.0(MAX 10M)",
+ "CONNECT 400.00 Mbps 802.11ac RSSI:56(AVG-LIN 600S) "
+ "TxBitRate:150.0(MAX 600S) RxBitRate:150.0(MAX 600S) "
+ "FrameLoss:3(ACC 60S) FrameRetry:6(ACC 60S)",
+ "CONNECT TxBitRate:150.0(MAX 30S) RxBitRate:120.5(MAX 30S) "
+ "RSSI:-65(AVG-EXP 6-100) FrameLoss:2(ACC 30S) FrameRetry:4(ACC 30S) "
+ "Global-OC:133",
+ # Case-varied forms of the above, conforming per RFC 5234 section 2.3.
+ "connect 11.00 mbps 802.11b",
+ "CONNECT RSSI:56(avg-lin 10m) TxBitRate:150.0(max 10m) "
+ "RxBitRate:150.0(MAX 10M)",
+ "connect 400.00 mbps 802.11AC channel: 44 rssi:50 global-oc:116",
+]
+
+# Strings no parse of Figure 4 accepts, in any mode, each naming the production
+# it violates. A rejection here indicts the string.
+DRAFT_COUNTEREXAMPLES = [
+ ("CONNECT 54.0 Mbps 802.11n", "MAXSPEED needs two fractional digits"),
+ ("CONNECT 54 Mbps 802.11n", "MAXSPEED needs a fractional part"),
+ ("CONNECT 054.00 Mbps 802.11n", "MAXSPEED has a leading zero"),
+ ("CONNECT 54.00 Mbps", "MAXSPEED without WIFIAMENDMENT"),
+ ("CONNECT 54.00Mbps 802.11n", "no space before Mbps"),
+ ("CONNECT 54.00 Mbps 802.11ah", "unknown amendment"),
+ # legacyAttributes joins "CONNECT" with 1*SP, not DELIMITER, so this is
+ # the one top-level join at which a slash does not parse.
+ ("CONNECT / 54.00 Mbps 802.11n", "slash before MAXSPEED"),
+ # RFC 2869 permits "<tx>/<rx> Mbps"; MAXSPEED is singular and cannot
+ # express it. The draft's own backwards-compatibility gap, not an AP's.
+ ("CONNECT 54.00/28.80 Mbps 802.11n", "RFC 2869 dual-speed slash form"),
+ ("CONNECT RSSI:-40(AVG 30S)", "bare AVG is not an ALGO"),
+ ("CONNECT RSSI:-40 (MAX 30S)", "space before the aggregation suffix"),
+ ("CONNECT RSSI:-40(MAX 30S)", "two spaces inside AGGR"),
+ ("CONNECT RSSI:56(MAX 6 - 100)", "spaces around the SAMPLE hyphen"),
+ ("CONNECT RSSI:56(MAX 60S", "unterminated aggregation suffix"),
+ ("CONNECT Global-OC:81(MAX 30S)", "Global-OC takes no AGGR"),
+ ("CONNECT RSSI:-40(MAX 1000S)", "WINDOW above three digits"),
+ ("CONNECT RSSI:-40(AVG-EXP 0)", "WEIGHT must be non-zero"),
+ ("CONNECT RSSI:-40(AVG-EXP 6-1000)", "SAMPLE above three digits"),
+ ("CONNECT RSSI:-40(MAX 30)", "WINDOW without a TIMEUNIT"),
+ ("NOTCONNECT RSSI:-40", "does not start with CONNECT"),
+]
+
+# Strings the full grammar ACCEPTS - the extensibility production at draft
+# line 317 excludes only space, slash and colon, so any colon-bearing token
+# free of those three parses as a vendor keyValueAttribute no matter what it
+# says. This validator rejects them anyway, because holding a recognised key
+# to its own production is the only way conformance to this draft is
+# falsifiable at all. A rejection here indicts nothing: it reports that the
+# value would not have satisfied the key's own production had the key been
+# read as the draft's own attribute rather than as a vendor extension.
+STRICT_POLICY_REJECTIONS = [
+ ("CONNECT RSSI:-200", "SS magnitude above 199"),
+ ("CONNECT RSSI:-05", "SS with a leading zero"),
+ ("CONNECT TxBitRate:150.00", "RATE with two fractional digits"),
+ ("CONNECT FrameLoss:101", "PCT above 100"),
+ ("CONNECT FrameLoss:07", "PCT with a leading zero"),
+ ("CONNECT FrameLoss:-3", "negative PCT"),
+ ("CONNECT Channel:0", "CHANNUM has no zero alternative"),
+ ("CONNECT Channel:250", "CHANNUM above 249"),
+ ("CONNECT Global-OC:0", "GOC has no zero alternative"),
+ ("CONNECT Global-OC:256", "GOC above 255"),
+ # The only aggregation-punctuation error with no space in it, and so the
+ # only one the catch-all can absorb; every other one in the list above is
+ # split by a space into a token with no colon.
+ ("CONNECT RSSI:56(MAX60S)", "no space between ALGO and its operand"),
+ # legacyChannelNum has one slot, but the second instance is an ordinary
+ # keyValueAttribute. This is the one entry the validator also accepts
+ # under allow_extensibility=True.
+ ("CONNECT Channel:1 Channel:2", "Channel appears more than once"),
+]
+
+
+def _self_test():
+ ok = True
+
+ def fail(msg):
+ nonlocal ok
+ ok = False
+ print("FAIL %s" % msg)
+
+ for text in DRAFT_EXAMPLES:
+ try:
+ parse(text)
+ except ConnectInfoError as e:
+ fail("(conforming example rejected): %s" % e)
+
+ for text, why in DRAFT_COUNTEREXAMPLES:
+ if is_valid(text):
+ fail("(grammar violation accepted: %s): %r" % (why, text))
+ elif is_valid(text, allow_extensibility=True):
+ fail("(classified as a grammar violation but the extensibility "
+ "production accepts it, so it belongs in "
+ "STRICT_POLICY_REJECTIONS: %s): %r" % (why, text))
+
+ for text, why in STRICT_POLICY_REJECTIONS:
+ if is_valid(text):
+ fail("(strict policy did not reject: %s): %r" % (why, text))
+
+ # The conforming examples must not need the extensibility production.
+ for text in DRAFT_EXAMPLES:
+ try:
+ a = parse(text, allow_extensibility=False)
+ b = parse(text, allow_extensibility=True).decomposition()
+ if a.decomposition() != b:
+ fail("(decomposition differs with extensibility): %r" % text)
+ if a.used_extensibility():
+ fail("(parsed via the extensibility production): %r" % text)
+ except ConnectInfoError as e:
+ fail(": %s" % e)
+
+ # T-28: a receiver built to Figure 4 accepts any casing, because RFC 5234
+ # literals are case-insensitive and the draft uses no "%s". This asserts
+ # nothing about a generator - emitting canonical uppercase is a free
+ # choice and asserting it would be over-fitting.
+ for text in DRAFT_EXAMPLES:
+ try:
+ want = parse(text).decomposition()
+ except ConnectInfoError:
+ continue
+ for variant in [text.lower(), text.upper()]:
+ try:
+ got = parse(variant).decomposition()
+ except ConnectInfoError as e:
+ fail("(case variant of a conforming string rejected): %s" % e)
+ continue
+ if got != want:
+ fail("(case variant decomposes differently): %r vs %r" %
+ (variant, text))
+
+ print("self-test: %s (%d conforming examples, %d grammar "
+ "counterexamples, %d strict-policy rejections)" %
+ ("PASS" if ok else "FAIL", len(DRAFT_EXAMPLES),
+ len(DRAFT_COUNTEREXAMPLES), len(STRICT_POLICY_REJECTIONS)))
+ return 0 if ok else 1
+
+
+def main():
+ if len(sys.argv) > 1 and sys.argv[1] == '--self-test':
+ return _self_test()
+ if len(sys.argv) > 1:
+ texts = sys.argv[1:]
+ else:
+ texts = [line.rstrip('\n') for line in sys.stdin if line.strip()]
+ rc = 0
+ for text in texts:
+ try:
+ ci = parse(text)
+ print("OK %r" % text)
+ for a in ci.attrs:
+ print(" %r" % a)
+ except ConnectInfoError as e:
+ rc = 1
+ print("FAIL %s" % e)
+ return rc
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/tests/hwsim/dictionary.radius b/tests/hwsim/dictionary.radius
index d2112dad3..78cbfcb0f 100644
--- a/tests/hwsim/dictionary.radius
+++ b/tests/hwsim/dictionary.radius
@@ -6,15 +6,21 @@ ATTRIBUTE Vendor-Specific 26 octets
ATTRIBUTE Session-Timeout 27 integer
ATTRIBUTE Calling-Station-Id 31 string
ATTRIBUTE NAS-Identifier 32 string
+ATTRIBUTE Acct-Status-Type 40 integer
ATTRIBUTE Acct-Session-Id 44 string
ATTRIBUTE Acct-Multi-Session-Id 50 string
ATTRIBUTE Event-Timestamp 55 date
ATTRIBUTE Tunnel-Type 64 integer
ATTRIBUTE Tunnel-Medium-Type 65 integer
ATTRIBUTE Tunnel-Password 69 octets
+ATTRIBUTE Connect-Info 77 string
ATTRIBUTE EAP-Message 79 string
ATTRIBUTE Message-Authenticator 80 octets
ATTRIBUTE Tunnel-Private-Group-ID 81 string
ATTRIBUTE Acct-Interim-Interval 85 integer
ATTRIBUTE Chargeable-User-Identity 89 string
ATTRIBUTE Error-Cause 101 integer
+
+VALUE Acct-Status-Type Start 1
+VALUE Acct-Status-Type Stop 2
+VALUE Acct-Status-Type Interim-Update 3
diff --git a/tests/hwsim/example-hostapd.config b/tests/hwsim/example-hostapd.config
index 92338aae0..1de11a00e 100644
--- a/tests/hwsim/example-hostapd.config
+++ b/tests/hwsim/example-hostapd.config
@@ -82,6 +82,8 @@ CFLAGS += -DALL_DH_GROUPS
CONFIG_FST=y
CONFIG_FST_TEST=y
+CONFIG_WIFI_STATS=y
+
CONFIG_MACSEC=y
CONFIG_DRIVER_MACSEC_LINUX=y
--
2.43.0
More information about the Hostap
mailing list