diff --git a/cmake/libe3Tests.cmake b/cmake/libe3Tests.cmake index 03e9d10..a965142 100644 --- a/cmake/libe3Tests.cmake +++ b/cmake/libe3Tests.cmake @@ -16,6 +16,16 @@ target_include_directories(libe3_test_framework INTERFACE file(GLOB LIBE3_TEST_SOURCES RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} "tests/*.cpp") +# Tests that hardcode the ASN.1 wire format: test_asn1_size exercises the +# APER encoder directly, and test_e2e_report_path configures its agent and +# fake-dApp peer with EncodingFormat::ASN1. On builds with +# LIBE3_ENABLE_ASN1=OFF the encoder factory cannot create an ASN.1 encoder, +# so these tests can only fail; exclude them instead of weakening them. +set(LIBE3_ASN1_ONLY_TESTS + asn1_size + e2e_report_path +) + foreach(test_src IN LISTS LIBE3_TEST_SOURCES) # Derive a target name from the source file name: tests/test_foo.cpp -> test_foo get_filename_component(test_name ${test_src} NAME_WE) @@ -27,6 +37,10 @@ foreach(test_src IN LISTS LIBE3_TEST_SOURCES) message(STATUS "Skipping test_json_encoder: JSON support disabled") continue() endif() + if(NOT LIBE3_ENABLE_ASN1 AND simple_name IN_LIST LIBE3_ASN1_ONLY_TESTS) + message(STATUS "Skipping test_${simple_name}: ASN.1 support disabled") + continue() + endif() add_executable(${target_name} "${CMAKE_CURRENT_SOURCE_DIR}/${test_src}") target_link_libraries(${target_name} diff --git a/include/libe3/e3_interface.hpp b/include/libe3/e3_interface.hpp index af00c61..9d45311 100644 --- a/include/libe3/e3_interface.hpp +++ b/include/libe3/e3_interface.hpp @@ -308,6 +308,26 @@ class E3Interface { void handle_release_message(const ReleaseMessage &release); void handle_dapp_disconnection(uint32_t dapp_id); + /** + * @brief Reply to a setup request that cannot be answered positively. + * + * The RAN side of the setup channel is a REQ/REP exchange: exactly one + * reply must be sent per received request before the next one can be + * received (a ZMQ REP socket enforces this in its state machine). + * Bailing out without replying (undecodable bytes, wrong PDU type, + * response-encode failure) wedges the setup channel for every + * subsequent dApp until the agent restarts. The encoding is fixed and + * known a priori by both peers, so a negative SetupResponse can always + * be produced; the peer gets an explicit rejection instead of a + * silent timeout. + * + * @param request_id Message ID of the offending request, or 0 when the + * request bytes never decoded — a fresh id is then + * substituted on the wire (E3-MessageID excludes 0; + * dApps do not correlate setup replies by ID). + */ + void send_negative_setup_reply(uint32_t request_id); + // dApp-role handlers void handle_setup_response(const SetupResponse& response); void handle_subscription_response(const SubscriptionResponse& response); diff --git a/src/core/e3_interface.cpp b/src/core/e3_interface.cpp index ddc0888..c8f0d09 100644 --- a/src/core/e3_interface.cpp +++ b/src/core/e3_interface.cpp @@ -352,26 +352,32 @@ void E3Interface::setup_loop_ran() { E3_LOG_INFO(LOG_TAG) << "Setup request received: " << ret << " bytes"; - // Decode the setup request + // Decode the setup request. On any failure the REQ/REP exchange + // must still be completed (see send_negative_setup_reply) or the + // setup channel wedges for every subsequent dApp. auto decode_result = encoder_->decode(buffer.data(), static_cast(ret)); if (!decode_result) { - E3_LOG_ERROR(LOG_TAG) << "Failed to decode setup request; ret=" << ret; + E3_LOG_WARN(LOG_TAG) << "Undecodable setup message (" << ret + << " bytes, wrong encoding or garbage); rejecting"; + send_negative_setup_reply(0); continue; } - + Pdu& pdu = *decode_result; if (pdu.type != PduType::SETUP_REQUEST) { - E3_LOG_ERROR(LOG_TAG) << "Unexpected PDU type in setup: " - << pdu_type_to_string(pdu.type); + E3_LOG_WARN(LOG_TAG) << "Unexpected PDU type in setup: " + << pdu_type_to_string(pdu.type) << "; rejecting"; + send_negative_setup_reply(pdu.message_id); continue; } - + auto* request = std::get_if(&pdu.choice); if (!request) { - E3_LOG_ERROR(LOG_TAG) << "Failed to get SetupRequest from PDU"; + E3_LOG_WARN(LOG_TAG) << "Failed to get SetupRequest from PDU; rejecting"; + send_negative_setup_reply(pdu.message_id); continue; } - + handle_setup_request(*request, pdu.message_id); } @@ -640,9 +646,11 @@ void E3Interface::handle_setup_request(const SetupRequest& request, uint32_t req if (!encode_result) { E3_LOG_ERROR(LOG_TAG) << "Failed to encode setup response for request id " << request_message_id; + // Still complete the REQ/REP exchange so the setup channel survives. + send_negative_setup_reply(request_message_id); return; } - + ErrorCode send_result = connector_->send_response(encode_result->buffer); if (send_result != ErrorCode::SUCCESS) { E3_LOG_ERROR(LOG_TAG) << "Failed to send setup response for request id " << request_message_id @@ -652,6 +660,41 @@ void E3Interface::handle_setup_request(const SetupRequest& request, uint32_t req } } +void E3Interface::send_negative_setup_reply(uint32_t request_id) { + // A bare negative SetupResponse omits every OPTIONAL field + // (ranFunctionList included), so with the fixed, mutually known + // encoding it always encodes. The mandatory fields have to satisfy + // their schema constraints, though: requestId is E3-MessageID + // INTEGER (1..1000) — an unrecoverable id (0) falls back to a freshly + // generated one — and ranIdentifier is SIZE (1..64), hence the + // placeholder when the config left it empty. + uint32_t message_id = generate_message_id(); + if (request_id == 0) { + request_id = message_id; + } + auto encode_result = encoder_->encode_setup_response( + message_id, + request_id, + ResponseCode::NEGATIVE, + config_.e3ap_version, + std::nullopt, // no dApp identifier assigned + config_.ran_identifier.empty() ? "unknown" : config_.ran_identifier, + {} // no RAN functions advertised + ); + + if (!encode_result) { + E3_LOG_ERROR(LOG_TAG) << "Failed to encode negative setup response for request id " + << request_id << "; setup channel may be left unusable"; + return; + } + + ErrorCode send_result = connector_->send_response(encode_result->buffer); + if (send_result != ErrorCode::SUCCESS) { + E3_LOG_WARN(LOG_TAG) << "Failed to send negative setup response for request id " + << request_id << "; error=" << error_code_to_string(send_result); + } +} + void E3Interface::handle_subscription_request(const SubscriptionRequest& request, uint32_t request_message_id) { E3_LOG_INFO(LOG_TAG) << "Handling subscription request from dApp " << request.dapp_identifier << " for RAN function " << request.ran_function_identifier; diff --git a/tests/test_setup_bad_request.cpp b/tests/test_setup_bad_request.cpp new file mode 100644 index 0000000..d87418a --- /dev/null +++ b/tests/test_setup_bad_request.cpp @@ -0,0 +1,167 @@ +/** + * @file test_setup_bad_request.cpp + * @brief Regression test: a malformed setup request must not wedge the + * RAN's setup REP socket. + * + * The RAN side of the E3 setup channel is a ZMQ REP socket. The REP state + * machine requires exactly one reply per received request before the socket + * can receive again. If the agent bails out of the setup loop without + * replying (e.g. the incoming bytes do not decode — wrong encoding dialect, + * or plain garbage), the socket is stuck in the send state and every later + * setup request from any dApp stalls until the agent restarts. + * + * Properties verified, using a raw ZMQ REQ peer against a real E3Agent: + * + * (1) Sending undecodable bytes on the setup channel produces an explicit + * rejection: a decodable SetupResponse with ResponseCode::NEGATIVE + * (the original request id is unrecoverable from garbage bytes, so + * the agent substitutes a fresh one — E3-MessageID excludes 0), + * i.e. the REQ/REP exchange always completes. + * + * (2) After the garbage exchange, a well-formed SetupRequest on the very + * same channel still receives a positive SetupResponse — the setup + * channel survived the malformed message. + * + * The test uses whichever encoding the build provides (JSON preferred) so + * it runs on JSON-only, ASN.1-only, and dual-encoder builds. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "test_framework.hpp" +#include "libe3/libe3.hpp" +#include "libe3/e3_agent.hpp" +#include "libe3/e3_encoder.hpp" +#include "libe3/types.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include // getpid + +using namespace libe3; +using namespace std::chrono_literals; + +namespace { + +inline int error_to_int(ErrorCode e) { return static_cast(e); } + +/// Counter so each test instance uses distinct IPC namespaces — required +/// when running ctest -j to avoid socket-file collisions. +std::atomic g_seq{0}; + +std::string unique_ipc(const char* tag) { + std::ostringstream oss; + oss << "ipc:///tmp/dapps/badsetup_test_" << getpid() << "_" + << g_seq.fetch_add(1) << "_" << tag; + return oss.str(); +} + +/// Pick an encoding that is compiled into this build (JSON preferred so the +/// test also runs on JSON-only builds). +EncodingFormat pick_encoding() { +#if defined(LIBE3_ENABLE_JSON) + return EncodingFormat::JSON; +#else + return EncodingFormat::ASN1; +#endif +} + +} // namespace + +TEST(SetupChannel_garbageRequest_repliesAndChannelSurvives) { + const std::string setup_ep = unique_ipc("setup"); + const std::string sub_ep = unique_ipc("inbound"); + const std::string pub_ep = unique_ipc("outbound"); + const EncodingFormat encoding = pick_encoding(); + + E3Config cfg; + cfg.role = E3Role::RAN; + cfg.link_layer = E3LinkLayer::ZMQ; + cfg.transport_layer = E3TransportLayer::IPC; + cfg.setup_endpoint = setup_ep; + cfg.subscriber_endpoint = sub_ep; + cfg.publisher_endpoint = pub_ep; + cfg.encoding = encoding; + cfg.log_level = 0; + cfg.ran_identifier = "badsetup-test"; + + E3Agent agent(std::move(cfg)); + ASSERT_EQ(error_to_int(agent.start()), error_to_int(ErrorCode::SUCCESS)); + ASSERT_TRUE(agent.is_running()); + + // The REP socket is bound synchronously inside start(); a short settle + // keeps the IPC connect race-free. + std::this_thread::sleep_for(100ms); + + void* ctx = zmq_ctx_new(); + ASSERT_TRUE(ctx != nullptr); + void* req = zmq_socket(ctx, ZMQ_REQ); + ASSERT_TRUE(req != nullptr); + int recv_timeout = 5000; // a wedged REP socket shows up as a timeout + zmq_setsockopt(req, ZMQ_RCVTIMEO, &recv_timeout, sizeof(recv_timeout)); + int linger = 0; + zmq_setsockopt(req, ZMQ_LINGER, &linger, sizeof(linger)); + ASSERT_EQ(zmq_connect(req, setup_ep.c_str()), 0); + + auto encoder = create_encoder(encoding); + ASSERT_TRUE(encoder != nullptr); + + // (1) Garbage on the setup channel: an explicit rejection — a decodable + // negative SetupResponse — must arrive. Without the fix the agent + // never replies and this recv times out with -1/EAGAIN. + const char garbage[] = "definitely-not-an-e3ap-setup-request"; + ASSERT_GE(zmq_send(req, garbage, sizeof(garbage) - 1, 0), 0); + + uint8_t buf[4096]; + int n = zmq_recv(req, buf, sizeof(buf), 0); + ASSERT_GT(n, 0); + auto rejection = encoder->decode(buf, static_cast(n)); + ASSERT_TRUE(rejection.has_value()); + ASSERT_EQ(static_cast(rejection->type), + static_cast(PduType::SETUP_RESPONSE)); + auto* neg = std::get_if(&rejection->choice); + ASSERT_TRUE(neg != nullptr); + ASSERT_EQ(static_cast(neg->response_code), + static_cast(ResponseCode::NEGATIVE)); + ASSERT_GE(neg->request_id, 1u); // id unrecoverable → substituted, never 0 + ASSERT_TRUE(neg->ran_function_list.empty()); + ASSERT_TRUE(!neg->dapp_identifier.has_value()); + + // (2) The same channel must still serve a well-formed SetupRequest. + auto setup = encoder->encode_setup_request( + 42, "1.0.0", "badsetup-dapp", "0.0.1", "test-vendor"); + ASSERT_TRUE(setup.has_value()); + ASSERT_GE(zmq_send(req, setup->buffer.data(), setup->buffer.size(), 0), 0); + + n = zmq_recv(req, buf, sizeof(buf), 0); + ASSERT_GT(n, 0); + auto decoded = encoder->decode(buf, static_cast(n)); + ASSERT_TRUE(decoded.has_value()); + ASSERT_EQ(static_cast(decoded->type), + static_cast(PduType::SETUP_RESPONSE)); + auto* resp = std::get_if(&decoded->choice); + ASSERT_TRUE(resp != nullptr); + ASSERT_EQ(static_cast(resp->response_code), + static_cast(ResponseCode::POSITIVE)); + ASSERT_EQ(resp->request_id, 42u); + ASSERT_TRUE(resp->dapp_identifier.has_value()); + + zmq_close(req); + zmq_ctx_destroy(ctx); + agent.stop(); +} + +// --------------------------------------------------------------------------- + +int main() { + return RUN_ALL_TESTS(); +}