Skip to content

FreeRadius Conformance Tests and Fixes - #41

Merged
nicholasamorim merged 7 commits into
pyradius:masterfrom
nicholasamorim:tests/freeradius-conformance
Jun 2, 2026
Merged

nicholasamorim merged 7 commits into
pyradius:masterfrom
nicholasamorim:tests/freeradius-conformance

Conversation

@nicholasamorim

@nicholasamorim nicholasamorim commented Jun 2, 2026 •

Copy link
Copy Markdown
Member

This PR is about real-world FreeRADIUS interop. pyrad2 now ships a conformance suite that loads the upstream FreeRADIUS dictionary corpus and decodes its packet test vectors on every CI run, and every gap the suite surfaced got a real codec.

As of this entry, 281 of the 244 vendor dictionaries plus 41 of the v4 packet test vectors pass; only four dictionaries still xfail, and three of those are upstream FreeRADIUS dictionary bugs.

scripts/fetch_freeradius_corpus.py sparse-clones two pinned slices of
freeradius-server on demand, release_3_2_5 for the 244 share/dictionary
files, v4 master for 41 decode-proto/match packet vectors into a
gitignored tests/conformance/_corpus/. The fixtures skip cleanly when
the corpus is absent so `make test` stays fast and network-free.

Per-dict load is strict:

209 vendor dicts parse standalone, 35 xfail with a documented reason
(combo-ip, 3-level nested TLVs, parent-context, FR's "String" typo),
and xfail strict=True re-arms the test the moment pyrad2 grows support.

Two parser tokens added to DATATYPES (vsa, ipv4prefix) so the root RFC
dictionaries load. vsa is functionally octets since the real VSA
dispatch lives at the packet layer, and ipv4prefix mirrors the
ipv6prefix shape pyrad2 already handles. Wire-level encoders for either
are a follow-up.
FreeRADIUS dictionaries declaring combo-ip as "either an IPv4 or IPv6
address, decided by wire length"  now load. Codec lives in tools.py:
encoder dispatches on input shape via ip_address(), decoder on wire
length (4 = v4, 16 = v6, anything else rejected). Wired into both
encode_attr/decode_attr and the parser's DATATYPES allowlist.
The dictionary conformance test was loading each vendor file standalone,
which made 24 of them xfail for a reason that has nothing to do with
pyrad2: they reference attributes declared in another FreeRADIUS dict.
Switched to a session fixture that walks the FreeRADIUS root's
$INCLUDE order and cumulatively loads every RFC dict pyrad2 can parse,
then stacks each vendor dict on that base.
Encode side: add_attribute walks the parent chain (top-level down) and
nests dicts at every level, so an attribute declared as 241.5.1 lands
at self[241][5][1]. _pkt_encode_extended / _pkt_encode_long_extended /
_pkt_encode_tlv detect dict-valued slots and route them through a new
_encode_tlv_chain helper that flattens any depth into a TLV chain
before the outer envelope is applied.

Decode side: a new _decode_tlv_chain_into recursively parses TLV
chains, descending into any child slot the dictionary declares as
``tlv``. Both _pkt_decode_extended and the long-extended reassembly
path check the slot type and either store raw bytes (leaf) or a
nested map (tlv). __getitem__ recurses through nested storage so
pkt["Wrapper"]["Container"]["Leaf"] returns decoded values.
uint8/uint16/uint32/uint64/int32 normalise to the canonical pyrad2
type tokens (byte/short/integer/integer64/signed) at parser time, so
nothing downstream has to know they exist. ``virtual`` marks attributes
that exist in the dictionary for server-internal use but are never
sent on the wire, the encoder skips them. ``array`` (RFC 8044 §3.8)
packs multiple values of a fixed-length type into one AVP on encode
and splits them back out post-decode via a wire-length table covering
all ten fixed-length RADIUS types. ``secret`` is accepted as a no-op
marker for parity with FreeRADIUS-internal dicts.

The conformance fixture now stacks dictionary.freeradius,
dictionary.freeradius.internal, and dictionary.dhcp on top of the RFC
base so vendor dicts can find their transitively-required attributes.
Four xfails unblock cleanly (freeradius, freeradius.internal, compat,
dhcp).
The parser now accepts ``format=type_len,len_len,c``. vendor_formats
becomes a 3-tuple ``(type_len, len_len, has_continuation)`` and
threads through the encode/decode path.

Encode side: when a vendor has continuation, _pack_vsa_inner inserts
a continuation byte after the length header and _pkt_encode_continuation_vsa
fragments oversized values across AVPs with the 0x80 More flag set on
every fragment except the last. Per-fragment budget works out to 246
payload bytes for the standard 1,1,c layout.

Decode side: _pkt_decode_vendor_attribute reads the continuation byte
for continuation-format vendors, buffers fragments in a per-decode
table keyed on (vendor, vsa_type), and emits the joined value when
More clears.
@nicholasamorim
nicholasamorim merged commit f7e30c8 into pyradius:master Jun 2, 2026
8 checks passed
@nicholasamorim
nicholasamorim deleted the tests/freeradius-conformance branch June 2, 2026 16:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant