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..9aac554 --- /dev/null +++ b/include/rcp/l2.hpp @@ -0,0 +1,808 @@ +// 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; + // 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; + out = avtp::encode_tscf_header(hdr); + } else { + avtp::NtscfHeader hdr; + hdr.stream_id = f.stream_id; + hdr.sequence_num = static_cast(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; + // 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; + out = avtp::encode_tscf_header(hdr); + } else { + avtp::NtscfHeader hdr; + hdr.stream_id = f.stream_id; + hdr.sequence_num = static_cast(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..655a50b --- /dev/null +++ b/tests/l2_veth_roundtrip.cpp @@ -0,0 +1,171 @@ +// 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 +#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 + +// 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::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::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::cerr << "usage: " << argv[0] + << " \n"; + 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",