diff --git a/.fusa-reqs.json b/.fusa-reqs.json index b9d908c..350d3db 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -3649,6 +3649,24 @@ "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" + }, + { + "id": "REQ-L2-009", + "title": "Server::FrameHandler reaches mock::Server's Table 24 suppression, conditional/cancellation-opcode routing, and E2E dispatch", + "text": "rcp::l2::Server::set_frame_handler()'s FrameHandler shall receive the raw, unparsed ACF payload of one whole inbound L2 frame together with that frame's own stream_id and sequence_num (decoded via rcp::l2::decode_l2_frame_header()/decode_avtp_frame_header()), so that a caller wiring it to rcp::mock::Server::dispatch_frame()/dispatch_frame_e2e() reaches TC18 Table 24 response/ack routing suppression, conditional/cancellation-opcode routing, and E2E/fragment-aware dispatch for every request carried over L2 -- none of which rcp::l2::Server::Handler's pre-existing single-message signature (no stream_id parameter, routes only through rcp::mock::Server::dispatch()) can reach.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B", + "tc18": "§12.7.7 Table 24 (response/ack routing suppression)" + }, + { + "id": "REQ-L2-010", + "title": "is_unicast_mac classifies a destination MAC as unicast or multicast/broadcast", + "text": "rcp::l2::is_unicast_mac shall return true iff the given MAC address's I/G (individual/group) bit -- the least-significant bit of its first octet -- is 0, matching standard IEEE 802.3 addressing and mirroring c-RCP's rcp_l2_mac_is_unicast() (REQ-L2-011 in that project's own catalog) -- the primitive TC18 §12.3.1.1/.2/.3's unicast-destination-required write-authorization rule (rcp::lifecycle::WriterCtx::via_non_unicast_frame, REQ-LIFECYCLE-027) needs to classify a frame's destination MAC.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B", + "tc18": "§12.3.1.1/.2/.3 (write request accepted only when its frame's destination MAC is unicast)" } ] } diff --git a/include/rcp/l2.hpp b/include/rcp/l2.hpp index 9aac554..9c6fe0c 100644 --- a/include/rcp/l2.hpp +++ b/include/rcp/l2.hpp @@ -6,6 +6,8 @@ // fusa:req REQ-L2-006 // fusa:req REQ-L2-007 // fusa:req REQ-L2-008 +// fusa:req REQ-L2-009 +// fusa:req REQ-L2-010 // Native IEEE 1722-over-Ethernet (raw L2) transport for the OPEN Alliance // TC18 Remote Control Protocol Specification v0.5.1_RC — carries real @@ -179,6 +181,33 @@ inline std::error_code decode_eth_header(const uint8_t* b, size_t len, EthHeader return {}; } +// is_unicast_mac — content gap found during this batch's own c-RCP delta +// verification pass: ported from c-RCP's rcp_l2_mac_is_unicast() +// (c-RCP/src/l2.c:79-82, REQ-L2-011), which this header had no equivalent +// of at all before this pass. True iff mac is a unicast address: the I/G +// (individual/group) bit — the least-significant bit of the first octet — +// is 0. False for any multicast address, including the all-ones broadcast +// address (ff:ff:ff:ff:ff:ff), itself a special case of multicast under +// this same bit test — standard IEEE 802.3 addressing, not TC18-specific. +// rcp::lifecycle::WriterCtx::via_non_unicast_frame (rcp/lifecycle.hpp, +// REQ-LIFECYCLE-027) is the intended consumer, matching c-RCP's own header +// comment for rcp_l2_mac_is_unicast() exactly ("the primitive an +// integrator uses to classify a frame's destination MAC... before +// constructing that writer context") — TC18 §12.3.1.1/.2/.3 requires a +// write request be accepted only when its frame's destination MAC is +// unicast. Nothing in this rewrite's dispatch path (rcp/mock.hpp, +// rcp/regmap.hpp::writer_ctx()) actually derives via_unicast from a live +// destination MAC yet — regmap::writer_ctx() takes it as an +// already-classified caller-supplied bool, and no production call site +// calls writer_ctx() at all today (confirmed by repo-wide search) — so +// wiring THIS primitive into that gate is a separate, broader gap than +// this batch's own dispatch-wiring scope (Server/dispatch, not transport); +// this function exists so that future wiring has the primitive ready, +// exactly as c-RCP already does. +inline bool is_unicast_mac(const MacAddress& mac) noexcept { + return (mac[0] & 0x01u) == 0u; +} + // ── 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 @@ -374,6 +403,149 @@ inline std::error_code decode_l2_multi_frame(const uint8_t* b, size_t len, return decode_multi_frame(b + kEthHeaderLen, len - kEthHeaderLen, out_frame); } +// ── AVTP envelope-only decode (pure, no socket, every platform) ───────────── +// AvtpFrameHeader/decode_avtp_frame_header/decode_l2_frame_header decode just +// the NTSCF/TSCF envelope of one inbound frame — use_tscf, stream_id, +// sequence_num, the TSCF timestamp fields, and where the ACF payload region +// begins — WITHOUT parsing that payload into individual acf::AcfEntry +// messages the way decode_multi_frame()/decode_l2_multi_frame() above do. +// Server::serve()'s FrameHandler path (below) needs exactly this: raw, +// unparsed ACF bytes to hand to a caller like rcp::mock::Server:: +// dispatch_frame()/dispatch_frame_e2e(), which do their own member-splitting +// (mock.hpp's own split_frame_members()), conditional/cancellation-opcode +// peeking (mock.hpp's own peek_conditional_request_type()), and E2E CRC +// verification directly off the wire bytes — CRC coverage in particular is +// computed over the raw header octets themselves (see mock.hpp's own +// dispatch_e2e() doc comment), so a decode-then-re-encode round trip through +// acf::AcfEntry is not just unnecessary here but the wrong shape of input for +// that caller. +struct AvtpFrameHeader { + bool use_tscf = false; // false = NTSCF, true = TSCF + avtp::StreamId stream_id{}; + uint16_t sequence_num = 0; + bool timestamp_valid = false; // TSCF "tv" bit; false under NTSCF + uint32_t avtp_timestamp = 0; // TSCF-only; 0 under NTSCF + size_t acf_offset = 0; // offset of the raw ACF payload region within the buffer this was decoded from +}; + +inline std::error_code decode_avtp_frame_header(const uint8_t* b, size_t len, AvtpFrameHeader& out) { + if (len < 1) return avtp::make_error_code(avtp::AvtpErrc::short_buffer); + + 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; + out.acf_offset = avtp::kTscfHeaderLen; + if (static_cast(hdr.control_data_length) != len - out.acf_offset) + 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; + out.acf_offset = avtp::kNtscfHeaderLen; + if (static_cast(hdr.control_data_length) != len - out.acf_offset) + return avtp::make_error_code(avtp::AvtpErrc::length_mismatch); + } + return {}; +} + +inline std::error_code decode_l2_frame_header(const uint8_t* b, size_t len, + EthHeader& out_hdr, AvtpFrameHeader& out_avtp) { + 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_avtp_frame_header(b + kEthHeaderLen, len - kEthHeaderLen, out_avtp); +} + +// ── FrameMemberResult / FrameHandler — frame-level dispatch wiring ────────── +// The dispatch-wiring gap this pair closes: rcp::l2::Server::Handler below +// (unchanged) dispatches ONE already-decoded ACF message at a time — the +// same shape rcp::mock::Server::dispatch() takes — which is the obvious, +// natural thing to wire a caller's own request handler to, but silently +// bypasses every one of rcp::mock::Server's frame-level behaviors: TC18 +// Table 24 response/ack routing suppression (mock::Server:: +// suppress_response_per_stream_cfg(), applied inside every one of that +// class's ten dispatch_() wrappers, never inside its single-message +// dispatch() itself), conditional/cancellation-opcode routing +// (mock::Server::peek_conditional_request_type(), reachable only through +// mock::Server::decode_and_dispatch()/dispatch_frame()/dispatch_frame_e2e(), +// never through dispatch()), and E2E/fragment-aware dispatch +// (mock::Server::dispatch_frame_e2e(), which independently CRC-verifies and +// reassembles each frame member). A caller that wires Handler straight to +// mock::Server::dispatch() gets every operational request answered, but with +// every one of those three behaviors silently downgraded. +// +// FrameHandler/set_frame_handler() below is the correct wiring path: it +// receives the RAW, unparsed ACF payload of one whole inbound frame (every +// member concatenated, exactly as split_frame_members() expects) plus the +// frame's own stream_id and sequence_num, and returns one FrameMemberResult +// per member — the same "no wire response was built for this member" ( +// response.rsp == false) contract mock::Server::FrameMemberResult's own doc +// comment establishes for the four cases that produce one: Table 24 +// suppression, an admission outcome with no evt[3] ack +// (Queued/Pending/Cancellation/Suspended), and (dispatch_frame_e2e() only) a +// frame-level sequence-gate rejection. Server::serve() (below) drops every +// such member from the outgoing response rather than encoding an empty +// response for it, and sends nothing at all if every member in the frame was +// dropped this way — matching the "0 means send nothing" half of Table 24 +// (REQ-RMAP-048/049) that mock::Server itself has no transport to actually +// enforce (see suppress_response_per_stream_cfg()'s own doc comment). +// +// FrameMemberResult is a deliberate PARALLEL type, not a shared one: +// mock::Server::FrameMemberResult (rcp/mock.hpp) has the identical field +// shape (result/byte_bus_id/response/response_payload) but this header does +// not include rcp/mock.hpp or depend on its type — matching this file's own +// documented preference (see Frame's own header comment above) for parallel +// concrete types over a shared interface. A caller wiring +// mock::Server::dispatch_frame()/dispatch_frame_e2e() as a FrameHandler +// copies each mock::Server::FrameMemberResult into one of these, field for +// field — e.g.: +// +// l2_server.set_frame_handler([&](size_t client, avtp::StreamId sid, +// uint8_t seq, const std::vector& acf, +// std::vector& out) { +// std::vector mres; +// size_t n = mock_server.dispatch_frame_e2e(client, sid, seq, acf, mres); +// out.reserve(mres.size()); +// for (auto& m : mres) +// out.push_back({m.result, m.byte_bus_id, m.response, std::move(m.response_payload)}); +// return n; +// }); +// +// message_timestamp is deliberately not carried here, matching +// mock::Server::FrameMemberResult's own identical omission — a GBB-typed +// response (make_response() echoes the request's own acf_msg_type, +// rcp/acf.hpp) loses its message_timestamp through this path exactly as it +// already does through Server::Handler's own pre-existing single-message +// signature below, which has never carried one either; not a regression +// this pair introduces. +struct FrameMemberResult { + std::error_code result; + avtp::ByteBusId byte_bus_id = 0; // 0 when result could not even be determined + acf::AcfMessageInfo response; // default-constructed (rsp == false) unless a genuine wire response was built + std::vector response_payload; +}; + +// FrameHandler — see the doc comment above this struct for the full +// contract. Takes the raw ACF payload of one whole inbound frame (`acf`, +// every member concatenated, unparsed) plus that frame's own `stream_id`/ +// `sequence_num`, fills `out_results` with one FrameMemberResult per member +// it dispatched, and returns how many that was (mirroring +// mock::Server::dispatch_frame()/dispatch_frame_e2e()'s own `size_t` +// return). Declared at namespace scope (not nested in Server) so it is +// visible identically on every platform, matching this header's own +// Handler-is-visible-everywhere-even-on-the-stub convention below. +using FrameHandler = std::function& acf, + std::vector& out_results)>; + #if defined(RCP_L2_LINUX) // kRecvTimeoutMillis: SO_RCVTIMEO applied to every real Server/Client socket @@ -444,12 +616,32 @@ inline bool set_recv_timeout(int fd, int millis) { // 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(). +// source address on every reply it sends, decodes each inbound frame's own +// AVTP envelope, and dispatches every ACF request the frame carries, 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(). +// +// Two handler shapes, set independently via set_handler()/set_frame_handler() +// below — if both are set, set_frame_handler()'s FrameHandler takes priority +// for every inbound frame (see serve()'s own comment): +// +// - Handler: dispatches ONE already-decoded ACF message at a time, same +// shape as rcp::mock::Server::dispatch() and rcp/udp.hpp::Server's own +// Handler. Simple, and sufficient for a caller (e.g. +// tests/l2_veth_roundtrip.cpp) that only needs to answer plain, +// unconditional Standard requests with no Table 24 routing config, no +// conditional/cancellation opcodes, and no E2E/fragmentation in play. +// - FrameHandler (this file's own namespace-scope type, see its own doc +// comment above): dispatches one WHOLE frame's raw ACF bytes at a time, +// the shape rcp::mock::Server::dispatch_frame()/dispatch_frame_e2e() +// take — this is the entry point that actually gets Table 24 suppression, +// conditional/cancellation-opcode routing, and E2E/fragment dispatch, and +// is the one a caller wiring this Server to a real rcp::mock::Server (or +// any dispatcher with the same frame-level contract) should use. See this +// file's own FrameHandler doc comment above for why Handler alone, +// wired straight to mock::Server::dispatch(), silently downgrades all +// three of those behaviors. class Server { public: using Handler = std::function lk(mu_); + frame_handler_ = std::move(h); + } + void close() { if (!closed_.exchange(true)) { if (fd_ >= 0) { @@ -517,6 +721,7 @@ class Server { std::atomic seq_{0}; std::mutex mu_; Handler handler_; + FrameHandler frame_handler_; std::thread serve_thread_; // client_ids_ assigns each distinct sender MAC a stable, opaque size_t @@ -556,27 +761,82 @@ class Server { // 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; + EthHeader eth_hdr; + AvtpFrameHeader avtp_hdr; + if (decode_l2_frame_header(buf.data(), static_cast(n), eth_hdr, avtp_hdr)) continue; + const size_t acf_off = kEthHeaderLen + avtp_hdr.acf_offset; MultiFrame resp; - resp.use_tscf = req.use_tscf; + resp.use_tscf = avtp_hdr.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()); + resp.timestamp_valid = avtp_hdr.timestamp_valid; + resp.avtp_timestamp = avtp_hdr.avtp_timestamp; + + // Snapshot both handlers under mu_ (protects against a + // concurrent set_handler()/set_frame_handler() call, same as + // this loop's own pre-existing client_id_for() lock scope) and + // release the lock again before running either one — a Handler/ + // FrameHandler body has no business running while this Server's + // own mutex is held. + size_t client; + Handler handler_copy; + FrameHandler frame_handler_copy; { std::lock_guard lk(mu_); - size_t client = client_id_for(hdr.src); + client = client_id_for(eth_hdr.src); + handler_copy = handler_; + frame_handler_copy = frame_handler_; + } + + if (frame_handler_copy) { + // FrameHandler path — see this class's own header comment + // and this file's own FrameMemberResult/FrameHandler doc + // comment above: Table 24 suppression, conditional/ + // cancellation-opcode routing, and E2E/fragment dispatch are + // all the FrameHandler's own job (rcp::mock::Server:: + // dispatch_frame()/dispatch_frame_e2e() already apply every + // one of them internally) — this Server's only remaining + // job is handing it the raw ACF bytes and re-framing + // whatever member responses it returns. + std::vector acf_bytes(buf.begin() + static_cast(acf_off), + buf.begin() + n); + std::vector results; + frame_handler_copy(client, avtp_hdr.stream_id, avtp_hdr.sequence_num, acf_bytes, results); + + resp.messages.reserve(results.size()); + for (auto& r : results) { + // response.rsp == false means "nothing to send for this + // member" (Table 24 suppression, an evt[3]-less + // admission outcome, or a frame-level sequence-gate + // rejection) — dropped, not encoded as an empty + // response. See this file's own FrameHandler doc + // comment above. + if (!r.response.rsp) continue; + acf::AcfEntry entry; + entry.info = r.response; + entry.payload = std::move(r.response_payload); + resp.messages.push_back(std::move(entry)); + } + // Every member in this frame was suppressed/produced no + // wire response: send nothing at all, matching Table 24's + // own "0 means send nothing" contract rather than emitting + // an empty AVTPDU. + if (resp.messages.empty()) continue; + } else { + std::vector req_messages; + if (acf::decode_acf_messages(buf.data() + acf_off, static_cast(n) - acf_off, + req_messages)) { + continue; + } + resp.messages.reserve(req_messages.size()); // §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) { + 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); + if (handler_copy) { + auto ec = handler_copy(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. @@ -587,12 +847,12 @@ class Server { } } - auto out_bytes = encode_l2_multi_frame(hdr.src, local_mac_, resp); + auto out_bytes = encode_l2_multi_frame(eth_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); + std::copy(eth_hdr.src.begin(), eth_hdr.src.end(), to.sll_addr); ::sendto(fd_, out_bytes.data(), out_bytes.size(), 0, reinterpret_cast(&to), sizeof(to)); } @@ -783,6 +1043,7 @@ class Server { Server(avtp::StreamId, const char*) {} MacAddress local_mac() const noexcept { return {}; } void set_handler(Handler) {} + void set_frame_handler(FrameHandler) {} void close() {} bool ok() const noexcept { return false; } }; diff --git a/tests/test_l2.cpp b/tests/test_l2.cpp index 5c0e257..ee36ba0 100644 --- a/tests/test_l2.cpp +++ b/tests/test_l2.cpp @@ -5,6 +5,8 @@ // fusa:test REQ-L2-005 // fusa:test REQ-L2-006 // fusa:test REQ-L2-007 +// fusa:test REQ-L2-009 +// fusa:test REQ-L2-010 // 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 @@ -20,9 +22,20 @@ // 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. +// +// The "── FrameHandler dispatch-wiring gap ──" section below (Phase 5 Wave 1, +// cpp-RCP issue #129) additionally includes — still no socket, +// still privilege-free — to prove the FrameHandler entry point this batch +// adds actually reaches rcp::mock::Server's own Table 24 suppression, +// conditional/cancellation-opcode routing, and E2E dispatch, none of which +// rcp::l2::Server::Handler's pre-existing single-message signature could +// ever reach (it carries no stream_id parameter at all, and always routes +// through mock::Server::dispatch() rather than decode_and_dispatch()/ +// dispatch_frame()/dispatch_frame_e2e()). #include #include +#include using namespace rcp; using namespace rcp::l2; @@ -224,6 +237,345 @@ TEST_CASE("encode_l2_multi_frame/decode_l2_multi_frame round-trip two ACF_ABB me REQUIRE(out.messages[1].payload == b.payload); } +// ── is_unicast_mac (content gap vs. c-RCP's rcp_l2_mac_is_unicast(), REQ-L2-011) ── + +TEST_CASE("is_unicast_mac reports true for a unicast MAC (I/G bit clear)", "[l2][REQ-L2-010]") { + MacAddress mac = make_mac(0x02); // 0x02 & 0x01 == 0 + REQUIRE(is_unicast_mac(mac)); +} + +TEST_CASE("is_unicast_mac reports false for a multicast MAC (I/G bit set)", "[l2][REQ-L2-010]") { + MacAddress mac = make_mac(0x01); // 0x01 & 0x01 == 1 + REQUIRE_FALSE(is_unicast_mac(mac)); +} + +TEST_CASE("is_unicast_mac reports false for the all-ones broadcast address", "[l2][REQ-L2-010]") { + MacAddress mac; + mac.fill(0xFF); + REQUIRE_FALSE(is_unicast_mac(mac)); +} + +// ── AVTP envelope-only decode (decode_avtp_frame_header/decode_l2_frame_header) ── + +TEST_CASE("decode_avtp_frame_header decodes an NTSCF envelope and reports the raw ACF offset", + "[l2][REQ-L2-009]") { + MultiFrame f; + f.use_tscf = false; + f.stream_id = make_stream_id(0x02, 0x1234); + f.sequence_num = 7; + acf::AcfEntry m; + m.info = standard_request(/*bus_id=*/3, /*transaction_num=*/9, /*write=*/true); + m.payload = {0x01, 0x02, 0x03, 0x04}; + f.messages = {m}; + + auto bytes = encode_multi_frame(f); + + AvtpFrameHeader hdr; + REQUIRE_FALSE(decode_avtp_frame_header(bytes.data(), bytes.size(), hdr)); + REQUIRE_FALSE(hdr.use_tscf); + REQUIRE(hdr.stream_id == f.stream_id); + REQUIRE(hdr.sequence_num == f.sequence_num); + REQUIRE(hdr.acf_offset == avtp::kNtscfHeaderLen); + + // The raw bytes at acf_offset must decode identically to what + // decode_multi_frame() (the full parse) itself produces for the same + // wire bytes -- this is the exact raw span Server::serve()'s + // FrameHandler path hands to a caller. + std::vector parsed; + REQUIRE_FALSE(acf::decode_acf_messages(bytes.data() + hdr.acf_offset, + bytes.size() - hdr.acf_offset, parsed)); + REQUIRE(parsed.size() == 1); + REQUIRE(parsed[0].info.byte_bus_id == 3); + REQUIRE(parsed[0].payload == m.payload); +} + +TEST_CASE("decode_avtp_frame_header decodes a TSCF envelope with its timestamp fields", + "[l2][REQ-L2-009]") { + 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); + + AvtpFrameHeader hdr; + REQUIRE_FALSE(decode_avtp_frame_header(bytes.data(), bytes.size(), hdr)); + REQUIRE(hdr.use_tscf); + REQUIRE(hdr.timestamp_valid); + REQUIRE(hdr.avtp_timestamp == 0xCAFEBABE); + REQUIRE(hdr.acf_offset == avtp::kTscfHeaderLen); +} + +TEST_CASE("decode_avtp_frame_header rejects a truncated buffer the same way decode_multi_frame does", + "[l2][REQ-L2-009]") { + MultiFrame f; + f.stream_id = make_stream_id(0x01, 1); + acf::AcfEntry m; + m.info = standard_request(1, 1); + f.messages = {m}; + auto bytes = encode_multi_frame(f); + + AvtpFrameHeader hdr; + REQUIRE(decode_avtp_frame_header(bytes.data(), avtp::kNtscfHeaderLen - 1, hdr)); +} + +TEST_CASE("decode_l2_frame_header combines the Ethernet header decode with the AVTP envelope decode", + "[l2][REQ-L2-009]") { + MacAddress dst = make_mac(0x10); + MacAddress src = make_mac(0x20); + + MultiFrame f; + f.use_tscf = false; + f.stream_id = make_stream_id(0x02, 0x5555); + f.sequence_num = 3; + acf::AcfEntry m; + m.info = standard_request(/*bus_id=*/4, /*transaction_num=*/12, /*write=*/true); + m.payload = {0xAA, 0xBB, 0xCC, 0xDD}; + f.messages = {m}; + + auto bytes = encode_l2_multi_frame(dst, src, f); + + EthHeader eth_hdr; + AvtpFrameHeader avtp_hdr; + REQUIRE_FALSE(decode_l2_frame_header(bytes.data(), bytes.size(), eth_hdr, avtp_hdr)); + REQUIRE(eth_hdr.dst == dst); + REQUIRE(eth_hdr.src == src); + REQUIRE(eth_hdr.ethertype == kEtherType); + REQUIRE(avtp_hdr.stream_id == f.stream_id); + REQUIRE(avtp_hdr.sequence_num == 3); + + const size_t acf_off = kEthHeaderLen + avtp_hdr.acf_offset; + std::vector parsed; + REQUIRE_FALSE(acf::decode_acf_messages(bytes.data() + acf_off, bytes.size() - acf_off, parsed)); + REQUIRE(parsed.size() == 1); + REQUIRE(parsed[0].info.byte_bus_id == 4); + REQUIRE(parsed[0].payload == m.payload); +} + +TEST_CASE("decode_l2_frame_header rejects a frame whose EtherType is not 0x22F0, same as decode_l2_multi_frame", + "[l2][REQ-L2-009]") { + MacAddress dst = make_mac(0x10); + MacAddress src = make_mac(0x20); + + MultiFrame f; + f.stream_id = make_stream_id(0x01, 1); + acf::AcfEntry m; + m.info = standard_request(1, 1); + f.messages = {m}; + auto bytes = encode_l2_multi_frame(dst, src, f); + bytes[12] = 0x08; + bytes[13] = 0x00; + + EthHeader eth_hdr; + AvtpFrameHeader avtp_hdr; + auto ec = decode_l2_frame_header(bytes.data(), bytes.size(), eth_hdr, avtp_hdr); + REQUIRE(ec); + REQUIRE(ec == make_error_code(L2Errc::bad_ethertype)); +} + +// ── FrameHandler dispatch-wiring gap (Phase 5 Wave 1, cpp-RCP issue #129) ──── +// The tests below prove rcp::l2::Server::FrameHandler (set via +// set_frame_handler()) reaches the rcp::mock::Server behaviors the OLD +// per-message rcp::l2::Server::Handler could never reach: Table 24 response +// suppression, conditional/cancellation-opcode routing, multi-member frame +// dispatch, and E2E dispatch. No socket is opened anywhere below — these +// exercise the pure decode_avtp_frame_header()/decode_l2_frame_header() split +// plus rcp::mock::Server's own frame-level entry points directly, the exact +// shape rcp::l2::Server::serve()'s own FrameHandler branch drives. + +TEST_CASE("mock::Server::dispatch_frame(), reached via decode_l2_frame_header()'s own " + "wire stream_id, applies Table 24 response suppression that the old " + "rcp::l2::Server::Handler signature (no stream_id parameter at all) could never reach", + "[l2][mock][REQ-L2-009]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + + const auto stream_id = make_stream_id(0x02, 0xABCD); + + // Table 24 (REQ-RMAP-048/049): rx_resp_stream_index == 0 means "no + // response is to be sent" for every non-Acknowledge response kind on + // this request stream. + regmap::RequestStreamConfig cfg{}; + cfg.stream_id = stream_id; + cfg.rx_ack_stream_index = 1; + cfg.rx_resp_stream_index = 0; + REQUIRE(server.set_request_stream_cfg({cfg})); + + MultiFrame req_frame; + req_frame.use_tscf = false; + req_frame.stream_id = stream_id; + req_frame.sequence_num = 1; + acf::AcfEntry read_req; + read_req.info = standard_request(mock::kGpioByteBusId, /*transaction_num=*/1, /*write=*/false); + req_frame.messages = {read_req}; + + auto wire = encode_multi_frame(req_frame); + AvtpFrameHeader hdr; + REQUIRE_FALSE(decode_avtp_frame_header(wire.data(), wire.size(), hdr)); + REQUIRE(hdr.stream_id == stream_id); + std::vector acf_bytes(wire.begin() + static_cast(hdr.acf_offset), wire.end()); + + std::vector results; + REQUIRE(server.dispatch_frame(/*client=*/0, hdr.stream_id, acf_bytes, results) == 1); + REQUIRE(results.size() == 1); + REQUIRE_FALSE(results[0].response.rsp); // suppressed -- Table 24 correctly reached via stream_id + + // Contrast: the same GPIO read dispatched through mock::Server::dispatch() + // with the default (unconfigured) stream_id -- the only stream_id an old + // rcp::l2::Server::Handler could ever have supplied, since its signature + // carried none at all -- is NOT suppressed. + acf::AcfMessageInfo unsuppressed_resp; + std::vector unsuppressed_payload; + server.dispatch(0, read_req.info, {}, unsuppressed_resp, unsuppressed_payload); + REQUIRE(unsuppressed_resp.rsp); +} + +TEST_CASE("A conditional/cancellation-opcode GBB request is correctly queued when routed through " + "mock::Server::decode_and_dispatch() (what FrameHandler-wired dispatch_frame() calls per " + "member), not silently executed as an ordinary Standard write the way dispatching it " + "straight through mock::Server::dispatch() -- the old rcp::l2::Server::Handler's only " + "reachable entry point -- would", + "[l2][mock][REQ-L2-009]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + + // A genuine conditional request (request::RequestTypeOpcode::Timed) addressed to GPIO, + // whose ordinary evt/op header bits ALSO look like a completely valid immediate GPIO + // Reconfigure write -- exactly the shape a real conditional/cancellation request has on + // the wire (rcp/request.hpp's own header comment: the repurposing trick touches only + // message_timestamp, never the shared evt/op header bits decode_acf_gbb() also decodes). + acf::AcfMessageInfo hdr = request::make_conditional_request(/*bus_id=*/mock::kGpioByteBusId, + /*transaction_num=*/1, /*cs=*/false); + hdr.op = true; + hdr.evt_op = static_cast(endpoint::WriteSemantics::Reconfigure); + const uint64_t repurposed_ts = request::encode_request_type(request::RequestTypeOpcode::Timed, {}); + auto payload = gpio::encode_gpio_payload(0x0000'000F); + auto raw = acf::encode_acf_gbb(hdr, repurposed_ts, payload); + + // Path A -- the OLD/naive wiring this batch fixes: decode as if it were an ordinary GBB + // Standard request (message_timestamp's real repurposed meaning discarded), then hand the + // decoded req straight to mock::Server::dispatch() -- exactly what rcp::l2::Server::Handler's + // own single-message signature did before set_frame_handler() existed. + acf::AcfMessageInfo naive_req; + uint64_t naive_ts = 0; + std::vector naive_payload; + REQUIRE_FALSE(acf::decode_acf_gbb(raw.data(), raw.size(), naive_req, naive_ts, naive_payload)); + acf::AcfMessageInfo naive_resp; + std::vector naive_resp_payload; + server.dispatch(0, naive_req, naive_payload, naive_resp, naive_resp_payload); + REQUIRE(naive_resp.rsp); // wrongly executed immediately, as an ordinary write + + // Path B -- the CORRECT wiring: mock::Server::dispatch_frame() (what a FrameHandler wires + // to) routes each member through decode_and_dispatch() internally, which peeks the + // repurposed opcode FIRST (peek_conditional_request_type()) and routes to admission + // instead of dispatch()'s ordinary ABB-oriented path. + std::vector results; + REQUIRE(server.dispatch_frame(0, avtp::StreamId{}, raw, results) == 1); + REQUIRE(results.size() == 1); + REQUIRE_FALSE(results[0].response.rsp); // queued, not executed immediately +} + +TEST_CASE("FrameHandler wired to mock::Server::dispatch_frame() dispatches every member of a " + "multi-member L2 frame independently, exactly the adapter pattern documented in " + "rcp/l2.hpp's own FrameHandler comment", + "[l2][mock][REQ-L2-009]") { + mock::Server mock_server; + REQUIRE_FALSE(mock_server.advance_to_rcp_configured()); + + FrameHandler fh = [&](size_t client, avtp::StreamId sid, uint8_t /*seq*/, + const std::vector& acf, + std::vector& out) -> size_t { + std::vector mres; + size_t n = mock_server.dispatch_frame(client, sid, acf, mres); + out.reserve(mres.size()); + for (auto& m : mres) { + FrameMemberResult r; + r.result = m.result; + r.byte_bus_id = m.byte_bus_id; + r.response = m.response; + r.response_payload = std::move(m.response_payload); + out.push_back(std::move(r)); + } + return n; + }; + + // Two-member frame: a plain GPIO read (answered normally) and an + // unmapped byte_bus_id (answered with an ErrorResponse) -- both must + // come back, in order, as separate FrameMemberResults. + MultiFrame req_frame; + req_frame.use_tscf = false; + req_frame.stream_id = make_stream_id(0x02, 1); + req_frame.sequence_num = 5; + acf::AcfEntry gpio_read; + gpio_read.info = standard_request(mock::kGpioByteBusId, /*transaction_num=*/1, /*write=*/false); + acf::AcfEntry bad_bus; + bad_bus.info = standard_request(/*bus_id=*/200, /*transaction_num=*/2, /*write=*/false); + req_frame.messages = {gpio_read, bad_bus}; + + auto wire = encode_multi_frame(req_frame); + AvtpFrameHeader hdr; + REQUIRE_FALSE(decode_avtp_frame_header(wire.data(), wire.size(), hdr)); + std::vector raw(wire.begin() + static_cast(hdr.acf_offset), wire.end()); + + std::vector results; + size_t n = fh(/*client=*/0, hdr.stream_id, static_cast(hdr.sequence_num), raw, results); + REQUIRE(n == 2); + REQUIRE(results.size() == 2); + REQUIRE(results[0].byte_bus_id == mock::kGpioByteBusId); + REQUIRE(results[0].response.rsp); + REQUIRE(acf::response_kind_of(results[0].response) == acf::ResponseKind::ReadResponse); + REQUIRE(results[1].response.rsp); + REQUIRE(acf::response_kind_of(results[1].response) == acf::ResponseKind::ErrorResponse); +} + +TEST_CASE("FrameHandler wired to mock::Server::dispatch_frame_e2e() is reachable through the same " + "adapter pattern, correctly dispatching a member on an endpoint with E2E CRC not enabled", + "[l2][mock][REQ-L2-009]") { + mock::Server mock_server; + REQUIRE_FALSE(mock_server.advance_to_rcp_configured()); + + FrameHandler fh = [&](size_t client, avtp::StreamId sid, uint8_t seq, + const std::vector& acf, + std::vector& out) -> size_t { + std::vector mres; + size_t n = mock_server.dispatch_frame_e2e(client, sid, seq, acf, mres); + out.reserve(mres.size()); + for (auto& m : mres) { + FrameMemberResult r; + r.result = m.result; + r.byte_bus_id = m.byte_bus_id; + r.response = m.response; + r.response_payload = std::move(m.response_payload); + out.push_back(std::move(r)); + } + return n; + }; + + MultiFrame req_frame; + req_frame.use_tscf = false; + req_frame.stream_id = make_stream_id(0x03, 1); + req_frame.sequence_num = 1; + acf::AcfEntry gpio_read; + gpio_read.info = standard_request(mock::kGpioByteBusId, /*transaction_num=*/1, /*write=*/false); + req_frame.messages = {gpio_read}; + + auto wire = encode_multi_frame(req_frame); + AvtpFrameHeader hdr; + REQUIRE_FALSE(decode_avtp_frame_header(wire.data(), wire.size(), hdr)); + std::vector raw(wire.begin() + static_cast(hdr.acf_offset), wire.end()); + + std::vector results; + size_t n = fh(/*client=*/0, hdr.stream_id, static_cast(hdr.sequence_num), raw, results); + REQUIRE(n == 1); + REQUIRE(results.size() == 1); + REQUIRE(results[0].response.rsp); + REQUIRE(acf::response_kind_of(results[0].response) == acf::ResponseKind::ReadResponse); +} + // ── Non-Linux stub surface (Windows/macOS — see rcp/l2.hpp's own comment) ─── #if !defined(RCP_L2_LINUX)