From a2f98b6e170174481e20e0486a214620fee6350d Mon Sep 17 00:00:00 2001 From: Matt <47545907+SoundMatt@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:56:33 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20v2.21.0=20=E2=80=94=20L2=20(native?= =?UTF-8?q?=20Ethernet)=20transport=20+=20UDP=20Annex=20J=20conformance=20?= =?UTF-8?q?fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROADMAP.md milestone 65. Two parts, per two independent public secondary sources (a Wireshark issue tracker discussion of the real Annex J wire text, and the COVESA Open1722 open-source reference implementation's Avtp_Udp_t header, github.com/COVESA/Open1722) — this repo has no verified access to the paywalled IEEE 1722-2016 standard text itself, so neither part is presented as verified against the primary standard: - rcp/udp.hpp Annex J conformance fix: an Annex J UDP payload actually begins with a 4-byte big-endian encapsulation sequence number before the AVTPDU, which udp::Frame/MultiFrame never had — encode_annexj_datagram/ decode_annexj_datagram add that envelope, and Server/Client now transparently prepend/strip it. The standard destination UDP ports are 17220 (Continuous) and 17221 (Discrete/control); RCP traffic is control-plane, so udp::kAnnexJControlPort (17221) is now the default port via a defaulted constructor parameter, with the explicit-port form unchanged for back-compat/testing. - rcp/l2.hpp (new): the native IEEE 1722-over-Ethernet transport udp.hpp's own header comment always described as the alternative to Annex J, now actually built — destination MAC + source MAC (auto-read via SIOCGIFHWADDR) + EtherType 0x22F0 + the AVTPDU directly, no encapsulation sequence number (that field is Annex J/UDP-specific). l2::Server/ l2::Client are structurally parallel to udp::Server/udp::Client (same method names, same Handler signature) with no new Transport interface. Linux-only (AF_PACKET/SOCK_RAW, needs CAP_NET_RAW/root); every other platform gets the same function_not_supported stub udp.hpp's own Windows branch already established. The pure Ethernet-header/Frame/MultiFrame codec is available on every platform unconditionally (unlike udp.hpp's own structure) so tests/test_l2.cpp can exercise it with no privileges and no Linux requirement. Neither transport deprecates or is preferred over the other; both are permanent, equally supported options. Verification: tests/l2_veth_roundtrip.cpp is a new standalone (non-ctest) program that drives a real l2::Server/l2::Client pair over a real Linux veth interface pair, run by the new l2-veth CI job (.github/workflows/ci.yml), which creates the veth pair with sudo and asserts a real Ethernet frame's payload round-trips byte-for-byte. Verified directly in a Linux container during development, including a genuine correctness fix that verification surfaced: AF_PACKET sockets do not support shutdown() on Linux (silently a no-op, unlike the AF_INET sockets rcp/udp.hpp uses), so l2::Server/l2::Client's serve/read threads would otherwise hang forever in close() after a request/response had already round-tripped correctly — fixed via a short SO_RCVTIMEO (detail::set_recv_timeout, 200ms) so those threads periodically recheck their own closed flag. Full local build (C++17/C++20, GCC 11/12, Clang 14, plus macOS) and ctest (53/53 suites, up from 52) pass. kVersion/CMakeLists.txt bump 2.20.0 → 2.21.0. Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com> --- .fusa-reqs.json | 80 ++++ .github/workflows/ci.yml | 40 ++ CMakeLists.txt | 2 +- ROADMAP.md | 97 ++++- include/rcp/l2.hpp | 800 ++++++++++++++++++++++++++++++++++++ include/rcp/udp.hpp | 185 +++++++-- include/rcp/version.hpp | 2 +- tests/CMakeLists.txt | 15 +- tests/l2_veth_roundtrip.cpp | 163 ++++++++ tests/test_l2.cpp | 244 +++++++++++ tests/test_udp.cpp | 133 +++++- 11 files changed, 1720 insertions(+), 41 deletions(-) create mode 100644 include/rcp/l2.hpp create mode 100644 tests/l2_veth_roundtrip.cpp create mode 100644 tests/test_l2.cpp diff --git a/.fusa-reqs.json b/.fusa-reqs.json index a31ee1f..96d7be1 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -2919,6 +2919,86 @@ "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" + }, + { + "id": "REQ-UDP-013", + "title": "Annex J encapsulation sequence number round-trips and increments monotonically", + "text": "rcp::udp::encode_annexj_datagram/decode_annexj_datagram shall round-trip a 4-byte big-endian encapsulation sequence number and the AVTPDU bytes that follow it unchanged, and rcp::udp::Client/rcp::udp::Server shall each maintain their own per-connection monotonically-incrementing counter for outgoing datagrams, exposed via Client::last_sent_encap_seq/last_recv_encap_seq and Server::last_recv_encap_seq(client).", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-UDP-014", + "title": "Server/Client default to the Annex J control port (17221) when no port is given", + "text": "rcp::udp::kAnnexJControlPort shall equal 17221, and rcp::udp::Server/rcp::udp::Client's port constructor parameter shall default to it so a caller that omits an explicit port binds/connects to 17221, while the explicit-port constructor form remains available.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-L2-001", + "title": "EthHeader encode/decode round-trips dst/src MAC and EtherType", + "text": "rcp::l2::encode_eth_header/decode_eth_header shall round-trip a 14-byte Ethernet header (destination MAC, source MAC, EtherType) unchanged, with the EtherType field always encoded as rcp::l2::kEtherType (0x22F0).", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-L2-002", + "title": "decode_eth_header rejects a buffer shorter than 14 bytes", + "text": "rcp::l2::decode_eth_header shall return a non-zero std::error_code when given fewer than rcp::l2::kEthHeaderLen (14) bytes.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-L2-003", + "title": "encode_l2_frame/decode_l2_frame round-trip an AVTPDU under the Ethernet header", + "text": "rcp::l2::encode_l2_frame/decode_l2_frame shall round-trip a destination MAC, source MAC, EtherType, and the AVTPDU (NTSCF/TSCF header plus ACF_ABB/ACF_GBB message) it carries unchanged, for both NTSCF and TSCF framing.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-L2-004", + "title": "Frame round-trips a TSCF-framed request with avtp_timestamp under L2 framing", + "text": "rcp::l2::encode_frame/decode_frame shall round-trip a Frame carrying a TSCF header, including its timestamp_valid flag and 32-bit avtp_timestamp, unchanged, the same as rcp::udp::Frame's equivalent guarantee.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-L2-005", + "title": "decode_l2_frame rejects a frame whose EtherType is not 0x22F0", + "text": "rcp::l2::decode_l2_frame/decode_l2_multi_frame shall return rcp::l2::L2Errc::bad_ethertype when the decoded Ethernet header's EtherType field is not rcp::l2::kEtherType (0x22F0).", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-L2-006", + "title": "decode_l2_frame rejects a buffer shorter than the Ethernet header", + "text": "rcp::l2::decode_l2_frame shall return a non-zero std::error_code when given fewer bytes than rcp::l2::kEthHeaderLen (14) requires.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-L2-007", + "title": "MultiFrame round-trips two ACF_ABB messages packed into one L2 frame", + "text": "rcp::l2::encode_l2_multi_frame/decode_l2_multi_frame shall round-trip two or more ACF_ABB/ACF_GBB messages packed into a single Ethernet frame's AVTPDU, per extraction §12.9.1.1's multiple-requests-in-one-frame requirement, the same as rcp::udp::MultiFrame's equivalent guarantee.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-L2-008", + "title": "l2::Server/l2::Client round-trip a real Ethernet frame byte-for-byte over a real interface pair", + "text": "rcp::l2::Server and rcp::l2::Client shall exchange a real AF_PACKET/SOCK_RAW Ethernet frame over a real Linux network interface pair and round-trip an ACF request/response payload byte-for-byte, verified by tests/l2_veth_roundtrip.cpp against a real veth pair in the dedicated l2-veth CI job (needs CAP_NET_RAW/root, not exercised by the privilege-free ctest suite).", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db9bd2c..aac07a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,46 @@ jobs: name: compile-commands path: build/compile_commands.json + # ── rcp/l2.hpp real-socket round trip (Linux, needs CAP_NET_RAW/root) ──────── + # Exercises rcp::l2::Server/Client over a real veth pair — real AF_PACKET + # sockets, a real Linux network interface pair, real Ethernet frames. This + # is deliberately separate from build-and-test above: it needs elevated + # privileges (CAP_NET_RAW, satisfied here via `sudo`) that job intentionally + # does not run with, and tests/test_l2.cpp's own header comment documents + # why its pure codec tests must not require them either. + l2-veth: + name: l2 veth round trip (rcp::l2::Server/Client, real frames) + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Install tools + run: | + sudo apt-get update -qq + sudo apt-get install -y cmake ninja-build + + - name: Configure + run: | + cmake -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_CXX_STANDARD=17 \ + -G Ninja + + - name: Build l2_veth_roundtrip + run: cmake --build build --target l2_veth_roundtrip --parallel + + - name: Create veth pair + run: | + sudo ip link add veth0 type veth peer name veth1 + sudo ip link set veth0 up + sudo ip link set veth1 up + + - name: Run real Server/Client round trip over the veth pair + run: | + SERVER_MAC=$(cat /sys/class/net/veth1/address) + echo "veth1 MAC: ${SERVER_MAC}" + sudo ./build/tests/l2_veth_roundtrip veth1 veth0 "${SERVER_MAC}" + # ── RELAY conformance gate (spec §12) ──────────────────────────────────────── relay-conform: name: relay conform (RELAY §12 conformance) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6d0ddb5..59af01d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) project(cpp-rcp - VERSION 2.20.0 + VERSION 2.21.0 DESCRIPTION "Remote Control Protocol for automotive zonal architecture (C++)" HOMEPAGE_URL "https://github.com/SoundMatt/cpp-RCP" LANGUAGES CXX diff --git a/ROADMAP.md b/ROADMAP.md index fa625ed..5203e3b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1567,10 +1567,11 @@ already predates milestones 44-61 (still documenting `rcp/firmware.hpp` and other since-deleted packages) and bringing it in line with the current architecture is a substantial, separately-scoped task this milestone's own text does not name. Milestone #63 (Wire Format Conformance Fix Pass, -v2.19.0) is next, ahead of #65 (TC18 RCP General Availability, v3.0.0), +v2.19.0) is next, ahead of #66 (TC18 RCP General Availability, v3.0.0), since it corrects a defect in the exact wire surface GA's own scope requires to be correct. (Milestone #64, TC18 Conformance Fix Pass II, -v2.20.0, was added later in the same vein — see its own entry.) +v2.20.0, and milestone #65, L2 Transport + UDP Annex J Conformance Fix, +v2.21.0, were added later in the same vein — see their own entries.) ### Retired-model residue cleanup (2026-07-30, v2.19.0 — audit findings cpp-RCP-FS-01..05, cpp-RCP-05, cpp-RCP-07) @@ -1767,7 +1768,7 @@ environment to interoperate-test against directly, so "full bit-for-bit wire conformance" is not claimed as an absolute fact here either — this milestone's own honest position is "high confidence, derivation-and-cross- check verified, not independently interop-tested," which is why GA -(milestone #65) remains a distinct, later milestone rather than being +(milestone #66) remains a distinct, later milestone rather than being folded into this one. Both `AcfMessageInfo`'s and the NTSCF/TSCF headers' public field names are @@ -1891,7 +1892,92 @@ convention of reserving version bumps for commits that touch library code/behavior (contrast the v0.36.0→v0.48.0 cpp-FuSa CI pin bump, which stayed at the same cpp-rcp version since it touched no library code). -### 65. TC18 RCP — General Availability (v3.0.0) +### 65. L2 (Native Ethernet) Transport + UDP Annex J Conformance Fix (v2.21.0) + +`rcp/udp.hpp`'s own header comment (since milestone 57, v2.13.0) named IEEE +1722 Annex J's UDP/IP encapsulation as an alternative to native Ethernet +framing "for links where native AVTP framing (destination MAC + EtherType +0x22F0) is not available" — but never actually implemented that native +framing, and, separately, its own reading of Annex J turned out to be +wrong. This milestone fixes both, using two independent public secondary +sources (a Wireshark issue tracker discussion of the real Annex J wire +text, and the COVESA Open1722 open-source reference implementation's +`Avtp_Udp_t` header struct, `include/avtp/Udp.h`, BSD-3-Clause, +github.com/COVESA/Open1722) — this codebase has no verified access to the +paywalled IEEE 1722-2016 standard text itself, so "Annex J conformant" +below means conformant with these two sources' reading of Annex J, not +verified against the primary standard. + +- **`rcp/udp.hpp` Annex J conformance fix.** Two gaps, both confirmed by + both secondary sources independently: (1) an Annex J UDP payload actually + begins with a 4-byte (32-bit) big-endian "encapsulation sequence number" + before the AVTPDU, which `udp::Frame`/`MultiFrame` never had — + `udp::encode_annexj_datagram`/`decode_annexj_datagram` are the new pure + codec for that envelope, and `udp::Server`/`udp::Client` now prepend/strip + it transparently around the existing AVTPDU encode/decode path. Both + track and expose the sequence number they send/receive + (`Client::last_sent_encap_seq`/`last_recv_encap_seq`, + `Server::last_recv_encap_seq(client)`) for a future caller's own use (e.g. + loss detection) — no such detection is implemented here, and the field's + exact intended semantics are not verified against the primary standard. + (2) the standard destination UDP ports are 17220 ("Continuous") and 17221 + ("Discrete"/control) — RCP request/response/acknowledge traffic is + control-plane, so `udp::kAnnexJControlPort` (17221) is now the default + port `Server`/`Client` bind/connect to via a defaulted constructor + parameter, while the explicit-port constructor form is unchanged for + back-compat/testing. +- **`rcp/l2.hpp` (new): native IEEE 1722-over-Ethernet transport.** The + transport `rcp/udp.hpp`'s own comment always described as the alternative + to Annex J, now actually built: destination MAC (6 bytes) + source MAC (6 + bytes, auto-read from the caller-specified interface via `SIOCGIFHWADDR`) + + EtherType `0x22F0` (2 bytes) + the AVTPDU bytes directly — no + encapsulation sequence number, since that field is Annex J/UDP-specific, + not part of native L2 framing. `l2::Server`/`l2::Client` are structurally + parallel to `udp::Server`/`udp::Client` (same method names, same Handler + signature, same request/response correlation strategy) with no new + abstract `Transport` interface introduced — this codebase has none today, + and matching shapes without formal inheritance matches its existing + style. Linux-only (`AF_PACKET`/`SOCK_RAW`, needs `CAP_NET_RAW` or root); + every other platform gets the same "compiles cleanly, every operation + returns `std::errc::function_not_supported`" stub `rcp/udp.hpp`'s own + Windows branch already established as this codebase's precedent. Unlike + `rcp/udp.hpp`, this header's pure Ethernet-header/Frame/MultiFrame codec + is available unconditionally on every platform (not gated behind the + socket-availability guard) specifically so `tests/test_l2.cpp` can + exercise it everywhere with no privileges and no Linux requirement — a + deliberate, documented deviation from `rcp/udp.hpp`'s own structure. A + caller supplies the destination MAC address itself (unicast or + multicast); this header does not derive or allocate a multicast MAC, the + same "caller decides addressing" division of responsibility + `udp::Client`'s own caller-supplied host/port already establishes. +- **Real-socket verification.** `tests/l2_veth_roundtrip.cpp` is a + standalone (non-`ctest`) program that drives a real `l2::Server`/ + `l2::Client` pair over a real Linux `veth` interface pair, run only by + the new `l2-veth` CI job (`.github/workflows/ci.yml`), which creates the + `veth` pair with `sudo` and asserts a real Ethernet frame's payload + round-trips byte-for-byte. One correctness fix this verification surfaced + during development: `AF_PACKET` sockets do not support `shutdown()` on + Linux (silently a no-op, unlike the `SOCK_DGRAM`/`AF_INET` sockets + `rcp/udp.hpp` uses, where `shutdown()` reliably unblocks a thread parked + in `recv`/`recvfrom`), so `l2::Server`/`l2::Client`'s serve/read threads + would otherwise hang forever in `close()` after a real request/response + had already round-tripped correctly. Fixed by giving every real socket a + short `SO_RCVTIMEO` (`detail::set_recv_timeout`, 200ms) so those threads + periodically recheck their own closed flag instead of relying on + `shutdown()` to wake them. + +Neither transport deprecates or is preferred over the other — `rcp/udp.hpp` +and `rcp/l2.hpp` are both permanent, equally supported options; callers +pick whichever fits their link, exactly as discussed and decided going into +this milestone. `kVersion`/`CMakeLists.txt` bump 2.20.0 → 2.21.0 +accordingly. Full local build (C++17/C++20, GCC 11/12, Clang 14) and +`ctest` (53/53 suites, up from 52 — `tests/test_l2.cpp` is new) pass; the +real `veth` round trip was verified directly in a Linux container during +development (see this milestone's own pull request description for the +byte-for-byte evidence), ahead of the dedicated CI job doing the same on +every future change. + +### 66. TC18 RCP — General Availability (v3.0.0) - First release where cpp-RCP's `RCP` conforms to the OPEN Alliance TC18 Remote Control Protocol Specification at the wire level: an RC Client @@ -1919,7 +2005,8 @@ orthogonal to the protocol replacement. |---|---|---| | `rcp.hpp` | REPLACE | Core `Zone`/`Command`/`Response`/`Status`/`Controller`/`Registry` model has no TC18 analog; becomes the new stream/endpoint/register-map model (v2.0.0-v2.1.0) | | `wire.hpp` | REPLACE | *Is* the old wire format; rebuilt as the AVTPDU/ACF codec (v2.0.0) | -| `udp.hpp` | REPLACE | Bespoke 16-byte-header UDP transport rebuilt to carry real AVTPDU frames (v2.13.0) | +| `udp.hpp` | REPLACE | Bespoke 16-byte-header UDP transport rebuilt to carry real AVTPDU frames (v2.13.0); Annex J conformance fix (4-byte encapsulation sequence number, port 17221 default) at v2.21.0 | +| `l2.hpp` | NEW | Native IEEE 1722-over-Ethernet (raw L2, `AF_PACKET`/`SOCK_RAW`, Linux-only) transport, structurally parallel to `udp.hpp`'s `Server`/`Client` — the native-Ethernet alternative `udp.hpp`'s own header comment named but never implemented (v2.21.0) | | `mock.hpp` | REPLACE | In-process mock is entirely `Zone`/`Command`-shaped; rebuilt as an RC Server simulator (v2.12.0) | | `sim.hpp` | REPLACE | Timing-realistic simulator implements the full old `Controller` interface; rebuilt against the new register/endpoint model (v2.12.0) | | `e2e.hpp` | REPLACE | Ad-hoc CRC-16 + replay-window wrapper superseded by the spec's actual CRC32 (poly `0xF4ACFB13`) safe-point mechanism and `rx_enforce_seq` monotonic check (v2.6.0) | diff --git a/include/rcp/l2.hpp b/include/rcp/l2.hpp new file mode 100644 index 0000000..a37de14 --- /dev/null +++ b/include/rcp/l2.hpp @@ -0,0 +1,800 @@ +// fusa:req REQ-L2-001 +// fusa:req REQ-L2-002 +// fusa:req REQ-L2-003 +// fusa:req REQ-L2-004 +// fusa:req REQ-L2-005 +// fusa:req REQ-L2-006 +// fusa:req REQ-L2-007 +// fusa:req REQ-L2-008 + +// Native IEEE 1722-over-Ethernet (raw L2) transport for the OPEN Alliance +// TC18 Remote Control Protocol Specification v0.5.1_RC — carries real +// AVTPDU (NTSCF/TSCF, rcp/avtp.hpp) frames wrapping ACF_ABB/ACF_GBB messages +// (rcp/acf.hpp) directly at layer 2, under EtherType 0x22F0, the framing +// rcp/udp.hpp's own header comment names as the alternative to IEEE 1722's +// Annex J UDP/IP encapsulation (which rcp/udp.hpp implements). This header +// is the native-Ethernet transport that comment named as deliberately +// deferred; it now exists alongside rcp/udp.hpp as a permanent, equally +// supported option — neither transport is preferred over or deprecates the +// other, and callers pick whichever fits their link. +// +// Wire frame: destination MAC (6 bytes) + source MAC (6 bytes) + EtherType +// 0x22F0 (2 bytes, big-endian) + the AVTPDU bytes directly. Unlike +// rcp/udp.hpp's Annex J UDP payload, there is NO 4-byte encapsulation +// sequence number here — that field is specific to the UDP/IP encapsulation +// case (see rcp/udp.hpp's own header comment for the two independent public +// secondary sources — a Wireshark issue tracker discussion of the real +// Annex J text, and the COVESA Open1722 open-source reference +// implementation, github.com/COVESA/Open1722 — this codebase's Annex J +// reading rests on, since this codebase has no access to the paywalled IEEE +// 1722-2016 standard text itself). Native Ethernet framing carries the +// AVTPDU unmodified, which is a more directly verifiable claim than the +// Annex J UDP encapsulation shape, since it follows straight from IEEE +// 1722's own "EtherType 0x22F0" identification quoted in TC18 §10.1 rather +// than from a secondary source's reading of Annex J specifically. Full +// bit-for-bit conformance against other TC18 implementations is still not +// claimed, same as the equivalent disclaimers in rcp/avtp.hpp, rcp/acf.hpp, +// rcp/udp.hpp, and rcp/discovery.hpp. +// +// Platform scope — deliberately narrower than rcp/udp.hpp's POSIX-wide +// support: raw Ethernet framing needs a socket API that gives this module +// direct control over the L2 header, which on Linux is AF_PACKET/SOCK_RAW. +// macOS and other BSDs expose a materially different raw-capture API +// (BPF, /dev/bpf*) this module does not implement; Windows has no POSIX +// raw-socket equivalent at all. So Server/Client below are Linux-only +// (guarded by `#ifdef __linux__`, matching how rcp/udp.hpp itself guards +// its POSIX-vs-Windows-stub split at the top of that file) — every other +// platform gets the same "compiles cleanly, every operation returns +// std::errc::function_not_supported" stub rcp/udp.hpp's own Windows branch +// already establishes as this codebase's precedent, followed directly here. +// Opening the raw socket needs CAP_NET_RAW (or root) — every real Server/ +// Client construction below will otherwise silently fail (ok() == false), +// the same "construct, then check ok()" contract rcp/udp.hpp's Server/ +// Client already use for their own bind()/connect() failure path. +// +// One deliberate structural difference from rcp/udp.hpp: rcp/udp.hpp's +// Frame/MultiFrame codec (encode_frame/decode_frame/encode_multi_frame/ +// decode_multi_frame) is compiled only under its POSIX guard, with a +// separate do-nothing stub redefined for the Windows branch, because that +// header treats "codec" and "socket transport" as one unit. This header +// keeps its own Frame/MultiFrame codec and Ethernet-header codec +// (encode_eth_header/decode_eth_header, encode_l2_frame/decode_l2_frame, +// encode_l2_multi_frame/decode_l2_multi_frame) available on every platform, +// unconditionally — none of that code touches a socket, so there is no +// platform reason to hide it, and doing so lets the pure encode/decode +// logic be exercised by tests/test_l2.cpp on every CI platform (including +// macOS and Windows) with no privileges and no Linux requirement, while +// only the raw-socket Server/Client classes below are actually gated to +// Linux. +// +// Symmetric division of responsibility with rcp/udp.hpp::Client, which +// takes a caller-supplied host/port rather than deriving one: Client below +// takes a caller-supplied destination MAC address (unicast or multicast). +// It does not derive or allocate a multicast MAC itself — that mapping (if +// IEEE 1722 defines one for AVTP streams) lives in the base standard, which +// this codebase does not have verified access to; the caller decides. +#pragma once + +#include "acf.hpp" +#include "avtp.hpp" +#include "rcp.hpp" // for rcp::Context/ErrClosed/ErrTimeout only — see rcp/udp.hpp's equivalent scope note + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) +# include +# include +# include +# include +# include +# include +# include +# include +# define RCP_L2_LINUX 1 +#endif + +namespace rcp { +namespace l2 { + +// ── Wire constants ─────────────────────────────────────────────────────────── + +using MacAddress = std::array; + +constexpr size_t kMacLen = 6; +constexpr uint16_t kEtherType = 0x22F0; // TC18 §10.1's own cited EtherType +constexpr size_t kEthHeaderLen = 2 * kMacLen + 2; // dst(6) + src(6) + ethertype(2) + +// kMaxFrame: receive-buffer ceiling for one raw Ethernet frame. Standard +// Ethernet MTU is 1500 bytes of payload (1514 total with this module's +// 14-byte L2 header); this also covers common jumbo-frame configurations +// (up to a 9000-byte payload) so a receive buffer sized to it does not +// truncate on links that enable them, the same conservative-sizing spirit +// as rcp/udp.hpp's kMaxDatagram. +constexpr size_t kMaxFrame = 9018; + +// ── Errors ──────────────────────────────────────────────────────────────────── +// short_buffer is reused from rcp/avtp.hpp (its own header comment already +// documents it as shared across modules) for "fewer bytes than required" +// conditions; bad_ethertype is specific to this module — rcp/avtp.hpp's own +// bad_subtype is documented as meaning "AVTPDU subtype byte is neither +// NTSCF nor TSCF," a different field than this header's EtherType check, so +// it is not reused for that case here. + +enum class L2Errc : int { + bad_ethertype = 1, // received frame's EtherType is not kEtherType (0x22F0) +}; + +inline const std::error_category& l2_category() noexcept { + struct Cat : std::error_category { + const char* name() const noexcept override { return "rcp.l2"; } + std::string message(int ev) const override { + switch (static_cast(ev)) { + case L2Errc::bad_ethertype: return "rcp/l2: EtherType is not 0x22F0"; + default: return "rcp/l2: unknown error"; + } + } + }; + static Cat instance; + return instance; +} + +inline std::error_code make_error_code(L2Errc e) noexcept { + return {static_cast(e), l2_category()}; +} + +// ── Ethernet header codec (pure, no socket, every platform) ───────────────── + +struct EthHeader { + MacAddress dst{}; + MacAddress src{}; + uint16_t ethertype = 0; +}; + +inline std::vector encode_eth_header(const MacAddress& dst, const MacAddress& src) { + std::vector out(kEthHeaderLen); + std::copy(dst.begin(), dst.end(), out.begin()); + std::copy(src.begin(), src.end(), out.begin() + static_cast(kMacLen)); + avtp::detail::put_u16(&out[2 * kMacLen], kEtherType); + return out; +} + +inline std::error_code decode_eth_header(const uint8_t* b, size_t len, EthHeader& out) { + if (len < kEthHeaderLen) return avtp::make_error_code(avtp::AvtpErrc::short_buffer); + std::copy(b, b + kMacLen, out.dst.begin()); + std::copy(b + kMacLen, b + 2 * kMacLen, out.src.begin()); + out.ethertype = avtp::detail::get_u16(b + 2 * kMacLen); + return {}; +} + +// ── Frame ───────────────────────────────────────────────────────────────────── +// Same AVTPDU shape as rcp/udp.hpp::Frame (one NTSCF/TSCF header wrapping +// one ACF_ABB/ACF_GBB message) — deliberately duplicated here rather than +// reused from rcp/udp.hpp, matching this codebase's documented preference +// (RELAY-side guidance for this change) for parallel concrete types over a +// shared Transport interface: this repository has no such interface today, +// and one is not introduced by this header either. encode_frame/decode_frame +// are pure functions — no socket I/O — exactly like rcp/udp.hpp's. +struct Frame { + bool use_tscf = false; // false = NTSCF, true = TSCF + avtp::StreamId stream_id{}; + uint16_t sequence_num = 0; + bool timestamp_valid = false; // TSCF "tv" bit; ignored under NTSCF + uint32_t avtp_timestamp = 0; // TSCF-only; ignored under NTSCF + + acf::AcfMessageInfo info{}; + uint64_t message_timestamp = 0; // honored only when info.acf_msg_type == kAcfMsgTypeGbb + std::vector payload; +}; + +inline std::vector encode_frame(const Frame& f) { + std::vector acf_msg = (f.info.acf_msg_type == acf::kAcfMsgTypeGbb) + ? acf::encode_acf_gbb(f.info, f.message_timestamp, f.payload) + : acf::encode_acf_abb(f.info, f.payload); + + std::vector out; + if (f.use_tscf) { + avtp::TscfHeader hdr; + hdr.stream_id = f.stream_id; + hdr.sequence_num = f.sequence_num; + hdr.control_data_length = static_cast(acf_msg.size()); + hdr.timestamp_valid = f.timestamp_valid; + hdr.avtp_timestamp = f.avtp_timestamp; + out = avtp::encode_tscf_header(hdr); + } else { + avtp::NtscfHeader hdr; + hdr.stream_id = f.stream_id; + hdr.sequence_num = f.sequence_num; + hdr.control_data_length = static_cast(acf_msg.size()); + out = avtp::encode_ntscf_header(hdr); + } + out.insert(out.end(), acf_msg.begin(), acf_msg.end()); + return out; +} + +inline std::error_code decode_frame(const uint8_t* b, size_t len, Frame& out) { + if (len < 1) return avtp::make_error_code(avtp::AvtpErrc::short_buffer); + + size_t acf_off; + out.use_tscf = (b[0] == avtp::kSubtypeTscf); + if (out.use_tscf) { + avtp::TscfHeader hdr; + if (auto ec = avtp::decode_tscf_header(b, len, hdr)) return ec; + out.stream_id = hdr.stream_id; + out.sequence_num = hdr.sequence_num; + out.timestamp_valid = hdr.timestamp_valid; + out.avtp_timestamp = hdr.avtp_timestamp; + acf_off = avtp::kTscfHeaderLen; + if (static_cast(hdr.control_data_length) != len - acf_off) + return avtp::make_error_code(avtp::AvtpErrc::length_mismatch); + } else { + avtp::NtscfHeader hdr; + if (auto ec = avtp::decode_ntscf_header(b, len, hdr)) return ec; + out.stream_id = hdr.stream_id; + out.sequence_num = hdr.sequence_num; + out.timestamp_valid = false; + out.avtp_timestamp = 0; + acf_off = avtp::kNtscfHeaderLen; + if (static_cast(hdr.control_data_length) != len - acf_off) + return avtp::make_error_code(avtp::AvtpErrc::length_mismatch); + } + + if (len < acf_off + 1) return avtp::make_error_code(avtp::AvtpErrc::short_buffer); + if (acf::peek_acf_msg_type(b + acf_off) == acf::kAcfMsgTypeGbb) { + return acf::decode_acf_gbb(b + acf_off, len - acf_off, out.info, + out.message_timestamp, out.payload); + } + out.message_timestamp = 0; + return acf::decode_acf_abb(b + acf_off, len - acf_off, out.info, out.payload); +} + +// ── MultiFrame — multiple ACF requests in one AVTPDU (extraction §12.9.1.1) ── +// Same role as rcp/udp.hpp::MultiFrame: one-or-more acf::AcfEntry messages +// under a single NTSCF/TSCF header, so Server below can answer every +// request a single Ethernet frame carries, per-request, in one reply frame. +struct MultiFrame { + bool use_tscf = false; // false = NTSCF, true = TSCF + avtp::StreamId stream_id{}; + uint16_t sequence_num = 0; + bool timestamp_valid = false; // TSCF "tv" bit; ignored under NTSCF + uint32_t avtp_timestamp = 0; // TSCF-only; ignored under NTSCF + + std::vector messages; // one or more ACF_ABB/ACF_GBB messages, in wire order +}; + +inline std::vector encode_multi_frame(const MultiFrame& f) { + std::vector acf_bytes; + for (const auto& m : f.messages) { + auto enc = (m.info.acf_msg_type == acf::kAcfMsgTypeGbb) + ? acf::encode_acf_gbb(m.info, m.message_timestamp, m.payload) + : acf::encode_acf_abb(m.info, m.payload); + acf_bytes.insert(acf_bytes.end(), enc.begin(), enc.end()); + } + + std::vector out; + if (f.use_tscf) { + avtp::TscfHeader hdr; + hdr.stream_id = f.stream_id; + hdr.sequence_num = f.sequence_num; + hdr.control_data_length = static_cast(acf_bytes.size()); + hdr.timestamp_valid = f.timestamp_valid; + hdr.avtp_timestamp = f.avtp_timestamp; + out = avtp::encode_tscf_header(hdr); + } else { + avtp::NtscfHeader hdr; + hdr.stream_id = f.stream_id; + hdr.sequence_num = f.sequence_num; + hdr.control_data_length = static_cast(acf_bytes.size()); + out = avtp::encode_ntscf_header(hdr); + } + out.insert(out.end(), acf_bytes.begin(), acf_bytes.end()); + return out; +} + +inline std::error_code decode_multi_frame(const uint8_t* b, size_t len, MultiFrame& out) { + if (len < 1) return avtp::make_error_code(avtp::AvtpErrc::short_buffer); + + size_t acf_off; + out.use_tscf = (b[0] == avtp::kSubtypeTscf); + if (out.use_tscf) { + avtp::TscfHeader hdr; + if (auto ec = avtp::decode_tscf_header(b, len, hdr)) return ec; + out.stream_id = hdr.stream_id; + out.sequence_num = hdr.sequence_num; + out.timestamp_valid = hdr.timestamp_valid; + out.avtp_timestamp = hdr.avtp_timestamp; + acf_off = avtp::kTscfHeaderLen; + if (static_cast(hdr.control_data_length) != len - acf_off) + return avtp::make_error_code(avtp::AvtpErrc::length_mismatch); + } else { + avtp::NtscfHeader hdr; + if (auto ec = avtp::decode_ntscf_header(b, len, hdr)) return ec; + out.stream_id = hdr.stream_id; + out.sequence_num = hdr.sequence_num; + out.timestamp_valid = false; + out.avtp_timestamp = 0; + acf_off = avtp::kNtscfHeaderLen; + if (static_cast(hdr.control_data_length) != len - acf_off) + return avtp::make_error_code(avtp::AvtpErrc::length_mismatch); + } + + if (len < acf_off + 1) return avtp::make_error_code(avtp::AvtpErrc::short_buffer); + return acf::decode_acf_messages(b + acf_off, len - acf_off, out.messages); +} + +// ── Full L2 wire frame (Ethernet header + AVTPDU), pure, every platform ───── + +inline std::vector encode_l2_frame(const MacAddress& dst, const MacAddress& src, + const Frame& f) { + auto out = encode_eth_header(dst, src); + auto body = encode_frame(f); + out.insert(out.end(), body.begin(), body.end()); + return out; +} + +inline std::error_code decode_l2_frame(const uint8_t* b, size_t len, + EthHeader& out_hdr, Frame& out_frame) { + if (auto ec = decode_eth_header(b, len, out_hdr)) return ec; + if (out_hdr.ethertype != kEtherType) return make_error_code(L2Errc::bad_ethertype); + return decode_frame(b + kEthHeaderLen, len - kEthHeaderLen, out_frame); +} + +inline std::vector encode_l2_multi_frame(const MacAddress& dst, const MacAddress& src, + const MultiFrame& f) { + auto out = encode_eth_header(dst, src); + auto body = encode_multi_frame(f); + out.insert(out.end(), body.begin(), body.end()); + return out; +} + +inline std::error_code decode_l2_multi_frame(const uint8_t* b, size_t len, + EthHeader& out_hdr, MultiFrame& out_frame) { + if (auto ec = decode_eth_header(b, len, out_hdr)) return ec; + if (out_hdr.ethertype != kEtherType) return make_error_code(L2Errc::bad_ethertype); + return decode_multi_frame(b + kEthHeaderLen, len - kEthHeaderLen, out_frame); +} + +#if defined(RCP_L2_LINUX) + +// kRecvTimeoutMillis: SO_RCVTIMEO applied to every real Server/Client socket +// below (detail::set_recv_timeout) — see that function's own comment for +// why AF_PACKET needs this where rcp/udp.hpp's INET sockets don't. Bounds +// how long close()/~Server()/~Client() can block waiting for their +// serve()/read_loop() thread to notice and exit. +constexpr int kRecvTimeoutMillis = 200; + +namespace detail { + +// mac_to_hex renders a MacAddress as "aa:bb:cc:dd:ee:ff" — used only as an +// internal map key (Server::client_id_for), not part of this header's wire +// format. +inline std::string mac_to_hex(const MacAddress& mac) { + static const char* kHex = "0123456789abcdef"; + std::string out; + out.reserve(17); + for (size_t i = 0; i < mac.size(); ++i) { + if (i) out.push_back(':'); + out.push_back(kHex[(mac[i] >> 4) & 0xF]); + out.push_back(kHex[mac[i] & 0xF]); + } + return out; +} + +// get_ifindex/get_hwaddr wrap the two ioctls this module needs to turn a +// caller-supplied interface name (e.g. "eth0") into what AF_PACKET/SOCK_RAW +// actually needs: an interface index to bind()/sendto() against +// (SIOCGIFINDEX) and this host's own MAC address on that interface +// (SIOCGIFHWADDR), auto-read so callers never have to supply their own MAC +// — symmetric to how rcp/udp.hpp never asks a caller for its own local IP. +inline bool get_ifindex(int fd, const char* ifname, int& out_ifindex) { + struct ifreq ifr{}; + std::strncpy(ifr.ifr_name, ifname, IFNAMSIZ - 1); + if (::ioctl(fd, SIOCGIFINDEX, &ifr) < 0) return false; + out_ifindex = ifr.ifr_ifindex; + return true; +} + +inline bool get_hwaddr(int fd, const char* ifname, MacAddress& out_mac) { + struct ifreq ifr{}; + std::strncpy(ifr.ifr_name, ifname, IFNAMSIZ - 1); + if (::ioctl(fd, SIOCGIFHWADDR, &ifr) < 0) return false; + std::memcpy(out_mac.data(), ifr.ifr_hwaddr.sa_data, kMacLen); + return true; +} + +// set_recv_timeout: unlike rcp/udp.hpp's Server/Client, which unblock a +// thread parked in a blocking recv/recvfrom by calling ::shutdown() on the +// socket from close(), AF_PACKET sockets do not support shutdown() on Linux +// (the kernel's packet socket proto_ops leaves shutdown unimplemented, so it +// is a silent no-op) — a blocked recvfrom() on an AF_PACKET socket is not +// woken up by shutdown() the way rcp/udp.hpp's INET sockets are. Instead, +// every real socket below sets a short SO_RCVTIMEO so its serve()/read_loop() +// wakes up periodically on its own to recheck the closed_ flag, bounding +// close()'s wait to about this timeout rather than hanging indefinitely. +inline bool set_recv_timeout(int fd, int millis) { + struct timeval tv{}; + tv.tv_sec = millis / 1000; + tv.tv_usec = (millis % 1000) * 1000; + return ::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) == 0; +} + +} // namespace detail + +// ── Server ──────────────────────────────────────────────────────────────────── +// Server opens an AF_PACKET/SOCK_RAW socket bound to one network interface, +// filtered to EtherType 0x22F0 at socket-creation time, reads this host's +// own MAC address off that interface (detail::get_hwaddr) to use as the +// source address on every reply it sends, decodes each inbound frame as a +// MultiFrame, and dispatches every ACF request the frame carries — same +// per-request handling as rcp/udp.hpp::Server, same Handler signature, +// shaped to match rcp::mock::Server::dispatch — then sends the reply back +// to the sender's own source MAC on the same interface. Needs CAP_NET_RAW +// (or root) to open the socket at all; construct, then call ok(). +class Server { +public: + using Handler = std::function& req_payload, + acf::AcfMessageInfo& out_resp, + std::vector& out_resp_payload)>; + + Server(avtp::StreamId stream_id, const char* ifname) + : stream_id_(stream_id), fd_(-1) { + fd_ = ::socket(AF_PACKET, SOCK_RAW, htons(kEtherType)); + if (fd_ < 0) return; + + if (!detail::get_ifindex(fd_, ifname, ifindex_) || + !detail::get_hwaddr(fd_, ifname, local_mac_)) { + ::close(fd_); + fd_ = -1; + return; + } + + sockaddr_ll sll{}; + sll.sll_family = AF_PACKET; + sll.sll_protocol = htons(kEtherType); + sll.sll_ifindex = ifindex_; + if (::bind(fd_, reinterpret_cast(&sll), sizeof(sll)) < 0) { + ::close(fd_); + fd_ = -1; + return; + } + // See detail::set_recv_timeout's own comment: AF_PACKET has no + // working shutdown(), so serve() needs to wake up on its own to + // notice close() was called. + detail::set_recv_timeout(fd_, kRecvTimeoutMillis); + serve_thread_ = std::thread([this]{ serve(); }); + } + + ~Server() { close(); } + + MacAddress local_mac() const noexcept { return local_mac_; } + + void set_handler(Handler h) { + std::lock_guard lk(mu_); + handler_ = std::move(h); + } + + void close() { + if (!closed_.exchange(true)) { + if (fd_ >= 0) { + ::shutdown(fd_, SHUT_RDWR); + ::close(fd_); + fd_ = -1; + } + } + if (serve_thread_.joinable()) serve_thread_.join(); + } + + bool ok() const noexcept { return fd_ >= 0; } + +private: + avtp::StreamId stream_id_; + int fd_; + int ifindex_ = -1; + MacAddress local_mac_{}; + std::atomic closed_{false}; + std::atomic seq_{0}; + std::mutex mu_; + Handler handler_; + std::thread serve_thread_; + + // client_ids_ assigns each distinct sender MAC a stable, opaque size_t + // identity, first-seen order — same role as rcp/udp.hpp::Server's own + // client_ids_, keyed by source MAC instead of "host:port". + std::map client_ids_; + size_t next_client_id_ = 0; + + size_t client_id_for(const MacAddress& src) { + auto key = detail::mac_to_hex(src); + auto it = client_ids_.find(key); + if (it != client_ids_.end()) return it->second; + size_t id = next_client_id_++; + client_ids_.emplace(key, id); + return id; + } + + void serve() { + std::vector buf(kMaxFrame); + while (!closed_.load()) { + sockaddr_ll from{}; + socklen_t flen = sizeof(from); + ssize_t n = ::recvfrom(fd_, buf.data(), buf.size(), 0, + reinterpret_cast(&from), &flen); + if (n < 0) { + // EAGAIN/EWOULDBLOCK is just the SO_RCVTIMEO timeout + // (detail::set_recv_timeout's own comment) firing so this + // loop can recheck closed_ — not a real error. + if (errno == EAGAIN || errno == EWOULDBLOCK) continue; + break; + } + if (n == 0) continue; + // AF_PACKET sockets bound to an interface also see a copy of + // this host's own outgoing frames on that interface + // (sll_pkttype == PACKET_OUTGOING); without this check, a + // Server's own reply would loop back into its own receive path + // and be mistaken for a fresh inbound request. + if (from.sll_pkttype == PACKET_OUTGOING) continue; + + EthHeader hdr; + MultiFrame req; + if (decode_l2_multi_frame(buf.data(), static_cast(n), hdr, req)) continue; + + MultiFrame resp; + resp.use_tscf = req.use_tscf; + resp.stream_id = stream_id_; + resp.sequence_num = static_cast(++seq_); + resp.timestamp_valid = req.timestamp_valid; + resp.avtp_timestamp = req.avtp_timestamp; + resp.messages.reserve(req.messages.size()); + { + std::lock_guard lk(mu_); + size_t client = client_id_for(hdr.src); + // §12.9.1.1: "check each of them individually if to be + // processed or not" — each request in the frame is + // dispatched and answered on its own, not as a batch. + for (const auto& m : req.messages) { + acf::AcfEntry out_entry; + if (handler_) { + auto ec = handler_(client, m.info, m.payload, out_entry.info, out_entry.payload); + (void)ec; // Handler always populates out_entry.info even on + // failure, same contract rcp::mock::Server::dispatch + // and rcp/udp.hpp::Server document. + } else { + out_entry.info = acf::make_response(m.info, acf::ResponseKind::Acknowledge); + } + resp.messages.push_back(std::move(out_entry)); + } + } + + auto out_bytes = encode_l2_multi_frame(hdr.src, local_mac_, resp); + sockaddr_ll to{}; + to.sll_family = AF_PACKET; + to.sll_ifindex = ifindex_; + to.sll_halen = kMacLen; + std::copy(hdr.src.begin(), hdr.src.end(), to.sll_addr); + ::sendto(fd_, out_bytes.data(), out_bytes.size(), 0, + reinterpret_cast(&to), sizeof(to)); + } + } +}; + +// ── Client ──────────────────────────────────────────────────────────────────── +// Client opens an AF_PACKET/SOCK_RAW socket bound to one network interface +// and sends AVTPDU-framed ACF requests to one caller-supplied destination +// MAC address (unicast or multicast — this header does not derive one, see +// this file's own header comment), correlating each response by the +// (byte_bus_id, transaction_num) pair rcp/acf.hpp's make_response() echoes +// back — identical correlation strategy, and near-identical request() +// signature, to rcp/udp.hpp::Client, so a caller can swap one transport for +// the other with minimal call-site change. +class Client { +public: + Client(avtp::StreamId stream_id, const char* ifname, const MacAddress& dest_mac) + : stream_id_(stream_id), dest_mac_(dest_mac), fd_(-1) { + fd_ = ::socket(AF_PACKET, SOCK_RAW, htons(kEtherType)); + if (fd_ < 0) return; + + if (!detail::get_ifindex(fd_, ifname, ifindex_) || + !detail::get_hwaddr(fd_, ifname, local_mac_)) { + ::close(fd_); + fd_ = -1; + return; + } + + sockaddr_ll sll{}; + sll.sll_family = AF_PACKET; + sll.sll_protocol = htons(kEtherType); + sll.sll_ifindex = ifindex_; + if (::bind(fd_, reinterpret_cast(&sll), sizeof(sll)) < 0) { + ::close(fd_); + fd_ = -1; + return; + } + // See detail::set_recv_timeout's own comment: AF_PACKET has no + // working shutdown(), so read_loop() needs to wake up on its own to + // notice close() was called. + detail::set_recv_timeout(fd_, kRecvTimeoutMillis); + read_thread_ = std::thread([this]{ read_loop(); }); + } + + ~Client() { auto ec = close(); (void)ec; } + + MacAddress local_mac() const noexcept { return local_mac_; } + MacAddress dest_mac() const noexcept { return dest_mac_; } + + // request sends one ACF request — ABB or GBB per req.info's own + // acf_msg_type, wrapped in the AVTPDU header rcp/avtp.hpp defines for + // whichever kind `use_tscf` selects — and blocks until the matching + // response arrives or `ctx` is done. Same signature as + // rcp/udp.hpp::Client::request. + std::error_code request(const rcp::Context& ctx, + const acf::AcfMessageInfo& req, + const std::vector& req_payload, + acf::AcfMessageInfo& out_resp, + std::vector& out_resp_payload, + bool use_tscf = false, + uint32_t avtp_timestamp = 0, + uint64_t message_timestamp = 0) { + if (closed_.load()) return ErrClosed; + if (ctx.done()) return ErrTimeout; + + Frame out; + out.use_tscf = use_tscf; + out.stream_id = stream_id_; + out.sequence_num = static_cast(++seq_); + out.timestamp_valid = use_tscf; + out.avtp_timestamp = avtp_timestamp; + out.info = req; + out.message_timestamp = message_timestamp; + out.payload = req_payload; + + const uint16_t key = pending_key(req.byte_bus_id, req.transaction_num); + auto result = std::make_shared>(); + auto future = result->get_future(); + { + std::lock_guard lk(mu_); + pending_[key] = result; + } + auto cleanup = [&]{ + std::lock_guard lk(mu_); + pending_.erase(key); + }; + + auto wire_bytes = encode_l2_frame(dest_mac_, local_mac_, out); + sockaddr_ll to{}; + to.sll_family = AF_PACKET; + to.sll_ifindex = ifindex_; + to.sll_halen = kMacLen; + std::copy(dest_mac_.begin(), dest_mac_.end(), to.sll_addr); + if (::sendto(fd_, wire_bytes.data(), wire_bytes.size(), 0, + reinterpret_cast(&to), sizeof(to)) < 0) { + cleanup(); + return ErrClosed; + } + + std::future_status st; + if (ctx.deadline()) { + st = future.wait_until(*ctx.deadline()); + } else { + future.wait(); + st = std::future_status::ready; + } + cleanup(); + if (st == std::future_status::timeout) return ErrTimeout; + if (!future.valid()) return ErrClosed; + + Frame resp = future.get(); + out_resp = resp.info; + out_resp_payload = std::move(resp.payload); + return {}; + } + + std::error_code close() { + if (!closed_.exchange(true)) { + if (fd_ >= 0) { + ::shutdown(fd_, SHUT_RDWR); + ::close(fd_); + } + } + if (read_thread_.joinable()) read_thread_.join(); + return {}; + } + + bool ok() const noexcept { return fd_ >= 0; } + +private: + static uint16_t pending_key(avtp::ByteBusId bus_id, uint8_t transaction_num) noexcept { + return static_cast((static_cast(bus_id) << 8) | transaction_num); + } + + avtp::StreamId stream_id_; + MacAddress dest_mac_{}; + MacAddress local_mac_{}; + int fd_; + int ifindex_ = -1; + std::atomic closed_{false}; + std::atomic seq_{0}; + std::mutex mu_; + std::map>> pending_; + std::thread read_thread_; + + void read_loop() { + std::vector buf(kMaxFrame); + while (!closed_.load()) { + sockaddr_ll from{}; + socklen_t flen = sizeof(from); + ssize_t n = ::recvfrom(fd_, buf.data(), buf.size(), 0, + reinterpret_cast(&from), &flen); + if (n < 0) { + // See Server::serve()'s identical comment: SO_RCVTIMEO + // firing, not a real error. + if (errno == EAGAIN || errno == EWOULDBLOCK) continue; + break; + } + if (n == 0) continue; + // See Server::serve()'s identical comment: skip this Client's + // own transmitted requests looping back as PACKET_OUTGOING. + if (from.sll_pkttype == PACKET_OUTGOING) continue; + + EthHeader hdr; + Frame resp; + if (decode_l2_frame(buf.data(), static_cast(n), hdr, resp)) continue; + + const uint16_t key = pending_key(resp.info.byte_bus_id, resp.info.transaction_num); + std::lock_guard lk(mu_); + auto it = pending_.find(key); + if (it != pending_.end()) { + it->second->set_value(std::move(resp)); + pending_.erase(it); + } + } + } +}; + +#else // !RCP_L2_LINUX (macOS/Windows/other stub — see this file's header comment) + +class Server { +public: + using Handler = std::function&, + acf::AcfMessageInfo&, std::vector&)>; + + Server(avtp::StreamId, const char*) {} + MacAddress local_mac() const noexcept { return {}; } + void set_handler(Handler) {} + void close() {} + bool ok() const noexcept { return false; } +}; + +class Client { +public: + Client(avtp::StreamId, const char*, const MacAddress&) {} + MacAddress local_mac() const noexcept { return {}; } + MacAddress dest_mac() const noexcept { return {}; } + std::error_code request(const rcp::Context&, const acf::AcfMessageInfo&, + const std::vector&, + acf::AcfMessageInfo&, std::vector&, + bool = false, uint32_t = 0, uint64_t = 0) { + return std::make_error_code(std::errc::function_not_supported); + } + std::error_code close() { return {}; } + bool ok() const noexcept { return false; } +}; + +#endif // RCP_L2_LINUX + +} // namespace l2 +} // namespace rcp diff --git a/include/rcp/udp.hpp b/include/rcp/udp.hpp index e511b50..5f26040 100644 --- a/include/rcp/udp.hpp +++ b/include/rcp/udp.hpp @@ -10,6 +10,8 @@ // fusa:req REQ-UDP-010 // fusa:req REQ-UDP-011 // fusa:req REQ-UDP-012 +// fusa:req REQ-UDP-013 +// fusa:req REQ-UDP-014 // Native IEEE 1722-over-UDP/IP transport for the OPEN Alliance TC18 Remote // Control Protocol Specification v0.5.1_RC — carries real AVTPDU (NTSCF/ @@ -46,16 +48,28 @@ // this header needing to depend on rcp/mock.hpp itself. // // IEEE 1722's own Annex J describes carrying AVTPDUs over UDP/IP instead of -// raw Ethernet, for links where native AVTP framing (destination MAC + -// EtherType 0x22F0) is not available. This implementation's own reading of -// that behavior — consistent with rcp/avtp.hpp's own header comment that its -// NTSCF/TSCF framing is transport-agnostic by design — is that the AVTPDU -// bytes rcp/avtp.hpp and rcp/acf.hpp already produce are carried unmodified -// as the UDP payload; no additional encapsulation header is layered on top -// here, and IP/UDP addressing substitutes for the Ethernet destination -// address a native AVTP link would otherwise use. Full bit-for-bit -// conformance against other TC18/Annex-J implementations is not claimed, -// same as the equivalent disclaimers in rcp/avtp.hpp, rcp/acf.hpp, and +// raw Ethernet (rcp/l2.hpp, added alongside this fix), for links where +// native AVTP framing (destination MAC + EtherType 0x22F0) is not +// available. An earlier revision of this file's comment claimed the AVTPDU +// bytes rcp/avtp.hpp/rcp/acf.hpp produce are carried unmodified as the UDP +// payload with no additional encapsulation — that claim was wrong. Per two +// independent public secondary sources — a Wireshark issue tracker +// discussion of the real Annex J wire text, and the COVESA Open1722 +// open-source reference implementation's actual `Avtp_Udp_t` header struct +// (`include/avtp/Udp.h`, BSD-3-Clause, github.com/COVESA/Open1722) — an +// Annex J UDP payload actually begins with a 4-byte (32-bit) big-endian +// "encapsulation sequence number" field before the AVTPDU itself +// (kEncapSeqLen/encode_annexj_datagram/decode_annexj_datagram below), and +// the standard destination UDP ports are 17220 ("Continuous"/streaming) and +// 17221 ("Discrete"/control — kAnnexJControlPort, the default this module's +// Server/Client now bind/connect to when a caller doesn't override it). +// THIS PROVENANCE CAVEAT APPLIES EVERYWHERE THIS FILE CITES THOSE TWO +// FACTS: this codebase has no access to the paywalled IEEE 1722-2016 +// standard text itself, so "Annex J conformant" below means "conformant +// with these two independent secondary sources' reading of Annex J," not +// verified against the primary standard. Full bit-for-bit conformance +// against other TC18/Annex-J implementations is not claimed, same as the +// equivalent disclaimers in rcp/avtp.hpp, rcp/acf.hpp, and // rcp/discovery.hpp. #pragma once @@ -91,6 +105,57 @@ namespace udp { // rcp/legacy_wire.hpp's MaxPayload used to size against. constexpr size_t kMaxDatagram = 65507; +// kAnnexJControlPort (17221): the standard destination UDP port for +// "Discrete" (control-plane) Annex J traffic, per the two secondary sources +// this file's header comment names — RCP requests/responses/acknowledges +// are control-plane traffic, so this is the applicable port, and the one +// Server/Client below now default to when a caller doesn't pass one +// explicitly. 17220 ("Continuous"/streaming traffic) has no consumer in +// this codebase today and is not defined here. +constexpr uint16_t kAnnexJControlPort = 17221; + +// kEncapSeqLen: width, in bytes, of the Annex J UDP-payload encapsulation +// sequence number (see header comment) that precedes every AVTPDU this +// module sends/receives over UDP — absent entirely from rcp/l2.hpp's native +// Ethernet framing, which carries the AVTPDU directly. +constexpr size_t kEncapSeqLen = 4; + +// encode_annexj_datagram/decode_annexj_datagram — pure, socket-free codec +// for the Annex J UDP-payload envelope: a 4-byte big-endian encapsulation +// sequence number followed by the AVTPDU bytes unchanged. Big-endian to +// match this repo's existing AVTPDU byte-order convention +// (avtp::detail::put_u32/get_u32, reused directly rather than re-derived). +// These are free functions, independent of RCP_UDP_POSIX, so they — and the +// tests exercising them — compile and run on every platform, including the +// Windows stub build, with no socket involved. +// +// This module tracks and exposes the encapsulation sequence number it +// sends and the most recent one it has received (Client::last_sent_encap_seq/ +// last_recv_encap_seq, Server::last_recv_encap_seq(client)) so a future +// caller has the raw data available for e.g. loss detection — but no such +// detection is implemented here. Annex J's own exact intended semantics for +// this field (e.g. whether gaps are meant to signal loss, whether it resets +// per-flow) are NOT verified against the primary standard; treat the value +// as an opaque per-sender monotonic counter only. +inline std::vector encode_annexj_datagram(uint32_t encap_seq, + const std::vector& avtpdu) { + std::vector out(kEncapSeqLen + avtpdu.size()); + avtp::detail::put_u32(out.data(), encap_seq); + std::copy(avtpdu.begin(), avtpdu.end(), out.begin() + static_cast(kEncapSeqLen)); + return out; +} + +inline std::error_code decode_annexj_datagram(const uint8_t* b, size_t len, + uint32_t& out_encap_seq, + const uint8_t*& out_avtpdu, + size_t& out_avtpdu_len) { + if (len < kEncapSeqLen) return avtp::make_error_code(avtp::AvtpErrc::short_buffer); + out_encap_seq = avtp::detail::get_u32(b); + out_avtpdu = b + kEncapSeqLen; + out_avtpdu_len = len - kEncapSeqLen; + return {}; +} + #if defined(RCP_UDP_POSIX) // ── Frame ───────────────────────────────────────────────────────────────────── @@ -271,17 +336,22 @@ inline std::error_code decode_multi_frame(const uint8_t* b, size_t len, MultiFra } // ── Server ──────────────────────────────────────────────────────────────────── -// Server binds a UDP socket, decodes each inbound datagram as a MultiFrame, -// and dispatches every ACF request the datagram carries — one, the common -// case, or more than one packed back to back (extraction §12.9.1.1, issue -// cpp-RCP-04-fresh) — to a caller-supplied Handler individually, shaped to -// match rcp::mock::Server::dispatch's signature, then encodes every -// response into a single reply MultiFrame (same order as the requests -// arrived in) and sends it back to the sender under the same header kind -// (NTSCF/TSCF) the request arrived under. Malformed datagrams (short -// buffer, bad subtype, unrecognized ACF message type on the very first -// message) are dropped silently, the same "drop rather than partially -// process" choice rcp/discovery.hpp documents for its own decode path. +// Server binds a UDP socket (port 17221/kAnnexJControlPort by default — see +// this file's header comment for the secondary-source provenance of that +// number — unless a caller passes a different one), decodes each inbound +// datagram as an Annex J envelope (4-byte encapsulation sequence number + +// MultiFrame AVTPDU), and dispatches every ACF request the datagram carries +// — one, the common case, or more than one packed back to back (extraction +// §12.9.1.1, issue cpp-RCP-04-fresh) — to a caller-supplied Handler +// individually, shaped to match rcp::mock::Server::dispatch's signature, +// then encodes every response into a single reply MultiFrame (same order as +// the requests arrived in), re-wraps it in a fresh encapsulation sequence +// number, and sends it back to the sender under the same header kind +// (NTSCF/TSCF) the request arrived under. Malformed datagrams (too short +// for even the encapsulation sequence number, short buffer, bad subtype, +// unrecognized ACF message type on the very first message) are dropped +// silently, the same "drop rather than partially process" choice +// rcp/discovery.hpp documents for its own decode path. class Server { public: using Handler = std::function& out_resp_payload)>; - Server(avtp::StreamId stream_id, const char* addr, uint16_t port) + Server(avtp::StreamId stream_id, const char* addr, uint16_t port = kAnnexJControlPort) : stream_id_(stream_id), fd_(-1) { fd_ = ::socket(AF_INET, SOCK_DGRAM, 0); if (fd_ < 0) return; @@ -348,12 +418,24 @@ class Server { bool ok() const noexcept { return fd_ >= 0; } + // last_recv_encap_seq returns the Annex J encapsulation sequence number + // (see this file's header comment) most recently received from the + // given client id, or 0 if that client has not sent anything yet — raw + // data only, no loss-detection semantics implied or implemented (see + // kEncapSeqLen's own comment above). + uint32_t last_recv_encap_seq(size_t client) const { + std::lock_guard lk(mu_); + auto it = last_recv_encap_seq_.find(client); + return it != last_recv_encap_seq_.end() ? it->second : 0; + } + private: avtp::StreamId stream_id_; int fd_; std::atomic closed_{false}; std::atomic seq_{0}; - std::mutex mu_; + std::atomic encap_seq_{0}; // Annex J encapsulation sequence number, outgoing replies + mutable std::mutex mu_; Handler handler_; std::thread serve_thread_; @@ -363,6 +445,10 @@ class Server { // the UDP sender address instead of an in-process connection index. std::map client_ids_; size_t next_client_id_ = 0; + // last_recv_encap_seq_ tracks the most recent inbound Annex J + // encapsulation sequence number per client id — see + // last_recv_encap_seq()'s own comment above for scope. + std::map last_recv_encap_seq_; static std::string addr_key(const sockaddr_in& sa) { char buf[INET_ADDRSTRLEN]; @@ -388,8 +474,18 @@ class Server { reinterpret_cast(&from), &flen); if (n <= 0) break; + // Strip the Annex J encapsulation sequence number before + // handing the remainder to the existing AVTPDU/ACF decode path + // (this file's header comment). + uint32_t in_encap_seq = 0; + const uint8_t* avtpdu = nullptr; + size_t avtpdu_len = 0; + if (decode_annexj_datagram(buf.data(), static_cast(n), + in_encap_seq, avtpdu, avtpdu_len)) + continue; + MultiFrame req; - if (decode_multi_frame(buf.data(), static_cast(n), req)) continue; + if (decode_multi_frame(avtpdu, avtpdu_len, req)) continue; MultiFrame resp; resp.use_tscf = req.use_tscf; @@ -401,6 +497,7 @@ class Server { { std::lock_guard lk(mu_); size_t client = client_id_for(from); + last_recv_encap_seq_[client] = in_encap_seq; // §12.9.1.1: "check each of them individually if to be // processed or not" — each request in the datagram is // dispatched and answered on its own, not as a batch. @@ -418,8 +515,9 @@ class Server { } } - auto out_frame = encode_multi_frame(resp); - ::sendto(fd_, out_frame.data(), out_frame.size(), 0, + auto out_frame = encode_multi_frame(resp); + auto out_wire = encode_annexj_datagram(++encap_seq_, out_frame); + ::sendto(fd_, out_wire.data(), out_wire.size(), 0, reinterpret_cast(&from), flen); } } @@ -446,7 +544,8 @@ class Server { // of its own client-side in this pass. class Client { public: - Client(avtp::StreamId stream_id, const char* server_host, uint16_t server_port) + Client(avtp::StreamId stream_id, const char* server_host, + uint16_t server_port = kAnnexJControlPort) : stream_id_(stream_id), fd_(-1) { fd_ = ::socket(AF_INET, SOCK_DGRAM, 0); if (fd_ < 0) return; @@ -505,7 +604,8 @@ class Client { }; auto frame_bytes = encode_frame(out); - if (::send(fd_, frame_bytes.data(), frame_bytes.size(), 0) < 0) { + auto wire_bytes = encode_annexj_datagram(++encap_seq_, frame_bytes); + if (::send(fd_, wire_bytes.data(), wire_bytes.size(), 0) < 0) { cleanup(); return ErrClosed; } @@ -540,6 +640,14 @@ class Client { bool ok() const noexcept { return fd_ >= 0; } + // last_sent_encap_seq/last_recv_encap_seq expose the Annex J + // encapsulation sequence number (this file's header comment) this + // Client most recently sent/received — raw counter values only, no + // loss-detection semantics implied or implemented (kEncapSeqLen's own + // comment above). + uint32_t last_sent_encap_seq() const noexcept { return encap_seq_.load(); } + uint32_t last_recv_encap_seq() const noexcept { return last_recv_encap_seq_.load(); } + private: static uint16_t pending_key(avtp::ByteBusId bus_id, uint8_t transaction_num) noexcept { return static_cast((static_cast(bus_id) << 8) | transaction_num); @@ -549,6 +657,8 @@ class Client { int fd_; std::atomic closed_{false}; std::atomic seq_{0}; + std::atomic encap_seq_{0}; // Annex J encapsulation sequence number, outgoing + std::atomic last_recv_encap_seq_{0}; // most recent one seen on an inbound datagram std::mutex mu_; std::map>> pending_; std::thread read_thread_; @@ -559,8 +669,18 @@ class Client { ssize_t n = ::recv(fd_, buf.data(), buf.size(), 0); if (n <= 0) break; + // Strip the Annex J encapsulation sequence number before + // handing the remainder to the existing AVTPDU/ACF decode path. + uint32_t in_encap_seq = 0; + const uint8_t* avtpdu = nullptr; + size_t avtpdu_len = 0; + if (decode_annexj_datagram(buf.data(), static_cast(n), + in_encap_seq, avtpdu, avtpdu_len)) + continue; + last_recv_encap_seq_.store(in_encap_seq); + Frame resp; - if (decode_frame(buf.data(), static_cast(n), resp)) continue; + if (decode_frame(avtpdu, avtpdu_len, resp)) continue; const uint16_t key = pending_key(resp.info.byte_bus_id, resp.info.transaction_num); std::lock_guard lk(mu_); @@ -597,17 +717,18 @@ class Server { const std::vector&, acf::AcfMessageInfo&, std::vector&)>; - Server(avtp::StreamId, const char*, uint16_t) {} + Server(avtp::StreamId, const char*, uint16_t = kAnnexJControlPort) {} std::string addr_string() const { return {}; } uint16_t port() const { return 0; } void set_handler(Handler) {} void close() {} bool ok() const noexcept { return false; } + uint32_t last_recv_encap_seq(size_t) const { return 0; } }; class Client { public: - Client(avtp::StreamId, const char*, uint16_t) {} + Client(avtp::StreamId, const char*, uint16_t = kAnnexJControlPort) {} std::error_code request(const rcp::Context&, const acf::AcfMessageInfo&, const std::vector&, acf::AcfMessageInfo&, std::vector&, @@ -616,6 +737,8 @@ class Client { } std::error_code close() { return {}; } bool ok() const noexcept { return false; } + uint32_t last_sent_encap_seq() const noexcept { return 0; } + uint32_t last_recv_encap_seq() const noexcept { return 0; } }; #endif // RCP_UDP_POSIX diff --git a/include/rcp/version.hpp b/include/rcp/version.hpp index fd5d19d..df7ec9d 100644 --- a/include/rcp/version.hpp +++ b/include/rcp/version.hpp @@ -6,6 +6,6 @@ namespace rcp { // Semantic version of the cpp-RCP implementation (matches the latest git tag). -constexpr std::string_view kVersion = "2.20.0"; +constexpr std::string_view kVersion = "2.21.0"; } // namespace rcp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a9b5efe..b9cdcd0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -169,8 +169,21 @@ add_test(NAME rcp_cli COMMAND test_cli) foreach(mod ddsbr doipbr grpcbridge mqttbr restbridge someipbr udsbr - tsn mdns powerstate tls udp) + tsn mdns powerstate tls udp l2) add_executable(test_${mod} test_${mod}.cpp) target_link_libraries(test_${mod} PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_${mod} COMMAND test_${mod}) endforeach() + +# ── rcp/l2.hpp real-socket round trip (Linux-only, needs CAP_NET_RAW/root) ─── +# l2_veth_roundtrip is a standalone program, not a Catch2 suite, and is +# deliberately NOT registered via add_test: rcp::l2::Server/Client need a +# real AF_PACKET/SOCK_RAW socket (CAP_NET_RAW or root) and a real network +# interface pair, neither of which the normal `ctest` run has or should +# require (see tests/test_l2.cpp's own header comment — that file covers +# the privilege-free, socket-free codec logic this program does not +# duplicate). Built here so it's available to build; only the dedicated +# "l2-veth" CI job (.github/workflows/ci.yml) actually creates a veth pair +# and runs this binary with sudo. +add_executable(l2_veth_roundtrip l2_veth_roundtrip.cpp) +target_link_libraries(l2_veth_roundtrip PRIVATE rcp::rcp) diff --git a/tests/l2_veth_roundtrip.cpp b/tests/l2_veth_roundtrip.cpp new file mode 100644 index 0000000..0adad98 --- /dev/null +++ b/tests/l2_veth_roundtrip.cpp @@ -0,0 +1,163 @@ +// fusa:test REQ-L2-008 + +// Real-socket round-trip harness for rcp/l2.hpp's rcp::l2::Server/Client — +// NOT a Catch2 suite and NOT part of the normal `ctest` run (see +// tests/CMakeLists.txt's own comment on why: this needs a real +// AF_PACKET/SOCK_RAW socket, CAP_NET_RAW or root, and a real Linux network +// interface pair). Only the dedicated "l2-veth" CI job +// (.github/workflows/ci.yml) actually runs this binary, against a real +// `veth` pair it creates with `sudo ip link add veth0 type veth peer name +// veth1`. +// +// Usage: l2_veth_roundtrip +// server_ifname — interface rcp::l2::Server binds (e.g. "veth1") +// client_ifname — interface rcp::l2::Client binds (e.g. "veth0") +// server_mac — server_ifname's own MAC address, "aa:bb:cc:dd:ee:ff", +// read by the calling CI script (e.g. from +// /sys/class/net/veth1/address) and passed in as the +// Client's caller-supplied destination MAC — this +// program does not derive it itself, matching +// rcp/l2.hpp::Client's own documented division of +// responsibility (the caller decides the destination +// MAC; this module never derives one on its own). +// +// Exit code 0 on a verified byte-for-byte round trip, non-zero (with a +// diagnostic on stderr) otherwise. + +#include + +#include +#include +#include +#include +#include +#include + +using namespace rcp; +using namespace rcp::l2; + +// The helpers below are only ever called from the RCP_L2_LINUX branch of +// main() (rcp::l2::Server/Client have nothing real to round-trip on any +// other platform — see rcp/l2.hpp's own header comment), so they are +// defined only in that branch too. Otherwise an unused-function warning on +// every other platform's build (this program is part of the default build +// target, per tests/CMakeLists.txt's own comment on why) would turn into a +// hard error under this repository's MSVC /WX setting. +#if defined(RCP_L2_LINUX) + +namespace { + +bool parse_mac(const std::string& s, MacAddress& out) { + unsigned bytes[6]; + if (std::sscanf(s.c_str(), "%x:%x:%x:%x:%x:%x", + &bytes[0], &bytes[1], &bytes[2], + &bytes[3], &bytes[4], &bytes[5]) != 6) { + return false; + } + for (size_t i = 0; i < 6; ++i) out[i] = static_cast(bytes[i]); + return true; +} + +std::string mac_to_string(const MacAddress& mac) { + char buf[18]; + std::snprintf(buf, sizeof(buf), "%02x:%02x:%02x:%02x:%02x:%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + return buf; +} + +avtp::StreamId make_stream_id(uint8_t mac_seed, uint16_t suffix) { + avtp::StreamId id; + for (auto& b : id.mac) b = mac_seed++; + id.suffix = suffix; + return id; +} + +} // namespace + +#endif // RCP_L2_LINUX + +#define CHECK(cond, msg) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "l2_veth_roundtrip: FAIL: %s (%s:%d)\n", \ + msg, __FILE__, __LINE__); \ + return 1; \ + } \ + } while (0) + +int main(int argc, char** argv) { +#if !defined(RCP_L2_LINUX) + std::fprintf(stderr, + "l2_veth_roundtrip: this program requires the Linux " + "rcp::l2::Server/Client implementation (RCP_L2_LINUX); " + "rcp/l2.hpp's own header comment documents every other " + "platform as a function_not_supported stub with nothing " + "real to round-trip.\n"); + return 1; +#else + if (argc != 4) { + std::fprintf(stderr, + "usage: %s \n", + argv[0]); + return 2; + } + const std::string server_ifname = argv[1]; + const std::string client_ifname = argv[2]; + + MacAddress server_mac{}; + CHECK(parse_mac(argv[3], server_mac), "could not parse as aa:bb:cc:dd:ee:ff"); + + std::printf("l2_veth_roundtrip: server on %s, client on %s, dest MAC %s\n", + server_ifname.c_str(), client_ifname.c_str(), mac_to_string(server_mac).c_str()); + + Server server(make_stream_id(0x02, 1), server_ifname.c_str()); + CHECK(server.ok(), "Server failed to open/bind its AF_PACKET socket " + "(needs CAP_NET_RAW/root, and server_ifname must exist)"); + std::printf("l2_veth_roundtrip: server local MAC %s\n", + mac_to_string(server.local_mac()).c_str()); + CHECK(server.local_mac() == server_mac, + "server's own auto-detected MAC does not match the the " + "caller supplied — interface mismatch?"); + + const std::vector req_payload = {0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04}; + + server.set_handler([&](size_t, const acf::AcfMessageInfo& req, + const std::vector& payload, + acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) { + out_resp_payload = payload; // echo back byte-for-byte + out_resp = acf::make_response(req, acf::ResponseKind::ReadResponse); + return std::error_code{}; + }); + + Client client(make_stream_id(0x03, 1), client_ifname.c_str(), server_mac); + CHECK(client.ok(), "Client failed to open/bind its AF_PACKET socket " + "(needs CAP_NET_RAW/root, and client_ifname must exist)"); + std::printf("l2_veth_roundtrip: client local MAC %s, dest MAC %s\n", + mac_to_string(client.local_mac()).c_str(), + mac_to_string(client.dest_mac()).c_str()); + + auto req = acf::make_standard_request(/*bus_id=*/7, /*transaction_num=*/42, + /*write=*/true, /*read_size=*/0); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ctx = Context::with_timeout(std::chrono::seconds(5)); + auto ec = client.request(ctx, req, req_payload, resp, resp_payload); + + CHECK(!ec, "client.request returned an error — no response arrived over the veth pair"); + CHECK(resp.byte_bus_id == 7, "response byte_bus_id does not match the request"); + CHECK(resp.transaction_num == 42, "response transaction_num does not match the request"); + CHECK(acf::response_kind_of(resp) == acf::ResponseKind::ReadResponse, + "response kind is not ReadResponse"); + CHECK(resp_payload == req_payload, + "response payload does not match the request payload byte-for-byte — " + "the real Ethernet frame did not round-trip correctly"); + + std::printf("l2_veth_roundtrip: OK — %zu-byte payload round-tripped byte-for-byte " + "over a real veth pair (%s -> %s)\n", + req_payload.size(), client_ifname.c_str(), server_ifname.c_str()); + + server.close(); + client.close(); + return 0; +#endif +} diff --git a/tests/test_l2.cpp b/tests/test_l2.cpp new file mode 100644 index 0000000..5c0e257 --- /dev/null +++ b/tests/test_l2.cpp @@ -0,0 +1,244 @@ +// fusa:test REQ-L2-001 +// fusa:test REQ-L2-002 +// fusa:test REQ-L2-003 +// fusa:test REQ-L2-004 +// fusa:test REQ-L2-005 +// fusa:test REQ-L2-006 +// fusa:test REQ-L2-007 + +// Tests for rcp/l2.hpp — the native IEEE 1722-over-Ethernet (raw L2) +// transport added alongside the rcp/udp.hpp Annex J conformance fix. Every +// TEST_CASE below exercises pure encode/decode functions only (EthHeader, +// Frame, MultiFrame, and the combined L2 wire-frame codec) — no AF_PACKET +// socket is opened, so this file compiles and runs on every platform this +// repository targets (including macOS and Windows, where rcp/l2.hpp's own +// Server/Client are the function_not_supported stub — see that header's +// own comment) with no elevated privileges and no Linux requirement. +// +// The real raw-socket Server/Client round trip (needs CAP_NET_RAW/root and +// a real Linux interface pair) is exercised separately by +// tests/l2_veth_roundtrip.cpp, run only by the dedicated "l2-veth" CI job +// (.github/workflows/ci.yml) against a real `veth` pair — not part of this +// file or the normal `ctest` run. + +#include +#include + +using namespace rcp; +using namespace rcp::l2; + +namespace { + +avtp::StreamId make_stream_id(uint8_t mac_seed, uint16_t suffix) { + avtp::StreamId id; + for (auto& b : id.mac) b = mac_seed++; + id.suffix = suffix; + return id; +} + +acf::AcfMessageInfo standard_request(avtp::ByteBusId bus_id, uint8_t transaction_num, + bool write = false, uint16_t read_size = 0) { + return acf::make_standard_request(bus_id, transaction_num, write, read_size); +} + +MacAddress make_mac(uint8_t seed) { + MacAddress mac{}; + for (auto& b : mac) b = seed++; + return mac; +} + +} // namespace + +// ── Ethernet header codec (pure, no socket) ────────────────────────────────── + +TEST_CASE("encode_eth_header/decode_eth_header round-trip dst/src MAC and EtherType", + "[l2][REQ-L2-001]") { + MacAddress dst = make_mac(0x02); + MacAddress src = make_mac(0xAA); + + auto bytes = encode_eth_header(dst, src); + REQUIRE(bytes.size() == kEthHeaderLen); + + EthHeader out; + REQUIRE_FALSE(decode_eth_header(bytes.data(), bytes.size(), out)); + REQUIRE(out.dst == dst); + REQUIRE(out.src == src); + REQUIRE(out.ethertype == kEtherType); +} + +TEST_CASE("decode_eth_header rejects a buffer shorter than 14 bytes", "[l2][REQ-L2-002]") { + MacAddress dst = make_mac(0x02); + MacAddress src = make_mac(0xAA); + auto bytes = encode_eth_header(dst, src); + + EthHeader out; + auto ec = decode_eth_header(bytes.data(), bytes.size() - 1, out); + REQUIRE(ec); + REQUIRE(ec == avtp::make_error_code(avtp::AvtpErrc::short_buffer)); +} + +// ── Frame encode/decode (pure codec, no sockets) ───────────────────────────── + +TEST_CASE("Frame round-trips an NTSCF-framed ACF_ABB request", "[l2][REQ-L2-003]") { + Frame f; + f.use_tscf = false; + f.stream_id = make_stream_id(0x02, 0x1234); + f.sequence_num = 7; + f.info = standard_request(/*bus_id=*/3, /*transaction_num=*/9, /*write=*/true); + f.payload = {0x01, 0x02, 0x03}; + + auto bytes = encode_frame(f); + REQUIRE(bytes[0] == avtp::kSubtypeNtscf); + + Frame out; + REQUIRE_FALSE(decode_frame(bytes.data(), bytes.size(), out)); + REQUIRE_FALSE(out.use_tscf); + REQUIRE(out.stream_id == f.stream_id); + REQUIRE(out.sequence_num == f.sequence_num); + REQUIRE(out.info.byte_bus_id == f.info.byte_bus_id); + REQUIRE(out.info.transaction_num == f.info.transaction_num); + REQUIRE(out.payload == f.payload); +} + +TEST_CASE("Frame round-trips a TSCF-framed request with avtp_timestamp", "[l2][REQ-L2-004]") { + Frame f; + f.use_tscf = true; + f.stream_id = make_stream_id(0xAA, 0x0001); + f.sequence_num = 42; + f.timestamp_valid = true; + f.avtp_timestamp = 0xCAFEBABE; + f.info = standard_request(/*bus_id=*/1, /*transaction_num=*/5); + f.payload = {0xFF}; + + auto bytes = encode_frame(f); + REQUIRE(bytes[0] == avtp::kSubtypeTscf); + + Frame out; + REQUIRE_FALSE(decode_frame(bytes.data(), bytes.size(), out)); + REQUIRE(out.use_tscf); + REQUIRE(out.timestamp_valid); + REQUIRE(out.avtp_timestamp == 0xCAFEBABE); + REQUIRE(out.info.byte_bus_id == 1); +} + +// ── Full L2 wire frame (Ethernet header + AVTPDU) ──────────────────────────── + +TEST_CASE("encode_l2_frame/decode_l2_frame round-trip dst/src MAC, EtherType, and the AVTPDU", + "[l2][REQ-L2-003]") { + MacAddress dst = make_mac(0x10); + MacAddress src = make_mac(0x20); + + Frame f; + f.use_tscf = false; + f.stream_id = make_stream_id(0x02, 0x5555); + f.sequence_num = 3; + f.info = standard_request(/*bus_id=*/4, /*transaction_num=*/12, /*write=*/true); + f.payload = {0xAA, 0xBB, 0xCC}; + + auto bytes = encode_l2_frame(dst, src, f); + REQUIRE(bytes.size() == kEthHeaderLen + encode_frame(f).size()); + + EthHeader hdr; + Frame out; + REQUIRE_FALSE(decode_l2_frame(bytes.data(), bytes.size(), hdr, out)); + REQUIRE(hdr.dst == dst); + REQUIRE(hdr.src == src); + REQUIRE(hdr.ethertype == kEtherType); + REQUIRE(out.info.byte_bus_id == 4); + REQUIRE(out.info.transaction_num == 12); + REQUIRE(out.payload == f.payload); +} + +TEST_CASE("decode_l2_frame rejects a frame whose EtherType is not 0x22F0", + "[l2][REQ-L2-005]") { + MacAddress dst = make_mac(0x10); + MacAddress src = make_mac(0x20); + + Frame f; + f.stream_id = make_stream_id(0x01, 1); + f.info = standard_request(1, 1); + auto bytes = encode_l2_frame(dst, src, f); + + // Corrupt the EtherType field (bytes 12-13) to something other than + // 0x22F0 — e.g. 0x0800 (IPv4). + bytes[12] = 0x08; + bytes[13] = 0x00; + + EthHeader hdr; + Frame out; + auto ec = decode_l2_frame(bytes.data(), bytes.size(), hdr, out); + REQUIRE(ec); + REQUIRE(ec == make_error_code(L2Errc::bad_ethertype)); +} + +TEST_CASE("decode_l2_frame rejects a buffer shorter than the Ethernet header", + "[l2][REQ-L2-006]") { + MacAddress dst = make_mac(0x10); + MacAddress src = make_mac(0x20); + + Frame f; + f.stream_id = make_stream_id(0x01, 1); + f.info = standard_request(1, 1); + auto bytes = encode_l2_frame(dst, src, f); + + EthHeader hdr; + Frame out; + REQUIRE(decode_l2_frame(bytes.data(), kEthHeaderLen - 1, hdr, out)); +} + +// ── MultiFrame — multiple ACF requests in one L2 frame ─────────────────────── + +TEST_CASE("encode_l2_multi_frame/decode_l2_multi_frame round-trip two ACF_ABB messages " + "packed into one Ethernet frame", + "[l2][REQ-L2-007]") { + MacAddress dst = make_mac(0x30); + MacAddress src = make_mac(0x40); + + // Payloads are quadlet-aligned (4 bytes each), same precondition + // rcp/udp.hpp's own equivalent test documents (see + // acf::decode_acf_messages's own scope-limit comment). + MultiFrame f; + f.use_tscf = false; + f.stream_id = make_stream_id(0x02, 0x5555); + f.sequence_num = 3; + + acf::AcfEntry a; + a.info = standard_request(/*bus_id=*/1, /*transaction_num=*/10, /*write=*/true); + a.payload = {0x01, 0x02, 0x03, 0x04}; + acf::AcfEntry b; + b.info = standard_request(/*bus_id=*/2, /*transaction_num=*/20, /*write=*/false); + b.payload = {0x05, 0x06, 0x07, 0x08}; + f.messages = {a, b}; + + auto bytes = encode_l2_multi_frame(dst, src, f); + + EthHeader hdr; + MultiFrame out; + REQUIRE_FALSE(decode_l2_multi_frame(bytes.data(), bytes.size(), hdr, out)); + REQUIRE(hdr.dst == dst); + REQUIRE(hdr.src == src); + REQUIRE(out.messages.size() == 2); + REQUIRE(out.messages[0].info.byte_bus_id == 1); + REQUIRE(out.messages[0].payload == a.payload); + REQUIRE(out.messages[1].info.byte_bus_id == 2); + REQUIRE(out.messages[1].payload == b.payload); +} + +// ── Non-Linux stub surface (Windows/macOS — see rcp/l2.hpp's own comment) ─── + +#if !defined(RCP_L2_LINUX) +TEST_CASE("Non-Linux stub reports function_not_supported", "[l2]") { + Server server(make_stream_id(0x02, 1), "eth0"); + REQUIRE_FALSE(server.ok()); + + MacAddress dest_mac{}; + Client client(make_stream_id(0x03, 1), "eth0", dest_mac); + REQUIRE_FALSE(client.ok()); + + auto ctx = Context::background(); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = client.request(ctx, standard_request(1, 1), {}, resp, resp_payload); + REQUIRE(ec == std::make_error_code(std::errc::function_not_supported)); +} +#endif // !RCP_L2_LINUX diff --git a/tests/test_udp.cpp b/tests/test_udp.cpp index 092e2c6..474fc16 100644 --- a/tests/test_udp.cpp +++ b/tests/test_udp.cpp @@ -10,6 +10,8 @@ // fusa:test REQ-UDP-010 // fusa:test REQ-UDP-011 // fusa:test REQ-UDP-012 +// fusa:test REQ-UDP-013 +// fusa:test REQ-UDP-014 // Tests for rcp/udp.hpp — the native IEEE 1722-over-UDP/IP transport // (ROADMAP.md milestone 57, "Native Transport Rebuild — UDP/IP (Annex J)", @@ -19,6 +21,7 @@ #include #include +#include #include using namespace rcp; @@ -191,6 +194,62 @@ TEST_CASE("decode_frame rejects a buffer whose control_data_length disagrees wit REQUIRE(ec == avtp::make_error_code(avtp::AvtpErrc::length_mismatch)); } +// ── Annex J encapsulation sequence number (pure codec, no sockets) ────────── +// See rcp/udp.hpp's own header comment for the two independent public +// secondary sources (a Wireshark issue tracker discussion and the COVESA +// Open1722 reference implementation) this 4-byte-prefix reading of Annex J +// rests on — this codebase has no verified access to the paywalled IEEE +// 1722-2016 standard text itself. + +TEST_CASE("encode_annexj_datagram/decode_annexj_datagram round-trip the sequence number " + "and AVTPDU bytes unchanged", + "[udp][REQ-UDP-013]") { + Frame f; + f.stream_id = make_stream_id(0x02, 1); + f.info = standard_request(1, 1); + f.payload = {0x01, 0x02, 0x03}; + auto avtpdu = encode_frame(f); + + auto wire = encode_annexj_datagram(0xDEADBEEF, avtpdu); + REQUIRE(wire.size() == kEncapSeqLen + avtpdu.size()); + + uint32_t out_seq = 0; + const uint8_t* out_avtpdu = nullptr; + size_t out_len = 0; + REQUIRE_FALSE(decode_annexj_datagram(wire.data(), wire.size(), out_seq, out_avtpdu, out_len)); + REQUIRE(out_seq == 0xDEADBEEF); + REQUIRE(out_len == avtpdu.size()); + REQUIRE(std::equal(avtpdu.begin(), avtpdu.end(), out_avtpdu)); +} + +TEST_CASE("decode_annexj_datagram rejects a buffer shorter than the 4-byte sequence number", + "[udp][REQ-UDP-013]") { + std::vector short_buf = {0x01, 0x02, 0x03}; + uint32_t out_seq = 0; + const uint8_t* out_avtpdu = nullptr; + size_t out_len = 0; + auto ec = decode_annexj_datagram(short_buf.data(), short_buf.size(), out_seq, out_avtpdu, out_len); + REQUIRE(ec); + REQUIRE(ec == avtp::make_error_code(avtp::AvtpErrc::short_buffer)); +} + +TEST_CASE("encode_annexj_datagram produces a monotonically increasing wire prefix " + "when a caller increments its own counter", + "[udp][REQ-UDP-013]") { + std::vector avtpdu = {0xAA}; + uint32_t prev_seq = 0; + for (uint32_t i = 1; i <= 5; ++i) { + auto wire = encode_annexj_datagram(i, avtpdu); + uint32_t out_seq = 0; + const uint8_t* out_avtpdu = nullptr; + size_t out_len = 0; + REQUIRE_FALSE(decode_annexj_datagram(wire.data(), wire.size(), out_seq, out_avtpdu, out_len)); + REQUIRE(out_seq == i); + REQUIRE(out_seq > prev_seq); + prev_seq = out_seq; + } +} + // ── MultiFrame — multiple ACF requests in one AVTPDU (cpp-RCP-04-fresh) ────── TEST_CASE("MultiFrame round-trips two ACF_ABB messages packed into one AVTPDU", @@ -291,7 +350,11 @@ TEST_CASE("Server handles multiple requests packed into a single datagram indivi req_b.payload = {0xBB, 0xBB, 0xBB, 0xBB}; req_frame.messages = {req_a, req_b}; - auto req_bytes = encode_multi_frame(req_frame); + // Since this drives Server with a raw socket rather than rcp::udp::Client, + // the Annex J encapsulation sequence number Server now expects on every + // inbound datagram (this file's header comment, cpp-RCP UDP Annex J fix) + // has to be prepended here explicitly too. + auto req_bytes = encode_annexj_datagram(/*encap_seq=*/0x11223344, encode_multi_frame(req_frame)); int raw_fd = ::socket(AF_INET, SOCK_DGRAM, 0); REQUIRE(raw_fd >= 0); @@ -308,8 +371,14 @@ TEST_CASE("Server handles multiple requests packed into a single datagram indivi REQUIRE(n > 0); ::close(raw_fd); + uint32_t resp_encap_seq = 0; + const uint8_t* resp_avtpdu = nullptr; + size_t resp_avtpdu_len = 0; + REQUIRE_FALSE(decode_annexj_datagram(recv_buf.data(), static_cast(n), + resp_encap_seq, resp_avtpdu, resp_avtpdu_len)); + MultiFrame resp_frame; - REQUIRE_FALSE(decode_multi_frame(recv_buf.data(), static_cast(n), resp_frame)); + REQUIRE_FALSE(decode_multi_frame(resp_avtpdu, resp_avtpdu_len, resp_frame)); REQUIRE(resp_frame.messages.size() == 2); REQUIRE(resp_frame.messages[0].info.byte_bus_id == 1); REQUIRE(resp_frame.messages[0].payload == req_a.payload); @@ -317,6 +386,12 @@ TEST_CASE("Server handles multiple requests packed into a single datagram indivi REQUIRE(resp_frame.messages[1].payload == req_b.payload); REQUIRE(seen_bus_ids == std::vector{1, 2}); // both dispatched individually, in order + // Server's own outgoing encapsulation sequence number counter (distinct + // from the arbitrary one this test sent inbound) started at 0 and this + // is its first reply, so it should read 1 — REQ-UDP-013. + REQUIRE(resp_encap_seq == 1); + REQUIRE(server.last_recv_encap_seq(0) == 0x11223344); + server.close(); } @@ -426,6 +501,60 @@ TEST_CASE("Client::request correlates concurrent requests by byte_bus_id/transac client.close(); } +// ── Annex J encapsulation sequence number (real sockets) ──────────────────── + +TEST_CASE("Client's Annex J encapsulation sequence number increments monotonically " + "across successive requests, and Server observes it", + "[udp][REQ-UDP-013]") { + Server server(make_stream_id(0x02, 9), "127.0.0.1", 0); + REQUIRE(server.ok()); + + server.set_handler([](size_t, const acf::AcfMessageInfo& req, + const std::vector&, + acf::AcfMessageInfo& out_resp, std::vector&) { + out_resp = acf::make_response(req, acf::ResponseKind::Acknowledge); + return std::error_code{}; + }); + + Client client(make_stream_id(0x03, 9), "127.0.0.1", server.port()); + REQUIRE(client.ok()); + REQUIRE(client.last_sent_encap_seq() == 0); // nothing sent yet + + auto ctx = Context::with_timeout(std::chrono::seconds(2)); + acf::AcfMessageInfo resp; + std::vector resp_payload; + + REQUIRE_FALSE(client.request(ctx, standard_request(1, 1), {}, resp, resp_payload)); + REQUIRE(client.last_sent_encap_seq() == 1); + REQUIRE(client.last_recv_encap_seq() > 0); // Server's own reply counter, opaque value + + REQUIRE_FALSE(client.request(ctx, standard_request(1, 2), {}, resp, resp_payload)); + REQUIRE(client.last_sent_encap_seq() == 2); // strictly increasing, one per request + + REQUIRE_FALSE(client.request(ctx, standard_request(1, 3), {}, resp, resp_payload)); + REQUIRE(client.last_sent_encap_seq() == 3); + + // Server saw the same client (same UDP sender address) across all three + // requests, so its per-client last_recv_encap_seq() also strictly + // increased and reflects the most recent (third) request's own counter. + REQUIRE(server.last_recv_encap_seq(0) == 3); + + server.close(); + client.close(); +} + +TEST_CASE("Server/Client default to the Annex J control port (17221) when no port is given", + "[udp][REQ-UDP-014]") { + REQUIRE(kAnnexJControlPort == 17221); + + // Bound to 127.0.0.1 with no explicit port argument — exercises the new + // defaulted constructor parameter, not just the constant's value. + Server server(make_stream_id(0x02, 10), "127.0.0.1"); + REQUIRE(server.ok()); + REQUIRE(server.port() == kAnnexJControlPort); + server.close(); +} + // ── Lifecycle ───────────────────────────────────────────────────────────────── TEST_CASE("Server and Client close() are idempotent and requests after close fail", From 0000ced7c9c82237accf1a3f3b256eba93108449 Mon Sep 17 00:00:00 2001 From: Matt <47545907+SoundMatt@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:15:06 -0700 Subject: [PATCH 2/3] fix: explicit narrowing cast for sequence_num in l2.hpp (MSVC C4244) Frame::sequence_num/MultiFrame::sequence_num are uint16_t, matching rcp/udp.hpp's identical fields, but avtp::TscfHeader/NtscfHeader's own sequence_num is the real 8-bit wire field (avtp.hpp's own header comment: "8 bits, not 16"). udp.hpp's identical assignment happens to not trip MSVC's /W4 today, but l2.hpp's did, failing the Windows build. Added the same explicit static_cast this codebase already uses elsewhere for intentional narrowing; behavior is unchanged (encode_tscf_header/encode_ntscf_header already only ever wrote the low 8 bits either way). Full local suite still 53/53. Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com> --- include/rcp/l2.hpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/include/rcp/l2.hpp b/include/rcp/l2.hpp index a37de14..9aac554 100644 --- a/include/rcp/l2.hpp +++ b/include/rcp/l2.hpp @@ -208,7 +208,13 @@ inline std::vector encode_frame(const Frame& f) { if (f.use_tscf) { avtp::TscfHeader hdr; hdr.stream_id = f.stream_id; - hdr.sequence_num = f.sequence_num; + // avtp::TscfHeader::sequence_num is the real 8-bit wire field + // (avtp.hpp's own header comment: "sequence_num -- 8 bits, not + // 16"); Frame::sequence_num above is 16 bits wide to match + // rcp/udp.hpp::Frame's identical field, so this narrowing is + // intentional -- explicit cast needed for MSVC's /W4 (matches this + // codebase's existing narrowing-cast convention elsewhere). + hdr.sequence_num = static_cast(f.sequence_num); hdr.control_data_length = static_cast(acf_msg.size()); hdr.timestamp_valid = f.timestamp_valid; hdr.avtp_timestamp = f.avtp_timestamp; @@ -216,7 +222,7 @@ inline std::vector encode_frame(const Frame& f) { } else { avtp::NtscfHeader hdr; hdr.stream_id = f.stream_id; - hdr.sequence_num = f.sequence_num; + hdr.sequence_num = static_cast(f.sequence_num); hdr.control_data_length = static_cast(acf_msg.size()); out = avtp::encode_ntscf_header(hdr); } @@ -287,7 +293,9 @@ inline std::vector encode_multi_frame(const MultiFrame& f) { if (f.use_tscf) { avtp::TscfHeader hdr; hdr.stream_id = f.stream_id; - hdr.sequence_num = f.sequence_num; + // See encode_frame's identical cast above for why this narrowing + // is intentional and needs to be explicit for MSVC's /W4. + hdr.sequence_num = static_cast(f.sequence_num); hdr.control_data_length = static_cast(acf_bytes.size()); hdr.timestamp_valid = f.timestamp_valid; hdr.avtp_timestamp = f.avtp_timestamp; @@ -295,7 +303,7 @@ inline std::vector encode_multi_frame(const MultiFrame& f) { } else { avtp::NtscfHeader hdr; hdr.stream_id = f.stream_id; - hdr.sequence_num = f.sequence_num; + hdr.sequence_num = static_cast(f.sequence_num); hdr.control_data_length = static_cast(acf_bytes.size()); out = avtp::encode_ntscf_header(hdr); } From 4b51c995da6f02b542c8d05a6c19b8bdacb2d9f5 Mon Sep 17 00:00:00 2001 From: Matt <47545907+SoundMatt@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:33:06 -0700 Subject: [PATCH 3/3] fix: use iostream instead of fprintf in l2_veth_roundtrip (cpfusa CYBER018 false positive) cpfusa's CYBER018 rule flags any printf/fprintf/sprintf/vprintf call whose first argument isn't a string literal -- correct for printf's own signature (format string first), but a false positive against fprintf's real signature (FILE* stream first, format string second): every fprintf(stderr, "literal", ...) call in this file was flagged even though the actual format string was always a literal. Confirmed by reading cpfusa's own regex source (src/cyber/cyber.cpp:154-159, v0.18.0) rather than assuming. No other file in this repo uses fprintf, so there's no established workaround to match -- switched this file's three fprintf(stderr, ...) call sites (plus the CHECK macro) to std::cerr, which has no format-string argument at all and sidesteps the false positive by construction rather than silencing the finding. Verified against cpfusa's actual regex (not just assumed fixed) and confirmed the binary's behavior is unchanged (macOS smoke test: correct message and exit code on the non-Linux early-return path). Full local suite still 53/53. The regex bug itself is being filed as a separate issue against cpp-FuSa (SoundMatt/cpp-FuSa) -- this commit works around it here, not there. Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com> --- tests/l2_veth_roundtrip.cpp | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/tests/l2_veth_roundtrip.cpp b/tests/l2_veth_roundtrip.cpp index 0adad98..655a50b 100644 --- a/tests/l2_veth_roundtrip.cpp +++ b/tests/l2_veth_roundtrip.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include @@ -76,29 +77,36 @@ avtp::StreamId make_stream_id(uint8_t mac_seed, uint16_t suffix) { #endif // RCP_L2_LINUX +// iostream, not the C stdio print family: every diagnostic below is a +// literal message, never runtime/user-controlled data in a format-string +// position — but cpfusa's own CYBER018 rule flags any of those stdio +// functions whenever their *first* argument isn't a literal, which for the +// two-stream-argument member of that family is always the stream itself, +// not the format text; a false positive against that member's real +// argument order, filed upstream against cpp-FuSa. Using iostream +// sidesteps the false positive by construction — there is no format-string +// argument at all — rather than silencing the finding. #define CHECK(cond, msg) \ do { \ if (!(cond)) { \ - std::fprintf(stderr, "l2_veth_roundtrip: FAIL: %s (%s:%d)\n", \ - msg, __FILE__, __LINE__); \ + std::cerr << "l2_veth_roundtrip: FAIL: " << (msg) << " (" \ + << __FILE__ << ":" << __LINE__ << ")\n"; \ return 1; \ } \ } while (0) int main(int argc, char** argv) { #if !defined(RCP_L2_LINUX) - std::fprintf(stderr, - "l2_veth_roundtrip: this program requires the Linux " - "rcp::l2::Server/Client implementation (RCP_L2_LINUX); " - "rcp/l2.hpp's own header comment documents every other " - "platform as a function_not_supported stub with nothing " - "real to round-trip.\n"); + std::cerr << "l2_veth_roundtrip: this program requires the Linux " + "rcp::l2::Server/Client implementation (RCP_L2_LINUX); " + "rcp/l2.hpp's own header comment documents every other " + "platform as a function_not_supported stub with nothing " + "real to round-trip.\n"; return 1; #else if (argc != 4) { - std::fprintf(stderr, - "usage: %s \n", - argv[0]); + std::cerr << "usage: " << argv[0] + << " \n"; return 2; } const std::string server_ifname = argv[1];